Compare commits

...

64 Commits

Author SHA1 Message Date
Jellyfin Release Bot 1fbd873929 Bump version to 10.11.11 2026-06-06 11:37:28 -04:00
Bond-009 39958ad9e5 Merge pull request #16944 from JPVenson/fix/UserManagerCallguard
Add lockhelper for UserManager
2026-06-03 19:49:57 +02:00
JPVenson 7bde1ac224 Add tests 2026-05-31 17:23:31 +00:00
JPVenson 143aee7e9e add hack for LDAP plugin 2026-05-31 15:12:49 +00:00
JPVenson 8c65dfefa1 Add more explicit copy operation in Update 2026-05-31 13:38:26 +00:00
JPVenson 869d8d3abc revert to block scope 2026-05-27 16:49:54 +00:00
JPVenson 8d0534195d Make auth method more resliant 2026-05-27 11:18:21 +00:00
JPVenson 36af7fa7bf Add lockhelper for UserManager 2026-05-26 20:10:56 +00:00
Jellyfin Release Bot 4b4b4cd94d Bump version to 10.11.10 2026-05-24 17:37:49 -04:00
Joshua M. Boniface f2b358a3c8 Merge commit from fork
Fix GHSA-wwwm-px48-fpvq
2026-05-24 17:34:48 -04:00
Joshua M. Boniface 95600752a3 Merge commit from fork
Fix GHSA-jg92-mrxq-vv75
2026-05-24 17:34:08 -04:00
Joshua M. Boniface 7249b5744c Merge commit from fork
Fix GHSA-f47c-m7gr-q92j
2026-05-24 17:33:51 -04:00
Joshua M. Boniface d8b0034a50 Merge pull request #15048 from theguymadmax/fix-cache-issue
Fix stale UserData cache
2026-05-24 12:30:47 -04:00
Bond-009 ed379a1882 Merge pull request #16906 from JPVenson/fix/UserManagerCollation
Fix/user manager collation
2026-05-24 15:52:43 +02:00
JPVenson 984e67c067 Move index creation to its own migration 2026-05-24 12:04:40 +00:00
JPVenson eece62a90b Add unqiue contraint for NormalizedUsername 2026-05-24 11:04:01 +00:00
JPVenson e5c34e7096 Fix non changing username creation 2026-05-23 17:46:39 +00:00
JPVenson 9526083523 Reorder setup user update 2026-05-23 15:28:30 +00:00
JPVenson c4fb0285fc Fixed startup user renaming 2026-05-23 15:15:09 +00:00
JPVenson 620b7a2495 Remove obsolete supressions 2026-05-22 22:41:23 +00:00
JPVenson ac9aa273ab Update gitignore 2026-05-22 21:34:51 +00:00
Shadowghost 72360ba292 Fix GHSA-f47c-m7gr-q92j 2026-05-22 22:32:48 +02:00
Shadowghost 706000cfce Add PathHelper 2026-05-22 22:26:20 +02:00
Shadowghost 734145ab98 Fix GHSA-jg92-mrxq-vv75 2026-05-22 22:24:44 +02:00
Shadowghost 89d32a9525 Add PathHelper 2026-05-22 22:24:30 +02:00
Shadowghost 19a35a6159 Fix GHSA-wwwm-px48-fpvq 2026-05-22 22:23:12 +02:00
JPVenson 8743c22551 Add tests 2026-05-22 18:55:01 +00:00
JPVenson d1ba366f97 Remove 3rd stage of migration 2026-05-22 17:56:11 +00:00
JPVenson 999de06d6b Add 3 part migration for normalized Username 2026-05-22 17:31:45 +00:00
JPVenson 6435600a9c Fix changing of username 2026-05-22 17:06:23 +00:00
JPVenson e52e448c30 Definitively fixed migration 2026-05-22 16:36:19 +00:00
JPVenson f1137a9587 Update default function for migration 2026-05-22 14:38:19 +00:00
JPVenson 6de99306ec Add normalized username column 2026-05-22 10:42:00 +00:00
JPVenson 03ff69a6e1 Update gitignore 2026-05-22 09:19:20 +00:00
theguymadmax 94d0f7b1ac Apply review feedback 2026-05-21 19:11:34 -04:00
Jellyfin Release Bot e83a7e62f2 Bump version to 10.11.9 2026-05-20 21:27:36 -04:00
Bond-009 445c6c9448 Merge pull request #16845 from JPVenson/backport/adaptLoggingForUserConcurrency
Update log for user session related concurrency update fails
2026-05-15 10:02:50 +02:00
JPVenson 5f3189af41 readded concurrency exception check 2026-05-14 13:09:00 +00:00
JPVenson b278dcf475 revert change 2026-05-14 13:08:24 +00:00
JPVenson f7d80ae9e6 backport changes from #16835 2026-05-14 12:54:35 +00:00
Bond-009 a023b9c88d Fix rate control in av1_amf encoder (#16819)
Only the h26x_amf in legacy driver needs this.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-11 18:25:50 +02:00
nyanmisaka 40f35f6094 Fix rate control in av1_amf encoder
Only the h26x_amf in legacy driver needs this.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-11 01:23:45 +08:00
Niels van Velzen 2b6fc19842 Merge pull request #15368 from JPVenson/bugfix/15153_FixUserConcurrency
Fix UserManager after EFcore refactor
2026-05-05 19:36:58 +02:00
Bond-009 8c29098c8a Use strict QSV CPB size for less powerful H.264 decoder (#16743)
* Fix int32 overflow in QSV rate-control parameter computation (#16376)

Fix int32 overflow in QSV rate-control parameter computation

* Use strict QSV CPB size for less powerful H.264 decoder

Signed-off-by: nyanmisaka <nst799610810@gmail.com>

---------

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
Co-authored-by: scheilch <christian.scheil@icloud.com>
2026-05-03 12:21:33 +02:00
nyanmisaka 758ee0af76 Use strict QSV CPB size for less powerful H.264 decoder
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-02 20:14:51 +08:00
scheilch 2e19c247ef Fix int32 overflow in QSV rate-control parameter computation (#16376)
Fix int32 overflow in QSV rate-control parameter computation
2026-05-02 18:45:41 +08:00
JPVenson 511f90d6d3 Update Reset method to not rely on externally available entity 2026-05-01 11:05:33 +00:00
JPVenson 1ae45519d0 Removed obsolete comment 2026-05-01 08:55:15 +00:00
JPVenson 586fa01e46 Apply review comments 2026-04-30 18:08:14 +00:00
JPVenson 2ac0edc052 Refactored all UserManager db access methods
Fixed stale cached entities in UserManager
Fixed wrong state persisting though method calls
2026-04-30 15:42:46 +00:00
JPVenson b37ebec5f6 Merge remote-tracking branch 'jellyfinorigin/release-10.11.z' into bugfix/15153_FixUserConcurrency 2026-04-30 12:46:39 +00:00
Bond-009 938c043596 Merge pull request #16718 from jellyfin/fix-vpp-hdr10
Allow HDR10 for VPP tonemapping
2026-04-29 21:36:24 +02:00
gnattu 46a53d0605 also allow hdr10plus 2026-04-28 00:09:31 +08:00
gnattu 97f88743b8 Allow HDR10 for VPP tonemapping
It mistakenly required OpenCL tone mapping but VPP can handle this
2026-04-27 20:10:12 +08:00
Jellyfin Release Bot 2c62d40f0d Bump version to 10.11.8 2026-04-05 15:08:15 -04:00
Niels van Velzen dca3cc74b7 Merge pull request #16538 from MBR-0001/fix-language-querying
Fix querying media with language filters
2026-04-05 17:52:57 +02:00
Niels van Velzen be095f85ab Merge pull request #16540 from Shadowghost/handle-bad-folders
Handle folders without associated library in FixLibrarySubtitleDownloadLanguages
2026-04-03 19:08:52 +02:00
Niels van Velzen f51c63e244 Merge pull request #16539 from MBR-0001/fix-language-saving
Fix subtitle saving
2026-04-03 19:08:21 +02:00
MBR-0001 cc678383c9 Simplify subtitle format validation condition 2026-04-02 00:23:18 +02:00
Shadowghost ba0720a555 Handle folders without associated library in FixLibrarySubtitleDownloadLanguages 2026-04-01 16:49:48 +02:00
MBR#0001 417df3df57 Fix subtitle saving 2026-04-01 16:33:04 +02:00
MBR#0001 169e48ac00 Fix querying media with language filters 2026-04-01 16:04:20 +02:00
JPVenson 01ae62aa49 #15153 fixed 2025-11-03 12:47:26 +00:00
theguymadmax d2df6adc16 Fix stale UserData cache 2025-10-20 12:46:07 -04:00
41 changed files with 4789 additions and 401 deletions
+4
View File
@@ -277,3 +277,7 @@ apiclient/generated
# Omnisharp crash logs # Omnisharp crash logs
mono_crash.*.json mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/
+1 -1
View File
@@ -36,7 +36,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId> <PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library; namespace Emby.Server.Implementations.Library;
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
/// </summary> /// </summary>
public class PathManager : IPathManager public class PathManager : IPathManager
{ {
private readonly ILogger<PathManager> _logger;
private readonly IServerConfigurationManager _config; private readonly IServerConfigurationManager _config;
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class. /// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger.</param>
/// <param name="config">The server configuration manager.</param> /// <param name="config">The server configuration manager.</param>
/// <param name="appPaths">The application paths.</param> /// <param name="appPaths">The application paths.</param>
public PathManager( public PathManager(
ILogger<PathManager> logger,
IServerConfigurationManager config, IServerConfigurationManager config,
IApplicationPaths appPaths) IApplicationPaths appPaths)
{ {
_logger = logger;
_config = config; _config = config;
_appPaths = appPaths; _appPaths = appPaths;
} }
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc /> /// <inheritdoc />
public string GetAttachmentPath(string mediaSourceId, string fileName) public string? GetAttachmentPath(string mediaSourceId, string fileName)
{ {
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName); var safeName = PathHelper.GetSafeLeafFileName(fileName);
if (safeName is null)
{
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
return null;
}
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
} }
/// <inheritdoc /> /// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item) public UserItemData GetUserData(User user, BaseItem item)
{ {
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() var cacheKey = GetCacheKey(user.InternalId, item.Id);
return _cache.GetOrAdd(
cacheKey,
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
{ {
Key = item.GetUserDataKeys()[0], Key = i.GetUserDataKeys()[0],
}; },
item);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -271,9 +271,9 @@ namespace Emby.Server.Implementations.Session
user.LastActivityDate = activityDate; user.LastActivityDate = activityDate;
await _userManager.UpdateUserAsync(user).ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
} }
catch (DbUpdateConcurrencyException e) catch (DbUpdateConcurrencyException)
{ {
_logger.LogDebug(e, "Error updating user's last activity date."); _logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event.");
} }
} }
} }
@@ -2043,7 +2043,7 @@ namespace Emby.Server.Implementations.Session
{ {
CheckDisposed(); CheckDisposed();
var adminUserIds = _userManager.Users var adminUserIds = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.IsAdministrator)) .Where(i => i.HasPermission(PermissionKind.IsAdministrator))
.Select(i => i.Id) .Select(i => i.Id)
.ToList(); .ToList();
+16 -9
View File
@@ -1,5 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.StartupDtos; using Jellyfin.Api.Models.StartupDtos;
@@ -111,7 +111,7 @@ public class StartupController : BaseJellyfinApiController
{ {
// TODO: Remove this method when startup wizard no longer requires an existing user. // TODO: Remove this method when startup wizard no longer requires an existing user.
await _userManager.InitializeAsync().ConfigureAwait(false); await _userManager.InitializeAsync().ConfigureAwait(false);
var user = _userManager.Users.First(); var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization.");
return new StartupUserDto return new StartupUserDto
{ {
Name = user.Username Name = user.Username
@@ -131,22 +131,29 @@ public class StartupController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
{ {
var user = _userManager.Users.First(); var user = _userManager.GetFirstUser();
if (user is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(startupUserDto.Password)) if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{ {
return BadRequest("Password must not be empty"); return BadRequest("Password must not be empty");
} }
if (startupUserDto.Name is not null)
{
user.Username = startupUserDto.Name;
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
#pragma warning disable CA1309 // Use ordinal string comparison
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
{
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
}
#pragma warning restore CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(startupUserDto.Password)) if (!string.IsNullOrEmpty(startupUserDto.Password))
{ {
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
} }
return NoContent(); return NoContent();
+7 -7
View File
@@ -288,7 +288,7 @@ public class UserController : BaseJellyfinApiController
if (request.ResetPassword) if (request.ResetPassword)
{ {
await _userManager.ResetPassword(user).ConfigureAwait(false); await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
} }
else else
{ {
@@ -306,7 +306,7 @@ public class UserController : BaseJellyfinApiController
} }
} }
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false);
var currentToken = User.GetToken(); var currentToken = User.GetToken();
@@ -392,7 +392,7 @@ public class UserController : BaseJellyfinApiController
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{ {
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false);
} }
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false); await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
@@ -448,7 +448,7 @@ public class UserController : BaseJellyfinApiController
// If removing admin access // If removing admin access
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
{ {
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
{ {
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
} }
@@ -463,7 +463,7 @@ public class UserController : BaseJellyfinApiController
// If disabling // If disabling
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
{ {
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
{ {
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
} }
@@ -545,7 +545,7 @@ public class UserController : BaseJellyfinApiController
// no need to authenticate password for new user // no need to authenticate password for new user
if (request.Password is not null) if (request.Password is not null)
{ {
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false);
} }
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString()); var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
@@ -620,7 +620,7 @@ public class UserController : BaseJellyfinApiController
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
{ {
var users = _userManager.Users; var users = _userManager.GetUsers();
if (isDisabled.HasValue) if (isDisabled.HasValue)
{ {
+1 -1
View File
@@ -18,7 +18,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId> <PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
@@ -33,6 +33,7 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying; using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -69,6 +70,7 @@ public sealed class BaseItemRepository
private readonly IItemTypeLookup _itemTypeLookup; private readonly IItemTypeLookup _itemTypeLookup;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly ILogger<BaseItemRepository> _logger; private readonly ILogger<BaseItemRepository> _logger;
private readonly ILocalizationManager _localizationManager;
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist]; private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
@@ -85,18 +87,21 @@ public sealed class BaseItemRepository
/// <param name="itemTypeLookup">The static type lookup.</param> /// <param name="itemTypeLookup">The static type lookup.</param>
/// <param name="serverConfigurationManager">The server Configuration manager.</param> /// <param name="serverConfigurationManager">The server Configuration manager.</param>
/// <param name="logger">System logger.</param> /// <param name="logger">System logger.</param>
/// <param name="localizationManager">Localization manager.</param>
public BaseItemRepository( public BaseItemRepository(
IDbContextFactory<JellyfinDbContext> dbProvider, IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost, IServerApplicationHost appHost,
IItemTypeLookup itemTypeLookup, IItemTypeLookup itemTypeLookup,
IServerConfigurationManager serverConfigurationManager, IServerConfigurationManager serverConfigurationManager,
ILogger<BaseItemRepository> logger) ILogger<BaseItemRepository> logger,
ILocalizationManager localizationManager)
{ {
_dbProvider = dbProvider; _dbProvider = dbProvider;
_appHost = appHost; _appHost = appHost;
_itemTypeLookup = itemTypeLookup; _itemTypeLookup = itemTypeLookup;
_serverConfigurationManager = serverConfigurationManager; _serverConfigurationManager = serverConfigurationManager;
_logger = logger; _logger = logger;
_localizationManager = localizationManager;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -2296,27 +2301,43 @@ public sealed class BaseItemRepository
} }
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoAudioTrackWithLanguage);
if (lang is not null)
{ {
baseQuery = baseQuery baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage)); .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
} }
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoInternalSubtitleTrackWithLanguage);
if (lang is not null)
{ {
baseQuery = baseQuery baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage)); .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
} }
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoExternalSubtitleTrackWithLanguage);
if (lang is not null)
{ {
baseQuery = baseQuery baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage)); .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
} }
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoSubtitleTrackWithLanguage);
if (lang is not null)
{ {
baseQuery = baseQuery baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage)); .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
} }
if (filter.HasSubtitles.HasValue) if (filter.HasSubtitles.HasValue)
@@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
var resetUser = userManager.GetUserByName(spr.UserName) var resetUser = userManager.GetUserByName(spr.UserName)
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found"); ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false); await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false);
usersReset.Add(resetUser.Username); usersReset.Add(resetUser.Username);
File.Delete(resetFile); File.Delete(resetFile);
} }
@@ -1,12 +1,13 @@
#pragma warning disable CA1307 #pragma warning disable RS0030 // Do not use banned APIs
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Data; using Jellyfin.Data;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Data.Events; using Jellyfin.Data.Events;
@@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users
/// <summary> /// <summary>
/// Manages the creation and retrieval of <see cref="User"/> instances. /// Manages the creation and retrieval of <see cref="User"/> instances.
/// </summary> /// </summary>
public partial class UserManager : IUserManager public partial class UserManager : IUserManager, IDisposable
{ {
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IEventManager _eventManager; private readonly IEventManager _eventManager;
@@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IDictionary<Guid, User> _users; private readonly LockHelper _userLock = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class. /// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First(); _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
.AsSplitQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsEnumerable())
{
_users.Add(user.Id, user);
}
} }
/// <inheritdoc/> /// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated; public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/> /// <inheritdoc/>
public IEnumerable<User> Users => _users.Values; public IEnumerable<User> GetUsers()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.ToArray();
}
/// <inheritdoc/> /// <inheritdoc/>
public IEnumerable<Guid> UsersIds => _users.Keys; public IEnumerable<Guid> GetUsersIds()
{
using var dbContext = _dbProvider.CreateDbContext();
return dbContext.Users
.AsNoTracking()
.Select(user => user.Id)
.ToArray();
}
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // This is some regex that matches only on unicode "word" characters, as well as -, _ and @
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
@@ -127,8 +127,28 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id)); throw new ArgumentException("Guid can't be empty", nameof(id));
} }
_users.TryGetValue(id, out var user); using var dbContext = _dbProvider.CreateDbContext();
return user; return UserQuery(dbContext)
.FirstOrDefault(user => user.Id == id);
}
private static IQueryable<User> UserQuery(JellyfinDbContext dbContext)
{
return dbContext.Users
.AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsNoTracking();
}
/// <inheritdoc/>
public User? GetFirstUser()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.FirstOrDefault();
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -139,29 +159,32 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name)); throw new ArgumentException("Invalid username", nameof(name));
} }
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); using var dbContext = _dbProvider.CreateDbContext();
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
return UserQuery(dbContext)
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task RenameUser(User user, string newName) public async Task RenameUser(Guid userId, string oldName, string newName)
{ {
ArgumentNullException.ThrowIfNull(user);
ThrowIfInvalidUsername(newName); ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.Ordinal)) if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
{ {
throw new ArgumentException("The new and old names must be different."); throw new ArgumentException("The new and old names must be different.");
} }
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id)) .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
.ConfigureAwait(false)) .ConfigureAwait(false))
{ {
throw new ArgumentException(string.Format( throw new ArgumentException(string.Format(
@@ -169,13 +192,19 @@ namespace Jellyfin.Server.Implementations.Users
"A user with the name '{0}' already exists.", "A user with the name '{0}' already exists.",
newName)); newName));
} }
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
user.Username = newName; user.Username = newName;
user.NormalizedUsername = newName.ToUpperInvariant();
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
} }
}
var eventArgs = new UserUpdatedEventArgs(user); var eventArgs = new UserUpdatedEventArgs(user);
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
@@ -184,11 +213,61 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/> /// <inheritdoc/>
public async Task UpdateUserAsync(User user) public async Task UpdateUserAsync(User user)
{
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
var dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == user.Id)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
dbUser.Permissions.Clear();
foreach (var permission in user.Permissions)
{
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
}
dbUser.Preferences.Clear();
foreach (var preference in user.Preferences)
{
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
}
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
{
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
}
if (user.ProfileImage is null)
{
if (dbUser.ProfileImage is not null)
{
dbContext.Remove(dbUser.ProfileImage);
dbUser.ProfileImage = null;
}
}
else if (dbUser.ProfileImage is null)
{
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
{
LastModified = user.ProfileImage.LastModified
};
}
else
{
dbUser.ProfileImage.Path = user.ProfileImage.Path;
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
}
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
} }
} }
@@ -218,23 +297,26 @@ namespace Jellyfin.Server.Implementations.Users
{ {
ThrowIfInvalidUsername(name); ThrowIfInvalidUsername(name);
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) User newUser;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
if (await dbContext.Users
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
.ConfigureAwait(false))
{ {
throw new ArgumentException(string.Format( throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.", "A user with the name '{0}' already exists.",
name)); name));
} }
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
User newUser;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
dbContext.Users.Add(newUser); dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
} }
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
@@ -245,12 +327,24 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/> /// <inheritdoc/>
public async Task DeleteUserAsync(Guid userId) public async Task DeleteUserAsync(Guid userId)
{ {
if (!_users.TryGetValue(userId, out var user)) User? user;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
user = await dbContext.Users
.Include(u => u.Permissions)
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
.ConfigureAwait(false);
if (user is null)
{ {
throw new ResourceNotFoundException(nameof(userId)); throw new ResourceNotFoundException(nameof(userId));
} }
if (_users.Count == 1) var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
if (userCount == 1)
{ {
throw new InvalidOperationException(string.Format( throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
@@ -259,7 +353,9 @@ namespace Jellyfin.Server.Implementations.Users
} }
if (user.HasPermission(PermissionKind.IsAdministrator) if (user.HasPermission(PermissionKind.IsAdministrator)
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) && await dbContext.Users
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
.ConfigureAwait(false) == 1)
{ {
throw new ArgumentException( throw new ArgumentException(
string.Format( string.Format(
@@ -269,38 +365,46 @@ namespace Jellyfin.Server.Implementations.Users
nameof(userId)); nameof(userId));
} }
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Users.Attach(user);
dbContext.Users.Remove(user); dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
} }
}
_users.Remove(userId);
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task ResetPassword(User user) public Task ResetPassword(Guid userId)
{ {
return ChangePassword(user, string.Empty); return ChangePassword(userId, string.Empty);
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task ChangePassword(User user, string newPassword) public async Task ChangePassword(Guid userId, string newPassword)
{ {
ArgumentNullException.ThrowIfNull(user); User dbUser = null!;
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
{ {
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
} }
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -403,11 +507,31 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentNullException(nameof(username)); throw new ArgumentNullException(nameof(username));
} }
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); bool success;
var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{
using var dbContext = _dbProvider.CreateDbContext();
// Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null)
{
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
}
var authResult = await AuthenticateLocalUser(username, password, user) var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false); .ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider; var authenticationProvider = authResult.AuthenticationProvider;
var success = authResult.Success; success = authResult.Success;
if (success && user is not null)
{
// refresh the user if the auth provider might have updated it in the auth method.
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
}
if (user is null) if (user is null)
{ {
@@ -422,11 +546,16 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again // Search the database for the user again
// the authentication provider might have created it // the authentication provider might have created it
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{ {
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
} }
} }
@@ -437,8 +566,10 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{ {
user.AuthenticationProviderId = providerId; await dbContext.Users
await UpdateUserAsync(user).ConfigureAwait(false); .Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
.ConfigureAwait(false);
} }
} }
@@ -485,21 +616,48 @@ namespace Jellyfin.Server.Implementations.Users
{ {
if (isUserSession) if (isUserSession)
{ {
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; var date = DateTime.UtcNow;
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
} }
user.InvalidLoginAttemptCount = 0; await dbContext.Users
await UpdateUserAsync(user).ConfigureAwait(false); .Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
.ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
} }
else else
{ {
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); user.InvalidLoginAttemptCount++;
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
.ConfigureAwait(false);
_logger.LogInformation( _logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).", "Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username, user.Username,
remoteEndPoint); remoteEndPoint);
} }
}
return success ? user : null; return success ? user : null;
} }
@@ -542,7 +700,10 @@ namespace Jellyfin.Server.Implementations.Users
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist. // TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
if (_users.Any()) var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
{ {
return; return;
} }
@@ -555,9 +716,6 @@ namespace Jellyfin.Server.Implementations.Users
_logger.LogWarning("No users, creating one with username {UserName}", defaultName); _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -565,7 +723,6 @@ namespace Jellyfin.Server.Implementations.Users
dbContext.Users.Add(newUser); dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
} }
} }
@@ -601,15 +758,14 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/> /// <inheritdoc/>
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var user = dbContext.Users var user = UserQuery(dbContext)
.Include(u => u.Permissions) .AsTracking()
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(userId)) .FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!"); ?? throw new ArgumentException("No user exists with given Id!");
@@ -638,22 +794,21 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user); dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
} }
} }
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var user = dbContext.Users var user = UserQuery(dbContext)
.Include(u => u.Permissions) .AsTracking()
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(userId)) .FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!"); ?? throw new ArgumentException("No user exists with given Id!");
@@ -716,10 +871,10 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user); dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
} }
} }
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task ClearProfileImageAsync(User user) public async Task ClearProfileImageAsync(User user)
@@ -729,6 +884,8 @@ namespace Jellyfin.Server.Implementations.Users
return; return;
} }
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
@@ -737,7 +894,7 @@ namespace Jellyfin.Server.Implementations.Users
} }
user.ProfileImage = null; user.ProfileImage = null;
_users[user.Id] = user; }
} }
internal static void ThrowIfInvalidUsername(string name) internal static void ThrowIfInvalidUsername(string name)
@@ -869,29 +1026,95 @@ namespace Jellyfin.Server.Implementations.Users
} }
} }
private async Task IncrementInvalidLoginAttemptCount(User user)
{
user.InvalidLoginAttemptCount++;
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await UpdateUserAsync(user).ConfigureAwait(false);
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{ {
dbContext.Users.Attach(user); dbContext.Users.Attach(user);
dbContext.Entry(user).State = EntityState.Modified; dbContext.Entry(user).State = EntityState.Modified;
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
} }
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes all members of this class.
/// </summary>
/// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_userLock.Dispose();
}
}
internal sealed class LockHelper : IDisposable
{
private readonly AsyncKeyedLocker<Guid> _userLock = new();
private bool _disposed;
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
public bool ShouldLock()
{
return IsNestedLock.Value == 0;
}
public ValueTask<IDisposable> LockAsync(Guid key)
{
ThrowIfDisposed();
var isNested = LockHelper.IsNestedLock.Value != 0;
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
if (isNested)
{
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
}
return AcquireLockAsync(key);
}
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
{
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
return new LockHandle { Parent = lockHandle };
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_userLock.Dispose();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class LockHandle : IDisposable
{
public required IDisposable? Parent { get; init; }
public void Dispose()
{
Parent?.Dispose();
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
if (LockHelper.IsNestedLock.Value < 0)
{
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
}
}
}
}
} }
} }
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Part 2 Migration for NormalisedUsername.
/// </summary>
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
#pragma warning disable SA1649 // File name should match first type name
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
#pragma warning restore SA1649 // File name should match first type name
{
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
/// </summary>
/// <param name="contextFactory">Db Context factory.</param>
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var user in users)
{
user.NormalizedUsername = user.Username.ToUpperInvariant();
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
}
@@ -7,7 +7,6 @@ using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines; namespace Jellyfin.Server.Migrations.Routines;
@@ -50,7 +49,7 @@ internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
foreach (var virtualFolder in virtualFolders) foreach (var virtualFolder in virtualFolders)
{ {
var options = virtualFolder.LibraryOptions; var options = virtualFolder.LibraryOptions;
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0) if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
{ {
continue; continue;
} }
@@ -181,7 +181,9 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
} }
} }
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); var attachmentIndexName = attachmentIndex.ToString(CultureInfo.InvariantCulture);
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndexName)
?? _pathManager.GetAttachmentPath(itemIdString, attachmentIndexName)!;
if (File.Exists(newAttachmentPath)) if (File.Exists(newAttachmentPath))
{ {
File.Delete(oldAttachmentPath); File.Delete(oldAttachmentPath);
@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId> <PackageId>Jellyfin.Common</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
@@ -1,6 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent namespace MediaBrowser.Controller.ClientEvent
{ {
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{ {
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
{
throw new ArgumentException("Path resolved to filename not in log directory");
}
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false)) await using (fileStream.ConfigureAwait(false))
{ {
+2 -2
View File
@@ -37,8 +37,8 @@ public interface IPathManager
/// </summary> /// </summary>
/// <param name="mediaSourceId">The media source id.</param> /// <param name="mediaSourceId">The media source id.</param>
/// <param name="fileName">The attachmentFileName index.</param> /// <param name="fileName">The attachmentFileName index.</param>
/// <returns>The absolute path.</returns> /// <returns>The absolute path, or <c>null</c> if <paramref name="fileName"/> cannot be reduced to a safe leaf name.</returns>
public string GetAttachmentPath(string mediaSourceId, string fileName); public string? GetAttachmentPath(string mediaSourceId, string fileName);
/// <summary> /// <summary>
/// Gets the path to the attachment folder. /// Gets the path to the attachment folder.
+17 -10
View File
@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
/// <summary> /// <summary>
/// Gets the users. /// Gets the users.
/// </summary> /// </summary>
/// <value>The users.</value> /// <returns>The users.</returns>
IEnumerable<User> Users { get; } IEnumerable<User> GetUsers();
/// <summary> /// <summary>
/// Gets the user ids. /// Gets the user ids.
/// </summary> /// </summary>
/// <value>The users ids.</value> /// <returns>The users ids.</returns>
IEnumerable<Guid> UsersIds { get; } IEnumerable<Guid> GetUsersIds();
/// <summary> /// <summary>
/// Initializes the user manager and ensures that a user exists. /// Initializes the user manager and ensures that a user exists.
@@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception> /// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
User? GetUserById(Guid id); User? GetUserById(Guid id);
/// <summary>
/// Gets the first available user.
/// </summary>
/// <returns>The first user, or <c>null</c> if no users exist.</returns>
User? GetFirstUser();
/// <summary> /// <summary>
/// Gets the name of the user by. /// Gets the name of the user by.
/// </summary> /// </summary>
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
/// <summary> /// <summary>
/// Renames the user. /// Renames the user.
/// </summary> /// </summary>
/// <param name="user">The user.</param> /// <param name="userId">The UserId to change.</param>
/// <param name="oldName">The old Username.</param>
/// <param name="newName">The new name.</param> /// <param name="newName">The new name.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
Task RenameUser(User user, string newName); Task RenameUser(Guid userId, string oldName, string newName);
/// <summary> /// <summary>
/// Updates the user. /// Updates the user.
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
/// <summary> /// <summary>
/// Resets the password. /// Resets the password.
/// </summary> /// </summary>
/// <param name="user">The user.</param> /// <param name="userId">The users Id.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
Task ResetPassword(User user); Task ResetPassword(Guid userId);
/// <summary> /// <summary>
/// Changes the password. /// Changes the password.
/// </summary> /// </summary>
/// <param name="user">The user.</param> /// <param name="userId">The users id.</param>
/// <param name="newPassword">New password to use.</param> /// <param name="newPassword">New password to use.</param>
/// <returns>Awaitable task.</returns> /// <returns>Awaitable task.</returns>
Task ChangePassword(User user, string newPassword); Task ChangePassword(Guid userId, string newPassword);
/// <summary> /// <summary>
/// Gets the user dto. /// Gets the user dto.
@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId> <PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
@@ -413,7 +413,9 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
return state.VideoStream.VideoRange == VideoRange.HDR return state.VideoStream.VideoRange == VideoRange.HDR
&& IsDoviWithHdr10Bl(state.VideoStream); && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream));
} }
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options) private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
@@ -428,8 +430,10 @@ namespace MediaBrowser.Controller.MediaEncoding
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding. // Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
// All other HDR formats working. // All other HDR formats working.
return state.VideoStream.VideoRange == VideoRange.HDR return state.VideoStream.VideoRange == VideoRange.HDR
&& (IsDoviWithHdr10Bl(state.VideoStream) && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| state.VideoStream.VideoRangeType is VideoRangeType.HLG); || IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream)
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
} }
private bool IsVideoStreamHevcRext(EncodingJobInfo state) private bool IsVideoStreamHevcRext(EncodingJobInfo state)
@@ -1301,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs); arg.Append(canvasArgs);
} }
arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"');
} }
if (state.AudioStream is not null && state.AudioStream.IsExternal) if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1313,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam); arg.Append(' ').Append(seekAudioParam);
} }
arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
} }
// Disable auto inserted SW scaler for HW decoders in case of changed resolution. // Disable auto inserted SW scaler for HW decoders in case of changed resolution.
@@ -1574,14 +1578,15 @@ namespace MediaBrowser.Controller.MediaEncoding
int bitrate = state.OutputVideoBitrate.Value; int bitrate = state.OutputVideoBitrate.Value;
// Bit rate under 1000k is not allowed in h264_qsv // Bit rate under 1000k is not allowed in h264_qsv.
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
{ {
bitrate = Math.Max(bitrate, 1000); bitrate = Math.Max(bitrate, 1000);
} }
// Currently use the same buffer size for all encoders // Currently use the same buffer size for all non-QSV encoders.
int bufsize = bitrate * 2; // Use long arithmetic to prevent int32 overflow for very high bitrate values.
int bufsize = (int)Math.Min((long)bitrate * 2, int.MaxValue);
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase)) if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
{ {
@@ -1609,16 +1614,33 @@ namespace MediaBrowser.Controller.MediaEncoding
mbbrcOpt = " -mbbrc 1"; mbbrcOpt = " -mbbrc 1";
} }
// Some less powerful H.264 HW decoders require strict CPB size
// So bufsize optimizations should not be applied to them
int factor = 2;
var codec = state.ActualOutputVideoCodec;
var level = state.GetRequestedLevel(codec);
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)
&& double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel)
&& requestedLevel < 51)
{
factor = 1;
}
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation // Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes // Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}"); // Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow
// (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million)
int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue);
int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue);
int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue);
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}");
} }
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
{ {
// Override the too high default qmin 18 in transcoding preset // Override the too high default qmin 18 in transcoding preset in legacy h26x_amf
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
} }
@@ -6,6 +6,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
@@ -98,9 +99,21 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaSourceInfo mediaSource, MediaSourceInfo mediaSource,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) var hasUnsafeFileName = mediaSource.MediaAttachments.Any(a => !IsSafeBulkAttachmentFileName(a.FileName));
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); var isMatroskaSubtitles = inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase);
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) if (hasUnsafeFileName && isMatroskaSubtitles)
{
_logger.LogError(
"Refusing attachment extraction for .mks input {InputFile}: an attachment FileName tag is not a safe leaf name.",
inputFile);
throw new InvalidOperationException(
string.Format(
CultureInfo.InvariantCulture,
"Refusing attachment extraction for .mks input {0}: unsafe attachment FileName tag.",
inputFile));
}
if (hasUnsafeFileName)
{ {
foreach (var attachment in mediaSource.MediaAttachments) foreach (var attachment in mediaSource.MediaAttachments)
{ {
@@ -241,7 +254,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id); var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{ {
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture)); var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath)) if (!File.Exists(attachmentPath))
{ {
await ExtractAttachmentInternal( await ExtractAttachmentInternal(
@@ -341,6 +356,27 @@ namespace MediaBrowser.MediaEncoding.Attachments
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
} }
/// <summary>
/// Decides whether an attachment FILENAME tag is safe to feed into ffmpeg's
/// bulk <c>-dump_attachment:t ""</c> mode, which writes each attachment using
/// its filename tag verbatim relative to the working directory. Anything that
/// could escape the working directory - path separators, "..", or empty
/// leaves - is rerouted to the one-by-one path where the filename is
/// sanitised via <see cref="IPathManager.GetAttachmentPath"/>.
/// </summary>
private static bool IsSafeBulkAttachmentFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return true;
}
// PathHelper.GetSafeLeafFileName collapses to the leaf component;
// the bulk path is only safe when the supplied name already _was_
// that leaf (no separators, no "."/"..").
return PathHelper.GetSafeLeafFileName(fileName) == fileName;
}
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
@@ -372,7 +373,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
CreateNoWindow = true, CreateNoWindow = true,
UseShellExecute = false, UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath, FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)),
WindowStyle = ProcessWindowStyle.Hidden, WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false ErrorDialog = false
}, },
+1 -1
View File
@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId> <PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{ {
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{ {
savePaths.Add(mediaFolderPath); savePaths.Add(mediaFolderPath);
} }
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{ {
savePaths.Add(internalPath); savePaths.Add(internalPath);
} }
@@ -239,7 +239,7 @@ namespace MediaBrowser.Providers.Subtitles
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension) private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
{ {
if (!_allowedSubtitleFormats.Contains("." + extension, StringComparison.OrdinalIgnoreCase)) if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
{ {
throw new ArgumentException($"Invalid subtitle format: {extension}"); throw new ArgumentException($"Invalid subtitle format: {extension}");
} }
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Reflection; using System.Reflection;
[assembly: AssemblyVersion("10.11.7")] [assembly: AssemblyVersion("10.11.11")]
[assembly: AssemblyFileVersion("10.11.7")] [assembly: AssemblyFileVersion("10.11.11")]
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId); ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
Username = username; Username = username;
NormalizedUsername = username.ToUpperInvariant();
AuthenticationProviderId = authenticationProviderId; AuthenticationProviderId = authenticationProviderId;
PasswordResetProviderId = passwordResetProviderId; PasswordResetProviderId = passwordResetProviderId;
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
[StringLength(255)] [StringLength(255)]
public string Username { get; set; } public string Username { get; set; }
/// <summary>
/// Gets or sets the user's normalized name.
/// </summary>
/// <remarks>
/// Required, Max length = 255.
/// </remarks>
[MaxLength(255)]
[StringLength(255)]
public string NormalizedUsername { get; set; }
/// <summary> /// <summary>
/// Gets or sets the user's password, or <c>null</c> if none is set. /// Gets or sets the user's password, or <c>null</c> if none is set.
/// </summary> /// </summary>
@@ -268,6 +268,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}).ConfigureAwait(false); }).ConfigureAwait(false);
return result; return result;
} }
catch (DbUpdateConcurrencyException)
{
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
throw;
}
catch (Exception e) catch (Exception e)
{ {
logger.LogError(e, "Error trying to save changes."); logger.LogError(e, "Error trying to save changes.");
@@ -289,6 +294,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}); });
return result; return result;
} }
catch (DbUpdateConcurrencyException)
{
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
throw;
}
catch (Exception e) catch (Exception e)
{ {
logger.LogError(e, "Error trying to save changes."); logger.LogError(e, "Error trying to save changes.");
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder builder
.HasIndex(entity => entity.Username) .HasIndex(entity => entity.Username)
.IsUnique(); .IsUnique();
builder
.HasIndex(entity => entity.NormalizedUsername)
.IsUnique();
} }
} }
} }
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddNormalizedUsername : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this is the first part of the migration. Add the column.
migrationBuilder.AddColumn<string>(
name: "NormalizedUsername",
table: "Users",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizedUsername",
table: "Users");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddUniqueNormalizedUsernameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedUsername",
table: "Users",
column: "NormalizedUsername",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_NormalizedUsername",
table: "Users");
}
}
}
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{ {
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool>("MustUpdatePassword") b.Property<bool>("MustUpdatePassword")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Password") b.Property<string>("Password")
.HasMaxLength(65535) .HasMaxLength(65535)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();
@@ -15,7 +15,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId> <PackageId>Jellyfin.Extensions</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.IO;
namespace Jellyfin.Extensions;
/// <summary>
/// Helpers for safely composing filesystem paths from untrusted input.
/// </summary>
/// <remarks>
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
/// any code that joins a trusted directory with an externally-supplied name:
/// it neither normalises <c>..</c> nor rejects a rooted second argument
/// (a rooted second arg silently discards the first). Use the helpers below
/// any time the name comes from media metadata, request input, archive
/// entries, or any other channel that can be influenced by a third party.
/// </remarks>
public static class PathHelper
{
/// <summary>
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
/// directory components.
/// </summary>
/// <param name="fileName">The candidate file name.</param>
/// <returns>
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
/// </returns>
public static string? GetSafeLeafFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var leaf = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
{
return null;
}
return leaf;
}
/// <summary>
/// Returns whether <paramref name="candidate"/> resolves to a path that
/// equals or is contained inside <paramref name="root"/>.
/// </summary>
/// <param name="root">The directory the candidate must remain inside.</param>
/// <param name="candidate">The candidate absolute or relative path.</param>
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
/// <remarks>
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
/// so <c>..</c> segments are collapsed before the comparison. The root is
/// compared with a trailing directory separator to prevent prefix
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
/// <c>/var/dataset</c>).
/// </remarks>
public static bool IsContainedIn(string root, string candidate)
{
ArgumentException.ThrowIfNullOrEmpty(root);
ArgumentException.ThrowIfNullOrEmpty(candidate);
var fullRoot = Path.GetFullPath(root);
var fullCandidate = Path.GetFullPath(candidate);
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
{
return true;
}
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
? fullRoot
: fullRoot + Path.DirectorySeparatorChar;
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
}
}
+2 -2
View File
@@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv
{ {
Services = services, Services = services,
IsEnabled = services.Length > 0, IsEnabled = services.Length > 0,
EnabledUsers = _userManager.Users EnabledUsers = _userManager.GetUsers()
.Where(IsLiveTvEnabled) .Where(IsLiveTvEnabled)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray() .ToArray()
@@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv
public IEnumerable<User> GetEnabledUsers() public IEnumerable<User> GetEnabledUsers()
{ {
return _userManager.Users return _userManager.GetUsers()
.Where(IsLiveTvEnabled); .Where(IsLiveTvEnabled);
} }
@@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings
private async Task SendMessage(SessionMessageType name, TimerEventInfo info) private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
{ {
var users = _userManager.Users var users = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
.Select(i => i.Id) .Select(i => i.Id)
.ToList(); .ToList();
@@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.ClientEvent;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests
{
public class ClientEventLoggerTests
{
[Theory]
[InlineData("../../../../etc/passwd", "1.0")]
[InlineData("..\\..\\windows\\system32", "1.0")]
[InlineData("normal-client", "../../../etc/passwd")]
[InlineData("/absolute/path", "1.0")]
public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
{
var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
Directory.CreateDirectory(logDir);
try
{
var paths = new Mock<IServerApplicationPaths>();
paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
var logger = new ClientEventLogger(paths.Object);
using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
Assert.True(File.Exists(resolved));
}
finally
{
Directory.Delete(logDir, recursive: true);
}
}
}
}
@@ -0,0 +1,89 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Server.Implementations.Users;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public class UserManagerLockHelperTests
{
[Fact]
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
Assert.True(helper.ShouldLock());
var outerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
var innerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
innerHandle.Dispose();
Assert.False(helper.ShouldLock());
outerHandle.Dispose();
Assert.True(helper.ShouldLock());
}
[Fact]
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var secondEntered = false;
var firstTask = Task.Run(async () =>
{
using var firstHandle = await helper.LockAsync(key);
firstAcquired.SetResult(true);
await releaseFirst.Task;
});
await firstAcquired.Task;
var secondTask = Task.Run(async () =>
{
using var secondHandle = await helper.LockAsync(key);
secondEntered = true;
});
await Task.Delay(100);
Assert.False(secondEntered);
releaseFirst.SetResult(true);
await Task.WhenAll(firstTask, secondTask);
Assert.True(secondEntered);
}
[Fact]
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
}
[Fact]
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
var ex = Record.Exception(() => helper.Dispose());
Assert.Null(ex);
}
}
}
@@ -0,0 +1,240 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class UserManagerNormalizedUsernameTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserManager _userManager;
public UserManagerNormalizedUsernameTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
// Create the schema
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
var cryptoProvider = new Mock<ICryptoProvider>();
var configManager = new Mock<IServerConfigurationManager>();
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
var appHost = new Mock<IApplicationHost>();
var defaultAuthProvider = new DefaultAuthenticationProvider(
NullLogger<DefaultAuthenticationProvider>.Instance,
cryptoProvider.Object);
var invalidAuthProvider = new InvalidAuthProvider();
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
configManager.Object,
appHost.Object);
_userManager = new UserManager(
factory.Object,
new NoopEventManager(),
new Mock<INetworkManager>().Object,
appHost.Object,
new Mock<IImageProcessor>().Object,
NullLogger<UserManager>.Instance,
configManager.Object,
new IPasswordResetProvider[] { defaultPasswordResetProvider },
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
}
public void Dispose()
{
_userManager.Dispose();
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
// ----- GetUserByName tests -----
[Theory]
// German umlauts
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n
[InlineData("Ñoño", "ÑOÑO")]
// ASCII, invariant uppercase lookup
[InlineData("jellyfin", "JELLYFIN")]
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
[InlineData("Çelebi", "ÇELEBI")]
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
string username, string normalizedLookup)
{
await _userManager.CreateUserAsync(username);
var found = _userManager.GetUserByName(normalizedLookup);
Assert.NotNull(found);
Assert.Equal(username, found.Username);
}
[Theory]
// German umlaut, look up by both upper and lower case
[InlineData("münchen")]
// Spanish tilde-n
[InlineData("Ñoño")]
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
[InlineData("ali")]
// mixed ASCII + umlaut
[InlineData("testüser")]
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
{
await _userManager.CreateUserAsync(username);
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
var exactFound = _userManager.GetUserByName(username);
Assert.NotNull(upperFound);
Assert.NotNull(lowerFound);
Assert.NotNull(exactFound);
}
[Theory]
[InlineData("nonexistent")]
// No user with NormalizedUsername = "MÜNCHEN" has been created
[InlineData("MÜNCHEN")]
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
{
var result = _userManager.GetUserByName(lookupName);
Assert.Null(result);
}
// ----- CreateUserAsync duplicate detection tests -----
[Theory]
// German umlaut, case-swapped duplicate
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n, lowercase duplicate
[InlineData("Ñoño", "ñoño")]
// ASCII, uppercase duplicate
[InlineData("alice", "ALICE")]
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
[InlineData("çelebi", "ÇELEBI")]
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
string existingUsername, string duplicateUsername)
{
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.CreateUserAsync(duplicateUsername));
}
[Theory]
// Different non-ASCII names that do not collide after normalization
[InlineData("münchen", "münchen2")]
[InlineData("ali", "ali2")]
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
[InlineData("noño", "nono")]
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
string firstUsername, string secondUsername)
{
var first = await _userManager.CreateUserAsync(firstUsername);
var second = await _userManager.CreateUserAsync(secondUsername);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.NotEqual(first.Id, second.Id);
}
// ----- RenameUser tests -----
[Theory]
// Rename to non-ASCII name
[InlineData("alice", "münchen")]
// Rename between similar non-ASCII and ASCII
[InlineData("müller", "mueller")]
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
[InlineData("ali", "ALI2")]
// Rename to Spanish tilde-n name
[InlineData("testuser", "Ñoño")]
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
string originalName, string newName)
{
var user = await _userManager.CreateUserAsync(originalName);
await _userManager.RenameUser(user.Id, originalName, newName);
var renamed = _userManager.GetUserById(user.Id);
Assert.NotNull(renamed);
Assert.Equal(newName, renamed.Username);
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
}
[Theory]
// Same name different case: NormalizedUsername already taken
[InlineData("münchen", "MÜNCHEN")]
// Spanish, lowercase conflicts with existing uppercase-normalised entry
[InlineData("Ñoño", "ñoño")]
// ASCII, capitalised conflict
[InlineData("alice", "Alice")]
// Mixed ASCII + umlaut
[InlineData("testüser", "TESTÜSER")]
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
string existingUsername, string conflictingNewName)
{
var targetUser = await _userManager.CreateUserAsync("renametarget");
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
}
private sealed class NoopEventManager : IEventManager
{
public void Publish<T>(T eventArgs)
where T : EventArgs
{
}
public Task PublishAsync<T>(T eventArgs)
where T : EventArgs
=> Task.CompletedTask;
}
}
}