Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bb9a1e8a | ||
|
|
44ab98e082 | ||
|
|
bb8814644c | ||
|
|
69f7a87cef | ||
|
|
fd08d7ea1a | ||
|
|
da779514fe | ||
|
|
dfab79e92a | ||
|
|
f430d288ea | ||
|
|
5c8430f207 | ||
|
|
cb95a317d0 |
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.0.1.0</Version>
|
||||
<AssemblyVersion>0.0.1.0</AssemblyVersion>
|
||||
<FileVersion>0.0.1.0</FileVersion>
|
||||
<Version>0.0.4.0</Version>
|
||||
<AssemblyVersion>0.0.4.0</AssemblyVersion>
|
||||
<FileVersion>0.0.4.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -63,8 +63,8 @@ public class AuthenticationTests
|
||||
|
||||
return new SharedAccountAuthenticationProvider(
|
||||
crypto,
|
||||
groups.Object,
|
||||
dynamic.Object,
|
||||
new Lazy<IGroupService>(() => groups.Object),
|
||||
new Lazy<IDynamicGroupService>(() => dynamic.Object),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ public class DynamicGroupTests
|
||||
|
||||
private sealed record Harness(
|
||||
DynamicGroupService Service,
|
||||
Mock<IProvisioningService> Provisioning);
|
||||
Mock<IProvisioningService> Provisioning,
|
||||
StubCryptoProvider Crypto);
|
||||
|
||||
private static Harness MakeService(
|
||||
IReadOnlyList<User> knownUsers,
|
||||
@@ -67,6 +68,13 @@ public class DynamicGroupTests
|
||||
return null!;
|
||||
});
|
||||
|
||||
// Any known user must also resolve by id, so an existing group can be followed back to its
|
||||
// shared account.
|
||||
foreach (var u in knownUsers)
|
||||
{
|
||||
userManager.Setup(m => m.GetUserById(u.Id)).Returns(u);
|
||||
}
|
||||
|
||||
var provisioning = new Mock<IProvisioningService>();
|
||||
var createdShared = MakeUser("created-shared");
|
||||
|
||||
@@ -78,13 +86,15 @@ public class DynamicGroupTests
|
||||
|
||||
userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared);
|
||||
|
||||
var crypto = new StubCryptoProvider(validPairs);
|
||||
|
||||
var service = new DynamicGroupService(
|
||||
userManager.Object,
|
||||
provisioning.Object,
|
||||
new StubCryptoProvider(validPairs),
|
||||
crypto,
|
||||
NullLogger<DynamicGroupService>.Instance);
|
||||
|
||||
return new Harness(service, provisioning);
|
||||
return new Harness(service, provisioning, crypto);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -98,13 +108,108 @@ public class DynamicGroupTests
|
||||
var result = await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
// No name is passed: provisioning generates the canonical alphabetical one.
|
||||
h.Provisioning.Verify(
|
||||
p => p.CreateGroupAsync(
|
||||
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
|
||||
"alice+bob"),
|
||||
null),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReversedNameOrder_ReusesTheExistingGroup()
|
||||
{
|
||||
// "john+jane" and "jane+john" are the same group. Jellyfin only calls this code when no
|
||||
// account matches the typed name, so without an order-independent lookup the reversed
|
||||
// spelling would quietly create a second account for the same two people.
|
||||
using var ctx = PluginTestContext.Create();
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var shared = MakeUser("alice+bob");
|
||||
|
||||
ctx.Configuration.Groups.Add(new SharedGroup
|
||||
{
|
||||
SharedUserId = shared.Id,
|
||||
MemberUserIds = [alice.Id, bob.Id]
|
||||
});
|
||||
|
||||
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
||||
|
||||
var result = await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("alice+bob", result!.SharedUsername);
|
||||
h.Provisioning.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewGroup_IsNamedCanonically()
|
||||
{
|
||||
// Provisioning is asked for no particular name so it generates the canonical sorted one,
|
||||
// rather than preserving whatever order happened to be typed.
|
||||
using var ctx = PluginTestContext.Create();
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||
|
||||
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||
|
||||
h.Provisioning.Verify(
|
||||
p => p.CreateGroupAsync(It.IsAny<IReadOnlyList<Guid>>(), null),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReversedNameOrder_WhenTheGroupIsDisabled_IsRejected()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var shared = MakeUser("alice+bob");
|
||||
|
||||
ctx.Configuration.Groups.Add(new SharedGroup
|
||||
{
|
||||
SharedUserId = shared.Id,
|
||||
MemberUserIds = [alice.Id, bob.Id],
|
||||
IsDisabled = true
|
||||
});
|
||||
|
||||
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
||||
|
||||
Assert.Null(await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw"));
|
||||
h.Provisioning.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstTypedMember_HasTheirPasswordCheckedFirst()
|
||||
{
|
||||
// Password verification is a deliberately slow hash comparison, so whoever puts their own
|
||||
// name first should be checked first.
|
||||
using var ctx = PluginTestContext.Create();
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||
|
||||
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
||||
|
||||
// Bob was typed first and his password matched, so alice's hash is never touched.
|
||||
Assert.Equal(["B2B2B2B2"], h.Crypto.VerifiedSalts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALaterMembersPassword_StillWorks()
|
||||
{
|
||||
// The first-typed member is only a preference: the second member's password must still
|
||||
// unlock the group once the first fails to match.
|
||||
using var ctx = PluginTestContext.Create();
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
||||
|
||||
Assert.NotNull(await h.Service.TryCreateFromLoginAsync("alice+bob", "bob-pw"));
|
||||
Assert.Equal(["A1A1A1A1", "B2B2B2B2"], h.Crypto.VerifiedSalts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnyNamedMembersPassword_Works()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers shared-account provisioning against a user manager that dispatches password changes the
|
||||
/// way Jellyfin's real one does.
|
||||
/// </summary>
|
||||
[Collection(nameof(PluginTestContext))]
|
||||
public class ProvisioningTests
|
||||
{
|
||||
private static readonly string AuthProviderId =
|
||||
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
|
||||
|
||||
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
||||
|
||||
/// <summary>
|
||||
/// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is
|
||||
/// routed to the provider named by the user's AuthenticationProviderId, so an account already
|
||||
/// claimed by us lands in our provider and is refused.
|
||||
/// </summary>
|
||||
private static Mock<IUserManager> MakeUserManager(List<User> users, List<string> callLog)
|
||||
{
|
||||
var userManager = new Mock<IUserManager>();
|
||||
|
||||
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||
.Returns((Guid id) => users.Find(u => u.Id == id));
|
||||
|
||||
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string name) =>
|
||||
{
|
||||
var created = MakeUser(name);
|
||||
users.Add(created);
|
||||
callLog.Add("CreateUser");
|
||||
return created;
|
||||
});
|
||||
|
||||
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||
.Returns((User user, string password) =>
|
||||
{
|
||||
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
|
||||
|
||||
// This is the dispatch that made provisioning fail in 0.0.3: once the account is
|
||||
// claimed, the call reaches our provider, which refuses it by design.
|
||||
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||
{
|
||||
return new Auth.SharedAccountAuthenticationProvider(
|
||||
Mock.Of<MediaBrowser.Model.Cryptography.ICryptoProvider>(),
|
||||
new Lazy<IGroupService>(() => Mock.Of<IGroupService>()),
|
||||
new Lazy<IDynamicGroupService>(() => Mock.Of<IDynamicGroupService>()),
|
||||
NullLogger<Auth.SharedAccountAuthenticationProvider>.Instance)
|
||||
.ChangePassword(user, password);
|
||||
}
|
||||
|
||||
user.Password = password;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
|
||||
.Returns((User user) =>
|
||||
{
|
||||
callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return userManager;
|
||||
}
|
||||
|
||||
private static ProvisioningService MakeService(Mock<IUserManager> userManager)
|
||||
=> new(
|
||||
userManager.Object,
|
||||
Mock.Of<ILibraryAccessService>(),
|
||||
NullLogger<ProvisioningService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount()
|
||||
{
|
||||
using var context = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice");
|
||||
var bob = MakeUser("bob");
|
||||
var users = new List<User> { alice, bob };
|
||||
var callLog = new List<string>();
|
||||
|
||||
var service = MakeService(MakeUserManager(users, callLog));
|
||||
|
||||
// Before the fix this threw NotSupportedException from our own ChangePassword.
|
||||
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, group.SharedUserId);
|
||||
|
||||
// The password must be set while the account is still on Jellyfin's default provider.
|
||||
var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal));
|
||||
var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal));
|
||||
|
||||
Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account");
|
||||
Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider");
|
||||
Assert.True(
|
||||
changeIndex < claimIndex,
|
||||
$"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider()
|
||||
{
|
||||
using var context = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice");
|
||||
var bob = MakeUser("bob");
|
||||
var users = new List<User> { alice, bob };
|
||||
var callLog = new List<string>();
|
||||
|
||||
var service = MakeService(MakeUserManager(users, callLog));
|
||||
|
||||
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||
|
||||
// Claiming the account is what routes its logins to us; provisioning is useless without it.
|
||||
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||
Assert.NotNull(sharedUser);
|
||||
Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword()
|
||||
{
|
||||
using var context = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice");
|
||||
var bob = MakeUser("bob");
|
||||
var users = new List<User> { alice, bob };
|
||||
var callLog = new List<string>();
|
||||
|
||||
var service = MakeService(MakeUserManager(users, callLog));
|
||||
|
||||
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||
|
||||
// A passwordless shared account would be directly loginable if the provider were ever
|
||||
// unassigned, which is the reason provisioning sets one at all.
|
||||
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||
Assert.NotNull(sharedUser);
|
||||
Assert.False(string.IsNullOrEmpty(sharedUser!.Password));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.WatchedTogether;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the plugin's service graph against container-level cycles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin's real <c>UserManager</c> constructor-injects <c>IEnumerable<IAuthenticationProvider></c>.
|
||||
/// That means any plugin service reachable eagerly from our authentication provider must not itself
|
||||
/// require <see cref="IUserManager"/>, or the host dies at startup with "a circular dependency was
|
||||
/// detected". A cycle like that is invisible to unit tests that construct services by hand, so these
|
||||
/// tests build the graph the way the host does.
|
||||
/// </remarks>
|
||||
public class ServiceRegistrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Stands in for Jellyfin's UserManager, whose constructor takes every registered authentication
|
||||
/// provider. Only the constructor shape matters here - it is what closes the cycle.
|
||||
/// </summary>
|
||||
private sealed class UserManagerWithAuthProviders
|
||||
{
|
||||
public UserManagerWithAuthProviders(IEnumerable<IAuthenticationProvider> authenticationProviders)
|
||||
{
|
||||
AuthenticationProviders = authenticationProviders;
|
||||
}
|
||||
|
||||
public IEnumerable<IAuthenticationProvider> AuthenticationProviders { get; }
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildHostLikeProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
|
||||
// Host services the plugin consumes, other than IUserManager.
|
||||
services.AddSingleton(Mock.Of<ILibraryManager>());
|
||||
services.AddSingleton(Mock.Of<IUserDataManager>());
|
||||
services.AddSingleton(Mock.Of<ICryptoProvider>());
|
||||
|
||||
// IUserManager resolves through the fake UserManager so that building it forces every
|
||||
// IAuthenticationProvider to be built first, exactly as the real host does.
|
||||
services.AddSingleton<UserManagerWithAuthProviders>();
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
provider.GetRequiredService<UserManagerWithAuthProviders>();
|
||||
return Mock.Of<IUserManager>();
|
||||
});
|
||||
|
||||
new ServiceRegistrator().RegisterServices(services, Mock.Of<MediaBrowser.Controller.IServerApplicationHost>());
|
||||
|
||||
return services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = true,
|
||||
ValidateScopes = true
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginServices_ResolveWithoutCircularDependency()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
// Resolving IUserManager is what the host does during startup, and is the exact path that
|
||||
// previously threw InvalidOperationException for a circular dependency.
|
||||
var userManager = provider.GetRequiredService<IUserManager>();
|
||||
|
||||
Assert.NotNull(userManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthenticationProvider_IsConstructedWithoutResolvingUserManager()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
var authProviders = provider.GetRequiredService<IEnumerable<IAuthenticationProvider>>();
|
||||
|
||||
Assert.Contains(authProviders, p => p is Auth.SharedAccountAuthenticationProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupServices_AreStillResolvableOnceTheHostIsUp()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
// The Lazy<T> indirection must not change what the services resolve to at authentication
|
||||
// time, and must hand back the same singletons the rest of the plugin uses.
|
||||
var lazyGroupService = provider.GetRequiredService<Lazy<Services.IGroupService>>();
|
||||
var lazyDynamicGroupService = provider.GetRequiredService<Lazy<Services.IDynamicGroupService>>();
|
||||
|
||||
Assert.Same(provider.GetRequiredService<Services.IGroupService>(), lazyGroupService.Value);
|
||||
Assert.Same(provider.GetRequiredService<Services.IDynamicGroupService>(), lazyDynamicGroupService.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob",
|
||||
/// then logged into again afterwards by each member in turn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other suites mock <see cref="IProvisioningService"/>, which is why an ordering bug inside the
|
||||
/// real provisioning code reached a release. These tests wire the real services together and only
|
||||
/// stub the host's user manager and crypto.
|
||||
/// </remarks>
|
||||
[Collection(nameof(PluginTestContext))]
|
||||
public class SharedAccountEndToEndTests
|
||||
{
|
||||
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
|
||||
private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
|
||||
|
||||
private static readonly string AuthProviderId =
|
||||
typeof(SharedAccountAuthenticationProvider).FullName!;
|
||||
|
||||
private sealed record Harness(
|
||||
SharedAccountAuthenticationProvider Provider,
|
||||
List<User> Users);
|
||||
|
||||
private static User MakeUser(string name, string? password = null)
|
||||
=> new(name, "Prov", "ResetProv") { Password = password! };
|
||||
|
||||
/// <summary>
|
||||
/// Wires the real provisioning, group, dynamic-group and authentication services over a user
|
||||
/// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned
|
||||
/// provider, and users resolve by both name and id.
|
||||
/// </summary>
|
||||
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
|
||||
{
|
||||
var crypto = new StubCryptoProvider(validPairs);
|
||||
var userManager = new Mock<IUserManager>();
|
||||
|
||||
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||
.Returns((Guid id) => users.Find(u => u.Id == id)!);
|
||||
|
||||
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
|
||||
.Returns((string n) => users.Find(
|
||||
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
|
||||
|
||||
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string name) =>
|
||||
{
|
||||
var created = MakeUser(name);
|
||||
users.Add(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||
|
||||
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||
.Returns((User user, string password) =>
|
||||
{
|
||||
// Jellyfin routes this to the user's assigned provider; ours refuses by design.
|
||||
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"A Watched Together shared account has no password of its own.");
|
||||
}
|
||||
|
||||
user.Password = password;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var groupService = new GroupService(
|
||||
userManager.Object,
|
||||
NullLogger<GroupService>.Instance);
|
||||
|
||||
var provisioning = new ProvisioningService(
|
||||
userManager.Object,
|
||||
Mock.Of<ILibraryAccessService>(),
|
||||
NullLogger<ProvisioningService>.Instance);
|
||||
|
||||
var dynamicGroups = new DynamicGroupService(
|
||||
userManager.Object,
|
||||
provisioning,
|
||||
crypto,
|
||||
NullLogger<DynamicGroupService>.Instance);
|
||||
|
||||
var provider = new SharedAccountAuthenticationProvider(
|
||||
crypto,
|
||||
new Lazy<IGroupService>(() => groupService),
|
||||
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
|
||||
return new Harness(provider, users);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice-pw")]
|
||||
[InlineData("bob-pw")]
|
||||
public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword)
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var users = new List<User> { alice, bob };
|
||||
|
||||
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||
|
||||
// Creating the group by typing both names. Whichever member types their own password, the
|
||||
// resulting account must behave identically.
|
||||
var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null);
|
||||
Assert.Equal("alice+bob", created.Username);
|
||||
|
||||
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||
Assert.NotNull(shared);
|
||||
|
||||
// The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both
|
||||
// members must be able to unlock it, regardless of who created it.
|
||||
var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared);
|
||||
Assert.Equal("alice+bob", asAlice.Username);
|
||||
|
||||
var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared);
|
||||
Assert.Equal("alice+bob", asBob.Username);
|
||||
|
||||
// And an outsider's password still must not.
|
||||
await Assert.ThrowsAsync<AuthenticationException>(
|
||||
() => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword()
|
||||
{
|
||||
using var ctx = PluginTestContext.Create();
|
||||
|
||||
var alice = MakeUser("alice", AliceHash);
|
||||
var bob = MakeUser("bob", BobHash);
|
||||
var users = new List<User> { alice, bob };
|
||||
|
||||
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||
|
||||
await h.Provider.Authenticate("alice+bob", "alice-pw", null);
|
||||
|
||||
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||
Assert.NotNull(shared);
|
||||
|
||||
// Claimed by us, so future logins route here, and holding a password of its own so it is
|
||||
// not directly loginable if the provider is ever unassigned.
|
||||
Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId);
|
||||
Assert.False(string.IsNullOrEmpty(shared.Password));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public sealed class StubCryptoProvider : ICryptoProvider
|
||||
_validPairs = validPairs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the salt of each credential this provider was asked to verify, in call order. Lets a
|
||||
/// test assert which member's password was checked first.
|
||||
/// </summary>
|
||||
public List<string> VerifiedSalts { get; } = new();
|
||||
|
||||
public string DefaultHashMethod => "PBKDF2-SHA512";
|
||||
|
||||
public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
|
||||
@@ -30,6 +36,7 @@ public sealed class StubCryptoProvider : ICryptoProvider
|
||||
// Identify the stored credential by its salt rather than by re-formatting the whole hash,
|
||||
// which need not round-trip through Parse/ToString byte for byte.
|
||||
var salt = Convert.ToHexString(hash.Salt);
|
||||
VerifiedSalts.Add(salt);
|
||||
|
||||
foreach (var (validHash, validPassword) in _validPairs)
|
||||
{
|
||||
|
||||
@@ -28,21 +28,28 @@ namespace Jellyfin.Plugin.WatchedTogether.Auth;
|
||||
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
|
||||
{
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly Services.IGroupService _groupService;
|
||||
private readonly Services.IDynamicGroupService _dynamicGroupService;
|
||||
private readonly Lazy<Services.IGroupService> _groupService;
|
||||
private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
|
||||
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="dynamicGroupService">The on-demand group creation service.</param>
|
||||
/// <param name="groupService">A deferred handle to the group service.</param>
|
||||
/// <param name="dynamicGroupService">A deferred handle to the on-demand group creation service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <remarks>
|
||||
/// The group services are taken as <see cref="Lazy{T}"/> to break a container-level cycle.
|
||||
/// Jellyfin's <c>UserManager</c> constructor-injects every <see cref="IAuthenticationProvider"/>,
|
||||
/// so resolving those services eagerly here would require <c>IUserManager</c> while it is still
|
||||
/// being built and the host would refuse to start. Deferring the lookup to the first
|
||||
/// authentication is safe: nobody can log in until the host is fully up.
|
||||
/// </remarks>
|
||||
public SharedAccountAuthenticationProvider(
|
||||
ICryptoProvider cryptoProvider,
|
||||
Services.IGroupService groupService,
|
||||
Services.IDynamicGroupService dynamicGroupService,
|
||||
Lazy<Services.IGroupService> groupService,
|
||||
Lazy<Services.IDynamicGroupService> dynamicGroupService,
|
||||
ILogger<SharedAccountAuthenticationProvider> logger)
|
||||
{
|
||||
_cryptoProvider = cryptoProvider;
|
||||
@@ -73,7 +80,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
// a real user with that exact name always resolves first and never reaches this branch.
|
||||
if (resolvedUser is null)
|
||||
{
|
||||
var created = await _dynamicGroupService
|
||||
var created = await _dynamicGroupService.Value
|
||||
.TryCreateFromLoginAsync(username, password)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -85,7 +92,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
return new ProviderAuthenticationResult { Username = created.SharedUsername };
|
||||
}
|
||||
|
||||
var group = _groupService.GetGroupForSharedUser(resolvedUser.Id);
|
||||
var group = _groupService.Value.GetGroupForSharedUser(resolvedUser.Id);
|
||||
if (group is null)
|
||||
{
|
||||
// Either not one of ours, or the group is disabled. Either way this account has no
|
||||
@@ -96,7 +103,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
|
||||
throw new AuthenticationException("Invalid username or password.");
|
||||
}
|
||||
|
||||
var members = _groupService.GetEligibleMembers(group);
|
||||
var members = _groupService.Value.GetEligibleMembers(group);
|
||||
if (members.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -20,6 +21,14 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
|
||||
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
|
||||
|
||||
// The auth provider takes these lazily so the container can build it while IUserManager is
|
||||
// still under construction; see SharedAccountAuthenticationProvider's constructor remarks.
|
||||
// Microsoft's container has no built-in Lazy<T> support, so the factories are explicit.
|
||||
serviceCollection.AddSingleton(
|
||||
provider => new Lazy<IGroupService>(provider.GetRequiredService<IGroupService>));
|
||||
serviceCollection.AddSingleton(
|
||||
provider => new Lazy<IDynamicGroupService>(provider.GetRequiredService<IDynamicGroupService>));
|
||||
|
||||
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
|
||||
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
|
||||
|
||||
|
||||
@@ -116,7 +116,12 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
|
||||
// The password must belong to one of the named members. Without this any visitor could
|
||||
// conjure a shared account out of two usernames they happened to know.
|
||||
if (!members.Any(m => VerifyPassword(m, password)))
|
||||
//
|
||||
// Members are tried in the order they were typed and the loop stops at the first match, so
|
||||
// whoever types their own name first has their password checked first. Verification is a
|
||||
// deliberately slow hash comparison, so the ordering is worth having.
|
||||
var matched = members.FirstOrDefault(m => VerifyPassword(m, password));
|
||||
if (matched is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dynamic group login for {Username} rejected: no named member's password matched",
|
||||
@@ -124,11 +129,45 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return null;
|
||||
}
|
||||
|
||||
var memberIds = members.Select(m => m.Id).ToList();
|
||||
|
||||
// The same people in a different order are the same group: someone typing "john+jane" must
|
||||
// land on the existing "jane+john" account rather than creating a second one. Jellyfin only
|
||||
// reaches this code when no account matches the typed name, so without this check every
|
||||
// ordering would spawn its own account.
|
||||
var existing = FindGroupWithSameMembers(config, memberIds);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.IsDisabled)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Login for {Username} rejected: the matching group is disabled",
|
||||
enteredUsername);
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingUser = _userManager.GetUserById(existing.SharedUserId);
|
||||
if (existingUser is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Group for {Username} references a shared account that no longer exists",
|
||||
enteredUsername);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Login as {Entered} resolved to the existing shared account {Username}",
|
||||
enteredUsername,
|
||||
existingUser.Username);
|
||||
|
||||
return new DynamicGroupResult(existing, existingUser.Username);
|
||||
}
|
||||
|
||||
// Passing no name lets provisioning generate the canonical alphabetically-sorted one, so
|
||||
// the account is named the same whichever order the members were typed in.
|
||||
// The account is limited to the libraries all named members share, so creating one at the
|
||||
// login screen cannot grant anybody access they did not already have.
|
||||
var group = await _provisioningService.CreateGroupAsync(
|
||||
members.Select(m => m.Id).ToList(),
|
||||
enteredUsername).ConfigureAwait(false);
|
||||
var group = await _provisioningService.CreateGroupAsync(memberIds, null).ConfigureAwait(false);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
if (sharedUser is null)
|
||||
@@ -144,6 +183,22 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return new DynamicGroupResult(group, sharedUser.Username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a configured group whose members are exactly the given set, ignoring order.
|
||||
/// </summary>
|
||||
/// <param name="config">The plugin configuration to search.</param>
|
||||
/// <param name="memberIds">The member identifiers to match.</param>
|
||||
/// <returns>The matching group, or <c>null</c> if no group has that membership.</returns>
|
||||
private static SharedGroup? FindGroupWithSameMembers(
|
||||
PluginConfiguration config,
|
||||
IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
var wanted = memberIds.ToHashSet();
|
||||
|
||||
return config.Groups.FirstOrDefault(g =>
|
||||
g.MemberUserIds.Count == wanted.Count && wanted.SetEquals(g.MemberUserIds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a submitted password against a member's live stored hash.
|
||||
/// </summary>
|
||||
|
||||
@@ -88,21 +88,32 @@ public class ProvisioningService : IProvisioningService
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
// Store members in the same alphabetical order the generated name uses, so that the stored
|
||||
// order is canonical however the members were supplied. Password checks then always run in
|
||||
// a predictable order too.
|
||||
members = members.OrderBy(m => m.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
distinctIds = members.Select(m => m.Id).ToList();
|
||||
|
||||
var accountName = string.IsNullOrWhiteSpace(name)
|
||||
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
|
||||
: name.Trim();
|
||||
|
||||
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
|
||||
|
||||
// The shared account never authenticates against its own password - our provider checks
|
||||
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
||||
// if the provider is ever unassigned.
|
||||
//
|
||||
// This must happen before the account is claimed below. IUserManager.ChangePassword
|
||||
// dispatches to the provider the user is currently assigned to, and ours refuses the call
|
||||
// by design, so claiming first would make provisioning throw NotSupportedException. A
|
||||
// freshly created user is still on Jellyfin's default provider, which stores the hash.
|
||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||
|
||||
// Route this account's logins through our provider. Jellyfin matches providers by
|
||||
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
|
||||
// once the user is updated.
|
||||
sharedUser.AuthenticationProviderId = AuthProviderId;
|
||||
|
||||
// The shared account never authenticates against its own password - our provider checks
|
||||
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
||||
// if the provider is ever unassigned.
|
||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||
@@ -165,6 +176,11 @@ public class ProvisioningService : IProvisioningService
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the stored order canonical, matching how groups are created.
|
||||
distinctIds = distinctIds
|
||||
.OrderBy(id => _userManager.GetUserById(id)?.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
group.MemberUserIds = distinctIds;
|
||||
group.SyncUnwatched = syncUnwatched;
|
||||
group.SyncPlayCount = syncPlayCount;
|
||||
@@ -211,15 +227,20 @@ public class ProvisioningService : IProvisioningService
|
||||
|
||||
/// <summary>
|
||||
/// Joins member names into a display name, falling back to a generic name if the result would
|
||||
/// exceed the username column limit. Membership is tracked by GUID, so the name is cosmetic.
|
||||
/// exceed the username column limit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Names are sorted alphabetically so that a given set of members always produces the same
|
||||
/// account name. Without this, "jane+john" and "john+jane" would be two different names for the
|
||||
/// same group and would end up as two separate accounts.
|
||||
/// </remarks>
|
||||
/// <param name="usernames">The member usernames.</param>
|
||||
/// <param name="separator">The configured separator.</param>
|
||||
/// <returns>A name that fits within the username length limit.</returns>
|
||||
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
|
||||
{
|
||||
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
|
||||
var joined = string.Join(sep, usernames);
|
||||
var joined = string.Join(sep, usernames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (joined.Length <= MaxUsernameLength)
|
||||
{
|
||||
|
||||
@@ -81,13 +81,23 @@ On an unrecognised name, it:
|
||||
|
||||
1. Splits the name on the separator (`+` by default) — `alice+bob+carol` → three parts.
|
||||
2. Requires **every part** to be an existing, enabled user that is not itself a shared account.
|
||||
3. Requires the submitted password to match **one of those members'** stored hashes.
|
||||
4. Only then creates the shared account, and returns its name so Jellyfin completes the login.
|
||||
3. Requires the submitted password to match **one of those members'** stored hashes. Members are
|
||||
checked in the order you typed them and the check stops at the first match, so putting your own
|
||||
name first is marginally quicker.
|
||||
4. Looks for an existing group with exactly those members. If one exists, you are logged into it.
|
||||
5. Otherwise creates the shared account, and returns its name so Jellyfin completes the login.
|
||||
|
||||
Step 3 is what stops this being an open door: knowing two usernames is not enough to bring an
|
||||
account into being. If any check fails, the plugin declines and the login fails exactly as an
|
||||
ordinary typo would.
|
||||
|
||||
#### Order does not matter
|
||||
|
||||
`john+jane` and `jane+john` are the same group. Member names are sorted alphabetically to build the
|
||||
account name, and the lookup in step 4 compares members as a set, so both spellings resolve to one
|
||||
account rather than creating a second one for the same two people. The account itself is named with
|
||||
the sorted spelling — `jane+john` — whichever order you happened to type.
|
||||
|
||||
#### The name collision, and why it is harmless
|
||||
|
||||
`+` is a legal Jellyfin username character:
|
||||
@@ -165,7 +175,7 @@ Download the release `.zip`, extract it into a `WatchedTogether` folder inside y
|
||||
|
||||
On the shared device, at the Jellyfin login screen:
|
||||
|
||||
- **Username:** `alice+bob` (the members' usernames, joined with `+`)
|
||||
- **Username:** `alice+bob` (the members' usernames, joined with `+`, in any order)
|
||||
- **Password:** your own
|
||||
|
||||
That is the whole setup. The account is created on first use and reused from then on. Add a third
|
||||
@@ -177,7 +187,8 @@ If you would rather provision groups explicitly — or you have turned auto-crea
|
||||
|
||||
1. Go to **Dashboard → Plugins → Watched Together**.
|
||||
2. Under **Create a group**, select **two or more** members.
|
||||
3. Optionally give the account a name. Left blank, the member names are joined with `+`.
|
||||
3. Optionally give the account a name. Left blank, the member names are sorted alphabetically and
|
||||
joined with `+`.
|
||||
4. Click **Create group**.
|
||||
|
||||
Either way, a new user appears in your user list and can be renamed like any other.
|
||||
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: "Watched Together"
|
||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||
version: "0.0.1.0"
|
||||
version: "0.0.4.0"
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: "net9.0"
|
||||
overview: "One shared login for several people; watched state flows back to each member's own account"
|
||||
@@ -26,6 +26,9 @@ dotnet_framework: "net9.0"
|
||||
# Point at the plugin project rather than the solution so the test project is not packaged.
|
||||
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
|
||||
changelog: >
|
||||
Initial release: shared accounts created on demand at login, multi-password
|
||||
authentication, one-way played-state sync to members, and library access
|
||||
computed as the intersection of the members'.
|
||||
Fixes creating a group by typing "alice+bob" at the login screen, which failed
|
||||
with "a Watched Together shared account has no password of its own".
|
||||
Provisioning set the new account's placeholder password after routing it
|
||||
through this plugin's authentication provider, which refuses password changes
|
||||
by design; the password is now set while the account is still on Jellyfin's
|
||||
default provider.
|
||||
|
||||
+34
-1
@@ -6,6 +6,39 @@
|
||||
"overview": "One shared login for several people; watched state flows back to each member's own account",
|
||||
"owner": "dtourolle",
|
||||
"category": "General",
|
||||
"versions": []
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.0.4.0",
|
||||
"changelog": "Release 0.0.4.0",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.4.0/watched-together_0.0.4.0.zip",
|
||||
"checksum": "f537c5305ac6fcb19024471968759bb5",
|
||||
"timestamp": "2026-08-09T08:59:11Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.3.0",
|
||||
"changelog": "Release 0.0.3.0",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.3.0/watched-together_0.0.3.0.zip",
|
||||
"checksum": "56aa6c2e8f12d7404889de8bc253eae3",
|
||||
"timestamp": "2026-08-09T08:49:27Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"changelog": "Release 0.0.2",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.2/watched-together_0.0.2.0.zip",
|
||||
"checksum": "0fedb68a0910414518d7dc45f05fd78f",
|
||||
"timestamp": "2026-07-31T07:45:08Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"changelog": "Release 0.0.1",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.1/watched-together_0.0.1.0.zip",
|
||||
"checksum": "f703f027db22c0bc348b1cb0cee6bb08",
|
||||
"timestamp": "2026-07-29T22:17:28Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user