Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fbd873929 | |||
| 39958ad9e5 | |||
| 7bde1ac224 | |||
| 143aee7e9e | |||
| 8c65dfefa1 | |||
| 869d8d3abc | |||
| 8d0534195d | |||
| 36af7fa7bf | |||
| 4b4b4cd94d | |||
| f2b358a3c8 | |||
| 95600752a3 | |||
| 7249b5744c | |||
| d8b0034a50 | |||
| ed379a1882 | |||
| 984e67c067 | |||
| eece62a90b | |||
| e5c34e7096 | |||
| 9526083523 | |||
| c4fb0285fc | |||
| 620b7a2495 | |||
| ac9aa273ab | |||
| 72360ba292 | |||
| 706000cfce | |||
| 734145ab98 | |||
| 89d32a9525 | |||
| 19a35a6159 | |||
| 8743c22551 | |||
| d1ba366f97 | |||
| 999de06d6b | |||
| 6435600a9c | |||
| e52e448c30 | |||
| f1137a9587 | |||
| 6de99306ec | |||
| 03ff69a6e1 | |||
| 94d0f7b1ac | |||
| e83a7e62f2 | |||
| 445c6c9448 | |||
| 5f3189af41 | |||
| b278dcf475 | |||
| f7d80ae9e6 | |||
| a023b9c88d | |||
| 40f35f6094 | |||
| 2b6fc19842 | |||
| 8c29098c8a | |||
| 758ee0af76 | |||
| 2e19c247ef | |||
| 511f90d6d3 | |||
| 1ae45519d0 | |||
| 586fa01e46 | |||
| 2ac0edc052 | |||
| b37ebec5f6 | |||
| 938c043596 | |||
| 46a53d0605 | |||
| 97f88743b8 | |||
| 2c62d40f0d | |||
| dca3cc74b7 | |||
| be095f85ab | |||
| f51c63e244 | |||
| cc678383c9 | |||
| ba0720a555 | |||
| 417df3df57 | |||
| 169e48ac00 | |||
| 01ae62aa49 | |||
| d2df6adc16 |
@@ -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/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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.");
|
||||||
|
|||||||
+4
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1726
File diff suppressed because it is too large
Load Diff
+31
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1729
File diff suppressed because it is too large
Load Diff
+28
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user