From 9f309e30639853f73b99597f085683b3d314b6fe Mon Sep 17 00:00:00 2001 From: JW-CH <17313367+JW-CH@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:16:48 +0000 Subject: [PATCH 1/2] admin ui --- Directory.Packages.props | 2 + .../Logic/ReloadingImmichFrameLogicTests.cs | 125 ++ .../Interfaces/IServerBehaviorSettings.cs | 1 + .../Interfaces/ISettingsProvider.cs | 17 + .../TotalAccountImagesSelectionStrategy.cs | 6 + .../Logic/MultiImmichFrameLogicDelegate.cs | 10 +- .../Logic/PooledImmichFrameLogic.cs | 4 +- .../Logic/ReloadingImmichFrameLogic.cs | 82 + .../Controllers/AdminControllerTests.cs | 260 +++ .../Controllers/AssetControllerTests.cs | 1 + .../Controllers/ConfigControllerTests.cs | 4 + .../Helpers/Config/ConfigLoaderTest.cs | 82 +- .../ImmichFrame.WebApi.Tests.csproj | 4 - .../Mocks/StaticSettingsProvider.cs | 17 + .../Resources/TestV1.json | 62 - .../Resources/TestV2.json | 1 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 1 + .../Services/SettingsServiceTests.cs | 215 ++ .../Controllers/AdminController.cs | 149 ++ .../20260814141028_InitialCreate.Designer.cs | 53 + .../20260814141028_InitialCreate.cs | 39 + .../SettingsDbContextModelSnapshot.cs | 50 + .../Database/SettingsDbContext.cs | 34 + .../Database/SettingsDbContextFactory.cs | 16 + .../Helpers/Config/ConfigLoader.cs | 96 +- .../Helpers/Config/ServerSettingsV1.cs | 141 -- .../Helpers/CustomAuthenticationMiddleware.cs | 19 +- .../ImmichFrameAdminAuthenticationHandler.cs | 62 + .../ImmichFrameAuthenticationHandler.cs | 3 +- .../Helpers/ImmichServerVersionChecker.cs | 70 +- .../Helpers/SettingsExtensonMethods.cs | 66 - ImmichFrame.WebApi/ImmichFrame.WebApi.csproj | 4 + ImmichFrame.WebApi/Models/LiveSettings.cs | 60 + ImmichFrame.WebApi/Models/ServerSettings.cs | 8 +- ImmichFrame.WebApi/Program.cs | 60 +- .../Services/AdminAuthService.cs | 68 + .../Services/SettingsService.cs | 255 +++ Install_Web.md | 14 + docker/docker-compose.yml | 2 + docker/example.env | 68 +- docs/docs/getting-started/admin-ui.md | 67 + docs/docs/getting-started/configuration.md | 17 +- .../getting-started/installation/docker.md | 10 +- immichFrame.Web/package-lock.json | 1876 ++++++++--------- immichFrame.Web/package.json | 10 +- immichFrame.Web/postcss.config.js | 3 +- immichFrame.Web/src/app.css | 46 +- .../components/admin/account-editor.svelte | 77 + .../lib/components/admin/admin-brand.svelte | 17 + .../src/lib/components/admin/admin-fields.ts | 179 ++ .../lib/components/admin/admin-login.svelte | 37 + .../lib/components/admin/admin-page.svelte | 258 +++ .../lib/components/admin/admin-setup.svelte | 65 + .../lib/components/admin/setting-field.svelte | 95 + .../components/admin/settings-section.svelte | 21 + .../components/elements/appointments.svelte | 4 +- .../elements/asset-component.svelte | 12 +- .../lib/components/elements/asset-info.svelte | 8 +- .../src/lib/components/elements/asset.svelte | 4 +- .../src/lib/components/elements/clock.svelte | 6 +- .../imageoverlay/image-overlay.svelte | 8 +- .../elements/imageoverlay/overlay-item.svelte | 2 +- .../elements/imageoverlay/overlay-qr.svelte | 4 +- .../elements/overlay-controls.svelte | 12 +- .../components/elements/progress-bar.svelte | 2 +- .../lib/components/home-page/home-page.svelte | 18 +- .../src/lib/constants/asset-type.ts | 14 +- immichFrame.Web/src/lib/hooks/useTheme.ts | 35 + immichFrame.Web/src/lib/immichFrameApi.ts | 176 +- immichFrame.Web/src/lib/index.ts | 10 +- immichFrame.Web/src/lib/services/admin.ts | 52 + immichFrame.Web/src/lib/stores/admin.store.ts | 21 + immichFrame.Web/src/routes/+page.ts | 31 +- immichFrame.Web/src/routes/admin/+page.svelte | 15 + immichFrame.Web/tailwind.config.js | 34 - immichFrame.Web/tailwind.plugins.ts | 16 - openApi/swagger.json | 568 ++++- 77 files changed, 4384 insertions(+), 1677 deletions(-) create mode 100644 ImmichFrame.Core.Tests/Logic/ReloadingImmichFrameLogicTests.cs create mode 100644 ImmichFrame.Core/Interfaces/ISettingsProvider.cs create mode 100644 ImmichFrame.Core/Logic/ReloadingImmichFrameLogic.cs create mode 100644 ImmichFrame.WebApi.Tests/Controllers/AdminControllerTests.cs create mode 100644 ImmichFrame.WebApi.Tests/Mocks/StaticSettingsProvider.cs delete mode 100644 ImmichFrame.WebApi.Tests/Resources/TestV1.json create mode 100644 ImmichFrame.WebApi.Tests/Services/SettingsServiceTests.cs create mode 100644 ImmichFrame.WebApi/Controllers/AdminController.cs create mode 100644 ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.Designer.cs create mode 100644 ImmichFrame.WebApi/Database/Migrations/20260814141028_InitialCreate.cs create mode 100644 ImmichFrame.WebApi/Database/Migrations/SettingsDbContextModelSnapshot.cs create mode 100644 ImmichFrame.WebApi/Database/SettingsDbContext.cs create mode 100644 ImmichFrame.WebApi/Database/SettingsDbContextFactory.cs delete mode 100644 ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs create mode 100644 ImmichFrame.WebApi/Helpers/ImmichFrameAdminAuthenticationHandler.cs delete mode 100644 ImmichFrame.WebApi/Helpers/SettingsExtensonMethods.cs create mode 100644 ImmichFrame.WebApi/Models/LiveSettings.cs create mode 100644 ImmichFrame.WebApi/Services/AdminAuthService.cs create mode 100644 ImmichFrame.WebApi/Services/SettingsService.cs create mode 100644 docs/docs/getting-started/admin-ui.md create mode 100644 immichFrame.Web/src/lib/components/admin/account-editor.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/admin-brand.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/admin-fields.ts create mode 100644 immichFrame.Web/src/lib/components/admin/admin-login.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/admin-page.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/admin-setup.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/setting-field.svelte create mode 100644 immichFrame.Web/src/lib/components/admin/settings-section.svelte create mode 100644 immichFrame.Web/src/lib/hooks/useTheme.ts create mode 100644 immichFrame.Web/src/lib/services/admin.ts create mode 100644 immichFrame.Web/src/lib/stores/admin.store.ts create mode 100644 immichFrame.Web/src/routes/admin/+page.svelte delete mode 100644 immichFrame.Web/tailwind.config.js delete mode 100644 immichFrame.Web/tailwind.plugins.ts 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..bb915406 --- /dev/null +++ b/ImmichFrame.Core/Logic/ReloadingImmichFrameLogic.cs @@ -0,0 +1,82 @@ +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 volatile IImmichFrameLogic _inner; + + 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; + + _logger.LogInformation("Account settings changed, rebuilding asset logic"); + var next = _innerFactory(); + var old = Interlocked.Exchange(ref _inner, next); + if (old is IDisposable disposable) + { + var delay = _disposeGraceDelay; + _ = Task.Run(async () => + { + await Task.Delay(delay); + 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; + (_inner as IDisposable)?.Dispose(); + } +} 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..12fff53c --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,215 @@ +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")); + } + + 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..20b4ea86 --- /dev/null +++ b/ImmichFrame.WebApi/Controllers/AdminController.cs @@ -0,0 +1,149 @@ +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] + public async Task> Setup([FromBody] AdminSetupDto setup) + { + if (!_adminAuthService.SetupRequired) + { + return Problem(detail: "ImmichFrame is already configured.", statusCode: StatusCodes.Status409Conflict); + } + + if (string.IsNullOrWhiteSpace(setup.AdminPassword)) + { + return Problem(detail: "An admin password is required.", statusCode: StatusCodes.Status400BadRequest); + } + + var settings = _settingsService.GetRawSettings(); + settings.GeneralSettingsImpl ??= new GeneralSettings(); + settings.GeneralSettingsImpl.AdminPassword = setup.AdminPassword; + + try + { + await _settingsService.UpdateAsync(settings); + } + catch (SettingsNotValidException ex) + { + _logger.LogWarning("Rejected setup: {message}", ex.Message); + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + + _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() + { + return _settingsService.GetRawSettings(); + } + + [HttpPut("Settings", Name = "UpdateAdminSettings")] + 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/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..1a2061f1 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -1,12 +1,17 @@ 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; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -57,10 +62,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 +87,26 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ 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.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 +142,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..d362a89b --- /dev/null +++ b/ImmichFrame.WebApi/Services/SettingsService.cs @@ -0,0 +1,255 @@ +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); + +/// +/// 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) + { + Normalize(raw); + var validated = Clone(raw); + try + { + validated.Validate(); + } + catch (Exception ex) + { + throw new SettingsNotValidException(ex.Message, ex); + } + + await _updateLock.WaitAsync(); + try + { + 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; + } + finally + { + _updateLock.Release(); + } + } + + /// 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_Web.md b/Install_Web.md index 5cf3f94c..4b846914 100644 --- a/Install_Web.md +++ b/Install_Web.md @@ -128,6 +128,20 @@ services: For more information, read [here](/README.md#configuration). +## 🛠️ Admin UI + +ImmichFrame has a built-in admin interface at `/admin` where all settings (including your +Immich accounts) can be edited in the browser — changes apply live, without a restart. + +- Enable it by setting the `IMMICHFRAME_ADMIN_PASSWORD` environment variable (without a + password the admin UI stays disabled). +- Settings are stored in a SQLite database inside the config directory, so mount + `/app/Config` as a **writable** volume. An existing `Settings.json`/`Settings.yml` or + env config is imported once on first start; after that the database is the source of + truth and file changes are ignored. +- If you lock yourself out, the `IMMICHFRAME_ADMIN_PASSWORD` environment variable always + overrides the stored admin password. + ## 🆘 Help [Discord Channel][support-url] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 04b13539..35f93686 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,3 +13,5 @@ services: - .env environment: TZ: "Europe/Berlin" + # Required — without it the admin UI at /admin stays disabled. + IMMICHFRAME_ADMIN_PASSWORD: "CHANGE_ME" diff --git a/docker/example.env b/docker/example.env index 51d80ed5..8b69d7db 100644 --- a/docker/example.env +++ b/docker/example.env @@ -1,50 +1,22 @@ -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 or Settings.yml +# 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. +# +# 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. Without it the admin UI stays disabled. +# 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..6d9df9ee --- /dev/null +++ b/docs/docs/getting-started/admin-ui.md @@ -0,0 +1,67 @@ +--- +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 password and is **disabled until a password is set**. +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 environment-variable config is imported into the + database once. After that, changes to those files are ignored (a log line reminds you + of this at startup). +- 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..fea7409b 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` +and `LOG_LEVEL` 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..9628e2b6 100644 --- a/docs/docs/getting-started/installation/docker.md +++ b/docs/docs/getting-started/installation/docker.md @@ -16,9 +16,12 @@ 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: set `IMMICHFRAME_ADMIN_PASSWORD` and +configure everything in the [admin UI](../admin-ui.md). --- @@ -41,6 +44,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..03f67e7f --- /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'} +