diff --git a/Directory.Packages.props b/Directory.Packages.props index 001e1169..7a7413d9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,8 @@ + + diff --git a/ImmichFrame.Core.Tests/Logic/ReloadingImmichFrameLogicTests.cs b/ImmichFrame.Core.Tests/Logic/ReloadingImmichFrameLogicTests.cs new file mode 100644 index 00000000..31b2bff4 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/ReloadingImmichFrameLogicTests.cs @@ -0,0 +1,125 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; + +namespace ImmichFrame.Core.Tests.Logic; + +[TestFixture] +public class ReloadingImmichFrameLogicTests +{ + private class FakeSettingsProvider : ISettingsProvider + { + public IServerSettings Current { get; set; } = Mock.Of(); + public event EventHandler? SettingsChanged; + + public void Raise(bool accountsChanged) => SettingsChanged?.Invoke(this, new SettingsChangedEventArgs + { + NewSettings = Current, + AccountsChanged = accountsChanged, + GeneralChanged = true + }); + } + + private class DisposableLogicMock : Mock + { + public DisposableLogicMock() : base(MockBehavior.Loose) + { + As(); + } + + public bool IsDisposed + { + get + { + try + { + As().Verify(d => d.Dispose(), Times.AtLeastOnce); + return true; + } + catch (MockException) + { + return false; + } + } + } + } + + private FakeSettingsProvider _provider; + private List _created; + private ReloadingImmichFrameLogic _logic; + + [SetUp] + public void Setup() + { + _provider = new FakeSettingsProvider(); + _created = new List(); + _logic = new ReloadingImmichFrameLogic(_provider, () => + { + var mock = new DisposableLogicMock(); + _created.Add(mock); + return mock.Object; + }, NullLogger.Instance, disposeGraceDelay: TimeSpan.Zero); + } + + [TearDown] + public void TearDown() => _logic.Dispose(); + + [Test] + public void Constructor_BuildsInnerOnce() + { + Assert.That(_created, Has.Count.EqualTo(1)); + } + + [Test] + public async Task AccountsChanged_SwapsInnerAndDisposesOld() + { + _provider.Raise(accountsChanged: true); + + Assert.That(_created, Has.Count.EqualTo(2)); + + await _logic.GetTotalAssets(); + _created[0].Verify(l => l.GetTotalAssets(), Times.Never); + _created[1].Verify(l => l.GetTotalAssets(), Times.Once); + + // Old instance is disposed after the (zero) grace delay + await WaitUntil(() => _created[0].IsDisposed); + } + + [Test] + public async Task GeneralOnlyChange_KeepsInner() + { + _provider.Raise(accountsChanged: false); + + Assert.That(_created, Has.Count.EqualTo(1)); + await _logic.GetTotalAssets(); + _created[0].Verify(l => l.GetTotalAssets(), Times.Once); + } + + [Test] + public void Dispose_DisposesCurrentInnerAndUnsubscribes() + { + _logic.Dispose(); + + Assert.That(_created[0].IsDisposed, Is.True); + + // Raising after dispose must not rebuild + _provider.Raise(accountsChanged: true); + Assert.That(_created, Has.Count.EqualTo(1)); + } + + private static async Task WaitUntil(Func condition, int timeoutMs = 2000) + { + var start = DateTime.UtcNow; + while (!condition()) + { + if ((DateTime.UtcNow - start).TotalMilliseconds > timeoutMs) + { + Assert.Fail("Condition was not met in time"); + } + + await Task.Delay(10); + } + } +} diff --git a/ImmichFrame.Core/Interfaces/IServerBehaviorSettings.cs b/ImmichFrame.Core/Interfaces/IServerBehaviorSettings.cs index 100abf39..7b593970 100644 --- a/ImmichFrame.Core/Interfaces/IServerBehaviorSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerBehaviorSettings.cs @@ -9,5 +9,6 @@ public interface IServerBehaviorSettings public string? UnitSystem { get; } public string? Webhook { get; } public string? AuthenticationSecret { get; } + public string? AdminPassword { get; } } } diff --git a/ImmichFrame.Core/Interfaces/ISettingsProvider.cs b/ImmichFrame.Core/Interfaces/ISettingsProvider.cs new file mode 100644 index 00000000..25d2b04d --- /dev/null +++ b/ImmichFrame.Core/Interfaces/ISettingsProvider.cs @@ -0,0 +1,17 @@ +namespace ImmichFrame.Core.Interfaces +{ + public interface ISettingsProvider + { + IServerSettings Current { get; } + event EventHandler? SettingsChanged; + } + + public class SettingsChangedEventArgs : EventArgs + { + public required IServerSettings NewSettings { get; init; } + // Accounts list changed or RefreshAlbumPeopleInterval changed — the account + // logic graph (pools, caches) must be rebuilt for these to take effect. + public bool AccountsChanged { get; init; } + public bool GeneralChanged { get; init; } + } +} diff --git a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs index 63474e5c..848e0d45 100644 --- a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs +++ b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs @@ -12,6 +12,12 @@ public class TotalAccountImagesSelectionStrategy( { public async Task<(IAccountImmichFrameLogic, AssetResponseDto)?> GetNextAsset() { + if (_accounts.Count == 0) + { + _logger.LogDebug("No accounts configured, returning no asset"); + return null; + } + var chosen = await _accounts.ChooseOne(logic => logic.GetTotalAssets()); var asset = await chosen.GetNextAsset(); diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 4bf3d3fb..5a8cf8a4 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -7,7 +7,7 @@ namespace ImmichFrame.Core.Logic; -public class MultiImmichFrameLogicDelegate : IImmichFrameLogic +public class MultiImmichFrameLogicDelegate : IImmichFrameLogic, IDisposable { private readonly FrozenDictionary _accountToDelegate; private readonly IServerSettings _serverSettings; @@ -56,6 +56,14 @@ public async Task GetTotalAssets() public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _serverSettings.GeneralSettings.Webhook); + + public void Dispose() + { + foreach (var accountLogic in _accountToDelegate.Values) + { + (accountLogic as IDisposable)?.Dispose(); + } + } } public static class AccountAndAssetExtensions diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 6e2a845c..452889d3 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -7,7 +7,7 @@ namespace ImmichFrame.Core.Logic; -public class PooledImmichFrameLogic : IAccountImmichFrameLogic +public class PooledImmichFrameLogic : IAccountImmichFrameLogic, IDisposable { private readonly IGeneralSettings _generalSettings; private readonly IApiCache _apiCache; @@ -204,4 +204,6 @@ public async Task SendWebhookNotification(IWebhookNotification notification) => await WebhookHelper.SendWebhookNotification(notification, _generalSettings.Webhook); public override string ToString() => $"Account Pool [{_immichApi.BaseUrl}]"; + + public void Dispose() => (_apiCache as IDisposable)?.Dispose(); } diff --git a/ImmichFrame.Core/Logic/ReloadingImmichFrameLogic.cs b/ImmichFrame.Core/Logic/ReloadingImmichFrameLogic.cs new file mode 100644 index 00000000..c4351b27 --- /dev/null +++ b/ImmichFrame.Core/Logic/ReloadingImmichFrameLogic.cs @@ -0,0 +1,110 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Models; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic; + +/// +/// Delegates to an inner that is rebuilt whenever the +/// account settings change, so account changes apply without a restart. The previous +/// instance is disposed after a grace delay to let in-flight requests finish. +/// +public class ReloadingImmichFrameLogic : IImmichFrameLogic, IDisposable +{ + private static readonly TimeSpan DefaultDisposeGraceDelay = TimeSpan.FromSeconds(60); + + private readonly Func _innerFactory; + private readonly ISettingsProvider _settingsProvider; + private readonly ILogger _logger; + private readonly TimeSpan _disposeGraceDelay; + private readonly object _swapLock = new(); + private volatile IImmichFrameLogic _inner; + private bool _disposed; + + public ReloadingImmichFrameLogic(ISettingsProvider settingsProvider, Func innerFactory, + ILogger logger, TimeSpan? disposeGraceDelay = null) + { + _settingsProvider = settingsProvider; + _innerFactory = innerFactory; + _logger = logger; + _disposeGraceDelay = disposeGraceDelay ?? DefaultDisposeGraceDelay; + _inner = innerFactory(); + _settingsProvider.SettingsChanged += OnSettingsChanged; + } + + private void OnSettingsChanged(object? sender, SettingsChangedEventArgs args) + { + if (!args.AccountsChanged) + return; + + IImmichFrameLogic? old; + lock (_swapLock) + { + if (_disposed) + return; + + _logger.LogInformation("Account settings changed, rebuilding asset logic"); + old = Interlocked.Exchange(ref _inner, _innerFactory()); + } + + if (old is IDisposable disposable) + { + var delay = _disposeGraceDelay; + _ = Task.Run(async () => + { + await Task.Delay(delay); + DisposeInner(disposable); + }); + } + } + + private void DisposeInner(IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispose previous asset logic"); + } + } + + public Task GetNextAsset() => _inner.GetNextAsset(); + + public Task> GetAssets() => _inner.GetAssets(); + + public Task GetAssetInfoById(Guid assetId) => _inner.GetAssetInfoById(assetId); + + public Task> GetAssetFacesById(Guid assetId) => _inner.GetAssetFacesById(assetId); + + public Task> GetAlbumInfoById(Guid assetId) => _inner.GetAlbumInfoById(assetId); + + public Task GetAsset(Guid assetId, AssetTypeEnum? assetType = null, string? rangeHeader = null) + => _inner.GetAsset(assetId, assetType, rangeHeader); + + public Task GetTotalAssets() => _inner.GetTotalAssets(); + + public Task SendWebhookNotification(IWebhookNotification notification) => _inner.SendWebhookNotification(notification); + + public void Dispose() + { + _settingsProvider.SettingsChanged -= OnSettingsChanged; + + IImmichFrameLogic? current; + lock (_swapLock) + { + if (_disposed) + return; + + _disposed = true; + current = _inner; + } + + if (current is IDisposable disposable) + { + DisposeInner(disposable); + } + } +} diff --git a/ImmichFrame.WebApi.Tests/Controllers/AdminControllerTests.cs b/ImmichFrame.WebApi.Tests/Controllers/AdminControllerTests.cs new file mode 100644 index 00000000..bbc35545 --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Controllers/AdminControllerTests.cs @@ -0,0 +1,260 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using ImmichFrame.WebApi.Tests.Mocks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Moq; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Controllers +{ + /// + /// End-to-end tests through the real Program.cs pipeline, including the real + /// SettingsService with a SQLite db in a temp config dir. + /// + [TestFixture] + [NonParallelizable] // manipulates process-wide environment variables + public class AdminControllerTests + { + private const string AdminPassword = "test-admin-password"; + + private string _configDir; + private WebApplicationFactory _factory; + + [SetUp] + public void Setup() + { + _configDir = Path.Combine(Path.GetTempPath(), "immichframe-tests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_configDir); + Environment.SetEnvironmentVariable("IMMICHFRAME_CONFIG_PATH", _configDir); + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", AdminPassword); + + var versionHandler = new Mock().WithServerVersion(); + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => services.UseMockHandler(versionHandler)); + }); + } + + [TearDown] + public void TearDown() + { + _factory.Dispose(); + Environment.SetEnvironmentVariable("IMMICHFRAME_CONFIG_PATH", null); + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", null); + if (Directory.Exists(_configDir)) + { + Directory.Delete(_configDir, true); + } + } + + private HttpClient CreateAdminClient(string? password = AdminPassword) + { + var client = _factory.CreateClient(); + if (password != null) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", password); + } + + return client; + } + + private static JsonNode ValidSettingsBody() => JsonNode.Parse(""" + { + "General": { "Interval": 42 }, + "Accounts": [ { "ImmichServerUrl": "http://mock-immich-server.com", "ApiKey": "key123" } ] + } + """)!; + + [Test] + public async Task GetStatus_IsAnonymousAndReportsSetupDone() + { + var client = _factory.CreateClient(); // no auth header + + var response = await client.GetAsync("/api/Admin/Status"); + + response.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.That((string)json["state"]!, Is.EqualTo("login")); + } + + [Test] + public async Task GetSettings_WithoutPassword_Returns401() + { + var client = CreateAdminClient(password: null); + + var response = await client.GetAsync("/api/Admin/Settings"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task GetSettings_WithWrongPassword_Returns401() + { + var client = CreateAdminClient("wrong-password"); + + var response = await client.GetAsync("/api/Admin/Settings"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task WithoutAnyPassword_Returns401AndStatusReportsSetupRequired() + { + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", null); + var client = CreateAdminClient(); + + var settingsResponse = await client.GetAsync("/api/Admin/Settings"); + var statusResponse = await client.GetAsync("/api/Admin/Status"); + + Assert.Multiple(async () => + { + Assert.That(settingsResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + statusResponse.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await statusResponse.Content.ReadAsStringAsync())!; + Assert.That((string)json["state"]!, Is.EqualTo("setup")); + }); + } + + [Test] + public async Task Setup_WithoutAnyPassword_ClaimsInstanceAnonymously() + { + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", null); + var client = _factory.CreateClient(); // no auth header + + var setupResponse = await client.PostAsJsonAsync("/api/Admin/Setup", + new { AdminPassword = "chosen-in-onboarding" }); + setupResponse.EnsureSuccessStatusCode(); + + var statusResponse = await client.GetAsync("/api/Admin/Status"); + var json = JsonNode.Parse(await statusResponse.Content.ReadAsStringAsync())!; + Assert.That((string)json["state"]!, Is.EqualTo("login")); + + var authed = CreateAdminClient("chosen-in-onboarding"); + var settingsResponse = await authed.GetAsync("/api/Admin/Settings"); + Assert.That(settingsResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ConfiguredWithoutPassword_ReportsDisabledAndRefusesSetup() + { + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", null); + // An imported config makes this instance somebody's, even without a password. + await File.WriteAllTextAsync(Path.Combine(_configDir, "Settings.json"), + """ + { + "General": { "Interval": 99 }, + "Accounts": [ { "ImmichServerUrl": "http://mock-immich-server.com", "ApiKey": "key123" } ] + } + """); + + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.ConfigureTestServices( + services => services.UseMockHandler(new Mock().WithServerVersion()))); + var client = factory.CreateClient(); + + var statusResponse = await client.GetAsync("/api/Admin/Status"); + var json = JsonNode.Parse(await statusResponse.Content.ReadAsStringAsync())!; + + var setupResponse = await client.PostAsJsonAsync("/api/Admin/Setup", + new { AdminPassword = "hijack-attempt" }); + + Assert.Multiple(() => + { + Assert.That((string)json["state"]!, Is.EqualTo("disabled")); + Assert.That(setupResponse.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + }); + } + + [Test] + public async Task Setup_WhenAlreadySetUp_Returns409() + { + var client = _factory.CreateClient(); // env password is set by Setup() + + var response = await client.PostAsJsonAsync("/api/Admin/Setup", + new { AdminPassword = "hijack-attempt" }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + } + + [Test] + public async Task Setup_WithEmptyPassword_Returns400() + { + Environment.SetEnvironmentVariable("IMMICHFRAME_ADMIN_PASSWORD", null); + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/Admin/Setup", new { AdminPassword = " " }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutAndGetSettings_RoundTrips() + { + var client = CreateAdminClient(); + + var putResponse = await client.PutAsJsonAsync("/api/Admin/Settings", ValidSettingsBody()); + putResponse.EnsureSuccessStatusCode(); + + var getResponse = await client.GetAsync("/api/Admin/Settings"); + getResponse.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await getResponse.Content.ReadAsStringAsync())!; + + Assert.Multiple(() => + { + Assert.That((int)json["General"]!["interval"]!, Is.EqualTo(42)); + Assert.That((string)json["Accounts"]![0]!["apiKey"]!, Is.EqualTo("key123")); + }); + } + + [Test] + public async Task PutSettings_Invalid_Returns400() + { + var client = CreateAdminClient(); + + var body = JsonNode.Parse(""" + { + "General": { "Interval": 42 }, + "Accounts": [ { "ImmichServerUrl": "http://mock-immich-server.com" } ] + } + """); + var response = await client.PutAsJsonAsync("/api/Admin/Settings", body); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task PutSettings_IsReflectedByClientConfig_WithoutRestart() + { + var adminClient = CreateAdminClient(); + var configClient = _factory.CreateClient(); + + var before = JsonNode.Parse(await configClient.GetStringAsync("/api/Config"))!; + Assert.That((int)before["interval"]!, Is.Not.EqualTo(42)); + + var putResponse = await adminClient.PutAsJsonAsync("/api/Admin/Settings", ValidSettingsBody()); + putResponse.EnsureSuccessStatusCode(); + + var after = JsonNode.Parse(await configClient.GetStringAsync("/api/Config"))!; + Assert.That((int)after["interval"]!, Is.EqualTo(42)); + } + + [Test] + public async Task TestAccount_ReturnsSuccessForReachableServer() + { + var client = CreateAdminClient(); + + var body = JsonNode.Parse(""" + { "ImmichServerUrl": "http://mock-immich-server.com", "ApiKey": "key123" } + """); + var response = await client.PostAsJsonAsync("/api/Admin/Settings/TestAccount", body); + + response.EnsureSuccessStatusCode(); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.That((bool)json["success"]!, Is.True); + } + } +} diff --git a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs index 838c117e..d8f0a35a 100644 --- a/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs +++ b/ImmichFrame.WebApi.Tests/Controllers/AssetControllerTests.cs @@ -73,6 +73,7 @@ public void Setup() AccountsImpl = new List { accountSettings } }; + services.AddSingleton(new StaticSettingsProvider(serverSettings)); services.AddSingleton(serverSettings); services.AddSingleton(generalSettings); // Ensure IAccountSettings can be resolved if needed by MultiImmichFrameLogicDelegate directly diff --git a/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerTests.cs b/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerTests.cs index de31232a..dbc5cbc3 100644 --- a/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerTests.cs +++ b/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerTests.cs @@ -57,6 +57,7 @@ public void Setup() UnitSystem = "metric", Webhook = "https://webhook.example.com/secret-hook", AuthenticationSecret = "secret-auth-token", + AdminPassword = "secret-admin-password", Webcalendars = new List { "https://calendar.example.com/secret.ics" }, RefreshAlbumPeopleInterval = 8, }; @@ -84,6 +85,7 @@ public void Setup() { services.UseMockHandler(versionHandler); + services.AddSingleton(new StaticSettingsProvider(serverSettings)); services.AddSingleton(serverSettings); services.AddSingleton(generalSettings); }); @@ -131,7 +133,9 @@ public async Task GetConfig_ContainsNoSecrets() Assert.That(json, Does.Not.Contain("secret-api-key")); Assert.That(json, Does.Not.Contain("secret-hook")); Assert.That(json, Does.Not.Contain("secret.ics")); + Assert.That(json, Does.Not.Contain("secret-admin-password")); Assert.That(json, Does.Not.Contain("authenticationSecret")); + Assert.That(json, Does.Not.Contain("adminPassword")); Assert.That(json, Does.Not.Contain("weatherApiKey")); Assert.That(json, Does.Not.Contain("apiKey")); Assert.That(json, Does.Not.Contain("webhook")); diff --git a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs index 15c3254b..d6452ec6 100644 --- a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs +++ b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs @@ -24,30 +24,12 @@ public void Setup() _configLoader = new ConfigLoader(loggerFactory.CreateLogger()); } - [Test] - public void TestLoadConfigV1Json() - { - var config = _configLoader.LoadConfigJson(Path.Combine( - TestContext.CurrentContext.TestDirectory, "Resources/TestV1.json")); - VerifyConfig(new ServerSettingsV1Adapter(config), false, true); - } - - [Test] - public void TestLoadConfigEnv() - { - var jsonConfig = _configLoader.LoadConfigJson(Path.Combine( - TestContext.CurrentContext.TestDirectory, "Resources/TestV1.json")); - - var config = _configLoader.LoadConfigFromDictionary(ToDictionary(jsonConfig)); - VerifyConfig(new ServerSettingsV1Adapter(config), false, true); - } - [Test] public void TestLoadConfigV2Json() { var config = _configLoader.LoadConfigJson(Path.Combine( TestContext.CurrentContext.TestDirectory, "Resources/TestV2.json")); - VerifyConfig(config, true, false); + VerifyConfig(config); } [Test] @@ -65,26 +47,26 @@ public void TestLoadConfigV2Yaml() { var config = _configLoader.LoadConfigYaml(Path.Combine( TestContext.CurrentContext.TestDirectory, "Resources/TestV2.yml")); - VerifyConfig(config, true, false); + VerifyConfig(config); } - private void VerifyConfig(IServerSettings serverSettings, bool usePrefix, bool expectNullApiKeyFile) + private void VerifyConfig(IServerSettings serverSettings) { VerifyProperties(serverSettings.GeneralSettings); - VerifyAccounts(serverSettings.Accounts, usePrefix, expectNullApiKeyFile); + VerifyAccounts(serverSettings.Accounts); } - private void VerifyAccounts(IEnumerable accounts, bool usePrefix, bool expectNullApiKeyFile) + private void VerifyAccounts(IEnumerable accounts) { var idx = 1; foreach (var account in accounts) { - VerifyProperties(account, usePrefix ? "Account" + idx + "." : "", expectNullApiKeyFile); + VerifyProperties(account, "Account" + idx + "."); idx++; } } - private void VerifyProperties(object o, string? prefix = "", bool expectNullApiKeyFile = false) + private void VerifyProperties(object o, string? prefix = "") { foreach (var prop in o.GetType().GetProperties()) { @@ -107,14 +89,7 @@ private void VerifyProperties(object o, string? prefix = "", bool expectNullApiK switch (type) { case var t when t == typeof(string): - if (prop.Name.Equals("ApiKeyFile") && expectNullApiKeyFile) - { - Assert.That(value, Is.EqualTo(null), prop.Name); - } - else - { - Assert.That(value, Is.EqualTo(prefix + prop.Name + "_TEST"), prop.Name); - } + Assert.That(value, Is.EqualTo(prefix + prop.Name + "_TEST"), prop.Name); break; case var t when t == typeof(Boolean): Assert.That(value, Is.EqualTo(true), prop.Name); @@ -137,45 +112,4 @@ private void VerifyProperties(object o, string? prefix = "", bool expectNullApiK } } - public static IDictionary ToDictionary(object obj, bool ignoreNullValues = false) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - var dictionary = new Dictionary(); - Type objType = obj.GetType(); - - // Get all public instance properties - PropertyInfo[] properties = objType.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (PropertyInfo prop in properties) - { - // Ensure the property has a public getter - if (prop.CanRead && prop.GetMethod?.IsPublic == true) - { - object value = prop.GetValue(obj); - - if (ignoreNullValues && value == null) - { - continue; // Skip if value is null and ignoreNullValues is true - } - - if (!(value is string) && value is IEnumerable) - { - value = string.Join(",", (value as IEnumerable).Cast().Select(x => x.ToString())); - } - else - { - value = value.ToString(); - } - - dictionary.Add(prop.Name, value); - } - } - - return dictionary; - } - } diff --git a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj index 5bb9e4d9..c9382f8b 100644 --- a/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj +++ b/ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj @@ -28,10 +28,6 @@ - - - PreserveNewest - PreserveNewest diff --git a/ImmichFrame.WebApi.Tests/Mocks/StaticSettingsProvider.cs b/ImmichFrame.WebApi.Tests/Mocks/StaticSettingsProvider.cs new file mode 100644 index 00000000..c76ddfac --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Mocks/StaticSettingsProvider.cs @@ -0,0 +1,17 @@ +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.WebApi.Tests.Mocks +{ + /// + /// Fixed-settings for tests. Registering it also keeps + /// Program.cs from running the real SettingsService.InitializeAsync() (no SQLite + /// files in test bins, no config import). + /// + public class StaticSettingsProvider(IServerSettings _settings) : ISettingsProvider + { + public IServerSettings Current => _settings; + public event EventHandler? SettingsChanged; + + public void RaiseSettingsChanged(SettingsChangedEventArgs args) => SettingsChanged?.Invoke(this, args); + } +} diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json deleted file mode 100644 index e6c49102..00000000 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "ImmichServerUrl": "ImmichServerUrl_TEST", - "ApiKey": "ApiKey_TEST", - "AuthenticationSecret": "AuthenticationSecret_TEST", - "Interval": 7, - "TransitionDuration": 7.7, - "ImageZoom": true, - "ImagePan": true, - "ImageFill": true, - "PlayAudio": true, - "Layout": "Layout_TEST", - "DownloadImages": true, - "ShowMemories": true, - "ShowFavorites": true, - "ShowArchived": true, - "ShowVideos": true, - "ImagesFromDays": 7, - "ImagesFromDate": "2020-01-02", - "ImagesUntilDate": "2020-01-02", - "RenewImagesDuration": 7, - "Rating": 7, - "Albums": [ - "00000000-0000-0000-0000-000000000001" - ], - "ExcludedAlbums": [ - "00000000-0000-0000-0000-000000000001" - ], - "People": [ - "00000000-0000-0000-0000-000000000001" - ], - "Tags": [ - "Tags_TEST" - ], - "Webcalendars": [ - "Webcalendars_TEST" - ], - "RefreshAlbumPeopleInterval": 7, - "ShowClock": true, - "ClockFormat": "ClockFormat_TEST", - "ClockDateFormat": "ClockDateFormat_TEST", - "ShowProgressBar": true, - "ShowPhotoDate": true, - "PhotoDateFormat": "PhotoDateFormat_TEST", - "ShowImageDesc": true, - "ShowPeopleDesc": true, - "ShowImageLocation": true, - "ImageLocationFormat": "ImageLocationFormat_TEST", - "PrimaryColor": "PrimaryColor_TEST", - "SecondaryColor": "SecondaryColor_TEST", - "Style": "Style_TEST", - "BaseFontSize": "BaseFontSize_TEST", - "WeatherApiKey": "WeatherApiKey_TEST", - "ShowWeatherDescription": true, - "WeatherIconUrl": "WeatherIconUrl_TEST", - "UnitSystem": "UnitSystem_TEST", - "WeatherLatLong": "WeatherLatLong_TEST", - "Language": "Language_TEST", - "Webhook": "Webhook_TEST", - "Account2.ImmichServerUrl": "Account2.ImmichServerUrl_TEST", - "Account2.ApiKey": "Account2.ApiKey_TEST", - "Account2.ImagesFromDate": "Account2.ImagesFromDate_TEST" -} diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..58163d7a 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -1,6 +1,7 @@ { "General": { "AuthenticationSecret": "AuthenticationSecret_TEST", + "AdminPassword": "AdminPassword_TEST", "DownloadImages": true, "RenewImagesDuration": 7, "Webcalendars": [ diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..d3b87791 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -1,6 +1,7 @@ --- General: AuthenticationSecret: AuthenticationSecret_TEST + AdminPassword: AdminPassword_TEST DownloadImages: true RenewImagesDuration: 7 Webcalendars: diff --git a/ImmichFrame.WebApi.Tests/Services/SettingsServiceTests.cs b/ImmichFrame.WebApi.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 00000000..f5ce9c15 --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,237 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Database; +using ImmichFrame.WebApi.Helpers.Config; +using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Services +{ + [TestFixture] + public class SettingsServiceTests + { + private string _configDir; + private ILoggerFactory _loggerFactory; + + [SetUp] + public void Setup() + { + _configDir = Path.Combine(Path.GetTempPath(), "immichframe-tests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_configDir); + _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + } + + [TearDown] + public void TearDown() + { + _loggerFactory.Dispose(); + if (Directory.Exists(_configDir)) + { + Directory.Delete(_configDir, true); + } + } + + private SettingsService CreateService() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={Path.Combine(_configDir, "immichframe.db")}") + .Options; + return new SettingsService( + new TestDbContextFactory(options), + new ConfigLoader(_loggerFactory.CreateLogger()), + _loggerFactory.CreateLogger(), + new SettingsServiceOptions(_configDir)); + } + + private void WriteSettingsJson(string json) + { + File.WriteAllText(Path.Combine(_configDir, "Settings.json"), json); + } + + private const string ValidV2Json = """ + { + "General": { "Interval": 10, "AuthenticationSecret": "secret" }, + "Accounts": [ { "ImmichServerUrl": "http://immich.local", "ApiKey": "key123" } ] + } + """; + + [Test] + public async Task InitializeAsync_ImportsFileConfigOnFirstRun() + { + WriteSettingsJson(ValidV2Json); + + var service = CreateService(); + await service.InitializeAsync(); + + Assert.Multiple(() => + { + Assert.That(service.Current.GeneralSettings.Interval, Is.EqualTo(10)); + Assert.That(service.Current.Accounts.Single().ApiKey, Is.EqualTo("key123")); + Assert.That(File.Exists(Path.Combine(_configDir, "immichframe.db")), Is.True); + }); + } + + [Test] + public async Task InitializeAsync_DbWinsOverChangedFile() + { + WriteSettingsJson(ValidV2Json); + var first = CreateService(); + await first.InitializeAsync(); + + // Change the file afterwards — the DB was already populated and must win + WriteSettingsJson(ValidV2Json.Replace("\"Interval\": 10", "\"Interval\": 99")); + + var second = CreateService(); + await second.InitializeAsync(); + + Assert.That(second.Current.GeneralSettings.Interval, Is.EqualTo(10)); + } + + [Test] + public async Task InitializeAsync_NoConfig_StartsWithDefaultsAndDoesNotPersist() + { + var service = CreateService(); + await service.InitializeAsync(); + + Assert.Multiple(() => + { + Assert.That(service.Current.Accounts, Is.Empty); + Assert.That(service.Current.GeneralSettings.Interval, Is.EqualTo(new GeneralSettings().Interval)); + }); + + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={Path.Combine(_configDir, "immichframe.db")}") + .Options; + await using var db = new SettingsDbContext(options); + Assert.That(await db.SettingsDocuments.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task InitializeAsync_ApiKeyFileStaysRawButResolvesInCurrent() + { + var keyFile = Path.Combine(_configDir, "apikey.txt"); + await File.WriteAllTextAsync(keyFile, "file-key\n"); + WriteSettingsJson($$""" + { + "General": { "Interval": 10 }, + "Accounts": [ { "ImmichServerUrl": "http://immich.local", "ApiKeyFile": {{System.Text.Json.JsonSerializer.Serialize(keyFile)}} } ] + } + """); + + var service = CreateService(); + await service.InitializeAsync(); + + Assert.Multiple(() => + { + // Runtime settings have the resolved key + Assert.That(service.Current.Accounts.Single().ApiKey, Is.EqualTo("file-key")); + // Raw settings keep ApiKeyFile unresolved and ApiKey empty (round-trip safe) + var rawAccount = service.GetRawSettings().AccountsImpl.Single(); + Assert.That(rawAccount.ApiKeyFile, Is.EqualTo(keyFile)); + Assert.That(rawAccount.ApiKey, Is.Empty); + }); + + // A second load from the DB must not trip the "both ApiKey and ApiKeyFile" validation + var second = CreateService(); + await second.InitializeAsync(); + Assert.That(second.Current.Accounts.Single().ApiKey, Is.EqualTo("file-key")); + } + + [Test] + public async Task UpdateAsync_PersistsAndRaisesEventWithFlags() + { + var service = CreateService(); + await service.InitializeAsync(); + + SettingsChangedEventArgs? received = null; + service.SettingsChanged += (_, args) => received = args; + + var settings = service.GetRawSettings(); + settings.AccountsImpl = new List + { + new() { ImmichServerUrl = "http://immich.local", ApiKey = "key123" } + }; + await service.UpdateAsync(settings); + + Assert.Multiple(() => + { + Assert.That(received, Is.Not.Null); + Assert.That(received!.AccountsChanged, Is.True); + Assert.That(service.Current.Accounts.Count(), Is.EqualTo(1)); + }); + + // Survives a restart + var second = CreateService(); + await second.InitializeAsync(); + Assert.That(second.Current.Accounts.Single().ApiKey, Is.EqualTo("key123")); + } + + [Test] + public async Task UpdateAsync_GeneralOnlyChange_DoesNotFlagAccounts() + { + WriteSettingsJson(ValidV2Json); + var service = CreateService(); + await service.InitializeAsync(); + + SettingsChangedEventArgs? received = null; + service.SettingsChanged += (_, args) => received = args; + + var settings = service.GetRawSettings(); + settings.GeneralSettingsImpl!.Interval = 60; + await service.UpdateAsync(settings); + + Assert.Multiple(() => + { + Assert.That(received!.AccountsChanged, Is.False); + Assert.That(received.GeneralChanged, Is.True); + Assert.That(service.Current.GeneralSettings.Interval, Is.EqualTo(60)); + }); + } + + [Test] + public async Task UpdateAsync_InvalidSettings_ThrowsAndKeepsCurrent() + { + WriteSettingsJson(ValidV2Json); + var service = CreateService(); + await service.InitializeAsync(); + + var settings = service.GetRawSettings(); + settings.AccountsImpl = new List + { + new() { ImmichServerUrl = "http://other.local" } // no ApiKey/ApiKeyFile + }; + + Assert.ThrowsAsync(() => service.UpdateAsync(settings)); + Assert.That(service.Current.Accounts.Single().ApiKey, Is.EqualTo("key123")); + } + + [Test] + public async Task TryClaimSetupAsync_OnlyFirstConcurrentClaimWins() + { + var service = CreateService(); + await service.InitializeAsync(); + + // Mirrors AdminAuthService.SetupRequired: no password anywhere yet. + bool SetupRequired() => string.IsNullOrWhiteSpace(service.Current.GeneralSettings.AdminPassword) + && service.IsUnconfigured; + + var results = await Task.WhenAll( + Enumerable.Range(0, 8).Select(i => Task.Run(() => service.TryClaimSetupAsync($"password-{i}", SetupRequired)))); + + Assert.Multiple(() => + { + Assert.That(results.Count(r => r == SetupResult.Claimed), Is.EqualTo(1)); + Assert.That(results.Count(r => r == SetupResult.AlreadyClaimed), Is.EqualTo(7)); + Assert.That(service.Current.GeneralSettings.AdminPassword, Does.StartWith("password-")); + Assert.That(service.IsUnconfigured, Is.False); + }); + } + + private class TestDbContextFactory(DbContextOptions _options) : IDbContextFactory + { + public SettingsDbContext CreateDbContext() => new(_options); + } + } +} diff --git a/ImmichFrame.WebApi/Controllers/AdminController.cs b/ImmichFrame.WebApi/Controllers/AdminController.cs new file mode 100644 index 00000000..2cf43f47 --- /dev/null +++ b/ImmichFrame.WebApi/Controllers/AdminController.cs @@ -0,0 +1,154 @@ +using ImmichFrame.Core.Exceptions; +using ImmichFrame.WebApi.Helpers; +using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ImmichFrame.WebApi.Controllers +{ + public class AdminStatusDto + { + /// What the admin UI should show. + public AdminUiState State { get; set; } = AdminUiState.Disabled; + } + + public class AdminSetupDto + { + public string AdminPassword { get; set; } = string.Empty; + } + + public class SettingsUpdateResultDto + { + public List Warnings { get; set; } = new(); + } + + public class AccountTestResultDto + { + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? Version { get; set; } + } + + [ApiController] + [Route("api/[controller]")] + [Authorize(AuthenticationSchemes = ImmichFrameAdminAuthenticationHandler.SchemeName)] + public class AdminController : ControllerBase + { + private readonly ILogger _logger; + private readonly SettingsService _settingsService; + private readonly AdminAuthService _adminAuthService; + private readonly IHttpClientFactory _httpClientFactory; + + public AdminController(ILogger logger, SettingsService settingsService, + AdminAuthService adminAuthService, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _settingsService = settingsService; + _adminAuthService = adminAuthService; + _httpClientFactory = httpClientFactory; + } + + [HttpGet("Status", Name = "GetAdminStatus")] + [AllowAnonymous] + public AdminStatusDto GetStatus() + { + return new AdminStatusDto { State = _adminAuthService.State }; + } + + /// + /// Claims an unconfigured instance by setting the admin password. Anonymous on + /// purpose: without it a fresh install has no way into the admin UI. Closes for + /// good as soon as a password exists, from either the environment or the database. + /// Any settings imported from a config file are preserved. + /// + [HttpPost("Setup", Name = "SetupAdmin")] + [AllowAnonymous] + [ProducesResponseType(typeof(SettingsUpdateResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task> Setup([FromBody] AdminSetupDto setup) + { + if (string.IsNullOrWhiteSpace(setup.AdminPassword)) + { + return Problem(detail: "An admin password is required.", statusCode: StatusCodes.Status400BadRequest); + } + + SetupResult result; + try + { + result = await _settingsService.TryClaimSetupAsync( + setup.AdminPassword, () => _adminAuthService.SetupRequired); + } + catch (SettingsNotValidException ex) + { + _logger.LogWarning("Rejected setup: {message}", ex.Message); + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + + if (result == SetupResult.AlreadyClaimed) + { + return Problem(detail: "ImmichFrame is already configured.", statusCode: StatusCodes.Status409Conflict); + } + + _logger.LogInformation("Admin password set through onboarding."); + return Ok(new SettingsUpdateResultDto()); + } + + /// + /// The raw settings for editing. Secrets are included on purpose: the caller is + /// authenticated with the dedicated admin password, and masking would break the + /// GET-edit-PUT round-trip. + /// + [HttpGet("Settings", Name = "GetAdminSettings")] + public ServerSettings GetSettings() + { + // Secrets in the body: keep them out of browser and proxy caches. + Response.Headers.CacheControl = "no-store"; + return _settingsService.GetRawSettings(); + } + + [HttpPut("Settings", Name = "UpdateAdminSettings")] + [ProducesResponseType(typeof(SettingsUpdateResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task> UpdateSettings([FromBody] ServerSettings settings) + { + Core.Interfaces.IServerSettings validated; + try + { + validated = await _settingsService.UpdateAsync(settings); + } + catch (SettingsNotValidException ex) + { + _logger.LogWarning("Rejected settings update: {message}", ex.Message); + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + + // Non-blocking checks: saving is allowed even if a server is currently unreachable + // (e.g. pre-provisioning), but the admin should know about it. + var checks = await Task.WhenAll(validated.Accounts + .Select(account => ImmichServerVersionChecker.CheckAccount(account, _httpClientFactory))); + + return Ok(new SettingsUpdateResultDto + { + Warnings = checks.Where(c => !c.Success).Select(c => c.Message).ToList() + }); + } + + [HttpPost("Settings/TestAccount", Name = "TestAccount")] + public async Task TestAccount([FromBody] ServerAccountSettings account) + { + try + { + account.ValidateAndInitialize(); // resolves ApiKeyFile if given + } + catch (Exception ex) + { + return new AccountTestResultDto { Success = false, Message = ex.Message }; + } + + var check = await ImmichServerVersionChecker.CheckAccount(account, _httpClientFactory); + return new AccountTestResultDto { Success = check.Success, Message = check.Message, Version = check.Version }; + } + } +} diff --git a/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.Designer.cs b/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.Designer.cs new file mode 100644 index 00000000..2c85c34d --- /dev/null +++ b/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.Designer.cs @@ -0,0 +1,53 @@ +// +using System; +using ImmichFrame.WebApi.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ImmichFrame.WebApi.Database.Migrations +{ + [DbContext(typeof(SettingsDbContext))] + [Migration("20260814141028_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.20"); + + modelBuilder.Entity("ImmichFrame.WebApi.Database.SettingsDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ImportedFrom") + .HasColumnType("TEXT"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SchemaVersion") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SettingsDocuments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.cs b/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.cs new file mode 100644 index 00000000..d406be11 --- /dev/null +++ b/ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ImmichFrame.WebApi.Database.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SettingsDocuments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Json = table.Column(type: "TEXT", nullable: false), + SchemaVersion = table.Column(type: "INTEGER", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false), + ImportedFrom = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SettingsDocuments", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SettingsDocuments"); + } + } +} diff --git a/ImmichFrame.WebApi/Database/Migrations/SettingsDbContextModelSnapshot.cs b/ImmichFrame.WebApi/Database/Migrations/SettingsDbContextModelSnapshot.cs new file mode 100644 index 00000000..e90f635f --- /dev/null +++ b/ImmichFrame.WebApi/Database/Migrations/SettingsDbContextModelSnapshot.cs @@ -0,0 +1,50 @@ +// +using System; +using ImmichFrame.WebApi.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ImmichFrame.WebApi.Database.Migrations +{ + [DbContext(typeof(SettingsDbContext))] + partial class SettingsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.20"); + + modelBuilder.Entity("ImmichFrame.WebApi.Database.SettingsDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ImportedFrom") + .HasColumnType("TEXT"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SchemaVersion") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SettingsDocuments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ImmichFrame.WebApi/Database/SettingsDbContext.cs b/ImmichFrame.WebApi/Database/SettingsDbContext.cs new file mode 100644 index 00000000..d9c9e75f --- /dev/null +++ b/ImmichFrame.WebApi/Database/SettingsDbContext.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; + +namespace ImmichFrame.WebApi.Database; + +public class SettingsDbContext : DbContext +{ + public SettingsDbContext(DbContextOptions options) : base(options) { } + + public DbSet SettingsDocuments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(d => d.Version) + .IsConcurrencyToken(); + } +} + +public class SettingsDocument +{ + public int Id { get; set; } + + // Raw ServerSettings V2 JSON, pre-Validate (ApiKeyFile stays unresolved) + public string Json { get; set; } = "{}"; + + public int SchemaVersion { get; set; } = 2; + + public DateTime UpdatedAtUtc { get; set; } + + // "Settings.json" | "Settings.yml" | "env" | null when created via admin UI + public string? ImportedFrom { get; set; } + + public long Version { get; set; } +} diff --git a/ImmichFrame.WebApi/Database/SettingsDbContextFactory.cs b/ImmichFrame.WebApi/Database/SettingsDbContextFactory.cs new file mode 100644 index 00000000..eb269e42 --- /dev/null +++ b/ImmichFrame.WebApi/Database/SettingsDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ImmichFrame.WebApi.Database; + +// Used by `dotnet ef` at design time only +public class SettingsDbContextFactory : IDesignTimeDbContextFactory +{ + public SettingsDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=design.db") + .Options; + return new SettingsDbContext(options); + } +} diff --git a/ImmichFrame.WebApi/Helpers/AdminSecuritySchemeOperationFilter.cs b/ImmichFrame.WebApi/Helpers/AdminSecuritySchemeOperationFilter.cs new file mode 100644 index 00000000..960c0e30 --- /dev/null +++ b/ImmichFrame.WebApi/Helpers/AdminSecuritySchemeOperationFilter.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ImmichFrame.WebApi.Helpers; + +/// +/// Documents the admin bearer scheme on the operations that actually require it. The +/// admin controller mixes authenticated and anonymous endpoints (Status and Setup must +/// stay reachable before a password exists), so the requirement is applied per operation +/// rather than globally. +/// +public class AdminSecuritySchemeOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var method = context.MethodInfo; + if (method?.DeclaringType == null) + return; + + var usesAdminScheme = method.DeclaringType.GetCustomAttributes(true) + .Concat(method.GetCustomAttributes(true)) + .OfType() + .Any(a => a.AuthenticationSchemes?.Contains(ImmichFrameAdminAuthenticationHandler.SchemeName) == true); + + var allowsAnonymous = method.GetCustomAttributes(true).OfType().Any(); + + if (!usesAdminScheme || allowsAnonymous) + return; + + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = ImmichFrameAdminAuthenticationHandler.SchemeName + } + }] = Array.Empty() + }); + + // CustomAuthenticationMiddleware answers a failed admin authentication with the + // plain-text reason, not a ProblemDetails body. + operation.Responses.TryAdd(StatusCodes.Status401Unauthorized.ToString(), new OpenApiResponse + { + Description = "Unauthorized", + Content = new Dictionary + { + ["text/plain"] = new() { Schema = new OpenApiSchema { Type = "string" } } + } + }); + } +} diff --git a/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs b/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs index 58782cba..b7828243 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ConfigLoader.cs @@ -1,12 +1,14 @@ -using System.Collections; using System.Text.Json; using ImmichFrame.Core.Exceptions; -using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Models; using YamlDotNet.Serialization; namespace ImmichFrame.WebApi.Helpers.Config; +/// +/// Reads an existing configuration file so it can be imported into the database once. +/// The database is the source of truth afterwards; see . +/// public class ConfigLoader(ILogger _logger) { private string FindConfigFile(string dir, params string[] fileNames) @@ -20,100 +22,27 @@ private string FindConfigFile(string dir, params string[] fileNames) .FirstOrDefault(f => fileNames.Any(name => string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase))) ?? Path.Combine(dir, fileNames.First()); } - public IServerSettings LoadConfig(string configPath) - { - var config = LoadConfigRaw(configPath); - config.Validate(); - return config; - } - private IServerSettings LoadConfigRaw(string configPath) + + internal ServerSettings LoadConfigRaw(string configPath) { var jsonConfigPath = FindConfigFile(configPath, "Settings.json"); if (File.Exists(jsonConfigPath)) { - try - { - return LoadConfigJson(jsonConfigPath); - } - catch (Exception e) - { - _logger.LogWarning("Failed to load config as current version JSON. ({errorMessage})", e.Message); - } - - try - { - var v1 = LoadConfigJson(jsonConfigPath); - return new ServerSettingsV1Adapter(v1); - } - catch (Exception e) - { - _logger.LogWarning("Failed to load config as old JSON. ({errorMessage})", e.Message); - } + _logger.LogInformation("Loading configuration from {path}", jsonConfigPath); + return LoadConfigJson(jsonConfigPath); } var ymlConfigPath = FindConfigFile(configPath, "Settings.yml", "Settings.yaml"); if (File.Exists(ymlConfigPath)) { - try - { - return LoadConfigYaml(ymlConfigPath); - } - catch (Exception e) - { - _logger.LogWarning("Failed to load config as current version YAML. ({errorMessage})", e.Message); - } - - try - { - var v1 = LoadConfigYaml(ymlConfigPath); - return new ServerSettingsV1Adapter(v1); - } - catch (Exception e) - { - _logger.LogWarning("Failed to load config as old YAML. ({errorMessage})", e.Message); - } - } - - try - { - var v1 = LoadConfigFromDictionary(Environment.GetEnvironmentVariables()); - return new ServerSettingsV1Adapter(v1); - } - catch (Exception e) - { - _logger.LogWarning("Failed to load config as env vars ({errorMessage})", e.Message); + _logger.LogInformation("Loading configuration from {path}", ymlConfigPath); + return LoadConfigYaml(ymlConfigPath); } throw new ImmichFrameException("Failed to load configuration"); } - internal T LoadConfigFromDictionary(IDictionary env) where T : IConfigSettable, new() - { - var config = new T(); - var propertiesSet = 0; - - foreach (var key in env.Keys) - { - if (key == null) continue; - - var propertyInfo = typeof(T).GetProperty(key.ToString() ?? string.Empty); - - if (propertyInfo != null) - { - config.SetValue(propertyInfo, env[key]?.ToString() ?? string.Empty); - propertiesSet++; - } - } - - if (propertiesSet < 2) - { - throw new ImmichFrameException("No environment variables found"); - } - - return config; - } - - internal T LoadConfigJson(string configPath) where T : IConfigSettable, new() + internal T LoadConfigJson(string configPath) where T : new() { try { @@ -131,7 +60,8 @@ private IServerSettings LoadConfigRaw(string configPath) throw new SettingsNotValidException($"Problem with parsing the settings: {ex.Message}", ex); } } - internal T LoadConfigYaml(string configPath) where T : IConfigSettable, new() + + internal T LoadConfigYaml(string configPath) where T : new() { try { diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs deleted file mode 100644 index 076f36da..00000000 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ /dev/null @@ -1,141 +0,0 @@ -using ImmichFrame.Core.Interfaces; - -namespace ImmichFrame.WebApi.Helpers; - -/// -/// Original 'flat' Settings definition, which contains settings related to all components -/// -public class ServerSettingsV1 : IConfigSettable -{ - public string ImmichServerUrl { get; set; } = string.Empty; - public string ApiKey { get; set; } = string.Empty; - public bool ShowMemories { get; set; } = false; - public bool ShowFavorites { get; set; } = false; - public bool ShowArchived { get; set; } = false; - public bool ShowVideos { get; set; } = false; - public bool DownloadImages { get; set; } = false; - public int RenewImagesDuration { get; set; } = 30; - public int? ImagesFromDays { get; set; } - public DateTime? ImagesFromDate { get; set; } - public DateTime? ImagesUntilDate { get; set; } - public List Albums { get; set; } = new List(); - public List ExcludedAlbums { get; set; } = new List(); - public List People { get; set; } = new List(); - public List Tags { get; set; } = new List(); - public int? Rating { get; set; } - public List Webcalendars { get; set; } = new List(); - public int RefreshAlbumPeopleInterval { get; set; } = 12; - public string? WeatherApiKey { get; set; } = string.Empty; - public string? UnitSystem { get; set; } = "imperial"; - public string? WeatherLatLong { get; set; } = "40.7128,74.0060"; - public string Language { get; set; } = "en"; - public string? Webhook { get; set; } - public string? AuthenticationSecret { get; set; } - public int Interval { get; set; } = 45; - public double TransitionDuration { get; set; } = 1; - public bool ShowClock { get; set; } = true; - public string? ClockFormat { get; set; } = "hh:mm"; - public string? ClockDateFormat { get; set; } = "eee, MMM d"; - public bool ShowProgressBar { get; set; } = true; - public bool ShowPhotoDate { get; set; } = true; - public string? PhotoDateFormat { get; set; } = "MM/dd/yyyy"; - public bool ShowImageDesc { get; set; } = true; - public bool ShowPeopleDesc { get; set; } = true; - public bool ShowTagsDesc { get; set; } = true; - public bool ShowAlbumName { get; set; } = true; - public bool ShowImageLocation { get; set; } = true; - public string? ImageLocationFormat { get; set; } = "City,State,Country"; - public string? PrimaryColor { get; set; } - public string? SecondaryColor { get; set; } - public string Style { get; set; } = "none"; - public string? BaseFontSize { get; set; } - public bool ShowWeatherDescription { get; set; } = true; - public string? WeatherIconUrl { get; set; } = "https://openweathermap.org/img/wn/{IconId}.png"; - public bool ImageZoom { get; set; } = true; - public bool ImagePan { get; set; } = false; - public bool ImageFill { get; set; } = false; - public bool PlayAudio { get; set; } = false; - public string Layout { get; set; } = "splitview"; -} - -/// -/// Adapter to present a SettingsV1 object as an IServerSettings -/// -/// the V1 settings object to wrap -public class ServerSettingsV1Adapter(ServerSettingsV1 _delegate) : IServerSettings -{ - public IEnumerable Accounts => new List { new(_delegate) }; - public IGeneralSettings GeneralSettings => new GeneralSettingsV1Adapter(_delegate); - - public void Validate() - { - GeneralSettings.Validate(); - foreach (var account in Accounts) - { - account.ValidateAndInitialize(); - } - } - - class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings - { - public string ImmichServerUrl => _delegate.ImmichServerUrl; - public string ApiKey => _delegate.ApiKey; - public string? ApiKeyFile => null; // V1 settings didn't support paths to api keys. - public bool ShowMemories => _delegate.ShowMemories; - public bool ShowFavorites => _delegate.ShowFavorites; - public bool ShowArchived => _delegate.ShowArchived; - public bool ShowVideos => _delegate.ShowVideos; - public bool PlayAudio => _delegate.PlayAudio; - public int? ImagesFromDays => _delegate.ImagesFromDays; - public DateTime? ImagesFromDate => _delegate.ImagesFromDate; - public DateTime? ImagesUntilDate => _delegate.ImagesUntilDate; - public List Albums => _delegate.Albums; - public List ExcludedAlbums => _delegate.ExcludedAlbums; - public List People => _delegate.People; - public List Tags => _delegate.Tags; - public int? Rating => _delegate.Rating; - - public void ValidateAndInitialize() { } - } - - class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings - { - public List Webcalendars => _delegate.Webcalendars; - public int RefreshAlbumPeopleInterval => _delegate.RefreshAlbumPeopleInterval; - public string? WeatherApiKey => _delegate.WeatherApiKey; - public string? WeatherLatLong => _delegate.WeatherLatLong; - public string? UnitSystem => _delegate.UnitSystem; - public string? Webhook => _delegate.Webhook; - public string? AuthenticationSecret => _delegate.AuthenticationSecret; - public int Interval => _delegate.Interval; - public double TransitionDuration => _delegate.TransitionDuration; - public bool DownloadImages => _delegate.DownloadImages; - public int RenewImagesDuration => _delegate.RenewImagesDuration; - public bool ShowClock => _delegate.ShowClock; - public string? ClockFormat => _delegate.ClockFormat; - public string? ClockDateFormat => _delegate.ClockDateFormat; - public bool ShowProgressBar => _delegate.ShowProgressBar; - public bool ShowPhotoDate => _delegate.ShowPhotoDate; - public string? PhotoDateFormat => _delegate.PhotoDateFormat; - public bool ShowImageDesc => _delegate.ShowImageDesc; - public bool ShowPeopleDesc => _delegate.ShowPeopleDesc; - public bool ShowTagsDesc => _delegate.ShowTagsDesc; - public bool ShowAlbumName => _delegate.ShowAlbumName; - public bool ShowImageLocation => _delegate.ShowImageLocation; - public string? ImageLocationFormat => _delegate.ImageLocationFormat; - public string? PrimaryColor => _delegate.PrimaryColor; - public string? SecondaryColor => _delegate.SecondaryColor; - public string Style => _delegate.Style; - public string? BaseFontSize => _delegate.BaseFontSize; - public bool ShowWeatherDescription => _delegate.ShowWeatherDescription; - public string? WeatherIconUrl => _delegate.WeatherIconUrl; - public bool ImageZoom => _delegate.ImageZoom; - public bool ImagePan => _delegate.ImagePan; - public bool ImageFill => _delegate.ImageFill; - public bool PlayAudio => _delegate.PlayAudio; - public string Layout => _delegate.Layout; - public string Language => _delegate.Language; - - public void Validate() { } - } -} diff --git a/ImmichFrame.WebApi/Helpers/CustomAuthenticationMiddleware.cs b/ImmichFrame.WebApi/Helpers/CustomAuthenticationMiddleware.cs index 9081068a..fcb46ae3 100644 --- a/ImmichFrame.WebApi/Helpers/CustomAuthenticationMiddleware.cs +++ b/ImmichFrame.WebApi/Helpers/CustomAuthenticationMiddleware.cs @@ -1,4 +1,6 @@ +using ImmichFrame.WebApi.Helpers; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; public class CustomAuthenticationMiddleware { @@ -11,7 +13,7 @@ public CustomAuthenticationMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { - var result = await context.AuthenticateAsync("ImmichFrameScheme"); + var result = await context.AuthenticateAsync(GetScheme(context)); if (!result.Succeeded) { @@ -22,4 +24,17 @@ public async Task InvokeAsync(HttpContext context) await _next(context); } -} \ No newline at end of file + + private static string GetScheme(HttpContext context) + { + // Admin endpoints declare their scheme via [Authorize(AuthenticationSchemes = ...)]; + // everything else keeps the original client scheme behavior. + var authorizeData = context.GetEndpoint()?.Metadata?.GetMetadata(); + if (authorizeData?.AuthenticationSchemes?.Contains(ImmichFrameAdminAuthenticationHandler.SchemeName) == true) + { + return ImmichFrameAdminAuthenticationHandler.SchemeName; + } + + return "ImmichFrameScheme"; + } +} diff --git a/ImmichFrame.WebApi/Helpers/ImmichFrameAdminAuthenticationHandler.cs b/ImmichFrame.WebApi/Helpers/ImmichFrameAdminAuthenticationHandler.cs new file mode 100644 index 00000000..cf779902 --- /dev/null +++ b/ImmichFrame.WebApi/Helpers/ImmichFrameAdminAuthenticationHandler.cs @@ -0,0 +1,62 @@ +using ImmichFrame.WebApi.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace ImmichFrame.WebApi.Helpers; + +public class ImmichFrameAdminAuthenticationHandler : AuthenticationHandler +{ + public const string SchemeName = "ImmichFrameAdminScheme"; + + private readonly AdminAuthService _adminAuthService; + + public ImmichFrameAdminAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + AdminAuthService adminAuthService) + : base(options, logger, encoder) + { + _adminAuthService = adminAuthService; + } + + protected override Task HandleAuthenticateAsync() + { + var endpoint = Context.GetEndpoint(); + if (endpoint?.Metadata?.GetMetadata() != null) + { + return Task.FromResult(AuthenticateResult.Success(CreateTicket("anonymous"))); + } + + if (!_adminAuthService.AdminEnabled) + { + return Task.FromResult(AuthenticateResult.Fail( + "ImmichFrame is not set up yet. Open /admin to choose an admin password, " + + $"or set the {AdminAuthService.AdminPasswordEnvVar} environment variable.")); + } + + var authHeader = Request.Headers.Authorization.ToString(); + if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + } + + var token = authHeader.Substring("Bearer ".Length).Trim(); + if (!_adminAuthService.ValidatePassword(token)) + { + return Task.FromResult(AuthenticateResult.Fail("The admin password was not correct!")); + } + + return Task.FromResult(AuthenticateResult.Success(CreateTicket("admin"))); + } + + private AuthenticationTicket CreateTicket(string name) + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, name) }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + return new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + } +} diff --git a/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs b/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs index 6a391fd7..41d331fb 100644 --- a/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs +++ b/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs @@ -24,8 +24,9 @@ protected override Task HandleAuthenticateAsync() { var endpoint = Context.GetEndpoint(); var authorizeAttribute = endpoint?.Metadata?.GetMetadata(); + var allowAnonymous = endpoint?.Metadata?.GetMetadata(); - if (_authenticationSecret == null || authorizeAttribute == null) + if (_authenticationSecret == null || authorizeAttribute == null || allowAnonymous != null) { // No auth is required var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "anonymous") }; diff --git a/ImmichFrame.WebApi/Helpers/ImmichServerVersionChecker.cs b/ImmichFrame.WebApi/Helpers/ImmichServerVersionChecker.cs index f8f4ae13..1810cf69 100644 --- a/ImmichFrame.WebApi/Helpers/ImmichServerVersionChecker.cs +++ b/ImmichFrame.WebApi/Helpers/ImmichServerVersionChecker.cs @@ -4,6 +4,8 @@ namespace ImmichFrame.WebApi.Helpers { + public record AccountCheckResult(bool Success, string Message, string? Version); + public static class ImmichServerVersionChecker { private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); @@ -13,14 +15,44 @@ public static class ImmichServerVersionChecker /// public const int MinimumSupportedMajorVersion = 3; + /// + /// Checks a single account: is the Immich server reachable and running a supported version? + /// + public static async Task CheckAccount(IAccountSettings account, IHttpClientFactory httpClientFactory) + { + try + { + var httpClient = httpClientFactory.CreateClient("ImmichApiAccountClient"); + httpClient.UseApiKey(account.ApiKey); + var immichApi = new ImmichApi(account.ImmichServerUrl, httpClient); + + using var cts = new CancellationTokenSource(RequestTimeout); + var version = await immichApi.GetServerVersionAsync(cts.Token); + var versionString = $"{version.Major}.{version.Minor}.{version.Patch}"; + + if (version.Major < MinimumSupportedMajorVersion) + { + return new AccountCheckResult(false, + $"Immich server {account.ImmichServerUrl} is running v{versionString}, but this version of ImmichFrame requires Immich v{MinimumSupportedMajorVersion} or newer. Please update your Immich server.", + versionString); + } + + return new AccountCheckResult(true, + $"Immich server {account.ImmichServerUrl} is running v{versionString}", versionString); + } + catch (Exception ex) + { + return new AccountCheckResult(false, + $"Could not determine Immich server version for {account.ImmichServerUrl}: {ex.Message}", null); + } + } + /// /// Checks and logs the version of every configured Immich server. /// /// /// true only if every configured Immich server was reachable and reported a version of - /// v or newer. Returns false if the configuration - /// could not be loaded, if a server reported an older version, or if a server's version could not - /// be determined (e.g. unreachable) — in every one of these cases ImmichFrame must not start. + /// v or newer. /// public static async Task CheckServerVersions(IServiceProvider services, ILogger logger) { @@ -37,38 +69,22 @@ public static async Task CheckServerVersions(IServiceProvider services, IL var httpClientFactory = services.GetRequiredService(); - var allCompatible = true; + // In parallel: one unreachable server must not add its timeout to all the others + var results = await Task.WhenAll(accounts.Select(account => CheckAccount(account, httpClientFactory))); - foreach (var account in accounts) + foreach (var result in results) { - try + if (result.Success) { - var httpClient = httpClientFactory.CreateClient("ImmichApiAccountClient"); - httpClient.UseApiKey(account.ApiKey); - var immichApi = new ImmichApi(account.ImmichServerUrl, httpClient); - - using var cts = new CancellationTokenSource(RequestTimeout); - var version = await immichApi.GetServerVersionAsync(cts.Token); - - logger.LogInformation("Immich server {Url} is running v{Major}.{Minor}.{Patch}", - account.ImmichServerUrl, version.Major, version.Minor, version.Patch); - - if (version.Major < MinimumSupportedMajorVersion) - { - allCompatible = false; - logger.LogCritical("Immich server {Url} is running v{Major}.{Minor}.{Patch}, but this version of ImmichFrame requires Immich v{Minimum} or newer. Please update your Immich server.", - account.ImmichServerUrl, version.Major, version.Minor, version.Patch, MinimumSupportedMajorVersion); - } + logger.LogInformation("{Message}", result.Message); } - catch (Exception ex) + else { - allCompatible = false; - logger.LogCritical("Could not determine Immich server version for {Url}: {Message}", - account.ImmichServerUrl, ex.Message); + logger.LogCritical("{Message}", result.Message); } } - return allCompatible; + return results.All(r => r.Success); } } } diff --git a/ImmichFrame.WebApi/Helpers/SettingsExtensonMethods.cs b/ImmichFrame.WebApi/Helpers/SettingsExtensonMethods.cs deleted file mode 100644 index 9fae7451..00000000 --- a/ImmichFrame.WebApi/Helpers/SettingsExtensonMethods.cs +++ /dev/null @@ -1,66 +0,0 @@ -using ImmichFrame.WebApi.Models; -using System.Reflection; - -namespace ImmichFrame.WebApi.Helpers -{ - public interface IConfigSettable - { - // marker interface denoting settable config class - } - - public static class SettingsExtensions - { - public static void SetValue(this IConfigSettable settings, PropertyInfo prop, string value) - { - var type = prop.PropertyType; - if (type == typeof(List)) - { - if (string.IsNullOrWhiteSpace(value)) - { - prop.SetValue(settings, new List()); - return; - } - - prop.SetValue(settings, value.Split(',').Select(x => new Guid(x.Trim())).ToList()); - } - else if (type == typeof(List)) - { - if (string.IsNullOrWhiteSpace(value)) - { - prop.SetValue(settings, new List()); - return; - } - - prop.SetValue(settings, value.Split(',').Select(x => x.Trim()).ToList()); - } - else if (type == typeof(string)) - { - prop.SetValue(settings, value); - } - else if (type == typeof(bool)) - { - prop.SetValue(settings, bool.Parse(value)); - } - else if (type == typeof(int) || type == typeof(int?)) - { - if (string.IsNullOrWhiteSpace(value)) return; - - prop.SetValue(settings, Convert.ToInt32(value)); - } - else if (type == typeof(double)) - { - prop.SetValue(settings, Convert.ToDouble(value)); - } - else if (type == typeof(DateTime) || type == typeof(DateTime?)) - { - if (string.IsNullOrWhiteSpace(value)) return; - - prop.SetValue(settings, Convert.ToDateTime(value)); - } - else - { - throw new ArgumentException($"{prop.Name} could not be parsed: {type.Name} is not supported in {nameof(SettingsExtensions)}.{nameof(SetValue)}"); - } - } - } -} diff --git a/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj b/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj index 3aae6933..7f165d23 100644 --- a/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj +++ b/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj @@ -12,6 +12,10 @@ + + + all + diff --git a/ImmichFrame.WebApi/Models/LiveSettings.cs b/ImmichFrame.WebApi/Models/LiveSettings.cs new file mode 100644 index 00000000..c7cc9803 --- /dev/null +++ b/ImmichFrame.WebApi/Models/LiveSettings.cs @@ -0,0 +1,60 @@ +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.WebApi.Models; + +/// +/// Delegating facade over so that +/// constructor-injected settings references in singletons never go stale +/// when the settings are updated at runtime. +/// +public class LiveServerSettings(ISettingsProvider _provider) : IServerSettings +{ + public IEnumerable Accounts => _provider.Current.Accounts; + public IGeneralSettings GeneralSettings => _provider.Current.GeneralSettings; + public void Validate() => _provider.Current.Validate(); +} + +/// +public class LiveGeneralSettings(ISettingsProvider _provider) : IGeneralSettings +{ + private IGeneralSettings Current => _provider.Current.GeneralSettings; + + public int Interval => Current.Interval; + public double TransitionDuration => Current.TransitionDuration; + public bool DownloadImages => Current.DownloadImages; + public int RenewImagesDuration => Current.RenewImagesDuration; + public bool ShowClock => Current.ShowClock; + public string? ClockFormat => Current.ClockFormat; + public string? ClockDateFormat => Current.ClockDateFormat; + public bool ShowPhotoDate => Current.ShowPhotoDate; + public bool ShowProgressBar => Current.ShowProgressBar; + public string? PhotoDateFormat => Current.PhotoDateFormat; + public bool ShowImageDesc => Current.ShowImageDesc; + public bool ShowPeopleDesc => Current.ShowPeopleDesc; + public bool ShowTagsDesc => Current.ShowTagsDesc; + public bool ShowAlbumName => Current.ShowAlbumName; + public bool ShowImageLocation => Current.ShowImageLocation; + public string? ImageLocationFormat => Current.ImageLocationFormat; + public string? PrimaryColor => Current.PrimaryColor; + public string? SecondaryColor => Current.SecondaryColor; + public string Style => Current.Style; + public string? BaseFontSize => Current.BaseFontSize; + public bool ShowWeatherDescription => Current.ShowWeatherDescription; + public string? WeatherIconUrl => Current.WeatherIconUrl; + public bool ImageZoom => Current.ImageZoom; + public bool ImagePan => Current.ImagePan; + public bool ImageFill => Current.ImageFill; + public bool PlayAudio => Current.PlayAudio; + public string Layout => Current.Layout; + public string Language => Current.Language; + public List Webcalendars => Current.Webcalendars; + public int RefreshAlbumPeopleInterval => Current.RefreshAlbumPeopleInterval; + public string? WeatherApiKey => Current.WeatherApiKey; + public string? WeatherLatLong => Current.WeatherLatLong; + public string? UnitSystem => Current.UnitSystem; + public string? Webhook => Current.Webhook; + public string? AuthenticationSecret => Current.AuthenticationSecret; + public string? AdminPassword => Current.AdminPassword; + + public void Validate() => Current.Validate(); +} diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..c18d6a73 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -1,11 +1,10 @@ using System.Text.Json.Serialization; using ImmichFrame.Core.Interfaces; -using ImmichFrame.WebApi.Helpers; using YamlDotNet.Serialization; namespace ImmichFrame.WebApi.Models; -public class ServerSettings : IServerSettings, IConfigSettable +public class ServerSettings : IServerSettings { [YamlMember(Alias = "General")] [JsonPropertyName("General")] @@ -35,7 +34,7 @@ public void Validate() } } -public class GeneralSettings : IGeneralSettings, IConfigSettable +public class GeneralSettings : IGeneralSettings { public bool DownloadImages { get; set; } = false; public string Language { get; set; } = "en"; @@ -72,11 +71,12 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public string? WeatherLatLong { get; set; } = "40.7128,74.0060"; public string? Webhook { get; set; } public string? AuthenticationSecret { get; set; } + public string? AdminPassword { get; set; } public void Validate() { } } -public class ServerAccountSettings : IAccountSettings, IConfigSettable +public class ServerAccountSettings : IAccountSettings { public string ImmichServerUrl { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty; diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index a38d9d0b..5cac7191 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -1,12 +1,18 @@ using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; -using ImmichFrame.WebApi.Models; using Microsoft.AspNetCore.Authentication; using System.Reflection; using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.AccountSelection; +using ImmichFrame.WebApi.Database; using ImmichFrame.WebApi.Helpers; using ImmichFrame.WebApi.Helpers.Config; +using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -57,10 +63,17 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ .FirstOrDefault(d => string.Equals(Path.GetFileName(d), "Config", StringComparison.OrdinalIgnoreCase)) ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config"); builder.Services.AddTransient(); -builder.Services.AddSingleton(srv => srv.GetRequiredService().LoadConfig(configPath)); -// Register sub-settings -builder.Services.AddSingleton(srv => srv.GetRequiredService().GeneralSettings); +// Settings live in a SQLite db in the config directory; file/env config is imported on first run +builder.Services.AddDbContextFactory(options => + options.UseSqlite($"Data Source={Path.Combine(configPath, "immichframe.db")}")); +builder.Services.AddSingleton(new SettingsServiceOptions(configPath)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(srv => srv.GetRequiredService()); + +// Register settings as live facades over the provider so config changes apply without restart +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(srv => srv.GetRequiredService()); builder.Services.AddSingleton(srv => srv.GetRequiredService()); @@ -75,17 +88,36 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddTransient>(srv => account => ActivatorUtilities.CreateInstance(srv, account)); -builder.Services.AddSingleton(); +// The account logic graph is frozen at construction; wrap it so it can be rebuilt on settings changes +builder.Services.AddSingleton>(srv => + () => ActivatorUtilities.CreateInstance(srv)); +builder.Services.AddSingleton(); -builder.Services.AddControllers(); +builder.Services.AddControllers() + .AddJsonOptions(options => + options.JsonSerializerOptions.Converters.Add( + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase))); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => options.SchemaFilter()); +builder.Services.AddSwaggerGen(options => +{ + options.SchemaFilter(); + options.AddSecurityDefinition(ImmichFrameAdminAuthenticationHandler.SchemeName, new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + Description = "The admin password, sent as a bearer token." + }); + options.OperationFilter(); +}); builder.Services.AddAuthorization(options => { options.AddPolicy("AllowAnonymous", policy => policy.RequireAssertion(context => true)); }); +builder.Services.AddSingleton(); + builder.Services.AddAuthentication("ImmichFrameScheme") - .AddScheme("ImmichFrameScheme", options => { }); + .AddScheme("ImmichFrameScheme", options => { }) + .AddScheme(ImmichFrameAdminAuthenticationHandler.SchemeName, options => { }); var app = builder.Build(); @@ -121,13 +153,30 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ app.MapFallbackToFile("/index.html"); -var immichStartupAllowed = await ImmichServerVersionChecker.CheckServerVersions(app.Services, app.Logger); -if (!immichStartupAllowed) +// Skipped when tests replace ISettingsProvider with a stub +if (app.Services.GetRequiredService() is SettingsService settingsService) { - app.Logger.LogCritical("ImmichFrame cannot start: Immich server requirements are not satisfied (see log above). Shutting down."); - Environment.Exit(1); + await settingsService.InitializeAsync(); } +// Deliberately not awaited: an unreachable Immich server must not delay startup, otherwise +// the admin UI needed to fix that very server stays unreachable too. +_ = Task.Run(async () => +{ + try + { + var immichServersOk = await ImmichServerVersionChecker.CheckServerVersions(app.Services, app.Logger); + if (!immichServersOk) + { + app.Logger.LogCritical("One or more Immich servers are unreachable or unsupported (see log above). The slideshow may not work — fix the account settings via the admin UI at /admin."); + } + } + catch (Exception ex) + { + app.Logger.LogCritical("Immich server version check failed: {Message}", ex.Message); + } +}); + app.Run(); // Make Program public for WebApplicationFactory diff --git a/ImmichFrame.WebApi/Services/AdminAuthService.cs b/ImmichFrame.WebApi/Services/AdminAuthService.cs new file mode 100644 index 00000000..107e8a5a --- /dev/null +++ b/ImmichFrame.WebApi/Services/AdminAuthService.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ImmichFrame.WebApi.Services; + +/// What the admin UI should show. +public enum AdminUiState +{ + /// Fresh install with no password anywhere — anonymous onboarding is open. + Setup, + + /// An admin password exists; ask for it. + Login, + + /// + /// Configured, but no admin password: the instance is already somebody's, so + /// onboarding stays shut. Only the environment variable gets you back in. + /// + Disabled +} + +/// +/// Resolves the admin password. The environment variable always wins so a lockout +/// (wrong password saved via the UI) can be recovered without touching the database. +/// If neither source is set, the admin interface is disabled. +/// +public class AdminAuthService(SettingsService _settingsService) +{ + public const string AdminPasswordEnvVar = "IMMICHFRAME_ADMIN_PASSWORD"; + + public string? GetAdminPassword() + { + var fromEnv = Environment.GetEnvironmentVariable(AdminPasswordEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return fromEnv; + } + + var fromSettings = _settingsService.Current.GeneralSettings.AdminPassword; + return string.IsNullOrWhiteSpace(fromSettings) ? null : fromSettings; + } + + public bool AdminEnabled => GetAdminPassword() != null; + + /// + /// Onboarding only opens on a genuinely fresh install. An instance that already has + /// settings — imported from a config file or saved earlier — is somebody's, even + /// without an admin password, so it reports instead. + /// + public bool SetupRequired => GetAdminPassword() == null && _settingsService.IsUnconfigured; + + public AdminUiState State => AdminEnabled + ? AdminUiState.Login + : SetupRequired ? AdminUiState.Setup : AdminUiState.Disabled; + + public bool ValidatePassword(string candidate) + { + var password = GetAdminPassword(); + if (password == null) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(candidate), + Encoding.UTF8.GetBytes(password)); + } +} diff --git a/ImmichFrame.WebApi/Services/SettingsService.cs b/ImmichFrame.WebApi/Services/SettingsService.cs new file mode 100644 index 00000000..cf1e060e --- /dev/null +++ b/ImmichFrame.WebApi/Services/SettingsService.cs @@ -0,0 +1,308 @@ +using System.Text.Json; +using ImmichFrame.Core.Exceptions; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Database; +using ImmichFrame.WebApi.Helpers.Config; +using ImmichFrame.WebApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace ImmichFrame.WebApi.Services; + +public record SettingsServiceOptions(string ConfigPath); + +/// Outcome of an anonymous onboarding attempt. +public enum SetupResult +{ + /// This request set the initial admin password. + Claimed, + + /// Somebody else got there first; the instance is already configured. + AlreadyClaimed +} + +/// +/// Owns the runtime settings. The SQLite database is the source of truth; an existing +/// file config is imported once on first run. always holds a +/// validated, immutable-after-publication snapshot; updates swap the reference and raise +/// . +/// +public class SettingsService : ISettingsProvider +{ + private readonly IDbContextFactory _dbFactory; + private readonly ConfigLoader _configLoader; + private readonly ILogger _logger; + private readonly SettingsServiceOptions _options; + private readonly SemaphoreSlim _updateLock = new(1, 1); + + private volatile IServerSettings _current = EmptySettings(); + // The raw (pre-Validate) form: ApiKeyFile stays unresolved so it round-trips to the admin UI and DB + private ServerSettings _raw = EmptySettings(); + private volatile bool _isUnconfigured; + + /// + /// This instance has never been configured: no database row, and no config file to + /// import either. Only then may anonymous onboarding claim it. Defaults to false so + /// an uninitialized service never opens onboarding by accident. + /// + public bool IsUnconfigured => _isUnconfigured; + + public SettingsService(IDbContextFactory dbFactory, ConfigLoader configLoader, + ILogger logger, SettingsServiceOptions options) + { + _dbFactory = dbFactory; + _configLoader = configLoader; + _logger = logger; + _options = options; + } + + public IServerSettings Current => _current; + public event EventHandler? SettingsChanged; + + public async Task InitializeAsync() + { + Directory.CreateDirectory(_options.ConfigPath); + await using var db = await _dbFactory.CreateDbContextAsync(); + await db.Database.MigrateAsync(); + + var row = await db.SettingsDocuments.FindAsync(1); + if (row != null) + { + LogIgnoredFileConfig(); + var raw = Deserialize(row.Json); + _raw = raw; + _current = ValidateOrSafeMode(raw); + _logger.LogInformation("Loaded settings from database (last updated {updatedAt:u})", row.UpdatedAtUtc); + return; + } + + await ImportOrBootstrap(db); + } + + private async Task ImportOrBootstrap(SettingsDbContext db) + { + ServerSettings raw; + try + { + raw = _configLoader.LoadConfigRaw(_options.ConfigPath); + } + catch (ImmichFrameException) + { + _logger.LogWarning("No configuration found (file or database). Starting with defaults — use the admin UI to configure ImmichFrame."); + _raw = EmptySettings(); + _current = _raw; + _isUnconfigured = true; + return; + } + + Normalize(raw); + + // Only lock the config into the DB if it is actually valid; otherwise the old + // fix-the-file-and-restart workflow must keep working. + var validated = Clone(raw); + try + { + validated.Validate(); + } + catch (Exception ex) + { + _logger.LogCritical("Existing configuration is invalid and was not imported: {message}. Fix the configuration and restart.", ex.Message); + _raw = raw; + _current = ValidateOrSafeMode(raw); + return; + } + + db.SettingsDocuments.Add(new SettingsDocument + { + Id = 1, + Json = Serialize(raw), + UpdatedAtUtc = DateTime.UtcNow, + ImportedFrom = DetectImportSource(), + Version = 1 + }); + await db.SaveChangesAsync(); + + _raw = raw; + _current = validated; + var source = DetectImportSource(); + _logger.LogInformation("Imported existing configuration ({source}) into the database. The database is now the source of truth; changes to the old config are ignored.", source); + _logger.LogWarning("DEPRECATED: configuring ImmichFrame via {source} will be removed in a future version. The import above runs once — manage your settings in the admin UI at /admin from now on.", source); + } + + public async Task UpdateAsync(ServerSettings raw) + { + var validated = NormalizeAndValidate(raw); + + await _updateLock.WaitAsync(); + try + { + return await PersistAsync(raw, validated); + } + finally + { + _updateLock.Release(); + } + } + + /// + /// Claims a never-configured instance by writing the initial admin password. The + /// check runs under the same lock as the write, so + /// two concurrent onboarding requests cannot both succeed; the loser gets + /// . + /// + public async Task TryClaimSetupAsync(string adminPassword, Func setupRequired) + { + await _updateLock.WaitAsync(); + try + { + if (!setupRequired()) + { + return SetupResult.AlreadyClaimed; + } + + var raw = Clone(_raw); + raw.GeneralSettingsImpl ??= new GeneralSettings(); + raw.GeneralSettingsImpl.AdminPassword = adminPassword; + + var validated = NormalizeAndValidate(raw); + await PersistAsync(raw, validated); + return SetupResult.Claimed; + } + finally + { + _updateLock.Release(); + } + } + + private static ServerSettings NormalizeAndValidate(ServerSettings raw) + { + Normalize(raw); + var validated = Clone(raw); + try + { + validated.Validate(); + } + catch (Exception ex) + { + throw new SettingsNotValidException(ex.Message, ex); + } + + return validated; + } + + /// Writes the settings and publishes them. Caller must hold . + private async Task PersistAsync(ServerSettings raw, ServerSettings validated) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var row = await db.SettingsDocuments.FindAsync(1); + if (row == null) + { + row = new SettingsDocument { Id = 1 }; + db.SettingsDocuments.Add(row); + } + + row.Json = Serialize(raw); + row.UpdatedAtUtc = DateTime.UtcNow; + row.Version++; + await db.SaveChangesAsync(); + + var old = _raw; + var accountsChanged = Serialize(old.AccountsImpl) != Serialize(raw.AccountsImpl) + || old.GeneralSettings.RefreshAlbumPeopleInterval != validated.GeneralSettings.RefreshAlbumPeopleInterval; + var generalChanged = Serialize(old.GeneralSettingsImpl) != Serialize(raw.GeneralSettingsImpl); + + _raw = Clone(raw); + _current = validated; + _isUnconfigured = false; + + _logger.LogInformation("Settings updated (accounts changed: {accountsChanged})", accountsChanged); + SettingsChanged?.Invoke(this, new SettingsChangedEventArgs + { + NewSettings = validated, + AccountsChanged = accountsChanged, + GeneralChanged = generalChanged + }); + + return validated; + } + + /// The raw settings for editing: secrets included, ApiKeyFile unresolved. + public ServerSettings GetRawSettings() => Clone(_raw); + + private ServerSettings ValidateOrSafeMode(ServerSettings raw) + { + var clone = Clone(raw); + try + { + clone.Validate(); + return clone; + } + catch (Exception ex) + { + _logger.LogCritical("Stored settings failed validation: {message}. Starting with the valid subset — fix the configuration via the admin UI.", ex.Message); + } + + var safe = Clone(raw); + var validAccounts = new List(); + foreach (var account in safe.AccountsImpl) + { + try + { + account.ValidateAndInitialize(); + validAccounts.Add(account); + } + catch (Exception ex) + { + _logger.LogCritical("Skipping invalid account '{url}': {message}", account.ImmichServerUrl, ex.Message); + } + } + + safe.AccountsImpl = validAccounts; + return safe; + } + + private void LogIgnoredFileConfig() + { + var ignored = new[] { "Settings.json", "Settings.yml", "Settings.yaml" } + .Where(f => File.Exists(Path.Combine(_options.ConfigPath, f))) + .ToList(); + if (ignored.Any()) + { + _logger.LogInformation("Config file(s) {files} exist but are ignored: settings were already imported into the database, which is the source of truth. Use the admin UI to change settings.", string.Join(", ", ignored)); + } + } + + private string DetectImportSource() + { + if (File.Exists(Path.Combine(_options.ConfigPath, "Settings.json"))) return "Settings.json"; + if (File.Exists(Path.Combine(_options.ConfigPath, "Settings.yml"))) return "Settings.yml"; + if (File.Exists(Path.Combine(_options.ConfigPath, "Settings.yaml"))) return "Settings.yaml"; + return "an unknown source"; + } + + private static ServerSettings EmptySettings() => new() + { + GeneralSettingsImpl = new GeneralSettings(), + AccountsImpl = new List() + }; + + private static void Normalize(ServerSettings settings) + { + settings.GeneralSettingsImpl ??= new GeneralSettings(); + settings.AccountsImpl ??= new List(); + } + + internal static string Serialize(object? value) => JsonSerializer.Serialize(value); + + private static ServerSettings Deserialize(string json) + { + var settings = JsonSerializer.Deserialize(json) + ?? throw new SettingsNotValidException("Stored settings could not be parsed"); + Normalize(settings); + return settings; + } + + private static ServerSettings Clone(ServerSettings settings) + { + return Deserialize(JsonSerializer.Serialize(settings)); + } +} diff --git a/Install_Client.md b/Install_Client.md deleted file mode 100644 index b4aa57c8..00000000 --- a/Install_Client.md +++ /dev/null @@ -1,148 +0,0 @@ -## 🔙 Back -Go back to the [Full Readme](/README.md). - -## 💻 ImmichFrame Client -- [🔙 Back](#-back) -- [💻 ImmichFrame Client](#-immichframe-client) -- [✨ Demo](#-demo) -- [🔧 Installation](#-installation) - - [Windows](#windows) - - [Linux](#linux) - - [Autostart on Raspberry Pi OS](#autostart-on-raspberry-pi-os) - - [MacOS](#macos) - - [iOS/iPadOS](#ios-and-ipados) - - [Apple TV](#apple-tv) - - [Android](#android) - - [Android Screensaver](#android-screensaver) -- [⚙️ Configuration](#️-configuration) -- [💬 Interactions](#-interactions) - - [Touch/Mouse](#touchmouse) - - [Keyboard:](#keyboard) - - [Settings GUI](#settings-gui) -- [🆘 Help](#-help) - - -## 🔧 Installation - -### Windows -- Double-click `Immich_Frame.exe`. -- Screensaver - Rename `Immich_Frame.exe` to `Immich_Frame.scr`. Right-click → Install. Configure screensaver settings and apply. - - You will still have to click middle/bottom in the app to exit screensaver. - -### Linux - -- GUI - Double-click `Immich_Frame`. -- CLI - CD into folder, and launch with `./Immich_Frame`. -- SSH - CD into folder, and launch with `DISPLAY=:0.0 ./Immich_Frame`. -- Ubuntu Desktop - Right-click Immich_Frame, properties, set 'Execute as program' to True, OK. Right-click Immich_Frame, Open with, choose 'Run Software', always use for this file type TRUE. -- If you get a permissions error run `chmod +x Immich_Frame`. - -#### Autostart on Raspberry Pi OS - -The latest Raspberry Pi OS, Bookworm uses Wayland as default, but also includes X11. The following assumes standard user is `pi` and you are working on a fresh install of "Raspberry Pi OS with desktop". -To autostart ImmichFrame in Wayland, run `nano /home/pi/.config/wayfire.ini` in terminal, to add the following at the end. - -``` -[autostart] -immichframe=/home/pi/{dir with Immich_Frame}/Immich_Frame -``` - -Wayland does not have an easy way to hide the cursor. If you want that, then change to X11 as the default desktop session through running `sudo raspi-config` in terminal, then Advanced Options → Wayland → X11. -Then install unclutter: - -``` -sudo apt install -y unclutter-xfixes -``` - -After this, copy the default autostart file to your home folder and add a line to enable autostart, by running the following in terminal: - -``` -cp /etc/xdg/lxsession/LXDE-pi/autostart /home/pi/.config/lxsession/LXDE-pi/autostart -echo "@/home/pi/{dir with Immich_Frame}/Immich_Frame" > sudo tee /home/pi/.config/lxsession/LXDE-pi/autostart -``` - -### MacOS - -- Open DMG, drag immichframe.app to applications folder. -- If you get an error that it is "damaged and can't be opened, you should move it to the trash" run `xattr -c /Applications/immichframe.app` in terminal. - -### iOS and iPadOS -You can "install" ImmichFrame as a PWA by opening in a browser and going to Share Menu-Add to Homescreen. - -### Apple TV -ImmichFrame is available on the [Apple TV App Store][app-store-link]. - -### Android -The Android-Version of ImmichFrame is available on the [Google Play Store][play-store-link]. Download it via the store for automatic updates. You can also sideload via APK available in [ImmichFrame_Android Releases](https://github.com/immichFrame/ImmichFrame_Android/releases). - - -#### Android Screensaver - -- Run the app normally and configure settings. -- Go to Settings, Display, Advanced, Screen Saver, Current Screen Saver, choose ImmichFrame. Settings, Display, Advanced, Sleep, choose your sleep timeout. The menu options may differ slightly on different Android versions. - - If you are unable to set ImmichFrame as a screen saver you may need to run this ADB command `adb shell settings put secure screensaver_components com.immichframe.immichframe/.ScreenSaverService` - - To view screensaver timeout use this ADB command `adb shell settings get system screen_off_timeout` - - To set screensaver timeout use this ADB command `adb shell settings put system screen_off_timeout 60000` (timeout is is ms, so this would be 60 seconds). - -#### Frameo -ImmichFrame can be run on inexpensive Frameo digital photo frames with some additional effort. You can typically find these for ~$40 USD. These devices are low powered and run a very old Android version, so they cannot run the full WebView version of the app (however most of the main features are still supported except SplitView). If you have not already, you will need to install ADB on your PC ([ADB instructions][ADB-link]). -ADB is often enabled on these devices by default, if it is not go to Frameo Settings-About-Enable Beta Program. Toggle ADB Access On-Off-On. Transfer from computer also must be enabled ( Frame → Settings → Manage photos → Transfer from computer → toggle “Enable transfer from computer” ON ). Use the ADB commands below to sideload ImmichFrame APK, configure it to your liking, then disable the Frameo app to to set ImmichFrame as default Home app: - - Sideload ImmichFrame: adb install /path/to/ImmichFrame_vXX.apk - - Update existing ImmichFrame: adb install -r /path/to/ImmichFrame_vXX.apk - - Start ImmichFrame: adb shell am start com.immichframe.immichframe/.MainActivity - - Swipe down to enter ImmichFrame Settings - - Configure URL and Authorization Secret (optional) - - Disable WebView - - Disable Frameo: - - adb shell su - - pm disable net.frameo.frame - - exit - - If this doesn't stick on reboot, repeat the commands but power cycle after exit command - - Some other useful ADB commands: - - Reboot: adb reboot - - You can also reboot or shutdown by holding down power button - - Access Android Settings: adb shell am start -a android.settings.SETTINGS - - Re-enable Frameo: repeat disable commands above but replace "disable" with "enable" - - Start Frameo app: adb shell am start net.frameo.frame - - Uninstall ImmichFrame: adb uninstall com.immichframe.immichframe - -## ⚙️ Configuration - -Simply enter the URL of ImmichFrame web Docker. All other configuration is handled in the Docker container. - -## 💬 Interactions - -### Android -Settings - swipe down. -Previous Image/Pause/Next Image - Touch the left side/center/or right side of screen -### Android TV and Apple TV -Settings - D-pad UP. -Previous Image/Pause/Next Image - D-pad left/D-pad center/D-pad right - -### Desktop (Windows, MacOS, Linux) - -The screen is configured in a 3x3 gird. You can touch or click: - -| - | **Settings** | - | -| :---------------: | :----------: | :---------------: | -| **Prev
image** | **Pause** | **Next
image** | -| - | **Quit** | - | - -### Keyboard: -**Settings** - Up arrow
-**Quit** - Down arrow
-**Prev Image** - Left arrow
-**Next Image** - Right arrow
-**Pause** - Enter/Return
- -## 🆘 Help - -[Discord Channel][support-url] - - - -[support-url]: https://discord.com/channels/979116623879368755/1217843270244372480 -[play-store-link]: https://play.google.com/store/apps/details?id=com.immichframe.immichframe -[app-store-link]: https://apps.apple.com/us/app/immichframe/id6742748077 -[releases-url]: https://github.com/3rob3/ImmichFrame/releases/latest -[ADB-link]: https://www.xda-developers.com/install-adb-windows-macos-linux/ diff --git a/Install_Web.md b/Install_Web.md deleted file mode 100644 index 5cf3f94c..00000000 --- a/Install_Web.md +++ /dev/null @@ -1,137 +0,0 @@ -## 🔙 Back -Go back to the [Full Readme](/README.md) - -## 🌐 ImmichFrame Web -- [🔙 Back](#-back) -- [🌐 ImmichFrame Web](#-immichframe-web) -- [✨ Demo](#-demo) -- [🔧 Installation](#-installation) -- [🐋 Docker Compose](#-docker-compose) - - [Docker Compose with environment variables](#docker-compose-with-environment-variables) - - [Docker Compose with Settings.json](#docker-compose-with-settingsjson) - - [Docker Compose with env file](#docker-compose-with-env-file) -- [⚙️ Configuration](#️-configuration) -- [🆘 Help](#-help) - -## ✨ Demo -![ImmichFrame Web](/design/demo/web_demo.png) - -## 🔧 Installation -ImmichFrame Web is installed via [Docker 🐋](#-docker-compose) - -## 🐋 Docker Compose -### Docker Compose with environment variables - -> [!NOTE] -> Not every setting is needed. Only configure what you need! - -```yaml -name: immichframe -services: - immichframe: - container_name: immichframe - image: ghcr.io/immichframe/immichframe:latest - restart: on-failure - ports: - - "8080:8080" - environment: - TZ: "Europe/Berlin" - ImmichServerUrl: "URL" - ApiKey: "KEY" - # AuthenticationSecret: "" - # Interval: "10" - # TransitionDuration: "2" - # ImageZoom: "true" - # ImagePan: "false" - # ImageFill: "false" - # Layout: "splitview" - # DownloadImages: "false" - # ShowMemories: "false" - # ShowFavorites: "false" - # ShowArchived: "false" - # ImagesFromDays: "" - # ImagesFromDate: "" - # ImagesUntilDate: "" - # RenewImagesDuration: "30" - # Rating: "5" - # Albums: "ALBUM1,ALBUM2" - # ExcludedAlbums: "ALBUM3,ALBUM4" - # People: "PERSON1,PERSON2" - # Webcalendars: "https://calendar.mycalendar.com/basic.ics,webcal://calendar.mycalendar.com/basic.ics" - # RefreshAlbumPeopleInterval: "12" - # ShowClock: "true" - # ClockFormat: "hh:mm" - # ClockDateFormat: "eee, MMM d" - # ShowProgressBar: "true" - # ShowPhotoDate: "true" - # PhotoDateFormat: "yyyy-MM-dd" - # ShowImageDesc: "true" - # ShowPeopleDesc: "true" - # ShowAlbumName: "true" - # ShowImageLocation: "true" - # ImageLocationFormat: "City,State,Country" - # PrimaryColor: "#F5DEB3" - # SecondaryColor: "#000000" - # Style: "none" - # BaseFontSize: "17px" - # WeatherApiKey: "" - # ShowWeatherDescription: "true" - # WeatherIconUrl: "https://openweathermap.org/img/wn/{IconId}.png" - # UnitSystem: "imperial" - # WeatherLatLong: "" - # Language: "en" - # Webhook: "" -``` - -### Docker Compose with Settings.json - -An example of the Settings.json can be found [here](/docker/Settings.example.json). - -> [!IMPORTANT] -> Change `PATH/TO/CONFIG` to the correct path! - -```yaml -name: immichframe -services: - immichframe: - container_name: immichframe - image: ghcr.io/immichframe/immichframe:latest - restart: on-failure - volumes: - - PATH/TO/CONFIG:/app/Config - ports: - - "8080:8080" - environment: - TZ: "Europe/Berlin" -``` - -### Docker Compose with env file - -An example of the .env can be found [here](/docker/example.env). - -```yaml -name: immichframe -services: - immichframe: - container_name: immichframe - image: ghcr.io/immichframe/immichframe:latest - restart: on-failure - ports: - - "8080:8080" - env_file: - - .env - environment: - TZ: "Europe/Berlin" -``` - -## ⚙️ Configuration - -For more information, read [here](/README.md#configuration). - -## 🆘 Help - -[Discord Channel][support-url] - - - -[support-url]: https://discord.com/channels/979116623879368755/1217843270244372480 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 04b13539..6982c1bc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,5 +11,3 @@ services: - "8080:8080" env_file: - .env - environment: - TZ: "Europe/Berlin" diff --git a/docker/example.env b/docker/example.env index 51d80ed5..a64d0214 100644 --- a/docker/example.env +++ b/docker/example.env @@ -1,50 +1,26 @@ -ImmichServerUrl=URL +# ImmichFrame is configured in the admin UI at /admin, not through this file. +# +# To bring an existing configuration along, mount a Settings.json, Settings.yml or +# Settings.yaml into the config directory. It is imported into the settings database +# on first start and ignored afterwards. See docker/Settings.example.json for the +# format. Configuration from environment variables is not imported — settings that +# used to live in this file have to be migrated by hand. +# +# The variables below are the only ones ImmichFrame still reads from the environment. -# Either ApiKey or ApiKeyFile must be specified. -ApiKey=KEY -# ApiKeyFile=/path/to/key +# Password for the admin UI at /admin. Optional: a fresh, never-configured instance +# lets you choose the password on the setup screen at /admin instead. Once the +# instance is configured, /admin stays disabled until a password exists. +# This always takes precedence over the password stored in the database, so it +# also works as a lockout recovery. +IMMICHFRAME_ADMIN_PASSWORD=CHANGE_ME -# AuthenticationSecret= -# Interval=10 -# TransitionDuration=2 -# ImageZoom=true -# ImagePan=false -# PlayAudio: false -# Layout=splitview -# DownloadImages=false -# ShowMemories=false -# ShowFavorites=false -# ShowArchived=false -# ShowVideos: false -# ImagesFromDays= -# ImagesFromDate= -# ImagesUntilDate= -# RenewImagesDuration=30 -# Rating=5 -# Albums=ALBUM1,ALBUM2 -# ExcludedAlbums=ALBUM3,ALBUM4 -# People=PERSON1,PERSON2 -# Webcalendars=https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics,https://user:pass@calendar.immichframe.dev/dav/calendars/basic.ics -# RefreshAlbumPeopleInterval=12 -# ShowClock=true -# ClockFormat=hh:mm -# ClockDateFormat=eee, MMM d -# ShowProgressBar=true -# ShowPhotoDate=true -# PhotoDateFormat=yyyy-MM-dd -# ShowImageDesc=true -# ShowPeopleDesc=true -# ShowAlbumName=true -# ShowImageLocation=true -# ImageLocationFormat=City,State,Country -# PrimaryColor=#F5DEB3 -# SecondaryColor=#000000 -# Style=none -# BaseFontSize=17px -# WeatherApiKey= -# ShowWeatherDescription=true -# WeatherIconUrl=https://openweathermap.org/img/wn/{IconId}.png -# UnitSystem=imperial -# WeatherLatLong= -# Language=en -# Webhook= +# Where the settings database and an optional Settings.json/yml live. +# Defaults to a "Config" directory next to the executable. +# IMMICHFRAME_CONFIG_PATH=/app/Config + +# Trace | Debug | Information | Warning | Error | Critical +# LOG_LEVEL=Information + +# Timezone used for clock and calendar display. +TZ=Europe/Berlin diff --git a/docs/docs/getting-started/admin-ui.md b/docs/docs/getting-started/admin-ui.md new file mode 100644 index 00000000..c2abc6c3 --- /dev/null +++ b/docs/docs/getting-started/admin-ui.md @@ -0,0 +1,78 @@ +--- +sidebar_position: 3 +--- + +# 🛠️ Admin UI + +ImmichFrame ships with a built-in admin interface at **`/admin`** where every setting — +display options, weather, calendars, and your Immich accounts — can be edited from the +browser. Changes are applied **live**, without restarting the container. + +## Enabling the admin UI + +The admin UI is protected by a dedicated admin password. Which screen `/admin` shows +depends on the state of your instance: + +| State | When | What you see | +| ------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **Setup** | Fresh install: no admin password anywhere and nothing configured yet | An anonymous onboarding screen where you choose the admin password | +| **Login** | An admin password exists (environment variable or database) | The password prompt | +| **Disabled** | The instance is already configured but has no admin password | A notice — onboarding stays shut so nobody can claim your instance | + +So on a brand-new instance you do **not** need to set anything: open `/admin` and pick a +password. To choose it up front instead — or to open the admin UI on an instance that is +already configured — set the `IMMICHFRAME_ADMIN_PASSWORD` environment variable: + +```yaml title="docker-compose.yml" +services: + immichframe: + image: ghcr.io/immichframe/immichframe:latest + environment: + IMMICHFRAME_ADMIN_PASSWORD: your-secret-password + volumes: + - ./config:/app/Config +``` + +Alternatively you can set the `AdminPassword` setting (in the `General` section) via the +admin UI itself once you are logged in. The environment variable **always wins** — if you +ever lock yourself out by saving a wrong password, set `IMMICHFRAME_ADMIN_PASSWORD` and +restart the container to regain access. + +:::caution +The admin UI exposes your Immich API keys to authenticated admins and transmits them in +plain text. Run ImmichFrame behind HTTPS (e.g. a reverse proxy) if it is reachable from +outside your trusted network. +::: + +## Where settings are stored + +Settings edited in the admin UI are stored in a SQLite database (`immichframe.db`) inside +the config directory (`/app/Config` in Docker). This makes the config directory the one +place to persist: + +- Mount `/app/Config` as a **writable** volume, otherwise saving settings fails. +- **The database is the source of truth.** On the very first start, an existing + `Settings.json`, `Settings.yml` or `Settings.yaml` is imported into the database once. + After that, changes to that file are ignored (a log line reminds you of this at startup). + Configuration from environment variables is **not** imported — accounts and display + settings that used to be set through the environment have to be migrated by hand. +- Back up the config directory to keep your settings. + +## Zero-configuration start + +ImmichFrame now also starts **without any configuration**: the slideshow shows a hint that +no accounts are configured, and you can do the entire initial setup — including adding +your first Immich account — through the admin UI. + +Related change: the container no longer exits when an Immich server is unreachable or +incompatible at startup. It logs a critical warning instead, so you can fix the account +settings via the admin UI. + +## Applying changes + +- **Display settings** (interval, layout, clock, …) are served live; slideshow devices pick + them up the next time the page loads. +- **Account changes** rebuild the asset pipeline in the background — no restart needed. +- **Weather and calendar** changes take effect within ~15 minutes (internal caches). +- Every account has a **Test connection** button to verify the server URL and API key + before saving. diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 7378c7d1..ca2c3704 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -4,6 +4,12 @@ sidebar_position: 2 # 🔧 Configuration +:::tip Admin UI +Settings can also be managed from the built-in [admin UI](./admin-ui.md) at `/admin`. +Note that once ImmichFrame has started for the first time, the settings database becomes +the source of truth and later changes to config files are ignored. +::: + ImmichFrame looks for a configuration directory in the following places: 1. It reads the `IMMICHFRAME_CONFIG_PATH` environment variable (if present). @@ -16,8 +22,15 @@ Inside the configuration directory, ImmichFrame loads the first of these files: 2. `Settings.yml` 3. `Settings.yaml` -If none of these files are found, ImmichFrame loads its config from environment -variables. +If none of these files are found, ImmichFrame starts with defaults and you configure +it in the [admin UI](./admin-ui.md). + +:::info Configuring via environment variables was removed +Settings are no longer read from environment variables. A configuration file is +imported into the settings database once on first start; after that, manage your +settings in the admin UI. `IMMICHFRAME_ADMIN_PASSWORD`, `IMMICHFRAME_CONFIG_PATH`, +`LOG_LEVEL` and `TZ` are still read from the environment. +::: ### Full configuration reference: diff --git a/docs/docs/getting-started/installation/docker.md b/docs/docs/getting-started/installation/docker.md index 1fc4cb4e..b4c5590f 100644 --- a/docs/docs/getting-started/installation/docker.md +++ b/docs/docs/getting-started/installation/docker.md @@ -16,9 +16,13 @@ For most users, the `Settings.yml` setup is easier to read and modify. Example configuration files: -- [`Settings.yml` example][example-yaml] -- [`Settings.json` example][example-json] -- [`.env` example][example-env] +- [`Settings.yml` example][example-yaml] — imported once on first start +- [`Settings.json` example][example-json] — imported once on first start +- [`.env` example][example-env] — admin password, config path and log level + +Starting without a settings file is fine: open the [admin UI](../admin-ui.md) at `/admin`, +pick an admin password on the setup screen and configure everything there. Setting +`IMMICHFRAME_ADMIN_PASSWORD` (as below) chooses that password up front instead. --- @@ -41,6 +45,7 @@ services: - "8080:8080" environment: TZ: "Europe/Berlin" + IMMICHFRAME_ADMIN_PASSWORD: "CHANGE_ME" ``` [github-root]: https://github.com/immichframe/ImmichFrame/blob/main diff --git a/immichFrame.Web/package-lock.json b/immichFrame.Web/package-lock.json index 63141496..d5f3d96a 100644 --- a/immichFrame.Web/package-lock.json +++ b/immichFrame.Web/package-lock.json @@ -9,12 +9,13 @@ "version": "0.0.1", "devDependencies": { "@castlenine/svelte-qrcode": "^2.3.0", + "@immich/ui": "^0.85.0", "@mdi/js": "^7.4.47", "@sveltejs/adapter-static": "^3.0.5", "@sveltejs/kit": "^2.46.4", "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/postcss": "^4.3.3", "@types/eslint": "^9.6.0", - "autoprefixer": "^10.4.20", "date-fns": "^4.1.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", @@ -27,7 +28,7 @@ "svelte": "^5.39.11", "svelte-check": "^4.0.0", "svelte-confetti": "^2.0.0", - "tailwindcss": "^3.4.10", + "tailwindcss": "^4.3.3", "thumbhash": "^0.1.1", "typescript": "^5.5.0", "typescript-eslint": "^8.0.0", @@ -747,6 +748,34 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -799,6 +828,49 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@immich/ui": { + "version": "0.85.0", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.85.0.tgz", + "integrity": "sha512-KCAEtVGexZ1G+nuT25bv1QS6Rc2wOlLymp4qDBZVqMxu3o3RWpUzafRHvZt5g4FnlAgfHZdXAIHVVrhJzkrlDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@internationalized/date": "^3.10.0", + "@mdi/js": "^7.4.47", + "bits-ui": "^2.15.7", + "fuse.js": "^7.4.2", + "luxon": "^3.7.2", + "simple-icons": "^16.0.0", + "svelte-highlight": "^7.8.4", + "tailwind-merge": "^3.0.0", + "tailwind-variants": "^3.0.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.13.0", + "svelte": "^5.0.0" + } + }, + "node_modules/@immich/ui/node_modules/svelte-highlight": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/svelte-highlight/-/svelte-highlight-7.14.1.tgz", + "integrity": "sha512-Ruxkxp9N9/hJE2TO/NU/LdIuK0LeXPPMOgAJIBHUvwXzryfLK2yeC1VZlbOcfjSB6JUhPU6QCBobTAG0HmMWpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "highlight.js": "11.11.1", + "magic-string": "^0.30.17" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -856,44 +928,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oazapfts/resolve": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oazapfts/resolve/-/resolve-1.0.0.tgz", @@ -1297,9 +1331,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.0.tgz", - "integrity": "sha512-RD4ntr/QiXsU5V0ADEoZ+R2lyPlwZbENJ6ss0YtgQXqWn3+U9PnU/EeitysgpVbDV8d9XTmFqdTMKOTOe3yb0A==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", "dependencies": { @@ -1377,6 +1411,299 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1615,16 +1942,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1754,47 +2081,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1812,47 +2098,10 @@ "node": ">= 0.4" } }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1866,36 +2115,35 @@ "dev": true, "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", "dev": true, "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1903,53 +2151,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -1967,37 +2168,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2061,16 +2231,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2162,33 +2322,46 @@ "node": ">=0.10.0" } }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } }, "node_modules/esbuild": { "version": "0.28.1", @@ -2232,16 +2405,6 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2504,36 +2667,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2549,9 +2682,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -2565,16 +2698,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2606,19 +2729,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2657,20 +2767,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2686,14 +2782,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/fuse.js": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz", + "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/krisk" } }, "node_modules/glob-parent": { @@ -2722,6 +2822,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2732,17 +2839,14 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" } }, "node_modules/ignore": { @@ -2782,34 +2886,12 @@ "node": ">=0.8.19" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/is-extglob": { "version": "2.1.1", @@ -2834,16 +2916,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2862,19 +2934,19 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -2956,105 +3028,354 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "p-locate": "^5.0.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=10" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=8.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=12" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/minimatch": { @@ -3107,22 +3428,10 @@ "dev": true, "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3145,23 +3454,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/oazapfts": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/oazapfts/-/oazapfts-7.4.2.tgz", @@ -3183,26 +3475,6 @@ "@oazapfts/runtime": "^1.2.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3304,13 +3576,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3331,83 +3596,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, { "type": "github", "url": "https://github.com/sponsors/ai" @@ -3415,13 +3617,12 @@ ], "license": "MIT", "dependencies": { - "camelcase-css": "^2.0.1" + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" + "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-load-config": { @@ -3464,46 +3665,6 @@ "node": ">= 6" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-safe-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", @@ -3572,13 +3733,6 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3626,37 +3780,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3681,27 +3804,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3712,17 +3814,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -3768,28 +3859,29 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", "dev": true, "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } } }, "node_modules/sade": { @@ -3848,6 +3940,26 @@ "node": ">=8" } }, + "node_modules/simple-icons": { + "version": "16.28.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.28.0.tgz", + "integrity": "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3886,27 +3998,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "inline-style-parser": "0.2.7" } }, "node_modules/supports-color": { @@ -3922,19 +4021,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svelte": { "version": "5.56.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", @@ -4028,182 +4114,79 @@ } } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", "dev": true, - "license": "MIT", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" + "node": ">=18", + "pnpm": ">=8.7.0" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "svelte": "^5.30.2" } }, - "node_modules/tailwindcss/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } + "license": "MIT" }, - "node_modules/tailwindcss/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "github", + "url": "https://github.com/sponsors/dcastil" } }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/tailwind-variants": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", + "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, "engines": { - "node": ">= 18" + "node": ">=16.9.x", + "pnpm": ">=7.x" }, "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" }, "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { + "tailwind-merge": { "optional": true }, - "yaml": { + "tailwindcss": { "optional": true } } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tailwindcss/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } + "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -4214,29 +4197,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/thumbhash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz", @@ -4261,19 +4221,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4297,12 +4244,12 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "Apache-2.0" + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4355,37 +4302,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/immichFrame.Web/package.json b/immichFrame.Web/package.json index dc4a7a52..e9485614 100644 --- a/immichFrame.Web/package.json +++ b/immichFrame.Web/package.json @@ -11,16 +11,17 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write .", - "api": "oazapfts ../openApi/swagger.json src/lib/immichFrameApi.ts" + "api": "oazapfts --enumStyle=as-const ../openApi/swagger.json src/lib/immichFrameApi.ts" }, "devDependencies": { "@castlenine/svelte-qrcode": "^2.3.0", + "@immich/ui": "^0.85.0", "@mdi/js": "^7.4.47", "@sveltejs/adapter-static": "^3.0.5", "@sveltejs/kit": "^2.46.4", "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/postcss": "^4.3.3", "@types/eslint": "^9.6.0", - "autoprefixer": "^10.4.20", "date-fns": "^4.1.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", @@ -33,11 +34,14 @@ "svelte": "^5.39.11", "svelte-check": "^4.0.0", "svelte-confetti": "^2.0.0", - "tailwindcss": "^3.4.10", + "tailwindcss": "^4.3.3", "thumbhash": "^0.1.1", "typescript": "^5.5.0", "typescript-eslint": "^8.0.0", "vite": "^7.1.9" }, + "overrides": { + "svelte-highlight": "7.14.1" + }, "type": "module" } diff --git a/immichFrame.Web/postcss.config.js b/immichFrame.Web/postcss.config.js index 0f772168..85b958cb 100644 --- a/immichFrame.Web/postcss.config.js +++ b/immichFrame.Web/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {} + '@tailwindcss/postcss': {} } }; diff --git a/immichFrame.Web/src/app.css b/immichFrame.Web/src/app.css index c73c4e49..b7e2ee37 100644 --- a/immichFrame.Web/src/app.css +++ b/immichFrame.Web/src/app.css @@ -1,6 +1,42 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +/* Pulls in Tailwind itself plus the @immich/ui design tokens. */ +@import '@immich/ui/theme/default.css'; + +/* + @immich/ui composes its classes at runtime (tailwind-variants), so the + package has to be scanned explicitly — Tailwind skips node_modules. +*/ +@source '../node_modules/@immich/ui'; + +/* + The user-configurable slideshow palette. Kept under its own `frame-` prefix + so it never collides with the @immich/ui tokens the admin UI is built on — + `bg-primary` stays Immich's, `bg-frame-primary` is the frame's. + + The underlying variables are set on :root at runtime from the server config + (see $lib/hooks/useTheme), so they are declared inline to keep that live. +*/ +@theme inline { + --color-frame-primary: var(--primary-color); + --color-frame-secondary: var(--secondary-color); +} + +@theme { + --text-shadow-sm: 2px 2px 4px rgb(0 0 0 / 0.8); /* for small text */ + --text-shadow-lg: 3px 3px 6px rgb(0 0 0 / 0.5); /* for large text */ +} + +/* Viewport height that accounts for mobile browser chrome, fed by useViewportHeight. */ +@utility h-dvh-safe { + height: calc(var(--vh) * 100); +} + +@utility min-h-dvh-safe { + min-height: calc(var(--vh) * 100); +} + +@utility max-h-dvh-safe { + max-height: calc(var(--vh) * 100); +} @layer base { :root { @@ -87,6 +123,6 @@ div.weather-unit { img.icon-weather { display: inline-block; vertical-align: middle; - margin-top: -8px; /* adjust icon top to line up with text */ + margin-top: -8px; /* adjust icon top to line up with text */ order: 1; -} \ No newline at end of file +} diff --git a/immichFrame.Web/src/lib/components/admin/account-editor.svelte b/immichFrame.Web/src/lib/components/admin/account-editor.svelte new file mode 100644 index 00000000..5ab9a9fa --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/account-editor.svelte @@ -0,0 +1,77 @@ + + + + +
+ Account {index + 1} + +
+
+ +
+ {#each accountFields as field (field.key)} + } /> + {/each} +
+ +
+ + {#if testResult} + {testResult.message} + {/if} +
+
+
diff --git a/immichFrame.Web/src/lib/components/admin/admin-brand.svelte b/immichFrame.Web/src/lib/components/admin/admin-brand.svelte new file mode 100644 index 00000000..120e99ec --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/admin-brand.svelte @@ -0,0 +1,17 @@ + + +
+ +
+ ImmichFrame + {subtitle} +
+
diff --git a/immichFrame.Web/src/lib/components/admin/admin-fields.ts b/immichFrame.Web/src/lib/components/admin/admin-fields.ts new file mode 100644 index 00000000..73133fd0 --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/admin-fields.ts @@ -0,0 +1,179 @@ +import type { GeneralSettings, ServerAccountSettings } from '$lib/immichFrameApi'; + +export type FieldType = + | 'text' + | 'number' + | 'checkbox' + | 'password' + | 'select' + | 'list' + | 'date' + | 'guid-list'; + +export interface FieldDef { + key: keyof T & string; + label: string; + type: FieldType; + options?: string[]; + placeholder?: string; + help?: string; + step?: string; + min?: number; + max?: number; +} + +// What setting-field.svelte accepts: any concrete FieldDef widens to this, +// since a literal key type is assignable to `string`. +export type AnyFieldDef = FieldDef>; + +export interface SectionDef { + title: string; + fields: FieldDef[]; +} + +export const generalSections: SectionDef[] = [ + { + title: 'Display', + fields: [ + { key: 'interval', label: 'Interval (seconds)', type: 'number' }, + { + key: 'transitionDuration', + label: 'Transition duration (seconds)', + type: 'number', + step: '0.5' + }, + { key: 'layout', label: 'Layout', type: 'select', options: ['single', 'splitview'] }, + { + key: 'style', + label: 'Style', + type: 'select', + options: ['none', 'solid', 'transition', 'blur'] + }, + { key: 'imageZoom', label: 'Image zoom', type: 'checkbox' }, + { key: 'imagePan', label: 'Image pan', type: 'checkbox' }, + { key: 'imageFill', label: 'Image fill', type: 'checkbox' }, + { key: 'playAudio', label: 'Play audio (videos)', type: 'checkbox' }, + { key: 'showProgressBar', label: 'Show progress bar', type: 'checkbox' }, + { key: 'primaryColor', label: 'Primary color', type: 'text', placeholder: '#f5deb3' }, + { key: 'secondaryColor', label: 'Secondary color', type: 'text', placeholder: '#0f0f0f' }, + { key: 'baseFontSize', label: 'Base font size', type: 'text', placeholder: '17px' }, + { key: 'language', label: 'Language', type: 'text', placeholder: 'en' } + ] + }, + { + title: 'Clock & Metadata', + fields: [ + { key: 'showClock', label: 'Show clock', type: 'checkbox' }, + { key: 'clockFormat', label: 'Clock format', type: 'text', placeholder: 'hh:mm' }, + { + key: 'clockDateFormat', + label: 'Clock date format', + type: 'text', + placeholder: 'eee, MMM d' + }, + { key: 'showPhotoDate', label: 'Show photo date', type: 'checkbox' }, + { + key: 'photoDateFormat', + label: 'Photo date format', + type: 'text', + placeholder: 'MM/dd/yyyy' + }, + { key: 'showImageDesc', label: 'Show image description', type: 'checkbox' }, + { key: 'showPeopleDesc', label: 'Show people', type: 'checkbox' }, + { key: 'showTagsDesc', label: 'Show tags', type: 'checkbox' }, + { key: 'showAlbumName', label: 'Show album name', type: 'checkbox' }, + { key: 'showImageLocation', label: 'Show image location', type: 'checkbox' }, + { + key: 'imageLocationFormat', + label: 'Image location format', + type: 'text', + placeholder: 'City,State,Country' + } + ] + }, + { + title: 'Weather & Calendar', + fields: [ + { + key: 'weatherApiKey', + label: 'OpenWeatherMap API key', + type: 'password', + help: 'Leave empty to disable the weather overlay.' + }, + { + key: 'weatherLatLong', + label: 'Weather latitude,longitude', + type: 'text', + placeholder: '40.7128,-74.0060' + }, + { key: 'unitSystem', label: 'Unit system', type: 'select', options: ['imperial', 'metric'] }, + { key: 'showWeatherDescription', label: 'Show weather description', type: 'checkbox' }, + { + key: 'weatherIconUrl', + label: 'Weather icon URL', + type: 'text', + placeholder: 'https://openweathermap.org/img/wn/{IconId}.png' + }, + { + key: 'webcalendars', + label: 'Web calendars (one .ics URL per line)', + type: 'list', + help: 'Changes are picked up within ~15 minutes.' + } + ] + }, + { + title: 'Server', + fields: [ + { key: 'downloadImages', label: 'Download images (cache on disk)', type: 'checkbox' }, + { key: 'renewImagesDuration', label: 'Renew cached images after (days)', type: 'number' }, + { + key: 'refreshAlbumPeopleInterval', + label: 'Refresh albums/people interval (hours)', + type: 'number' + }, + { key: 'webhook', label: 'Webhook URL', type: 'text' }, + { + key: 'authenticationSecret', + label: 'Client authentication secret', + type: 'password', + help: 'When set, slideshow clients must authenticate with this secret.' + }, + { + key: 'adminPassword', + label: 'Admin password', + type: 'password', + help: 'Password for this admin UI. Careful: saving a wrong value locks you out unless the IMMICHFRAME_ADMIN_PASSWORD environment variable is set (it always wins).' + } + ] + } +]; + +export const accountFields: FieldDef[] = [ + { + key: 'immichServerUrl', + label: 'Immich server URL', + type: 'text', + placeholder: 'http://immich:2283' + }, + { key: 'apiKey', label: 'API key', type: 'password' }, + { + key: 'apiKeyFile', + label: 'API key file', + type: 'text', + placeholder: '/run/secrets/immich-api-key', + help: 'Alternative to the API key.' + }, + { key: 'showMemories', label: 'Memories', type: 'checkbox' }, + { key: 'showFavorites', label: 'Favorites', type: 'checkbox' }, + { key: 'showArchived', label: 'Archived', type: 'checkbox' }, + { key: 'showVideos', label: 'Videos', type: 'checkbox' }, + { key: 'imagesFromDays', label: 'Images from (days back)', type: 'number' }, + { key: 'rating', label: 'Minimum rating', type: 'number', min: 1, max: 5 }, + { key: 'imagesFromDate', label: 'Images from date', type: 'date' }, + { key: 'imagesUntilDate', label: 'Images until date', type: 'date' }, + { key: 'albums', label: 'Albums', type: 'guid-list', help: 'One ID per line.' }, + { key: 'excludedAlbums', label: 'Excluded albums', type: 'guid-list', help: 'One ID per line.' }, + { key: 'people', label: 'People', type: 'guid-list', help: 'One ID per line.' }, + { key: 'tags', label: 'Tags', type: 'list', help: 'One tag per line.' } +]; diff --git a/immichFrame.Web/src/lib/components/admin/admin-login.svelte b/immichFrame.Web/src/lib/components/admin/admin-login.svelte new file mode 100644 index 00000000..9f1a07f5 --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/admin-login.svelte @@ -0,0 +1,37 @@ + + + + +
+ + + + + + + {#if error} + {error} + {/if} + + + +
+
diff --git a/immichFrame.Web/src/lib/components/admin/admin-page.svelte b/immichFrame.Web/src/lib/components/admin/admin-page.svelte new file mode 100644 index 00000000..8dacf4eb --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/admin-page.svelte @@ -0,0 +1,258 @@ + + + + {#if pageState === 'editor'} + +
+
+ + +
+ + +
+
+
+
+ {/if} +
+ {#if pageState === 'loading'} + Loading… + {:else if pageState === 'error'} + + Could not reach the ImmichFrame server. Check the container logs and reload this page. + + {:else if pageState === 'setup'} + + {:else if pageState === 'disabled'} + + + Admin UI is disabled + + This instance is already configured but has no admin password. Set the + IMMICHFRAME_ADMIN_PASSWORD environment variable and restart the container to + get in. + + + + {:else if pageState === 'login'} + + {:else} +
+ {#if saveSuccess} + + Changes are applied live. Slideshow devices pick up display changes on their next + reload; weather and calendars refresh within ~15 minutes. + {#if saveWarnings.length} +
    + {#each saveWarnings as warning (warning)} +
  • {warning}
  • + {/each} +
+ {/if} +
+ {/if} + {#if saveError} + {saveError} + {/if} + +
+ {#each generalSections as section (section.title)} + +
+ {#each section.fields as field (field.key)} + + {/each} +
+
+ {/each} + + +
+ {#key accountsVersion} + {#each settings.Accounts ?? [] as account, index (index)} + removeAccount(index)} /> + {/each} + {/key} + {#if !(settings.Accounts ?? []).length} + + No accounts configured yet — the slideshow has nothing to show. Add your first + Immich account below. + + {/if} + +
+
+
+
+ {/if} +
+
diff --git a/immichFrame.Web/src/lib/components/admin/admin-setup.svelte b/immichFrame.Web/src/lib/components/admin/admin-setup.svelte new file mode 100644 index 00000000..57309cba --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/admin-setup.svelte @@ -0,0 +1,65 @@ + + + + +
+ + + + Choose a password for the admin UI. You will use it to sign in from now on, and you can + configure your Immich accounts right after. + + + + + + + + + {#if mismatch} + The passwords do not match. + {/if} + + + {#if error} + {error} + {/if} + + + +
+
diff --git a/immichFrame.Web/src/lib/components/admin/setting-field.svelte b/immichFrame.Web/src/lib/components/admin/setting-field.svelte new file mode 100644 index 00000000..c07b9237 --- /dev/null +++ b/immichFrame.Web/src/lib/components/admin/setting-field.svelte @@ -0,0 +1,95 @@ + + + 0}> + {#if field.type === 'checkbox'} + (target[field.key] = checked)} + /> + {:else if field.type === 'select'} + updateDate(e.currentTarget.value)} /> + {:else if field.type === 'list' || field.type === 'guid-list'} +