mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-27 10:58:44 +01:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
01ae62aa49 | ||
|
|
d2df6adc16 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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.8</VersionPrefix>
|
<VersionPrefix>10.11.10</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(
|
||||||
Key = item.GetUserDataKeys()[0],
|
cacheKey,
|
||||||
};
|
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||||
|
{
|
||||||
|
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.8</VersionPrefix>
|
<VersionPrefix>10.11.10</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>
|
||||||
|
|||||||
@@ -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 AsyncKeyedLocker<Guid> _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,42 +159,51 @@ 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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
|
||||||
await using (dbContext.ConfigureAwait(false))
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
await using (dbContext.ConfigureAwait(false))
|
||||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
|
||||||
if (await dbContext.Users
|
|
||||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
throw new ArgumentException(string.Format(
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
CultureInfo.InvariantCulture,
|
if (await dbContext.Users
|
||||||
"A user with the name '{0}' already exists.",
|
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
|
||||||
newName));
|
.ConfigureAwait(false))
|
||||||
}
|
{
|
||||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
throw new ArgumentException(string.Format(
|
||||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
CultureInfo.InvariantCulture,
|
||||||
|
"A user with the name '{0}' already exists.",
|
||||||
|
newName));
|
||||||
|
}
|
||||||
#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.Username = newName;
|
user = await UserQuery(dbContext)
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(userId));
|
||||||
|
|
||||||
|
user.Username = newName;
|
||||||
|
user.NormalizedUsername = newName.ToUpperInvariant();
|
||||||
|
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var eventArgs = new UserUpdatedEventArgs(user);
|
var eventArgs = new UserUpdatedEventArgs(user);
|
||||||
@@ -185,10 +214,9 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateUserAsync(User user)
|
public async Task UpdateUserAsync(User user)
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,23 +246,26 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
ThrowIfInvalidUsername(name);
|
ThrowIfInvalidUsername(name);
|
||||||
|
|
||||||
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
throw new ArgumentException(string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"A user with the name '{0}' already exists.",
|
|
||||||
name));
|
|
||||||
}
|
|
||||||
|
|
||||||
User newUser;
|
User newUser;
|
||||||
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
|
||||||
|
if (await dbContext.Users
|
||||||
|
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"A user with the name '{0}' already exists.",
|
||||||
|
name));
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
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,62 +276,84 @@ 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))
|
||||||
{
|
{
|
||||||
throw new ResourceNotFoundException(nameof(userId));
|
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 (_users.Count == 1)
|
if (user is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(string.Format(
|
throw new ResourceNotFoundException(nameof(userId));
|
||||||
CultureInfo.InvariantCulture,
|
}
|
||||||
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
|
||||||
user.Username));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
|
||||||
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
if (userCount == 1)
|
||||||
{
|
{
|
||||||
throw new ArgumentException(
|
throw new InvalidOperationException(string.Format(
|
||||||
string.Format(
|
CultureInfo.InvariantCulture,
|
||||||
CultureInfo.InvariantCulture,
|
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
||||||
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
user.Username));
|
||||||
user.Username),
|
}
|
||||||
nameof(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||||
await using (dbContext.ConfigureAwait(false))
|
&& await dbContext.Users
|
||||||
{
|
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
|
||||||
dbContext.Users.Attach(user);
|
.ConfigureAwait(false) == 1)
|
||||||
dbContext.Users.Remove(user);
|
{
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
throw new ArgumentException(
|
||||||
}
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
||||||
|
user.Username),
|
||||||
|
nameof(userId));
|
||||||
|
}
|
||||||
|
|
||||||
_users.Remove(userId);
|
dbContext.Users.Remove(user);
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -403,102 +456,114 @@ 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 authResult = await AuthenticateLocalUser(username, password, user)
|
var user = GetUserByName(username);
|
||||||
.ConfigureAwait(false);
|
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
||||||
var authenticationProvider = authResult.AuthenticationProvider;
|
|
||||||
var success = authResult.Success;
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
{
|
||||||
string updatedUsername = authResult.Username;
|
// 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
|
||||||
if (success
|
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
||||||
&& authenticationProvider is not null
|
if (user is not null)
|
||||||
&& authenticationProvider is not DefaultAuthenticationProvider)
|
|
||||||
{
|
{
|
||||||
// Trust the username returned by the authentication provider
|
user = GetUserById(user.Id) ?? user;
|
||||||
username = updatedUsername;
|
}
|
||||||
|
|
||||||
// Search the database for the user again
|
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||||
// the authentication provider might have created it
|
.ConfigureAwait(false);
|
||||||
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
var authenticationProvider = authResult.AuthenticationProvider;
|
||||||
|
success = authResult.Success;
|
||||||
|
|
||||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
if (user is null)
|
||||||
|
{
|
||||||
|
string updatedUsername = authResult.Username;
|
||||||
|
|
||||||
|
if (success
|
||||||
|
&& authenticationProvider is not null
|
||||||
|
&& authenticationProvider is not DefaultAuthenticationProvider)
|
||||||
{
|
{
|
||||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
// Trust the username returned by the authentication provider
|
||||||
|
username = updatedUsername;
|
||||||
|
|
||||||
|
// Search the database for the user again
|
||||||
|
// the authentication provider might have created it
|
||||||
|
user = GetUserByName(username);
|
||||||
|
|
||||||
|
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||||
|
{
|
||||||
|
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (success && user is not null && authenticationProvider is not null)
|
if (success && user is not null && authenticationProvider is not null)
|
||||||
{
|
|
||||||
var providerId = authenticationProvider.GetType().FullName;
|
|
||||||
|
|
||||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
user.AuthenticationProviderId = providerId;
|
var providerId = authenticationProvider.GetType().FullName;
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user is null)
|
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
user.AuthenticationProviderId = providerId;
|
||||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||||
username,
|
}
|
||||||
remoteEndPoint);
|
|
||||||
throw new AuthenticationException("Invalid username or password entered.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.HasPermission(PermissionKind.IsDisabled))
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
|
|
||||||
username,
|
|
||||||
remoteEndPoint);
|
|
||||||
throw new SecurityException(
|
|
||||||
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
|
|
||||||
!_networkManager.IsInLocalNetwork(remoteEndPoint))
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
|
|
||||||
username,
|
|
||||||
remoteEndPoint);
|
|
||||||
throw new SecurityException("Forbidden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.IsParentalScheduleAllowed())
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
|
|
||||||
username,
|
|
||||||
remoteEndPoint);
|
|
||||||
throw new SecurityException("User is not allowed access at this time.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update LastActivityDate and LastLoginDate, then save
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
if (isUserSession)
|
|
||||||
{
|
|
||||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.InvalidLoginAttemptCount = 0;
|
if (user is null)
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
{
|
||||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
_logger.LogInformation(
|
||||||
}
|
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||||
else
|
username,
|
||||||
{
|
remoteEndPoint);
|
||||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
throw new AuthenticationException("Invalid username or password entered.");
|
||||||
_logger.LogInformation(
|
}
|
||||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
|
||||||
user.Username,
|
if (user.HasPermission(PermissionKind.IsDisabled))
|
||||||
remoteEndPoint);
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
|
||||||
|
username,
|
||||||
|
remoteEndPoint);
|
||||||
|
throw new SecurityException(
|
||||||
|
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
|
||||||
|
!_networkManager.IsInLocalNetwork(remoteEndPoint))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
|
||||||
|
username,
|
||||||
|
remoteEndPoint);
|
||||||
|
throw new SecurityException("Forbidden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.IsParentalScheduleAllowed())
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
|
||||||
|
username,
|
||||||
|
remoteEndPoint);
|
||||||
|
throw new SecurityException("User is not allowed access at this time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update LastActivityDate and LastLoginDate, then save
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
if (isUserSession)
|
||||||
|
{
|
||||||
|
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.InvalidLoginAttemptCount = 0;
|
||||||
|
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||||
|
user.Username,
|
||||||
|
remoteEndPoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return success ? user : null;
|
return success ? user : null;
|
||||||
@@ -542,22 +607,22 @@ 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())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultName = Environment.UserName;
|
|
||||||
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
|
||||||
{
|
|
||||||
defaultName = "MyJellyfinUser";
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultName = Environment.UserName;
|
||||||
|
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
||||||
|
{
|
||||||
|
defaultName = "MyJellyfinUser";
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||||
|
|
||||||
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 +630,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,122 +666,120 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
.Include(u => u.Permissions)
|
await using (dbContext.ConfigureAwait(false))
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
|
||||||
|
|
||||||
user.SubtitleMode = config.SubtitleMode;
|
|
||||||
user.HidePlayedInLatest = config.HidePlayedInLatest;
|
|
||||||
user.EnableLocalPassword = config.EnableLocalPassword;
|
|
||||||
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
|
|
||||||
user.DisplayCollectionsView = config.DisplayCollectionsView;
|
|
||||||
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
|
|
||||||
user.AudioLanguagePreference = config.AudioLanguagePreference;
|
|
||||||
user.RememberAudioSelections = config.RememberAudioSelections;
|
|
||||||
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
|
|
||||||
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
|
|
||||||
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
|
|
||||||
|
|
||||||
// Only set cast receiver id if it is passed in and it exists in the server config.
|
|
||||||
if (!string.IsNullOrEmpty(config.CastReceiverId)
|
|
||||||
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
|
|
||||||
{
|
{
|
||||||
user.CastReceiverId = config.CastReceiverId;
|
var user = UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
|
user.SubtitleMode = config.SubtitleMode;
|
||||||
|
user.HidePlayedInLatest = config.HidePlayedInLatest;
|
||||||
|
user.EnableLocalPassword = config.EnableLocalPassword;
|
||||||
|
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
|
||||||
|
user.DisplayCollectionsView = config.DisplayCollectionsView;
|
||||||
|
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
|
||||||
|
user.AudioLanguagePreference = config.AudioLanguagePreference;
|
||||||
|
user.RememberAudioSelections = config.RememberAudioSelections;
|
||||||
|
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
|
||||||
|
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
|
||||||
|
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
|
||||||
|
|
||||||
|
// Only set cast receiver id if it is passed in and it exists in the server config.
|
||||||
|
if (!string.IsNullOrEmpty(config.CastReceiverId)
|
||||||
|
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
user.CastReceiverId = config.CastReceiverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
|
||||||
|
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
|
||||||
|
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
|
||||||
|
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||||
|
|
||||||
|
dbContext.Update(user);
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
|
|
||||||
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
|
|
||||||
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
|
|
||||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
|
||||||
|
|
||||||
dbContext.Update(user);
|
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
.Include(u => u.Permissions)
|
await using (dbContext.ConfigureAwait(false))
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
|
||||||
|
|
||||||
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
|
|
||||||
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
|
|
||||||
{
|
{
|
||||||
-1 => null,
|
var user = UserQuery(dbContext)
|
||||||
0 => 3,
|
.AsTracking()
|
||||||
_ => policy.LoginAttemptsBeforeLockout
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
};
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
user.MaxParentalRatingScore = policy.MaxParentalRating;
|
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
|
||||||
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
|
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
|
||||||
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
|
{
|
||||||
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
|
-1 => null,
|
||||||
user.AuthenticationProviderId = policy.AuthenticationProviderId;
|
0 => 3,
|
||||||
user.PasswordResetProviderId = policy.PasswordResetProviderId;
|
_ => policy.LoginAttemptsBeforeLockout
|
||||||
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
|
};
|
||||||
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
|
|
||||||
user.MaxActiveSessions = policy.MaxActiveSessions;
|
|
||||||
user.SyncPlayAccess = policy.SyncPlayAccess;
|
|
||||||
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
|
||||||
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
|
|
||||||
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
|
|
||||||
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
|
|
||||||
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
|
|
||||||
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
|
|
||||||
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
|
|
||||||
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
|
|
||||||
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
|
|
||||||
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
|
|
||||||
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
|
||||||
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
|
|
||||||
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
|
|
||||||
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
|
|
||||||
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
|
|
||||||
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
|
|
||||||
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
|
|
||||||
|
|
||||||
user.AccessSchedules.Clear();
|
user.MaxParentalRatingScore = policy.MaxParentalRating;
|
||||||
foreach (var policyAccessSchedule in policy.AccessSchedules)
|
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
|
||||||
{
|
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
|
||||||
user.AccessSchedules.Add(policyAccessSchedule);
|
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
|
||||||
|
user.AuthenticationProviderId = policy.AuthenticationProviderId;
|
||||||
|
user.PasswordResetProviderId = policy.PasswordResetProviderId;
|
||||||
|
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
|
||||||
|
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
|
||||||
|
user.MaxActiveSessions = policy.MaxActiveSessions;
|
||||||
|
user.SyncPlayAccess = policy.SyncPlayAccess;
|
||||||
|
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
||||||
|
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
|
||||||
|
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
|
||||||
|
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
|
||||||
|
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
|
||||||
|
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
|
||||||
|
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
|
||||||
|
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
|
||||||
|
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
|
||||||
|
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
|
||||||
|
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
|
||||||
|
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
|
||||||
|
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
|
||||||
|
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
|
||||||
|
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
|
||||||
|
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
|
||||||
|
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
||||||
|
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
|
||||||
|
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
|
||||||
|
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
|
||||||
|
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
|
||||||
|
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
|
||||||
|
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
|
||||||
|
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
|
||||||
|
|
||||||
|
user.AccessSchedules.Clear();
|
||||||
|
foreach (var policyAccessSchedule in policy.AccessSchedules)
|
||||||
|
{
|
||||||
|
user.AccessSchedules.Add(policyAccessSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix this at some point
|
||||||
|
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
|
||||||
|
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
|
||||||
|
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
|
||||||
|
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
|
||||||
|
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
|
||||||
|
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
|
||||||
|
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||||
|
|
||||||
|
dbContext.Update(user);
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fix this at some point
|
|
||||||
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
|
|
||||||
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
|
|
||||||
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
|
|
||||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
|
||||||
|
|
||||||
dbContext.Update(user);
|
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,15 +791,17 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
dbContext.Remove(user.ProfileImage);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await using (dbContext.ConfigureAwait(false))
|
||||||
}
|
{
|
||||||
|
dbContext.Remove(user.ProfileImage);
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
user.ProfileImage = null;
|
user.ProfileImage = null;
|
||||||
_users[user.Id] = user;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void ThrowIfInvalidUsername(string name)
|
internal static void ThrowIfInvalidUsername(string name)
|
||||||
@@ -883,15 +947,42 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
user.InvalidLoginAttemptCount);
|
user.InvalidLoginAttemptCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateUserInternalAsync(User user)
|
||||||
|
{
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await UpdateUserInternalAsync(dbContext, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.8</VersionPrefix>
|
<VersionPrefix>10.11.10</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.8</VersionPrefix>
|
<VersionPrefix>10.11.10</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.8</VersionPrefix>
|
<VersionPrefix>10.11.10</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
[assembly: AssemblyVersion("10.11.8")]
|
[assembly: AssemblyVersion("10.11.10")]
|
||||||
[assembly: AssemblyFileVersion("10.11.8")]
|
[assembly: AssemblyFileVersion("10.11.10")]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
||||||
|
|
||||||
Username = username;
|
Username = username;
|
||||||
|
NormalizedUsername = username.ToUpperInvariant();
|
||||||
AuthenticationProviderId = authenticationProviderId;
|
AuthenticationProviderId = authenticationProviderId;
|
||||||
PasswordResetProviderId = passwordResetProviderId;
|
PasswordResetProviderId = passwordResetProviderId;
|
||||||
|
|
||||||
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
|
|||||||
[StringLength(255)]
|
[StringLength(255)]
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user's normalized name.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required, Max length = 255.
|
||||||
|
/// </remarks>
|
||||||
|
[MaxLength(255)]
|
||||||
|
[StringLength(255)]
|
||||||
|
public string NormalizedUsername { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Error trying to save changes.");
|
logger.LogError(e, "Error trying to save changes.");
|
||||||
@@ -289,6 +294,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Error trying to save changes.");
|
logger.LogError(e, "Error trying to save changes.");
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
|||||||
builder
|
builder
|
||||||
.HasIndex(entity => entity.Username)
|
.HasIndex(entity => entity.Username)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
builder
|
||||||
|
.HasIndex(entity => entity.NormalizedUsername)
|
||||||
|
.IsUnique();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUniqueNormalizedUsernameIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_NormalizedUsername",
|
||||||
|
table: "Users",
|
||||||
|
column: "NormalizedUsername",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_NormalizedUsername",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
|
||||||
|
|
||||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||||
{
|
{
|
||||||
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
b.Property<bool>("MustUpdatePassword")
|
b.Property<bool>("MustUpdatePassword")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Password")
|
b.Property<string>("Password")
|
||||||
.HasMaxLength(65535)
|
.HasMaxLength(65535)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUsername")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Username")
|
b.HasIndex("Username")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Extensions</PackageId>
|
<PackageId>Jellyfin.Extensions</PackageId>
|
||||||
<VersionPrefix>10.11.8</VersionPrefix>
|
<VersionPrefix>10.11.10</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
77
src/Jellyfin.Extensions/PathHelper.cs
Normal file
77
src/Jellyfin.Extensions/PathHelper.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Jellyfin.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers for safely composing filesystem paths from untrusted input.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
|
||||||
|
/// any code that joins a trusted directory with an externally-supplied name:
|
||||||
|
/// it neither normalises <c>..</c> nor rejects a rooted second argument
|
||||||
|
/// (a rooted second arg silently discards the first). Use the helpers below
|
||||||
|
/// any time the name comes from media metadata, request input, archive
|
||||||
|
/// entries, or any other channel that can be influenced by a third party.
|
||||||
|
/// </remarks>
|
||||||
|
public static class PathHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
|
||||||
|
/// directory components.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileName">The candidate file name.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
|
||||||
|
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
|
||||||
|
/// </returns>
|
||||||
|
public static string? GetSafeLeafFileName(string? fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf = Path.GetFileName(fileName);
|
||||||
|
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns whether <paramref name="candidate"/> resolves to a path that
|
||||||
|
/// equals or is contained inside <paramref name="root"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The directory the candidate must remain inside.</param>
|
||||||
|
/// <param name="candidate">The candidate absolute or relative path.</param>
|
||||||
|
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
|
||||||
|
/// so <c>..</c> segments are collapsed before the comparison. The root is
|
||||||
|
/// compared with a trailing directory separator to prevent prefix
|
||||||
|
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
|
||||||
|
/// <c>/var/dataset</c>).
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsContainedIn(string root, string candidate)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(root);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(candidate);
|
||||||
|
|
||||||
|
var fullRoot = Path.GetFullPath(root);
|
||||||
|
var fullCandidate = Path.GetFullPath(candidate);
|
||||||
|
|
||||||
|
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? fullRoot
|
||||||
|
: fullRoot + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
44
tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
Normal file
44
tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
Normal file
@@ -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,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