mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-07 08:18:54 +01:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbd873929 | ||
|
|
39958ad9e5 | ||
|
|
7bde1ac224 | ||
|
|
143aee7e9e | ||
|
|
8c65dfefa1 | ||
|
|
869d8d3abc | ||
|
|
8d0534195d | ||
|
|
36af7fa7bf | ||
|
|
4b4b4cd94d | ||
|
|
f2b358a3c8 | ||
|
|
95600752a3 | ||
|
|
7249b5744c | ||
|
|
d8b0034a50 | ||
|
|
ed379a1882 | ||
|
|
984e67c067 | ||
|
|
eece62a90b | ||
|
|
e5c34e7096 | ||
|
|
9526083523 | ||
|
|
c4fb0285fc | ||
|
|
620b7a2495 | ||
|
|
ac9aa273ab | ||
|
|
72360ba292 | ||
|
|
706000cfce | ||
|
|
734145ab98 | ||
|
|
89d32a9525 | ||
|
|
19a35a6159 | ||
|
|
8743c22551 | ||
|
|
d1ba366f97 | ||
|
|
999de06d6b | ||
|
|
6435600a9c | ||
|
|
e52e448c30 | ||
|
|
f1137a9587 | ||
|
|
6de99306ec | ||
|
|
03ff69a6e1 | ||
|
|
94d0f7b1ac | ||
|
|
d2df6adc16 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -277,3 +277,7 @@ apiclient/generated
|
||||
|
||||
# Omnisharp crash logs
|
||||
mono_crash.*.json
|
||||
|
||||
# Devcontainer temp files
|
||||
.devcontainer/devcontainer-lock.json
|
||||
dotnet/
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,10 +2,12 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library;
|
||||
|
||||
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
|
||||
/// </summary>
|
||||
public class PathManager : IPathManager
|
||||
{
|
||||
private readonly ILogger<PathManager> _logger;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="config">The server configuration manager.</param>
|
||||
/// <param name="appPaths">The application paths.</param>
|
||||
public PathManager(
|
||||
ILogger<PathManager> logger,
|
||||
IServerConfigurationManager config,
|
||||
IApplicationPaths appPaths)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
|
||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||
|
||||
/// <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 />
|
||||
|
||||
@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
Key = item.GetUserDataKeys()[0],
|
||||
};
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
return _cache.GetOrAdd(
|
||||
cacheKey,
|
||||
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||
{
|
||||
Key = i.GetUserDataKeys()[0],
|
||||
},
|
||||
item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -142,13 +142,15 @@ public class StartupController : BaseJellyfinApiController
|
||||
return BadRequest("Password must not be empty");
|
||||
}
|
||||
|
||||
if (startupUserDto.Name is not null)
|
||||
{
|
||||
user.Username = startupUserDto.Name;
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#pragma warning disable CA1307
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
@@ -52,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||
private readonly LockHelper _userLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||
@@ -162,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
return UserQuery(dbContext)
|
||||
.FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper());
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
}
|
||||
|
||||
@@ -188,10 +183,8 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
if (await dbContext.Users
|
||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId)
|
||||
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
@@ -199,8 +192,6 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
"A user with the name '{0}' already exists.",
|
||||
newName));
|
||||
}
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
user = await UserQuery(dbContext)
|
||||
@@ -210,6 +201,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
?? throw new ResourceNotFoundException(nameof(userId));
|
||||
|
||||
user.Username = newName;
|
||||
user.NormalizedUsername = newName.ToUpperInvariant();
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -224,7 +216,58 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||
{
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
|
||||
var dbUser = await UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == user.Id)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ResourceNotFoundException(nameof(user.Id));
|
||||
|
||||
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
|
||||
dbUser.Permissions.Clear();
|
||||
foreach (var permission in user.Permissions)
|
||||
{
|
||||
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
|
||||
}
|
||||
|
||||
dbUser.Preferences.Clear();
|
||||
foreach (var preference in user.Preferences)
|
||||
{
|
||||
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
|
||||
}
|
||||
|
||||
dbUser.AccessSchedules.Clear();
|
||||
foreach (var accessSchedule in user.AccessSchedules)
|
||||
{
|
||||
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
|
||||
}
|
||||
|
||||
if (user.ProfileImage is null)
|
||||
{
|
||||
if (dbUser.ProfileImage is not null)
|
||||
{
|
||||
dbContext.Remove(dbUser.ProfileImage);
|
||||
dbUser.ProfileImage = null;
|
||||
}
|
||||
}
|
||||
else if (dbUser.ProfileImage is null)
|
||||
{
|
||||
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
|
||||
{
|
||||
LastModified = user.ProfileImage.LastModified
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
dbUser.ProfileImage.Path = user.ProfileImage.Path;
|
||||
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +302,8 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
if (await dbContext.Users
|
||||
.AnyAsync(u => u.Username.ToUpper() == name.ToUpper())
|
||||
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
throw new ArgumentException(string.Format(
|
||||
@@ -270,8 +311,6 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
"A user with the name '{0}' already exists.",
|
||||
name));
|
||||
}
|
||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
||||
@@ -472,12 +511,14 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
var user = GetUserByName(username);
|
||||
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
// Reload the user now that we hold the lock so the RowVersion is current.
|
||||
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
|
||||
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
||||
if (user is not null)
|
||||
{
|
||||
user = GetUserById(user.Id) ?? user;
|
||||
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
|
||||
}
|
||||
|
||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||
@@ -485,6 +526,13 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
var authenticationProvider = authResult.AuthenticationProvider;
|
||||
success = authResult.Success;
|
||||
|
||||
if (success && user is not null)
|
||||
{
|
||||
// refresh the user if the auth provider might have updated it in the auth method.
|
||||
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
|
||||
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
string updatedUsername = authResult.Username;
|
||||
@@ -498,11 +546,16 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
// Search the database for the user again
|
||||
// the authentication provider might have created it
|
||||
user = GetUserByName(username);
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
user = await UserQuery(dbContext)
|
||||
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||
|
||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||
{
|
||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||
user = await UserQuery(dbContext)
|
||||
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,8 +566,10 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.AuthenticationProviderId = providerId;
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
await dbContext.Users
|
||||
.Where(e => e.Id == user.Id)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,16 +616,42 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
if (isUserSession)
|
||||
{
|
||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
||||
var date = DateTime.UtcNow;
|
||||
await dbContext.Users
|
||||
.Where(e => e.Id == user.Id)
|
||||
.ExecuteUpdateAsync(e => e
|
||||
.SetProperty(f => f.LastActivityDate, date)
|
||||
.SetProperty(f => f.LastLoginDate, date))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
user.InvalidLoginAttemptCount = 0;
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
await dbContext.Users
|
||||
.Where(e => e.Id == user.Id)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
|
||||
.ConfigureAwait(false);
|
||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
||||
user.InvalidLoginAttemptCount++;
|
||||
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||
await dbContext.SaveChangesAsync()
|
||||
.ConfigureAwait(false);
|
||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
||||
user.Username,
|
||||
user.InvalidLoginAttemptCount);
|
||||
}
|
||||
|
||||
await dbContext.Users
|
||||
.Where(e => e.Id == user.Id)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
user.Username,
|
||||
@@ -945,32 +1026,6 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
}
|
||||
}
|
||||
|
||||
private async Task IncrementInvalidLoginAttemptCount(User user)
|
||||
{
|
||||
user.InvalidLoginAttemptCount++;
|
||||
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
||||
user.Username,
|
||||
user.InvalidLoginAttemptCount);
|
||||
}
|
||||
|
||||
await 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)
|
||||
{
|
||||
dbContext.Users.Attach(user);
|
||||
@@ -996,5 +1051,70 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
_userLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LockHelper : IDisposable
|
||||
{
|
||||
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
|
||||
|
||||
public bool ShouldLock()
|
||||
{
|
||||
return IsNestedLock.Value == 0;
|
||||
}
|
||||
|
||||
public ValueTask<IDisposable> LockAsync(Guid key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var isNested = LockHelper.IsNestedLock.Value != 0;
|
||||
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
|
||||
if (isNested)
|
||||
{
|
||||
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
|
||||
}
|
||||
|
||||
return AcquireLockAsync(key);
|
||||
}
|
||||
|
||||
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
|
||||
{
|
||||
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
|
||||
return new LockHandle { Parent = lockHandle };
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_userLock.Dispose();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private sealed class LockHandle : IDisposable
|
||||
{
|
||||
public required IDisposable? Parent { get; init; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Parent?.Dispose();
|
||||
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
|
||||
|
||||
if (LockHelper.IsNestedLock.Value < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Part 2 Migration for NormalisedUsername.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
|
||||
#pragma warning disable SA1649 // File name should match first type name
|
||||
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
|
||||
#pragma warning restore SA1649 // File name should match first type name
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contextFactory">Db Context factory.</param>
|
||||
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.NormalizedUsername = user.Username.ToUpperInvariant();
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
File.Delete(oldAttachmentPath);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Common</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
|
||||
namespace MediaBrowser.Controller.ClientEvent
|
||||
{
|
||||
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
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);
|
||||
await using (fileStream.ConfigureAwait(false))
|
||||
{
|
||||
|
||||
@@ -37,8 +37,8 @@ public interface IPathManager
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="fileName">The attachmentFileName index.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
public string GetAttachmentPath(string mediaSourceId, string fileName);
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the attachment folder.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1305,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
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)
|
||||
@@ -1317,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
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.
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
@@ -98,9 +99,21 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
MediaSourceInfo mediaSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
|
||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
||||
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
var hasUnsafeFileName = mediaSource.MediaAttachments.Any(a => !IsSafeBulkAttachmentFileName(a.FileName));
|
||||
var isMatroskaSubtitles = 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)
|
||||
{
|
||||
@@ -241,7 +254,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||
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))
|
||||
{
|
||||
await ExtractAttachmentInternal(
|
||||
@@ -341,6 +356,27 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
_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 />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.MediaEncoding.Encoder;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
@@ -372,7 +373,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
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,
|
||||
ErrorDialog = false
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
|
||||
{
|
||||
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.");
|
||||
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
|
||||
if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
|
||||
{
|
||||
savePaths.Add(mediaFolderPath);
|
||||
}
|
||||
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
|
||||
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyVersion("10.11.9")]
|
||||
[assembly: AssemblyFileVersion("10.11.9")]
|
||||
[assembly: AssemblyVersion("10.11.11")]
|
||||
[assembly: AssemblyFileVersion("10.11.11")]
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
|
||||
|
||||
Username = username;
|
||||
NormalizedUsername = username.ToUpperInvariant();
|
||||
AuthenticationProviderId = authenticationProviderId;
|
||||
PasswordResetProviderId = passwordResetProviderId;
|
||||
|
||||
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
[StringLength(255)]
|
||||
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>
|
||||
/// Gets or sets the user's password, or <c>null</c> if none is set.
|
||||
/// </summary>
|
||||
|
||||
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
builder
|
||||
.HasIndex(entity => entity.Username)
|
||||
.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)
|
||||
{
|
||||
#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 =>
|
||||
{
|
||||
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<bool>("MustUpdatePassword")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(65535)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Extensions</PackageId>
|
||||
<VersionPrefix>10.11.9</VersionPrefix>
|
||||
<VersionPrefix>10.11.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
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,89 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||
{
|
||||
public class UserManagerLockHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
var key = Guid.NewGuid();
|
||||
|
||||
Assert.True(helper.ShouldLock());
|
||||
|
||||
var outerHandle = await helper.LockAsync(key);
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
var innerHandle = await helper.LockAsync(key);
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
innerHandle.Dispose();
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
outerHandle.Dispose();
|
||||
Assert.True(helper.ShouldLock());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
var key = Guid.NewGuid();
|
||||
|
||||
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var secondEntered = false;
|
||||
|
||||
var firstTask = Task.Run(async () =>
|
||||
{
|
||||
using var firstHandle = await helper.LockAsync(key);
|
||||
firstAcquired.SetResult(true);
|
||||
await releaseFirst.Task;
|
||||
});
|
||||
|
||||
await firstAcquired.Task;
|
||||
|
||||
var secondTask = Task.Run(async () =>
|
||||
{
|
||||
using var secondHandle = await helper.LockAsync(key);
|
||||
secondEntered = true;
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
Assert.False(secondEntered);
|
||||
|
||||
releaseFirst.SetResult(true);
|
||||
|
||||
await Task.WhenAll(firstTask, secondTask);
|
||||
Assert.True(secondEntered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
helper.Dispose();
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
|
||||
helper.Dispose();
|
||||
var ex = Record.Exception(() => helper.Dispose());
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Users;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||
{
|
||||
public sealed class UserManagerNormalizedUsernameTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly UserManager _userManager;
|
||||
|
||||
public UserManagerNormalizedUsernameTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Create the schema
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateDbContext);
|
||||
|
||||
var cryptoProvider = new Mock<ICryptoProvider>();
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
var appPaths = new Mock<IServerApplicationPaths>();
|
||||
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
|
||||
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
|
||||
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
|
||||
var defaultAuthProvider = new DefaultAuthenticationProvider(
|
||||
NullLogger<DefaultAuthenticationProvider>.Instance,
|
||||
cryptoProvider.Object);
|
||||
var invalidAuthProvider = new InvalidAuthProvider();
|
||||
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
|
||||
configManager.Object,
|
||||
appHost.Object);
|
||||
|
||||
_userManager = new UserManager(
|
||||
factory.Object,
|
||||
new NoopEventManager(),
|
||||
new Mock<INetworkManager>().Object,
|
||||
appHost.Object,
|
||||
new Mock<IImageProcessor>().Object,
|
||||
NullLogger<UserManager>.Instance,
|
||||
configManager.Object,
|
||||
new IPasswordResetProvider[] { defaultPasswordResetProvider },
|
||||
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_userManager.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
// ----- GetUserByName tests -----
|
||||
|
||||
[Theory]
|
||||
// German umlauts
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish tilde-n
|
||||
[InlineData("Ñoño", "ÑOÑO")]
|
||||
// ASCII, invariant uppercase lookup
|
||||
[InlineData("jellyfin", "JELLYFIN")]
|
||||
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
|
||||
[InlineData("Çelebi", "ÇELEBI")]
|
||||
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
|
||||
string username, string normalizedLookup)
|
||||
{
|
||||
await _userManager.CreateUserAsync(username);
|
||||
|
||||
var found = _userManager.GetUserByName(normalizedLookup);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(username, found.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// German umlaut, look up by both upper and lower case
|
||||
[InlineData("münchen")]
|
||||
// Spanish tilde-n
|
||||
[InlineData("Ñoño")]
|
||||
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
|
||||
[InlineData("ali")]
|
||||
// mixed ASCII + umlaut
|
||||
[InlineData("testüser")]
|
||||
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
|
||||
{
|
||||
await _userManager.CreateUserAsync(username);
|
||||
|
||||
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
|
||||
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
|
||||
var exactFound = _userManager.GetUserByName(username);
|
||||
|
||||
Assert.NotNull(upperFound);
|
||||
Assert.NotNull(lowerFound);
|
||||
Assert.NotNull(exactFound);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nonexistent")]
|
||||
// No user with NormalizedUsername = "MÜNCHEN" has been created
|
||||
[InlineData("MÜNCHEN")]
|
||||
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
|
||||
{
|
||||
var result = _userManager.GetUserByName(lookupName);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ----- CreateUserAsync duplicate detection tests -----
|
||||
|
||||
[Theory]
|
||||
// German umlaut, case-swapped duplicate
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish tilde-n, lowercase duplicate
|
||||
[InlineData("Ñoño", "ñoño")]
|
||||
// ASCII, uppercase duplicate
|
||||
[InlineData("alice", "ALICE")]
|
||||
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
|
||||
[InlineData("çelebi", "ÇELEBI")]
|
||||
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
|
||||
string existingUsername, string duplicateUsername)
|
||||
{
|
||||
await _userManager.CreateUserAsync(existingUsername);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _userManager.CreateUserAsync(duplicateUsername));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Different non-ASCII names that do not collide after normalization
|
||||
[InlineData("münchen", "münchen2")]
|
||||
[InlineData("ali", "ali2")]
|
||||
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
|
||||
[InlineData("noño", "nono")]
|
||||
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
|
||||
string firstUsername, string secondUsername)
|
||||
{
|
||||
var first = await _userManager.CreateUserAsync(firstUsername);
|
||||
var second = await _userManager.CreateUserAsync(secondUsername);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
}
|
||||
|
||||
// ----- RenameUser tests -----
|
||||
|
||||
[Theory]
|
||||
// Rename to non-ASCII name
|
||||
[InlineData("alice", "münchen")]
|
||||
// Rename between similar non-ASCII and ASCII
|
||||
[InlineData("müller", "mueller")]
|
||||
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
|
||||
[InlineData("ali", "ALI2")]
|
||||
// Rename to Spanish tilde-n name
|
||||
[InlineData("testuser", "Ñoño")]
|
||||
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
|
||||
string originalName, string newName)
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync(originalName);
|
||||
|
||||
await _userManager.RenameUser(user.Id, originalName, newName);
|
||||
|
||||
var renamed = _userManager.GetUserById(user.Id);
|
||||
Assert.NotNull(renamed);
|
||||
Assert.Equal(newName, renamed.Username);
|
||||
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Same name different case: NormalizedUsername already taken
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish, lowercase conflicts with existing uppercase-normalised entry
|
||||
[InlineData("Ñoño", "ñoño")]
|
||||
// ASCII, capitalised conflict
|
||||
[InlineData("alice", "Alice")]
|
||||
// Mixed ASCII + umlaut
|
||||
[InlineData("testüser", "TESTÜSER")]
|
||||
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
|
||||
string existingUsername, string conflictingNewName)
|
||||
{
|
||||
var targetUser = await _userManager.CreateUserAsync("renametarget");
|
||||
await _userManager.CreateUserAsync(existingUsername);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
|
||||
}
|
||||
|
||||
private sealed class NoopEventManager : IEventManager
|
||||
{
|
||||
public void Publish<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
{
|
||||
}
|
||||
|
||||
public Task PublishAsync<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user