From fd006649188edbf1a18c601d051821d96529b6c0 Mon Sep 17 00:00:00 2001 From: keyldev Date: Mon, 20 Jul 2026 21:58:17 +0300 Subject: [PATCH 01/33] feat(web): add server-only web host (Phase 20 slice A) - New OpenIPC.Viewer.Web project: Kestrel host via ASP.NET shared framework - WebServer + WebServerOptions: localhost-only bind by default, opt-in --lan - --server-only headless entry in Desktop (Program.cs early-return, no GUI) - /healthz, /api/v1/version endpoints + minimal security-header floor --- OpenIPC.Viewer.slnx | 1 + .../OpenIPC.Viewer.Desktop.csproj | 1 + src/OpenIPC.Viewer.Desktop/Program.cs | 5 + src/OpenIPC.Viewer.Desktop/ServerOnly.cs | 63 ++++++++++++ .../OpenIPC.Viewer.Web.csproj | 14 +++ src/OpenIPC.Viewer.Web/WebServer.cs | 95 +++++++++++++++++++ src/OpenIPC.Viewer.Web/WebServerOptions.cs | 21 ++++ 7 files changed, 200 insertions(+) create mode 100644 src/OpenIPC.Viewer.Desktop/ServerOnly.cs create mode 100644 src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj create mode 100644 src/OpenIPC.Viewer.Web/WebServer.cs create mode 100644 src/OpenIPC.Viewer.Web/WebServerOptions.cs diff --git a/OpenIPC.Viewer.slnx b/OpenIPC.Viewer.slnx index 12ba2b6..2a6b4cd 100644 --- a/OpenIPC.Viewer.slnx +++ b/OpenIPC.Viewer.slnx @@ -12,6 +12,7 @@ + diff --git a/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj b/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj index 1cf153b..4abc1f8 100644 --- a/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj +++ b/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj @@ -31,6 +31,7 @@ + diff --git a/src/OpenIPC.Viewer.Desktop/Program.cs b/src/OpenIPC.Viewer.Desktop/Program.cs index 41cc454..dd50688 100644 --- a/src/OpenIPC.Viewer.Desktop/Program.cs +++ b/src/OpenIPC.Viewer.Desktop/Program.cs @@ -16,6 +16,11 @@ public static int Main(string[] args) System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.ConsoleTraceListener()); #endif + // Headless web-server mode (Phase 20): run Kestrel without Avalonia and + // return, before the single-instance guard or any GUI service spins up. + if (ServerOnly.IsRequested(args)) + return ServerOnly.Run(args); + // Opt-in single-instance mode: when another copy already runs and the // user enabled the guard, hand focus to it and bail out before any // services (logs, db) spin up. diff --git a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs new file mode 100644 index 0000000..57443a3 --- /dev/null +++ b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs @@ -0,0 +1,63 @@ +using System; +using System.Globalization; +using System.Linq; +using OpenIPC.Viewer.Web; + +namespace OpenIPC.Viewer.Desktop; + +// Headless entry for `--server-only`: runs the embedded web server without the +// Avalonia GUI (Phase 20 §20.1). Same distributable as the desktop app, just a +// different launch mode — no offscreen render surface needed since we simply +// never start Avalonia. +// +// Flags: +// --server-only run headless as a web server +// --port listen port (default WebServerOptions.DefaultPort) +// --lan bind 0.0.0.0 (reachable from the LAN); default is +// localhost-only +internal static class ServerOnly +{ + public const string Flag = "--server-only"; + + public static bool IsRequested(string[] args) => + args.Any(a => string.Equals(a, Flag, StringComparison.OrdinalIgnoreCase)); + + public static int Run(string[] args) + { + var options = ParseOptions(args); + try + { + WebServer.RunAsync(options).GetAwaiter().GetResult(); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"server-only failed: {ex}"); + return 1; + } + } + + private static WebServerOptions ParseOptions(string[] args) + { + var port = WebServerOptions.DefaultPort; + var lan = false; + + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--lan", StringComparison.OrdinalIgnoreCase)) + { + lan = true; + } + else if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase) + && i + 1 < args.Length + && int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + && parsed is > 0 and <= 65535) + { + port = parsed; + i++; + } + } + + return new WebServerOptions { Port = port, BindLan = lan }; + } +} diff --git a/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj new file mode 100644 index 0000000..dab99e6 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + + + + + + + + diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs new file mode 100644 index 0000000..3fb0ae0 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -0,0 +1,95 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace OpenIPC.Viewer.Web; + +// The embedded ASP.NET Core (Kestrel) host. +// +// Phase 20 slice A (foundation): process lifecycle + health/version endpoints +// and safe bind defaults only. The camera/API/live-video surface, auth, and the +// shared backend composition land in later slices (§20.2–20.5). Keeping this +// slice backend-free is deliberate — it proves the headless host boots and +// serves before the data layer is wired in. +public static class WebServer +{ + // Version string surfaced by /healthz and /api/v1/version. Prefers the + // git-derived InformationalVersion (see Directory.Build.targets), falling + // back to the assembly version. + public static string Version => + typeof(WebServer).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? typeof(WebServer).Assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Runs the server until the process is stopped (Ctrl-C / SIGTERM via the + // default host lifetime) or the supplied token is cancelled — the latter is + // how the in-process desktop host (a later slice) will stop it. + public static async Task RunAsync(WebServerOptions options, CancellationToken ct = default) + { + var builder = WebApplication.CreateSlimBuilder(); + + var app = builder.Build(); + app.Urls.Add(options.Url); + MapEndpoints(app); + + var logger = app.Services.GetRequiredService().CreateLogger("OpenIPC.Web"); + logger.LogInformation( + "OpenIPC Viewer web server listening on {Url} ({Scope})", + options.Url, options.BindLan ? "LAN" : "localhost only"); + if (options.BindLan) + { + logger.LogWarning( + "LAN bind is on: the server is reachable from the local network without TLS. " + + "Expose it to a domain only behind a trusted reverse-proxy with HTTPS."); + } + + if (ct.CanBeCanceled) + ct.Register(() => _ = app.StopAsync()); + + await app.RunAsync(); + } + + private static void MapEndpoints(WebApplication app) + { + // A minimal security-header floor on every response. The auth slice + // (§20.2) builds a full CSP / CSRF / rate-limit story on top; setting + // these now costs nothing and keeps responses honest from day one. + app.Use(async (HttpContext context, RequestDelegate next) => + { + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "no-referrer"; + await next(context); + }); + + // Liveness probe — touches no backend, so it answers even before the + // data layer exists. Used by --server-only smoke checks and, later, + // container/service health. + app.MapGet("/healthz", () => Results.Json(new + { + status = "ok", + version = Version, + })); + + app.MapGet("/api/v1/version", () => Results.Json(new + { + product = "OpenIPC Viewer", + version = Version, + })); + + app.MapGet("/", () => Results.Content(PlaceholderPage, "text/html; charset=utf-8")); + } + + private const string PlaceholderPage = + "OpenIPC Viewer" + + "" + + "

OpenIPC Viewer — web server

" + + "

Phase 20 · slice A. The camera monitor and API arrive in later slices.

" + + "

Health: /healthz · " + + "/api/v1/version

" + + ""; +} diff --git a/src/OpenIPC.Viewer.Web/WebServerOptions.cs b/src/OpenIPC.Viewer.Web/WebServerOptions.cs new file mode 100644 index 0000000..6d6abf8 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/WebServerOptions.cs @@ -0,0 +1,21 @@ +namespace OpenIPC.Viewer.Web; + +// Bind knobs for the embedded web server. Defaults are deliberately safe: +// localhost-only, so nothing is exposed to the network until the user opts in +// (Phase 20 §20.7 — LAN/domain is an explicit, documented step behind a +// reverse-proxy, never the default). +public sealed record WebServerOptions +{ + public const int DefaultPort = 8787; + + // TCP port Kestrel listens on. + public int Port { get; init; } = DefaultPort; + + // false (default) → bind 127.0.0.1, reachable only from this machine. + // true → bind 0.0.0.0, reachable from the LAN. Opt-in and warned about. + public bool BindLan { get; init; } + + public string BindHost => BindLan ? "0.0.0.0" : "127.0.0.1"; + + public string Url => $"http://{BindHost}:{Port}"; +} From 10a3ea4284a7731ed78d3e0dd1926efe12890392 Mon Sep 17 00:00:00 2001 From: keyldev Date: Mon, 20 Jul 2026 22:16:22 +0300 Subject: [PATCH 02/33] feat(web): minimal auth behind IWebAuthProvider (Phase 20 slice 20.2) - IWebAuthProvider seam; public build ships single-admin PasswordAuthProvider (PBKDF2) - Admin password from OPENIPC_WEB_ADMIN_PASSWORD env, else generated + logged once - SessionStore: 256-bit opaque bearer tokens, stores only SHA-256 digest, sliding TTL - login (rate-limited 5/min/IP) / logout / me endpoints + bearer guard over /api/v1 - Origin check on mutations; private .ovac RBAC can plug in as another provider later --- src/OpenIPC.Viewer.Desktop/ServerOnly.cs | 11 +- src/OpenIPC.Viewer.Web/Auth/AuthApi.cs | 140 ++++++++++++++++++ .../Auth/IWebAuthProvider.cs | 13 ++ .../Auth/PasswordAuthProvider.cs | 55 +++++++ src/OpenIPC.Viewer.Web/Auth/PasswordHash.cs | 43 ++++++ src/OpenIPC.Viewer.Web/Auth/SessionStore.cs | 77 ++++++++++ src/OpenIPC.Viewer.Web/Auth/WebAuthOptions.cs | 16 ++ src/OpenIPC.Viewer.Web/Auth/WebIdentity.cs | 20 +++ src/OpenIPC.Viewer.Web/WebServer.cs | 52 ++++++- 9 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Auth/AuthApi.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/IWebAuthProvider.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/PasswordAuthProvider.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/PasswordHash.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/SessionStore.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/WebAuthOptions.cs create mode 100644 src/OpenIPC.Viewer.Web/Auth/WebIdentity.cs diff --git a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs index 57443a3..84db7a6 100644 --- a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs +++ b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Linq; using OpenIPC.Viewer.Web; +using OpenIPC.Viewer.Web.Auth; namespace OpenIPC.Viewer.Desktop; @@ -15,9 +16,13 @@ namespace OpenIPC.Viewer.Desktop; // --port listen port (default WebServerOptions.DefaultPort) // --lan bind 0.0.0.0 (reachable from the LAN); default is // localhost-only +// +// The admin password is read from the OPENIPC_WEB_ADMIN_PASSWORD environment +// variable; when unset, a random one is generated and logged on startup. internal static class ServerOnly { public const string Flag = "--server-only"; + private const string AdminPasswordEnv = "OPENIPC_WEB_ADMIN_PASSWORD"; public static bool IsRequested(string[] args) => args.Any(a => string.Equals(a, Flag, StringComparison.OrdinalIgnoreCase)); @@ -25,9 +30,13 @@ public static bool IsRequested(string[] args) => public static int Run(string[] args) { var options = ParseOptions(args); + var authOptions = new WebAuthOptions + { + AdminPassword = Environment.GetEnvironmentVariable(AdminPasswordEnv), + }; try { - WebServer.RunAsync(options).GetAwaiter().GetResult(); + WebServer.RunAsync(options, authOptions).GetAwaiter().GetResult(); return 0; } catch (Exception ex) diff --git a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs new file mode 100644 index 0000000..1d707f0 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace OpenIPC.Viewer.Web.Auth; + +// Auth wiring for the API: the login/logout/me endpoints, a bearer-token guard +// over protected paths, and an Origin check on mutations. Bearer tokens (not +// cookies) already blunt CSRF — the Origin check is defense-in-depth for the +// browser client. Behind a reverse-proxy the Origin/host comparison relies on +// forwarded headers (wired in §20.7). +public static class AuthApi +{ + // Stashes the authenticated identity for the endpoint to read. + public const string IdentityItemKey = "openipc.web.identity"; + + public static WebIdentity? GetIdentity(this HttpContext ctx) => + ctx.Items.TryGetValue(IdentityItemKey, out var value) ? value as WebIdentity : null; + + // Reject cross-origin mutations, then require a valid bearer token on + // protected API paths. Registered before the endpoints so it can short-circuit. + public static void UseWebAuth(this WebApplication app) + { + app.Use(async (HttpContext ctx, RequestDelegate next) => + { + if (IsMutation(ctx.Request.Method) && !OriginAllowed(ctx.Request)) + { + await WriteError(ctx, StatusCodes.Status403Forbidden, "bad_origin"); + return; + } + + if (RequiresAuth(ctx.Request.Path)) + { + var store = ctx.RequestServices.GetRequiredService(); + var identity = BearerToken(ctx) is { } token ? store.Validate(token) : null; + if (identity is null) + { + await WriteError(ctx, StatusCodes.Status401Unauthorized, "unauthorized"); + return; + } + + ctx.Items[IdentityItemKey] = identity; + } + + await next(ctx); + }); + } + + public static void MapAuthEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/auth"); + + group.MapPost("/login", async ( + LoginRequest request, + IWebAuthProvider provider, + SessionStore store, + CancellationToken ct) => + { + if (request is null || string.IsNullOrEmpty(request.User)) + return Results.BadRequest(new { error = "missing_credentials" }); + + var identity = await provider.ValidateCredentialsAsync(request.User, request.Password ?? "", ct); + if (identity is null) + return Results.Json(new { error = "invalid_credentials" }, statusCode: StatusCodes.Status401Unauthorized); + + var (token, expiresAt) = store.Create(identity); + return Results.Json(new + { + token, + user = identity.Name, + roles = identity.Roles, + expiresAt, + }); + }).RequireRateLimiting(LoginRateLimitPolicy); + + group.MapPost("/logout", (HttpContext ctx, SessionStore store) => + { + if (BearerToken(ctx) is { } token) + store.Revoke(token); + return Results.NoContent(); + }); + + group.MapGet("/me", (HttpContext ctx) => + { + var identity = ctx.GetIdentity(); + return identity is null + ? Results.Json(new { error = "unauthorized" }, statusCode: StatusCodes.Status401Unauthorized) + : Results.Json(new { user = identity.Name, roles = identity.Roles }); + }); + } + + public const string LoginRateLimitPolicy = "login"; + + private static bool RequiresAuth(PathString path) + { + if (!path.StartsWithSegments("/api/v1", out var rest)) + return false; + // Public within the API surface: version discovery and the login endpoint. + if (rest.Equals("/version", StringComparison.OrdinalIgnoreCase)) + return false; + if (rest.Equals("/auth/login", StringComparison.OrdinalIgnoreCase)) + return false; + return true; + } + + private static bool IsMutation(string method) => + HttpMethods.IsPost(method) || HttpMethods.IsPut(method) || + HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method); + + // Same-origin only when an Origin header is present. Native clients (curl, + // the desktop app) send no Origin and are allowed — CSRF is a browser threat. + private static bool OriginAllowed(HttpRequest request) + { + var origin = request.Headers.Origin.ToString(); + if (string.IsNullOrEmpty(origin)) + return true; + return Uri.TryCreate(origin, UriKind.Absolute, out var uri) + && string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); + } + + private static string? BearerToken(HttpContext ctx) + { + var header = ctx.Request.Headers.Authorization.ToString(); + const string scheme = "Bearer "; + return header.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) + ? header[scheme.Length..].Trim() + : null; + } + + private static Task WriteError(HttpContext ctx, int statusCode, string error) + { + ctx.Response.StatusCode = statusCode; + return ctx.Response.WriteAsJsonAsync(new { error }); + } +} + +public sealed record LoginRequest(string User, string? Password); diff --git a/src/OpenIPC.Viewer.Web/Auth/IWebAuthProvider.cs b/src/OpenIPC.Viewer.Web/Auth/IWebAuthProvider.cs new file mode 100644 index 0000000..d5415ec --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/IWebAuthProvider.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenIPC.Viewer.Web.Auth; + +// The authentication seam. Returns the identity for valid credentials, or null +// to reject. The public build ships PasswordAuthProvider (single admin); the +// private .ovac RBAC plugs in here as another implementation over the real +// user/role store — no endpoint or session-store changes. +public interface IWebAuthProvider +{ + ValueTask ValidateCredentialsAsync(string user, string password, CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Web/Auth/PasswordAuthProvider.cs b/src/OpenIPC.Viewer.Web/Auth/PasswordAuthProvider.cs new file mode 100644 index 0000000..e34da4f --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/PasswordAuthProvider.cs @@ -0,0 +1,55 @@ +using System; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenIPC.Viewer.Web.Auth; + +// The minimal public-build auth provider: one admin account. The password comes +// from WebAuthOptions; when absent, a random one is generated for this run and +// logged once (so a fresh server is protected without shipping a default). The +// hash lives only in memory here — persisting it is part of the backend slice. +public sealed class PasswordAuthProvider : IWebAuthProvider +{ + private readonly string _adminUser; + private readonly string _passwordHash; + + public PasswordAuthProvider(WebAuthOptions options, ILogger logger) + { + _adminUser = options.AdminUser; + + var password = options.AdminPassword; + if (string.IsNullOrEmpty(password)) + { + password = GeneratePassword(); + logger.LogWarning( + "No admin password configured — generated one for this run. " + + "User '{User}', password: {Password}. Set an admin password to make it stable.", + _adminUser, password); + } + + _passwordHash = PasswordHash.Create(password); + } + + public ValueTask ValidateCredentialsAsync(string user, string password, CancellationToken ct) + { + // Verify the password hash regardless of the username outcome so a wrong + // username and a wrong password cost the same time (no user enumeration + // via timing). Username itself is not a secret, so an ordinal compare is + // fine. + var passwordOk = PasswordHash.Verify(password, _passwordHash); + var userOk = string.Equals(user, _adminUser, StringComparison.Ordinal); + + WebIdentity? identity = userOk && passwordOk + ? new WebIdentity(_adminUser, new HashSet(StringComparer.Ordinal) { WebRoles.Admin }) + : null; + + return new ValueTask(identity); + } + + private static string GeneratePassword() => + Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(12)); +} diff --git a/src/OpenIPC.Viewer.Web/Auth/PasswordHash.cs b/src/OpenIPC.Viewer.Web/Auth/PasswordHash.cs new file mode 100644 index 0000000..445bb95 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/PasswordHash.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography; + +namespace OpenIPC.Viewer.Web.Auth; + +// PBKDF2 password hashing (SHA-256). No third-party dependency — the framework +// primitive. Encoded as "iterations.salt.key" (base64) so the parameters travel +// with the hash and verification is self-describing. +internal static class PasswordHash +{ + private const int Iterations = 100_000; + private const int SaltSize = 16; + private const int KeySize = 32; + private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256; + + public static string Create(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var key = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, KeySize); + return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(key)}"; + } + + public static bool Verify(string password, string encoded) + { + var parts = encoded.Split('.'); + if (parts.Length != 3 || !int.TryParse(parts[0], out var iterations)) + return false; + + byte[] salt, expected; + try + { + salt = Convert.FromBase64String(parts[1]); + expected = Convert.FromBase64String(parts[2]); + } + catch (FormatException) + { + return false; + } + + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } +} diff --git a/src/OpenIPC.Viewer.Web/Auth/SessionStore.cs b/src/OpenIPC.Viewer.Web/Auth/SessionStore.cs new file mode 100644 index 0000000..4f58b2f --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/SessionStore.cs @@ -0,0 +1,77 @@ +using System; +using System.Buffers.Text; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace OpenIPC.Viewer.Web.Auth; + +// In-memory session store. Tokens are 256-bit opaque values handed to the +// client; only their SHA-256 digest is kept here — the raw token is never +// stored, so a store dump can't be replayed. Sliding TTL: each successful +// validation extends the expiry. Thread-safe for concurrent requests. +// +// In-memory is fine for a single-process self-host server; a fresh start simply +// invalidates all sessions (users log in again). Durable sessions can come with +// the backend slice if needed. +public sealed class SessionStore +{ + private sealed class Entry + { + public required WebIdentity Identity { get; init; } + public DateTimeOffset CreatedUtc { get; init; } + public DateTimeOffset ExpiresUtc { get; set; } + } + + private readonly ConcurrentDictionary _byDigest = new(StringComparer.Ordinal); + private readonly TimeSpan _ttl; + + public SessionStore(WebAuthOptions options) => _ttl = options.SessionTtl; + + public int ActiveCount => _byDigest.Count; + + // Mints a new token for an authenticated identity. Returns the raw token + // (shown to the client once) and its expiry. + public (string Token, DateTimeOffset ExpiresUtc) Create(WebIdentity identity) + { + var token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + var now = DateTimeOffset.UtcNow; + var entry = new Entry { Identity = identity, CreatedUtc = now, ExpiresUtc = now + _ttl }; + _byDigest[Digest(token)] = entry; + return (token, entry.ExpiresUtc); + } + + // Returns the identity for a live token, sliding its expiry forward. Null + // when unknown or expired (expired entries are evicted on touch). + public WebIdentity? Validate(string token) + { + if (string.IsNullOrEmpty(token)) + return null; + + var digest = Digest(token); + if (!_byDigest.TryGetValue(digest, out var entry)) + return null; + + var now = DateTimeOffset.UtcNow; + if (now >= entry.ExpiresUtc) + { + _byDigest.TryRemove(digest, out _); + return null; + } + + entry.ExpiresUtc = now + _ttl; + return entry.Identity; + } + + public bool Revoke(string token) => _byDigest.TryRemove(Digest(token), out _); + + public int RevokeAll() + { + var count = _byDigest.Count; + _byDigest.Clear(); + return count; + } + + private static string Digest(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); +} diff --git a/src/OpenIPC.Viewer.Web/Auth/WebAuthOptions.cs b/src/OpenIPC.Viewer.Web/Auth/WebAuthOptions.cs new file mode 100644 index 0000000..f0286d7 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/WebAuthOptions.cs @@ -0,0 +1,16 @@ +using System; + +namespace OpenIPC.Viewer.Web.Auth; + +// Auth knobs for the minimal (single-admin) provider. When AdminPassword is +// null, a random one is generated at startup and logged once — so a fresh +// --server-only run is never silently wide open, and never ships a default +// password either. +public sealed record WebAuthOptions +{ + public string AdminUser { get; init; } = "admin"; + + public string? AdminPassword { get; init; } + + public TimeSpan SessionTtl { get; init; } = TimeSpan.FromHours(12); +} diff --git a/src/OpenIPC.Viewer.Web/Auth/WebIdentity.cs b/src/OpenIPC.Viewer.Web/Auth/WebIdentity.cs new file mode 100644 index 0000000..3c7d74c --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Auth/WebIdentity.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace OpenIPC.Viewer.Web.Auth; + +// Who a web request is acting as, once authenticated. Roles are kept as a set +// (not a bool) so the richer RBAC provider (private .ovac) can populate real +// roles later without changing this shape or the endpoints that read it. +public sealed record WebIdentity(string Name, IReadOnlySet Roles) +{ + public bool IsInRole(string role) => Roles.Contains(role); + + public bool IsAdmin => Roles.Contains(WebRoles.Admin); +} + +// Role names. The minimal provider only issues Admin; the RBAC provider adds +// the rest against the same constants. +public static class WebRoles +{ + public const string Admin = "admin"; +} diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 3fb0ae0..ee14102 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -1,8 +1,11 @@ using System.Reflection; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Web.Auth; namespace OpenIPC.Viewer.Web; @@ -27,9 +30,11 @@ public static class WebServer // Runs the server until the process is stopped (Ctrl-C / SIGTERM via the // default host lifetime) or the supplied token is cancelled — the latter is // how the in-process desktop host (a later slice) will stop it. - public static async Task RunAsync(WebServerOptions options, CancellationToken ct = default) + public static async Task RunAsync( + WebServerOptions options, WebAuthOptions? authOptions = null, CancellationToken ct = default) { var builder = WebApplication.CreateSlimBuilder(); + ConfigureAuthServices(builder, authOptions ?? new WebAuthOptions()); var app = builder.Build(); app.Urls.Add(options.Url); @@ -52,11 +57,32 @@ public static async Task RunAsync(WebServerOptions options, CancellationToken ct await app.RunAsync(); } + private static void ConfigureAuthServices(WebApplicationBuilder builder, WebAuthOptions authOptions) + { + builder.Services.AddSingleton(authOptions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Per-IP fixed window on the login endpoint — blunts credential stuffing + // without touching the rest of the API. + builder.Services.AddRateLimiter(limiter => + { + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + limiter.AddPolicy(AuthApi.LoginRateLimitPolicy, http => + RateLimitPartition.GetFixedWindowLimiter( + http.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + Window = TimeSpan.FromMinutes(1), + PermitLimit = 5, + QueueLimit = 0, + })); + }); + } + private static void MapEndpoints(WebApplication app) { - // A minimal security-header floor on every response. The auth slice - // (§20.2) builds a full CSP / CSRF / rate-limit story on top; setting - // these now costs nothing and keeps responses honest from day one. + // A minimal security-header floor on every response. app.Use(async (HttpContext context, RequestDelegate next) => { var headers = context.Response.Headers; @@ -66,9 +92,13 @@ private static void MapEndpoints(WebApplication app) await next(context); }); + app.UseRateLimiter(); + // Origin check on mutations + bearer-token guard over protected API paths. + app.UseWebAuth(); + // Liveness probe — touches no backend, so it answers even before the - // data layer exists. Used by --server-only smoke checks and, later, - // container/service health. + // data layer exists. Public (no auth) for --server-only smoke checks and, + // later, container/service health. app.MapGet("/healthz", () => Results.Json(new { status = "ok", @@ -81,6 +111,16 @@ private static void MapEndpoints(WebApplication app) version = Version, })); + // First protected endpoint — proves the auth pipeline end-to-end until + // the real camera API lands. Requires a valid bearer token. + app.MapGet("/api/v1/ping", (HttpContext ctx) => + { + var identity = ctx.GetIdentity(); + return Results.Json(new { pong = true, user = identity?.Name }); + }); + + app.MapAuthEndpoints(); + app.MapGet("/", () => Results.Content(PlaceholderPage, "text/html; charset=utf-8")); } From d96f0c9ac8df778f25224168cb7a6afe0f1de01e Mon Sep 17 00:00:00 2001 From: keyldev Date: Mon, 20 Jul 2026 22:29:08 +0300 Subject: [PATCH 03/33] feat(web): read-only cameras API + lean backend (Phase 20 slice 20.3) - WebBackend.AddWebBackend(dbPath): SQLite persistence only, no Avalonia/App - WebServer runs migrations on startup when a backend is composed - GET /api/v1/cameras behind auth; CameraDto strips RTSP creds, exposes hasCredentials bool - Desktop server-only points the web host at the same on-disk database - New OpenIPC.Viewer.Web.Tests: golden no-secret DTO test + auth/session tests --- OpenIPC.Viewer.slnx | 1 + src/OpenIPC.Viewer.Desktop/ServerOnly.cs | 10 ++- src/OpenIPC.Viewer.Web/Api/CameraApi.cs | 45 ++++++++++++ src/OpenIPC.Viewer.Web/Api/CameraDto.cs | 64 +++++++++++++++++ src/OpenIPC.Viewer.Web/Backend/WebBackend.cs | 23 +++++++ .../OpenIPC.Viewer.Web.csproj | 7 ++ src/OpenIPC.Viewer.Web/WebServer.cs | 22 +++++- tests/OpenIPC.Viewer.Web.Tests/AuthTests.cs | 68 +++++++++++++++++++ .../CameraDtoTests.cs | 64 +++++++++++++++++ .../OpenIPC.Viewer.Web.Tests.csproj | 27 ++++++++ 10 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/CameraApi.cs create mode 100644 src/OpenIPC.Viewer.Web/Api/CameraDto.cs create mode 100644 src/OpenIPC.Viewer.Web/Backend/WebBackend.cs create mode 100644 tests/OpenIPC.Viewer.Web.Tests/AuthTests.cs create mode 100644 tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs create mode 100644 tests/OpenIPC.Viewer.Web.Tests/OpenIPC.Viewer.Web.Tests.csproj diff --git a/OpenIPC.Viewer.slnx b/OpenIPC.Viewer.slnx index 2a6b4cd..c1151c3 100644 --- a/OpenIPC.Viewer.slnx +++ b/OpenIPC.Viewer.slnx @@ -21,5 +21,6 @@
+
diff --git a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs index 84db7a6..5b4bd5e 100644 --- a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs +++ b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs @@ -1,8 +1,12 @@ using System; using System.Globalization; +using System.IO; using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.App; using OpenIPC.Viewer.Web; using OpenIPC.Viewer.Web.Auth; +using OpenIPC.Viewer.Web.Backend; namespace OpenIPC.Viewer.Desktop; @@ -34,9 +38,13 @@ public static int Run(string[] args) { AdminPassword = Environment.GetEnvironmentVariable(AdminPasswordEnv), }; + // Same on-disk database as the desktop app — the web server reads/writes + // the very same cameras. + var dbPath = Path.Combine(AppPaths.AppDataDir.FullName, "openipc-viewer.db"); try { - WebServer.RunAsync(options, authOptions).GetAwaiter().GetResult(); + WebServer.RunAsync(options, authOptions, services => services.AddWebBackend(dbPath)) + .GetAwaiter().GetResult(); return 0; } catch (Exception ex) diff --git a/src/OpenIPC.Viewer.Web/Api/CameraApi.cs b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs new file mode 100644 index 0000000..5448236 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.Core.Persistence; + +namespace OpenIPC.Viewer.Web.Api; + +// Read-only camera endpoints (§20.3). Behind the auth guard (path under +// /api/v1). The backend is resolved per-request and may be absent (e.g. a host +// started without AddWebBackend) — in that case we answer 503 rather than throw. +public static class CameraApi +{ + public static void MapCameraEndpoints(this WebApplication app) + { + app.MapGet("/api/v1/cameras", async (HttpContext ctx, CancellationToken ct) => + { + var repo = ctx.RequestServices.GetService(); + if (repo is null) + return Results.Json(new { error = "backend_unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + + var cameras = await repo.GetAllAsync(ct); + + var groupNames = new Dictionary(); + var groups = ctx.RequestServices.GetService(); + if (groups is not null) + { + foreach (var g in await groups.GetAllAsync(ct)) + groupNames[g.Id.Value] = g.Name; + } + + var dtos = cameras + .Select(c => + { + var name = c.GroupId is { } gid && groupNames.TryGetValue(gid.Value, out var n) ? n : null; + return CameraDto.From(c, name); + }) + .ToList(); + + return Results.Json(dtos); + }); + } +} diff --git a/src/OpenIPC.Viewer.Web/Api/CameraDto.cs b/src/OpenIPC.Viewer.Web/Api/CameraDto.cs new file mode 100644 index 0000000..97955ee --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/CameraDto.cs @@ -0,0 +1,64 @@ +using System; +using OpenIPC.Viewer.Core.Entities; + +namespace OpenIPC.Viewer.Web.Api; + +// The browser-safe projection of a Camera. It exposes NOTHING secret: +// * no camera password (it's not on the entity — only secret-store key refs, +// which we also don't expose; HasCredentials is a plain bool instead); +// * RTSP URIs are sanitized — any embedded user:pass@ is stripped so a URL the +// user typed with credentials never leaves the server. +// This is the contract the §20.3 "golden" test guards against regressions. +public sealed record CameraDto( + string Id, + string Name, + string Host, + int HttpPort, + int? OnvifPort, + int? GroupId, + string? GroupName, + string RtspMain, + string? RtspSub, + bool OnvifEnabled, + string? ChipModel, + string? FirmwareVersion, + bool IncludedInGrid, + bool HasPtz, + bool IsMajestic, + bool HasAudioIn, + bool HasAudioOut, + bool HasCredentials, + string StreamQuality, + int SortOrder) +{ + public static CameraDto From(Camera c, string? groupName) => new( + Id: c.Id.ToString(), + Name: c.Name, + Host: c.Host, + HttpPort: c.HttpPort, + OnvifPort: c.OnvifPort, + GroupId: c.GroupId?.Value, + GroupName: groupName, + RtspMain: SanitizeUri(c.RtspMainUri), + RtspSub: c.RtspSubUri is null ? null : SanitizeUri(c.RtspSubUri), + OnvifEnabled: c.OnvifEnabled, + ChipModel: c.ChipModel, + FirmwareVersion: c.FirmwareVersion, + IncludedInGrid: c.IncludedInGrid, + HasPtz: c.HasPtz, + IsMajestic: c.IsMajestic, + HasAudioIn: c.HasAudioIn, + HasAudioOut: c.HasAudioOut, + HasCredentials: c.UsernameRef is not null, + StreamQuality: c.StreamQualityOverride.ToString(), + SortOrder: c.SortOrder); + + // Drops any embedded credentials (user:pass@) from an RTSP URI so they never + // cross the API boundary, even if the camera was configured with them inline. + public static string SanitizeUri(Uri uri) + { + if (string.IsNullOrEmpty(uri.UserInfo)) + return uri.ToString(); + return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString(); + } +} diff --git a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs new file mode 100644 index 0000000..53a9856 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.Core.Persistence; +using OpenIPC.Viewer.Infrastructure.Persistence; + +namespace OpenIPC.Viewer.Web.Backend; + +// The lean, UI-free backend the web API composes: SQLite persistence only. +// +// Deliberately does NOT go through SharedComposition — that registers the +// Avalonia App layer (ViewModels, dialog factories) the headless server has no +// use for and shouldn't drag in. Video/ONVIF/Majestic join here in later slices +// as their endpoints arrive, each a small explicit registration. +public static class WebBackend +{ + public static IServiceCollection AddWebBackend(this IServiceCollection services, string databasePath) + { + services.AddSingleton(_ => new SqliteConnectionFactory(databasePath)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj index dab99e6..baf4362 100644 --- a/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj +++ b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj @@ -11,4 +11,11 @@ + + + + + +
diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index ee14102..73b59dd 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Persistence; +using OpenIPC.Viewer.Web.Api; using OpenIPC.Viewer.Web.Auth; namespace OpenIPC.Viewer.Web; @@ -31,16 +33,21 @@ public static class WebServer // default host lifetime) or the supplied token is cancelled — the latter is // how the in-process desktop host (a later slice) will stop it. public static async Task RunAsync( - WebServerOptions options, WebAuthOptions? authOptions = null, CancellationToken ct = default) + WebServerOptions options, + WebAuthOptions? authOptions = null, + Action? configureBackend = null, + CancellationToken ct = default) { var builder = WebApplication.CreateSlimBuilder(); ConfigureAuthServices(builder, authOptions ?? new WebAuthOptions()); + configureBackend?.Invoke(builder.Services); var app = builder.Build(); app.Urls.Add(options.Url); MapEndpoints(app); var logger = app.Services.GetRequiredService().CreateLogger("OpenIPC.Web"); + await MigrateAsync(app, logger, ct); logger.LogInformation( "OpenIPC Viewer web server listening on {Url} ({Scope})", options.Url, options.BindLan ? "LAN" : "localhost only"); @@ -57,6 +64,18 @@ public static async Task RunAsync( await app.RunAsync(); } + // Runs schema migrations when a backend is composed. No-op when the host was + // started without one (e.g. auth-only runs / tests). + private static async Task MigrateAsync(WebApplication app, ILogger logger, CancellationToken ct) + { + var migrator = app.Services.GetService(); + if (migrator is null) + return; + + logger.LogInformation("Running database migrations…"); + await migrator.MigrateAsync(ct); + } + private static void ConfigureAuthServices(WebApplicationBuilder builder, WebAuthOptions authOptions) { builder.Services.AddSingleton(authOptions); @@ -120,6 +139,7 @@ private static void MapEndpoints(WebApplication app) }); app.MapAuthEndpoints(); + app.MapCameraEndpoints(); app.MapGet("/", () => Results.Content(PlaceholderPage, "text/html; charset=utf-8")); } diff --git a/tests/OpenIPC.Viewer.Web.Tests/AuthTests.cs b/tests/OpenIPC.Viewer.Web.Tests/AuthTests.cs new file mode 100644 index 0000000..428d140 --- /dev/null +++ b/tests/OpenIPC.Viewer.Web.Tests/AuthTests.cs @@ -0,0 +1,68 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OpenIPC.Viewer.Web.Auth; + +namespace OpenIPC.Viewer.Web.Tests; + +// Auth primitives: credential validation (PBKDF2 via the provider) and the +// opaque-token session store. +public sealed class AuthTests +{ + private static WebIdentity Admin() => + new("admin", new HashSet(StringComparer.Ordinal) { WebRoles.Admin }); + + [Fact] + public async Task Provider_AcceptsCorrectCredentials_RejectsWrong() + { + var provider = new PasswordAuthProvider( + new WebAuthOptions { AdminUser = "admin", AdminPassword = "hunter2" }, + NullLogger.Instance); + + Assert.NotNull(await provider.ValidateCredentialsAsync("admin", "hunter2", CancellationToken.None)); + Assert.Null(await provider.ValidateCredentialsAsync("admin", "wrong", CancellationToken.None)); + Assert.Null(await provider.ValidateCredentialsAsync("root", "hunter2", CancellationToken.None)); + } + + [Fact] + public void Session_CreateThenValidate_RoundTrips() + { + var store = new SessionStore(new WebAuthOptions()); + var (token, _) = store.Create(Admin()); + + var identity = store.Validate(token); + Assert.NotNull(identity); + Assert.Equal("admin", identity!.Name); + Assert.True(identity.IsAdmin); + } + + [Fact] + public void Session_UnknownToken_IsRejected() + { + var store = new SessionStore(new WebAuthOptions()); + Assert.Null(store.Validate("not-a-real-token")); + Assert.Null(store.Validate("")); + } + + [Fact] + public void Session_Revoke_InvalidatesToken() + { + var store = new SessionStore(new WebAuthOptions()); + var (token, _) = store.Create(Admin()); + + Assert.True(store.Revoke(token)); + Assert.Null(store.Validate(token)); + } + + [Fact] + public void Session_RevokeAll_ClearsEverything() + { + var store = new SessionStore(new WebAuthOptions()); + store.Create(Admin()); + store.Create(Admin()); + + Assert.Equal(2, store.ActiveCount); + Assert.Equal(2, store.RevokeAll()); + Assert.Equal(0, store.ActiveCount); + } +} diff --git a/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs b/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs new file mode 100644 index 0000000..19e7cce --- /dev/null +++ b/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Web.Api; + +namespace OpenIPC.Viewer.Web.Tests; + +// The "golden" §20.3 guard: the camera DTO must never carry a secret across the +// API boundary — no camera password, no secret-store key refs, no RTSP URL with +// inline credentials. +public sealed class CameraDtoTests +{ + private static Camera SampleCamera() => new( + Id: CameraId.Parse("11111111-1111-1111-1111-111111111111"), + GroupId: new GroupId(3), + Name: "Front door", + Host: "10.0.0.9", + OnvifPort: 8899, + HttpPort: 80, + RtspMainUri: new Uri("rtsp://admin:s3cr3tPass@10.0.0.9:554/stream0"), + RtspSubUri: new Uri("rtsp://admin:s3cr3tPass@10.0.0.9:554/stream1"), + UsernameRef: "cam:11111111-1111-1111-1111-111111111111:username", + PasswordRef: "cam:11111111-1111-1111-1111-111111111111:password", + OnvifEnabled: true, + OnvifProfileToken: "Profile_1", + ChipModel: "SSC338Q", + FirmwareVersion: "fw1", + IncludedInGrid: true, + HasPtz: false, + IsMajestic: true, + SortOrder: 0, + CreatedAt: DateTime.UtcNow, + UpdatedAt: DateTime.UtcNow); + + [Fact] + public void From_StripsRtspCredentials() + { + var dto = CameraDto.From(SampleCamera(), groupName: "Outdoor"); + + Assert.Equal("rtsp://10.0.0.9:554/stream0", dto.RtspMain); + Assert.Equal("rtsp://10.0.0.9:554/stream1", dto.RtspSub); + Assert.DoesNotContain("@", dto.RtspMain); + Assert.DoesNotContain("s3cr3tPass", dto.RtspMain); + } + + [Fact] + public void From_ExposesHasCredentialsInsteadOfSecretRefs() + { + var dto = CameraDto.From(SampleCamera(), groupName: null); + Assert.True(dto.HasCredentials); + } + + [Fact] + public void SerializedDto_LeaksNoSecret() + { + var dto = CameraDto.From(SampleCamera(), groupName: "Outdoor"); + var json = JsonSerializer.Serialize(dto); + + // No RTSP-embedded password, no secret-store key refs, no ref property names. + Assert.DoesNotContain("s3cr3tPass", json); + Assert.DoesNotContain("cam:", json); + Assert.DoesNotContain("UsernameRef", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("PasswordRef", json, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/OpenIPC.Viewer.Web.Tests/OpenIPC.Viewer.Web.Tests.csproj b/tests/OpenIPC.Viewer.Web.Tests/OpenIPC.Viewer.Web.Tests.csproj new file mode 100644 index 0000000..f28f8bc --- /dev/null +++ b/tests/OpenIPC.Viewer.Web.Tests/OpenIPC.Viewer.Web.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + false + + + + + + + + + + + + + + + + + + + + + + From 26a012d4049bec0e418b71fc01a35780466f79b8 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 00:15:49 +0300 Subject: [PATCH 04/33] feat(web): camera CRUD with validation + audit (Phase 20 slice 20.3) - POST/PUT/DELETE /api/v1/cameras via CameraDirectoryService (creds to secret store) - CameraWriteRequest.TryValidate: required fields, port ranges, RTSP URI, creds all-or-nothing - Audit line per mutation (action/camera/user/ip); 400/404/503 handled - WebBackend registers CameraDirectoryService; server-only reuses Desktop platform services --- src/OpenIPC.Viewer.Desktop/Composition.cs | 5 +- src/OpenIPC.Viewer.Desktop/ServerOnly.cs | 11 +- src/OpenIPC.Viewer.Web/Api/CameraApi.cs | 131 +++++++++++++++--- .../Api/CameraWriteRequest.cs | 94 +++++++++++++ src/OpenIPC.Viewer.Web/Backend/WebBackend.cs | 6 + 5 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/CameraWriteRequest.cs diff --git a/src/OpenIPC.Viewer.Desktop/Composition.cs b/src/OpenIPC.Viewer.Desktop/Composition.cs index 49e7591..414e57a 100644 --- a/src/OpenIPC.Viewer.Desktop/Composition.cs +++ b/src/OpenIPC.Viewer.Desktop/Composition.cs @@ -102,7 +102,10 @@ void Apply() _ => LangCode.System, }; - private static void AddPlatformServices(IServiceCollection services) + // Internal so the headless server-only path (ServerOnly) can reuse the exact + // same platform trio (IFileSystem / ISecretsStore / …) without duplicating + // the per-OS guards. + internal static void AddPlatformServices(IServiceCollection services) { // Platform — one trio of IFileSystem / ISecretsStore / IHwDecoderFactory // per OS. Selection happens once at startup; downstream services see a diff --git a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs index 5b4bd5e..6a08b56 100644 --- a/src/OpenIPC.Viewer.Desktop/ServerOnly.cs +++ b/src/OpenIPC.Viewer.Desktop/ServerOnly.cs @@ -43,8 +43,7 @@ public static int Run(string[] args) var dbPath = Path.Combine(AppPaths.AppDataDir.FullName, "openipc-viewer.db"); try { - WebServer.RunAsync(options, authOptions, services => services.AddWebBackend(dbPath)) - .GetAwaiter().GetResult(); + WebServer.RunAsync(options, authOptions, ConfigureBackend).GetAwaiter().GetResult(); return 0; } catch (Exception ex) @@ -52,6 +51,14 @@ public static int Run(string[] args) Console.Error.WriteLine($"server-only failed: {ex}"); return 1; } + + void ConfigureBackend(IServiceCollection services) + { + // Platform trio (IFileSystem / ISecretsStore / …) then the lean + // web backend (persistence + CameraDirectoryService). + Composition.AddPlatformServices(services); + services.AddWebBackend(dbPath); + } } private static WebServerOptions ParseOptions(string[] args) diff --git a/src/OpenIPC.Viewer.Web/Api/CameraApi.cs b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs index 5448236..2d4077b 100644 --- a/src/OpenIPC.Viewer.Web/Api/CameraApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs @@ -1,16 +1,23 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Persistence; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Web.Auth; namespace OpenIPC.Viewer.Web.Api; -// Read-only camera endpoints (§20.3). Behind the auth guard (path under -// /api/v1). The backend is resolved per-request and may be absent (e.g. a host -// started without AddWebBackend) — in that case we answer 503 rather than throw. +// Camera CRUD (§20.3). All paths sit under /api/v1 so the auth guard requires a +// bearer token; mutations additionally pass the Origin check. Writes go through +// CameraDirectoryService (credentials land in the secrets store, never the DB +// row / API), and each mutation writes an audit line. The backend is resolved +// per-request and may be absent (503). public static class CameraApi { public static void MapCameraEndpoints(this WebApplication app) @@ -19,27 +26,117 @@ public static void MapCameraEndpoints(this WebApplication app) { var repo = ctx.RequestServices.GetService(); if (repo is null) - return Results.Json(new { error = "backend_unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + return BackendUnavailable(); var cameras = await repo.GetAllAsync(ct); + var groupNames = await LoadGroupNamesAsync(ctx, ct); + var dtos = cameras.Select(c => CameraDto.From(c, GroupName(c, groupNames))).ToList(); + return Results.Json(dtos); + }); + + app.MapPost("/api/v1/cameras", async (CameraWriteRequest? body, HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) + return BackendUnavailable(); + if (body is null) + return ValidationError(new List { "missing body" }); + if (!body.TryValidate(out var valid, out var errors)) + return ValidationError(errors); - var groupNames = new Dictionary(); - var groups = ctx.RequestServices.GetService(); - if (groups is not null) + var id = await dir.AddAsync(valid.ToNew(), ct); + Audit(ctx, "camera.create", id); + + var created = await dir.GetAsync(id, ct); + var dto = created is null ? null : CameraDto.From(created, null); + return Results.Created($"/api/v1/cameras/{id}", dto); + }); + + app.MapPut("/api/v1/cameras/{id}", async (string id, CameraWriteRequest? body, HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) + return BackendUnavailable(); + if (!TryParseId(id, out var cameraId)) + return ValidationError(new List { "invalid camera id" }); + if (body is null) + return ValidationError(new List { "missing body" }); + if (!body.TryValidate(out var valid, out var errors)) + return ValidationError(errors); + + try + { + await dir.UpdateAsync(cameraId, valid.ToUpdate(), ct); + } + catch (InvalidOperationException) { - foreach (var g in await groups.GetAllAsync(ct)) - groupNames[g.Id.Value] = g.Name; + return Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound); } - var dtos = cameras - .Select(c => - { - var name = c.GroupId is { } gid && groupNames.TryGetValue(gid.Value, out var n) ? n : null; - return CameraDto.From(c, name); - }) - .ToList(); + Audit(ctx, "camera.update", cameraId); + var updated = await dir.GetAsync(cameraId, ct); + return updated is null + ? Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound) + : Results.Json(CameraDto.From(updated, null)); + }); - return Results.Json(dtos); + app.MapDelete("/api/v1/cameras/{id}", async (string id, HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) + return BackendUnavailable(); + if (!TryParseId(id, out var cameraId)) + return ValidationError(new List { "invalid camera id" }); + + var existing = await dir.GetAsync(cameraId, ct); + if (existing is null) + return Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound); + + await dir.RemoveAsync(cameraId, ct); + Audit(ctx, "camera.delete", cameraId); + return Results.NoContent(); }); } + + private static async System.Threading.Tasks.Task> LoadGroupNamesAsync( + HttpContext ctx, CancellationToken ct) + { + var names = new Dictionary(); + var groups = ctx.RequestServices.GetService(); + if (groups is not null) + { + foreach (var g in await groups.GetAllAsync(ct)) + names[g.Id.Value] = g.Name; + } + return names; + } + + private static string? GroupName(Camera c, IReadOnlyDictionary names) => + c.GroupId is { } gid && names.TryGetValue(gid.Value, out var n) ? n : null; + + private static bool TryParseId(string id, out CameraId cameraId) + { + if (Guid.TryParse(id, out var guid)) + { + cameraId = new CameraId(guid); + return true; + } + cameraId = default; + return false; + } + + private static IResult BackendUnavailable() => + Results.Json(new { error = "backend_unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + + private static IResult ValidationError(List errors) => + Results.Json(new { error = "validation", details = errors }, statusCode: StatusCodes.Status400BadRequest); + + // One audit line per mutation: who did what to which camera, from where. + private static void Audit(HttpContext ctx, string action, CameraId id) + { + var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Audit"); + logger.LogInformation( + "audit {Action} camera={CameraId} user={User} ip={Ip}", + action, id, ctx.GetIdentity()?.Name ?? "?", ctx.Connection.RemoteIpAddress); + } } diff --git a/src/OpenIPC.Viewer.Web/Api/CameraWriteRequest.cs b/src/OpenIPC.Viewer.Web/Api/CameraWriteRequest.cs new file mode 100644 index 0000000..3721db4 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/CameraWriteRequest.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Video; + +namespace OpenIPC.Viewer.Web.Api; + +// The browser's create/update payload. Loose (all-optional) on the wire so bad +// input produces a 400 with reasons rather than a bind failure; TryValidate +// turns it into the strongly-typed Core request or a list of errors. +public sealed record CameraWriteRequest( + string? Name, + string? Host, + int? HttpPort, + int? OnvifPort, + string? RtspMain, + string? RtspSub, + int? GroupId, + string? Username, + string? Password, + string? StreamQuality) +{ + public bool TryValidate(out ValidatedCamera result, out List errors) + { + errors = new List(); + result = default!; + + var name = Name?.Trim() ?? ""; + if (name.Length == 0) errors.Add("name is required"); + + var host = Host?.Trim() ?? ""; + if (host.Length == 0) errors.Add("host is required"); + + var httpPort = HttpPort ?? 80; + if (httpPort is < 1 or > 65535) errors.Add("httpPort must be 1..65535"); + + if (OnvifPort is { } op && op is < 1 or > 65535) + errors.Add("onvifPort must be 1..65535"); + + Uri? rtspMain = null; + if (string.IsNullOrWhiteSpace(RtspMain)) + errors.Add("rtspMain is required"); + else if (!Uri.TryCreate(RtspMain, UriKind.Absolute, out rtspMain)) + errors.Add("rtspMain must be an absolute URI"); + + Uri? rtspSub = null; + if (!string.IsNullOrWhiteSpace(RtspSub) && !Uri.TryCreate(RtspSub, UriKind.Absolute, out rtspSub)) + errors.Add("rtspSub must be an absolute URI"); + + var quality = StreamQualityOverride.Auto; + if (!string.IsNullOrWhiteSpace(StreamQuality) + && !Enum.TryParse(StreamQuality, ignoreCase: true, out quality)) + errors.Add("streamQuality must be Auto, AlwaysHd, or AlwaysSd"); + + // Credentials are all-or-nothing. Both empty => no credentials set/kept. + CameraCredentials? credentials = null; + var hasUser = !string.IsNullOrEmpty(Username); + var hasPass = !string.IsNullOrEmpty(Password); + if (hasUser ^ hasPass) + errors.Add("username and password must be provided together"); + else if (hasUser && hasPass) + credentials = new CameraCredentials(Username!, Password!); + + if (errors.Count > 0) + return false; + + result = new ValidatedCamera( + name, host, httpPort, OnvifPort, rtspMain!, rtspSub, + GroupId is { } g ? new GroupId(g) : null, credentials, quality); + return true; + } +} + +// Validated, strongly-typed form ready to become a NewCameraRequest / +// UpdateCameraRequest. +public sealed record ValidatedCamera( + string Name, + string Host, + int HttpPort, + int? OnvifPort, + Uri RtspMain, + Uri? RtspSub, + GroupId? GroupId, + CameraCredentials? Credentials, + StreamQualityOverride StreamQuality) +{ + public NewCameraRequest ToNew() => new( + Name, Host, HttpPort, OnvifPort, RtspMain, RtspSub, Credentials, + GroupId, StreamQuality); + + public UpdateCameraRequest ToUpdate() => new( + Name, Host, HttpPort, OnvifPort, RtspMain, RtspSub, Credentials, + GroupId, StreamQuality); +} diff --git a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs index 53a9856..2a73278 100644 --- a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs +++ b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using OpenIPC.Viewer.Core.Persistence; +using OpenIPC.Viewer.Core.Services; using OpenIPC.Viewer.Infrastructure.Persistence; namespace OpenIPC.Viewer.Web.Backend; @@ -18,6 +19,11 @@ public static IServiceCollection AddWebBackend(this IServiceCollection services, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // CRUD goes through the directory service so credentials land in the + // secrets store (never the DB row / API). It resolves ISecretsStore, + // which the platform host (Desktop) registers alongside this call; the + // optional settings/layout deps default to null when absent. + services.AddSingleton(); return services; } } From d8b03cee1cbdcc9a821de6e99d3dc08576793884 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 00:20:53 +0300 Subject: [PATCH 05/33] feat(web): camera groups CRUD + shared API helpers (Phase 20 slice 20.3) - GET/POST/PUT/DELETE /api/v1/groups via IGroupRepository; delete-in-use -> 409 - Shared ApiHelpers (backend/validation/not-found/audit); CameraApi refactored onto them --- src/OpenIPC.Viewer.Web/Api/ApiHelpers.cs | 32 ++++++++ src/OpenIPC.Viewer.Web/Api/CameraApi.cs | 26 +------ src/OpenIPC.Viewer.Web/Api/GroupApi.cs | 94 ++++++++++++++++++++++++ src/OpenIPC.Viewer.Web/WebServer.cs | 1 + 4 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/ApiHelpers.cs create mode 100644 src/OpenIPC.Viewer.Web/Api/GroupApi.cs diff --git a/src/OpenIPC.Viewer.Web/Api/ApiHelpers.cs b/src/OpenIPC.Viewer.Web/Api/ApiHelpers.cs new file mode 100644 index 0000000..12df48b --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/ApiHelpers.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Web.Auth; + +namespace OpenIPC.Viewer.Web.Api; + +// Shared result + audit helpers for the API endpoints, so cameras/groups/etc. +// answer with one consistent shape. +internal static class ApiHelpers +{ + public static IResult BackendUnavailable() => + Results.Json(new { error = "backend_unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); + + public static IResult NotFound() => + Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound); + + public static IResult ValidationError(IReadOnlyList errors) => + Results.Json(new { error = "validation", details = errors }, statusCode: StatusCodes.Status400BadRequest); + + public static IResult ValidationError(string error) => ValidationError(new[] { error }); + + // One audit line per mutation: who did what to which subject, from where. + public static void Audit(HttpContext ctx, string action, object subject) + { + var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Audit"); + logger.LogInformation( + "audit {Action} subject={Subject} user={User} ip={Ip}", + action, subject, ctx.GetIdentity()?.Name ?? "?", ctx.Connection.RemoteIpAddress); + } +} diff --git a/src/OpenIPC.Viewer.Web/Api/CameraApi.cs b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs index 2d4077b..a66ec29 100644 --- a/src/OpenIPC.Viewer.Web/Api/CameraApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/CameraApi.cs @@ -5,11 +5,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Persistence; using OpenIPC.Viewer.Core.Services; -using OpenIPC.Viewer.Web.Auth; +using static OpenIPC.Viewer.Web.Api.ApiHelpers; namespace OpenIPC.Viewer.Web.Api; @@ -70,14 +69,12 @@ public static void MapCameraEndpoints(this WebApplication app) } catch (InvalidOperationException) { - return Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound); + return NotFound(); } Audit(ctx, "camera.update", cameraId); var updated = await dir.GetAsync(cameraId, ct); - return updated is null - ? Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound) - : Results.Json(CameraDto.From(updated, null)); + return updated is null ? NotFound() : Results.Json(CameraDto.From(updated, null)); }); app.MapDelete("/api/v1/cameras/{id}", async (string id, HttpContext ctx, CancellationToken ct) => @@ -90,7 +87,7 @@ public static void MapCameraEndpoints(this WebApplication app) var existing = await dir.GetAsync(cameraId, ct); if (existing is null) - return Results.Json(new { error = "not_found" }, statusCode: StatusCodes.Status404NotFound); + return NotFound(); await dir.RemoveAsync(cameraId, ct); Audit(ctx, "camera.delete", cameraId); @@ -124,19 +121,4 @@ private static bool TryParseId(string id, out CameraId cameraId) cameraId = default; return false; } - - private static IResult BackendUnavailable() => - Results.Json(new { error = "backend_unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable); - - private static IResult ValidationError(List errors) => - Results.Json(new { error = "validation", details = errors }, statusCode: StatusCodes.Status400BadRequest); - - // One audit line per mutation: who did what to which camera, from where. - private static void Audit(HttpContext ctx, string action, CameraId id) - { - var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Audit"); - logger.LogInformation( - "audit {Action} camera={CameraId} user={User} ip={Ip}", - action, id, ctx.GetIdentity()?.Name ?? "?", ctx.Connection.RemoteIpAddress); - } } diff --git a/src/OpenIPC.Viewer.Web/Api/GroupApi.cs b/src/OpenIPC.Viewer.Web/Api/GroupApi.cs new file mode 100644 index 0000000..d822dfa --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/GroupApi.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Persistence; + +namespace OpenIPC.Viewer.Web.Api; + +// Camera-group CRUD (§20.3). Same auth/Origin guards as the camera endpoints. +// Deleting a group still referenced by cameras is rejected by the DB FK — we +// surface that as 409 rather than a 500. +public static class GroupApi +{ + public static void MapGroupEndpoints(this WebApplication app) + { + app.MapGet("/api/v1/groups", async (HttpContext ctx, CancellationToken ct) => + { + var repo = ctx.RequestServices.GetService(); + if (repo is null) + return ApiHelpers.BackendUnavailable(); + var groups = await repo.GetAllAsync(ct); + return Results.Json(groups.Select(GroupDto.From).ToList()); + }); + + app.MapPost("/api/v1/groups", async (GroupWriteRequest? body, HttpContext ctx, CancellationToken ct) => + { + var repo = ctx.RequestServices.GetService(); + if (repo is null) + return ApiHelpers.BackendUnavailable(); + var name = body?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) + return ApiHelpers.ValidationError("name is required"); + + var id = await repo.AddAsync(name, sortOrder: 0, ct); + ApiHelpers.Audit(ctx, "group.create", id.Value); + var created = await repo.GetAsync(id, ct); + return Results.Created($"/api/v1/groups/{id.Value}", created is null ? null : GroupDto.From(created)); + }); + + app.MapPut("/api/v1/groups/{id:int}", async (int id, GroupWriteRequest? body, HttpContext ctx, CancellationToken ct) => + { + var repo = ctx.RequestServices.GetService(); + if (repo is null) + return ApiHelpers.BackendUnavailable(); + var name = body?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) + return ApiHelpers.ValidationError("name is required"); + + var gid = new GroupId(id); + if (await repo.GetAsync(gid, ct) is null) + return ApiHelpers.NotFound(); + + await repo.RenameAsync(gid, name, ct); + ApiHelpers.Audit(ctx, "group.rename", id); + var updated = await repo.GetAsync(gid, ct); + return updated is null ? ApiHelpers.NotFound() : Results.Json(GroupDto.From(updated)); + }); + + app.MapDelete("/api/v1/groups/{id:int}", async (int id, HttpContext ctx, CancellationToken ct) => + { + var repo = ctx.RequestServices.GetService(); + if (repo is null) + return ApiHelpers.BackendUnavailable(); + + var gid = new GroupId(id); + if (await repo.GetAsync(gid, ct) is null) + return ApiHelpers.NotFound(); + + try + { + await repo.RemoveAsync(gid, ct); + } + catch (Exception) + { + // The schema references Groups(Id) without ON DELETE, so SQLite + // rejects removing a group that cameras still point at. + return Results.Json(new { error = "group_in_use" }, statusCode: StatusCodes.Status409Conflict); + } + + ApiHelpers.Audit(ctx, "group.delete", id); + return Results.NoContent(); + }); + } +} + +public sealed record GroupDto(int Id, string Name, int SortOrder) +{ + public static GroupDto From(CameraGroup g) => new(g.Id.Value, g.Name, g.SortOrder); +} + +public sealed record GroupWriteRequest(string? Name); diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 73b59dd..39ee831 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -140,6 +140,7 @@ private static void MapEndpoints(WebApplication app) app.MapAuthEndpoints(); app.MapCameraEndpoints(); + app.MapGroupEndpoints(); app.MapGet("/", () => Results.Content(PlaceholderPage, "text/html; charset=utf-8")); } From 975777241cfa2705e9a77f28a835f13eff976280 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 00:46:49 +0300 Subject: [PATCH 06/33] =?UTF-8?q?feat(web):=20server-rendered=20UI=20shell?= =?UTF-8?q?=20=E2=80=94=20login=20+=20cameras=20(Phase=2020=20slice=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pivot UI to server-rendered Razor (static SSR): no wasm-tools/node, same Kestrel host - Web project -> Microsoft.NET.Sdk.Razor; AddRazorComponents + MapRazorComponents - Cookie-carried session (HttpOnly openipc_session); AuthApi.TokenFrom = bearer or cookie - Login/logout form-post endpoints under /app/auth/*; cameras list page renders sanitized DTOs --- src/OpenIPC.Viewer.Web/Api/UiApi.cs | 39 +++++++++ src/OpenIPC.Viewer.Web/Auth/AuthApi.cs | 29 ++++++- src/OpenIPC.Viewer.Web/Components/App.razor | 41 ++++++++++ .../Components/Pages/Cameras.razor | 82 +++++++++++++++++++ .../Components/Pages/Home.razor | 6 ++ .../Components/Pages/Login.razor | 24 ++++++ .../Components/Routes.razor | 9 ++ .../Components/_Imports.razor | 8 ++ .../OpenIPC.Viewer.Web.csproj | 2 +- src/OpenIPC.Viewer.Web/WebServer.cs | 18 ++-- 10 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/UiApi.cs create mode 100644 src/OpenIPC.Viewer.Web/Components/App.razor create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/Home.razor create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/Login.razor create mode 100644 src/OpenIPC.Viewer.Web/Components/Routes.razor create mode 100644 src/OpenIPC.Viewer.Web/Components/_Imports.razor diff --git a/src/OpenIPC.Viewer.Web/Api/UiApi.cs b/src/OpenIPC.Viewer.Web/Api/UiApi.cs new file mode 100644 index 0000000..e7625fa --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/UiApi.cs @@ -0,0 +1,39 @@ +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using OpenIPC.Viewer.Web.Auth; + +namespace OpenIPC.Viewer.Web.Api; + +// Form-post endpoints for the server-rendered UI. Plain HTML forms (no JS) post +// here; we validate, mint a session, and set the HttpOnly cookie the pages read. +// Antiforgery is disabled on these because the Origin check (UseWebAuth) already +// blocks cross-site posts, and login is rate-limited. +public static class UiApi +{ + public static void MapUiEndpoints(this WebApplication app) + { + app.MapPost("/app/auth/login", async (HttpContext ctx, IWebAuthProvider provider, SessionStore store, CancellationToken ct) => + { + var form = await ctx.Request.ReadFormAsync(ct); + var user = form["user"].ToString(); + var password = form["password"].ToString(); + + var identity = await provider.ValidateCredentialsAsync(user, password, ct); + if (identity is null) + return Results.Redirect("/app/login?error=1"); + + var (token, _) = store.Create(identity); + AuthApi.SetSessionCookie(ctx, token); + return Results.Redirect("/app/cameras"); + }).DisableAntiforgery().RequireRateLimiting(AuthApi.LoginRateLimitPolicy); + + app.MapPost("/app/auth/logout", (HttpContext ctx, SessionStore store) => + { + if (AuthApi.CookieToken(ctx) is { } token) + store.Revoke(token); + AuthApi.ClearSessionCookie(ctx); + return Results.Redirect("/app/login"); + }).DisableAntiforgery(); + } +} diff --git a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs index 1d707f0..af998b3 100644 --- a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs +++ b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs @@ -17,6 +17,11 @@ public static class AuthApi // Stashes the authenticated identity for the endpoint to read. public const string IdentityItemKey = "openipc.web.identity"; + // Browser navigations can't set an Authorization header, so the same session + // token also rides in an HttpOnly cookie for the server-rendered UI. API + // clients keep using the bearer header; either is accepted. + public const string SessionCookieName = "openipc_session"; + public static WebIdentity? GetIdentity(this HttpContext ctx) => ctx.Items.TryGetValue(IdentityItemKey, out var value) ? value as WebIdentity : null; @@ -35,7 +40,7 @@ public static void UseWebAuth(this WebApplication app) if (RequiresAuth(ctx.Request.Path)) { var store = ctx.RequestServices.GetRequiredService(); - var identity = BearerToken(ctx) is { } token ? store.Validate(token) : null; + var identity = TokenFrom(ctx) is { } token ? store.Validate(token) : null; if (identity is null) { await WriteError(ctx, StatusCodes.Status401Unauthorized, "unauthorized"); @@ -130,6 +135,28 @@ private static bool OriginAllowed(HttpRequest request) : null; } + // The session token from either transport: bearer header (API) or the + // HttpOnly cookie (server-rendered UI navigations). + public static string? TokenFrom(HttpContext ctx) => + BearerToken(ctx) ?? CookieToken(ctx); + + public static string? CookieToken(HttpContext ctx) => + ctx.Request.Cookies.TryGetValue(SessionCookieName, out var value) && !string.IsNullOrEmpty(value) + ? value + : null; + + public static void SetSessionCookie(HttpContext ctx, string token) => + ctx.Response.Cookies.Append(SessionCookieName, token, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Secure = ctx.Request.IsHttps, // set once TLS is terminated (reverse-proxy) + Path = "/", + }); + + public static void ClearSessionCookie(HttpContext ctx) => + ctx.Response.Cookies.Delete(SessionCookieName); + private static Task WriteError(HttpContext ctx, int statusCode, string error) { ctx.Response.StatusCode = statusCode; diff --git a/src/OpenIPC.Viewer.Web/Components/App.razor b/src/OpenIPC.Viewer.Web/Components/App.razor new file mode 100644 index 0000000..32e2e3b --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/App.razor @@ -0,0 +1,41 @@ +@* Root document for the server-rendered UI (static SSR — no WASM, no SignalR). *@ + + + + + + OpenIPC Viewer + + + + + + + + diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor new file mode 100644 index 0000000..1b043c0 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor @@ -0,0 +1,82 @@ +@page "/app/cameras" +@using OpenIPC.Viewer.Core.Persistence +@using OpenIPC.Viewer.Web.Api +@using OpenIPC.Viewer.Web.Auth +@inject SessionStore Sessions +@inject ICameraRepository CameraRepo +@inject IGroupRepository GroupRepo +@inject NavigationManager Nav + +
+ OpenIPC Viewer + + @_user +
+ +
+
+
+ +
+

Cameras (@(_cameras?.Count ?? 0))

+ + @if (_cameras is null) + { +

Loading…

+ } + else if (_cameras.Count == 0) + { +

No cameras yet.

+ } + else + { + + + + + + @foreach (var c in _cameras) + { + + + + + + + + } + +
NameHostGroupRTSPGrid
@c.Name@c.Host@(c.GroupName ?? "—")@c.RtspMain@if (c.IncludedInGrid) { yes } else { no }
+ } +
+ +@code { + [CascadingParameter] + public HttpContext? Http { get; set; } + + private IReadOnlyList? _cameras; + private string _user = ""; + + protected override async Task OnInitializedAsync() + { + var token = Http is not null ? AuthApi.CookieToken(Http) : null; + var identity = token is null ? null : Sessions.Validate(token); + if (identity is null) + { + Nav.NavigateTo("/app/login", forceLoad: false); + return; + } + + _user = identity.Name; + var ct = Http!.RequestAborted; + + var groups = await GroupRepo.GetAllAsync(ct); + var groupNames = groups.ToDictionary(g => g.Id.Value, g => g.Name); + + var cameras = await CameraRepo.GetAllAsync(ct); + _cameras = cameras + .Select(c => CameraDto.From( + c, c.GroupId is { } gid && groupNames.TryGetValue(gid.Value, out var n) ? n : null)) + .ToList(); + } +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Home.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Home.razor new file mode 100644 index 0000000..af92059 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Home.razor @@ -0,0 +1,6 @@ +@page "/app" +@inject NavigationManager Nav + +@code { + protected override void OnInitialized() => Nav.NavigateTo("/app/cameras", forceLoad: false); +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor new file mode 100644 index 0000000..584f874 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor @@ -0,0 +1,24 @@ +@page "/app/login" + +
+

OpenIPC Viewer

+

Sign in to the web console

+ +
+
+ + + +
+
+ + @if (Error is not null) + { +

Invalid username or password.

+ } +
+ +@code { + [SupplyParameterFromQuery(Name = "error")] + public string? Error { get; set; } +} diff --git a/src/OpenIPC.Viewer.Web/Components/Routes.razor b/src/OpenIPC.Viewer.Web/Components/Routes.razor new file mode 100644 index 0000000..faf6324 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Routes.razor @@ -0,0 +1,9 @@ +@* Static-SSR router over the @@page components in this assembly. *@ + + + + + +

Not found

Back

+
+
diff --git a/src/OpenIPC.Viewer.Web/Components/_Imports.razor b/src/OpenIPC.Viewer.Web/Components/_Imports.razor new file mode 100644 index 0000000..d7bbcbc --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/_Imports.razor @@ -0,0 +1,8 @@ +@using System.Linq +@using System.Net.Http +@using Microsoft.AspNetCore.Http +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using OpenIPC.Viewer.Web.Components diff --git a/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj index baf4362..472aa14 100644 --- a/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj +++ b/src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 39ee831..6a14d71 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -8,6 +8,7 @@ using OpenIPC.Viewer.Core.Persistence; using OpenIPC.Viewer.Web.Api; using OpenIPC.Viewer.Web.Auth; +using OpenIPC.Viewer.Web.Components; namespace OpenIPC.Viewer.Web; @@ -38,8 +39,9 @@ public static async Task RunAsync( Action? configureBackend = null, CancellationToken ct = default) { - var builder = WebApplication.CreateSlimBuilder(); + var builder = WebApplication.CreateBuilder(); ConfigureAuthServices(builder, authOptions ?? new WebAuthOptions()); + builder.Services.AddRazorComponents(); configureBackend?.Invoke(builder.Services); var app = builder.Build(); @@ -114,6 +116,7 @@ private static void MapEndpoints(WebApplication app) app.UseRateLimiter(); // Origin check on mutations + bearer-token guard over protected API paths. app.UseWebAuth(); + app.UseAntiforgery(); // Liveness probe — touches no backend, so it answers even before the // data layer exists. Public (no auth) for --server-only smoke checks and, @@ -141,16 +144,9 @@ private static void MapEndpoints(WebApplication app) app.MapAuthEndpoints(); app.MapCameraEndpoints(); app.MapGroupEndpoints(); + app.MapUiEndpoints(); + app.MapRazorComponents(); - app.MapGet("/", () => Results.Content(PlaceholderPage, "text/html; charset=utf-8")); + app.MapGet("/", () => Results.Redirect("/app")); } - - private const string PlaceholderPage = - "OpenIPC Viewer" + - "" + - "

OpenIPC Viewer — web server

" + - "

Phase 20 · slice A. The camera monitor and API arrive in later slices.

" + - "

Health: /healthz · " + - "/api/v1/version

" + - ""; } From 7cf66b59d46e82c193eb1dbcfa5fba602ff25a19 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 00:54:36 +0300 Subject: [PATCH 07/33] feat(web): honour reverse-proxy forwarded headers (Phase 20 slice 20.7) - UseForwardedHeaders (X-Forwarded-For/Proto/Host) first in the pipeline - Trusts loopback by default + WebServerOptions.TrustedProxies for off-host proxies - Fixes Origin/CSRF check rejecting proxied mutations; direct exposure ignores spoofed headers --- src/OpenIPC.Viewer.Web/WebServer.cs | 24 ++++++++++++++++++++-- src/OpenIPC.Viewer.Web/WebServerOptions.cs | 8 ++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 6a14d71..448f6c6 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -1,7 +1,9 @@ +using System.Net; using System.Reflection; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -46,7 +48,7 @@ public static async Task RunAsync( var app = builder.Build(); app.Urls.Add(options.Url); - MapEndpoints(app); + MapEndpoints(app, options); var logger = app.Services.GetRequiredService().CreateLogger("OpenIPC.Web"); await MigrateAsync(app, logger, ct); @@ -101,8 +103,26 @@ private static void ConfigureAuthServices(WebApplicationBuilder builder, WebAuth }); } - private static void MapEndpoints(WebApplication app) + private static void MapEndpoints(WebApplication app, WebServerOptions options) { + // Honour a reverse-proxy's X-Forwarded-* so request.Host/Scheme reflect + // the public origin — otherwise the Origin/CSRF check compares against + // the internal host and rejects every proxied mutation. Only loopback + // (default) plus explicitly trusted proxy IPs are believed; a directly + // exposed server ignores spoofed headers. Must run first. + var forwarded = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor + | ForwardedHeaders.XForwardedProto + | ForwardedHeaders.XForwardedHost, + }; + foreach (var proxy in options.TrustedProxies) + { + if (IPAddress.TryParse(proxy, out var addr)) + forwarded.KnownProxies.Add(addr); + } + app.UseForwardedHeaders(forwarded); + // A minimal security-header floor on every response. app.Use(async (HttpContext context, RequestDelegate next) => { diff --git a/src/OpenIPC.Viewer.Web/WebServerOptions.cs b/src/OpenIPC.Viewer.Web/WebServerOptions.cs index 6d6abf8..1800434 100644 --- a/src/OpenIPC.Viewer.Web/WebServerOptions.cs +++ b/src/OpenIPC.Viewer.Web/WebServerOptions.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace OpenIPC.Viewer.Web; // Bind knobs for the embedded web server. Defaults are deliberately safe: @@ -18,4 +21,9 @@ public sealed record WebServerOptions public string BindHost => BindLan ? "0.0.0.0" : "127.0.0.1"; public string Url => $"http://{BindHost}:{Port}"; + + // Reverse-proxy IPs whose X-Forwarded-* headers are trusted (in addition to + // loopback, which is always trusted). Set this when the proxy runs on a + // different host than the server. Empty = only a same-host proxy is trusted. + public IReadOnlyList TrustedProxies { get; init; } = Array.Empty(); } From 8f750ec2d2101a6594af9f491b1b73515a76f9bd Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:07:44 +0300 Subject: [PATCH 08/33] feat(web): live H.264 video over WebSocket (Phase 20 slice 20.4 spike) - WS GET /api/v1/cameras/{id}/live: ffmpeg remuxes RTSP H.264 -> fMP4 (copy) to the socket - Credentials injected into the RTSP URL for ffmpeg only, never surfaced to any client - Live.razor plays via MSE; JS reads the exact codec from the avcC init box (any H.264 profile) - UseWebSockets; auth-guarded (cookie/bearer); ffmpeg killed on client disconnect - Verified live: valid fMP4 (ftyp/moov/moof/avcC) over WS on a real camera; no token -> 401 --- src/OpenIPC.Viewer.Web/Api/LiveApi.cs | 181 ++++++++++++++++++ .../Components/Pages/Cameras.razor | 4 +- .../Components/Pages/Live.razor | 86 +++++++++ src/OpenIPC.Viewer.Web/WebServer.cs | 2 + 4 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/LiveApi.cs create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/Live.razor diff --git a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs new file mode 100644 index 0000000..64c868b --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs @@ -0,0 +1,181 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; + +namespace OpenIPC.Viewer.Web.Api; + +// Live video over WebSocket (Phase 20 §20.4, spike). ffmpeg remuxes the camera's +// RTSP H.264 into fragmented MP4 (no re-encode) straight to stdout, and we pump +// those bytes to the browser, which plays them via Media Source Extensions. +// H.265 cameras won't play in most browsers over MSE — a MJPEG fallback and/or +// transcode is future work. +public static class LiveApi +{ + public static void MapLiveEndpoints(this WebApplication app) + { + app.Map("/api/v1/cameras/{id}/live", async (string id, HttpContext ctx, CancellationToken ct) => + { + if (!ctx.WebSockets.IsWebSocketRequest) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var dir = ctx.RequestServices.GetService(); + if (dir is null) + { + ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + if (!Guid.TryParse(id, out var guid)) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var camera = await dir.GetAsync(new CameraId(guid), ct); + if (camera is null) + { + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + var credentials = await dir.GetCredentialsAsync(camera.Id, ct); + var rtspUrl = BuildRtspUrl(camera.RtspMainUri, credentials); + + var socket = await ctx.WebSockets.AcceptWebSocketAsync(); + var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Live"); + await PumpAsync(socket, rtspUrl, logger, ct); + }); + } + + // Injects credentials into the RTSP URL for ffmpeg (they live in the secrets + // store, not the entity's URI). Never surfaced back to any client. + private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials) + { + if (credentials is null || string.IsNullOrEmpty(credentials.Username)) + return baseUri.ToString(); + return new UriBuilder(baseUri) + { + UserName = credentials.Username, + Password = credentials.Password, + }.Uri.ToString(); + } + + private static async Task PumpAsync(WebSocket socket, string rtspUrl, ILogger logger, CancellationToken ct) + { + Process? proc = null; + try + { + proc = StartFfmpeg(rtspUrl); + _ = DrainStderrAsync(proc, logger); + + var stdout = proc.StandardOutput.BaseStream; + var buffer = new byte[32 * 1024]; + int read; + while (socket.State == WebSocketState.Open && + (read = await stdout.ReadAsync(buffer, ct)) > 0) + { + await socket.SendAsync( + buffer.AsMemory(0, read), WebSocketMessageType.Binary, endOfMessage: true, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + // A closed socket makes SendAsync throw — that's the normal "viewer + // navigated away" path, so keep it at debug. + logger.LogDebug(ex, "live pump ended for {Rtsp}", Redact(rtspUrl)); + } + finally + { + KillQuietly(proc); + if (socket.State == WebSocketState.Open) + { + try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stream ended", CancellationToken.None); } + catch { /* best effort */ } + } + } + } + + private static Process StartFfmpeg(string rtspUrl) + { + var psi = new ProcessStartInfo(ResolveFfmpeg()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in new[] + { + "-rtsp_transport", "tcp", + "-fflags", "nobuffer", + "-i", rtspUrl, + "-an", + "-c:v", "copy", + "-f", "mp4", + "-movflags", "+frag_keyframe+empty_moov+default_base_moof", + "-flush_packets", "1", + "pipe:1", + }) + { + psi.ArgumentList.Add(arg); + } + return Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch ffmpeg"); + } + + private static async Task DrainStderrAsync(Process proc, ILogger logger) + { + try + { + string? line; + while ((line = await proc.StandardError.ReadLineAsync()) is not null) + logger.LogTrace("ffmpeg: {Line}", line); + } + catch { /* process gone */ } + } + + private static void KillQuietly(Process? proc) + { + try + { + if (proc is { HasExited: false }) + proc.Kill(entireProcessTree: true); + } + catch { /* already gone */ } + proc?.Dispose(); + } + + // Same bundled-then-PATH resolution as FfmpegSubprocessRecorder. + private static string ResolveFfmpeg() + { + string? rid = null; + var exe = "ffmpeg"; + if (OperatingSystem.IsWindows()) { rid = "win-x64"; exe = "ffmpeg.exe"; } + else if (OperatingSystem.IsLinux()) { rid = "linux-x64"; } + else if (OperatingSystem.IsMacOS()) { rid = "osx-x64"; } + + if (rid is not null) + { + var bundled = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", exe); + if (File.Exists(bundled)) + return bundled; + } + return "ffmpeg"; + } + + private static string Redact(string rtspUrl) => + Uri.TryCreate(rtspUrl, UriKind.Absolute, out var u) && !string.IsNullOrEmpty(u.UserInfo) + ? rtspUrl.Replace(u.UserInfo + "@", "***@", StringComparison.Ordinal) + : rtspUrl; +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor index 1b043c0..d55e26b 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor @@ -32,7 +32,7 @@ { - + @foreach (var c in _cameras) @@ -41,8 +41,8 @@ - + } diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor new file mode 100644 index 0000000..dbadc54 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor @@ -0,0 +1,86 @@ +@page "/app/cameras/{Id}/live" + +
+

← Cameras

+

Live

+ +

+
+ + + +@code { + [Parameter] + public string Id { get; set; } = ""; +} diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 448f6c6..aaaa610 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -122,6 +122,7 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) forwarded.KnownProxies.Add(addr); } app.UseForwardedHeaders(forwarded); + app.UseWebSockets(); // A minimal security-header floor on every response. app.Use(async (HttpContext context, RequestDelegate next) => @@ -164,6 +165,7 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) app.MapAuthEndpoints(); app.MapCameraEndpoints(); app.MapGroupEndpoints(); + app.MapLiveEndpoints(); app.MapUiEndpoints(); app.MapRazorComponents(); From 731c917ec9361ebc74f75bbc651fa2edbe8a114d Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:13:35 +0300 Subject: [PATCH 09/33] feat(web): multi-tile live grid 1/4/9 (Phase 20 slice 20.5) - Grid.razor at /app/grid[/{size}] with 1/4/9 layouts, auth-guarded - Auto-fills cells from grid-included cameras; each tile plays via shared MSE starter - Extract window.openipcLive.start into App head; Live.razor reuses it - Cameras header links to Grid --- src/OpenIPC.Viewer.Web/Components/App.razor | 64 +++++++++++ .../Components/Pages/Cameras.razor | 1 + .../Components/Pages/Grid.razor | 100 ++++++++++++++++++ .../Components/Pages/Live.razor | 74 +------------ 4 files changed, 169 insertions(+), 70 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor diff --git a/src/OpenIPC.Viewer.Web/Components/App.razor b/src/OpenIPC.Viewer.Web/Components/App.razor index 32e2e3b..03d99fb 100644 --- a/src/OpenIPC.Viewer.Web/Components/App.razor +++ b/src/OpenIPC.Viewer.Web/Components/App.razor @@ -32,8 +32,72 @@ .badge.on { color: #3fb950; } .muted { color: #8b949e; } .err { color: #f85149; } + .tabs a { display:inline-block; margin-right:.5rem; padding:.3rem .7rem; border:1px solid #30363d; border-radius:6px; text-decoration:none; } + .tabs a.active { background:#238636; border-color:#238636; color:#fff; } + .videos { display:grid; gap:8px; margin-top:1rem; } + .videos.n1 { grid-template-columns: 1fr; } + .videos.n4 { grid-template-columns: repeat(2, 1fr); } + .videos.n9 { grid-template-columns: repeat(3, 1fr); } + .cell { position:relative; background:#000; border-radius:6px; overflow:hidden; aspect-ratio:16/9; } + .cell video { width:100%; height:100%; object-fit:contain; background:#000; } + .cell .label { position:absolute; left:6px; bottom:6px; font-size:.75rem; background:rgba(0,0,0,.55); padding:.1rem .4rem; border-radius:4px; color:#fff; } + .cell .empty { display:flex; align-items:center; justify-content:center; height:100%; color:#8b949e; } + diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor index d55e26b..f2ac730 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor @@ -10,6 +10,7 @@
OpenIPC Viewer + Grid @_user
diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor new file mode 100644 index 0000000..c47354e --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor @@ -0,0 +1,100 @@ +@page "/app/grid" +@page "/app/grid/{Size:int}" +@using OpenIPC.Viewer.Core.Persistence +@using OpenIPC.Viewer.Web.Auth +@inject SessionStore Sessions +@inject ICameraRepository CameraRepo +@inject NavigationManager Nav + +
+ OpenIPC Viewer + + Cameras + @_user + + +
+ +
+
+ 1 + 4 + 9 +
+ + @if (_ids is null) + { +

Loading…

+ } + else + { +
+ @for (var i = 0; i < Cells; i++) + { +
+ @if (i < _ids.Count) + { + + @_names[i] + } + else + { +
+ } +
+ } +
+ } +
+ +@if (_ids is { Count: > 0 }) +{ + +} + +@code { + [Parameter] + public int Size { get; set; } + + [CascadingParameter] + public HttpContext? Http { get; set; } + + // Only 1/4/9 are valid; anything else (incl. the no-arg route) falls to 4. + private int Cells => Size is 1 or 4 or 9 ? Size : 4; + + private IReadOnlyList? _ids; + private List _names = new(); + private string _user = ""; + + private string IdsJs => string.Join(",", _ids!.Select(id => $"\"{id}\"")); + + protected override async Task OnInitializedAsync() + { + var token = Http is not null ? AuthApi.CookieToken(Http) : null; + var identity = token is null ? null : Sessions.Validate(token); + if (identity is null) + { + Nav.NavigateTo("/app/login", forceLoad: false); + return; + } + + _user = identity.Name; + var ct = Http!.RequestAborted; + + var cameras = await CameraRepo.GetAllAsync(ct); + // Grid-included cameras first, then the rest, capped at the cell count. + var ordered = cameras + .Where(c => c.IncludedInGrid) + .Concat(cameras.Where(c => !c.IncludedInGrid)) + .Take(Cells) + .ToList(); + + _ids = ordered.Select(c => c.Id.ToString()).ToList(); + _names = ordered.Select(c => c.Name).ToList(); + } +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor index dbadc54..b49f406 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor @@ -8,76 +8,10 @@ @code { From 1c2c827125c9c1fadefa0dcec59ce7a19eb3ce79 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:19:42 +0300 Subject: [PATCH 10/33] fix(web): treat loopback aliases / ports as same-origin in CSRF check - localhost / 127.0.0.1 / ::1 mix no longer false-rejects with bad_origin - Compare hostnames port-agnostically (matches SameSite scope); cross-site still blocked --- src/OpenIPC.Viewer.Web/Auth/AuthApi.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs index af998b3..76f8e6a 100644 --- a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs +++ b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs @@ -116,16 +116,26 @@ private static bool IsMutation(string method) => HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method); // Same-origin only when an Origin header is present. Native clients (curl, - // the desktop app) send no Origin and are allowed — CSRF is a browser threat. + // the desktop app) send no Origin and are allowed — CSRF is a browser threat, + // and SameSite=Strict on the session cookie is the primary defense anyway. + // We compare hostnames (port-agnostic, matching SameSite semantics) and treat + // loopback aliases as equal, so localhost/127.0.0.1 mixes don't false-reject. private static bool OriginAllowed(HttpRequest request) { var origin = request.Headers.Origin.ToString(); if (string.IsNullOrEmpty(origin)) return true; - return Uri.TryCreate(origin, UriKind.Absolute, out var uri) - && string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); + if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri)) + return false; + return HostsEquivalent(originUri.Host, request.Host.Host); } + private static bool HostsEquivalent(string a, string b) => + string.Equals(a, b, StringComparison.OrdinalIgnoreCase) || (IsLoopback(a) && IsLoopback(b)); + + private static bool IsLoopback(string host) => + host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host is "127.0.0.1" or "::1" or "[::1]"; + private static string? BearerToken(HttpContext ctx) { var header = ctx.Request.Headers.Authorization.ToString(); From 36e1bf5c3c0f1e6ec123dd66ca7c8ec44af518fd Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:26:07 +0300 Subject: [PATCH 11/33] fix(web): stop rejecting login with Origin: null - Referrer-Policy no-referrer made Chrome send "Origin: null" on same-site form POST, tripping the CSRF check; switch to strict-origin-when-cross-origin - Treat null/opaque Origin as allowed (SameSite=Strict is the real CSRF defense) - Log Origin/Host on bad_origin rejections for diagnosis --- src/OpenIPC.Viewer.Web/Auth/AuthApi.cs | 10 +++++++++- src/OpenIPC.Viewer.Web/WebServer.cs | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs index 76f8e6a..3fad504 100644 --- a/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs +++ b/src/OpenIPC.Viewer.Web/Auth/AuthApi.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace OpenIPC.Viewer.Web.Auth; @@ -33,6 +34,10 @@ public static void UseWebAuth(this WebApplication app) { if (IsMutation(ctx.Request.Method) && !OriginAllowed(ctx.Request)) { + ctx.RequestServices.GetRequiredService() + .CreateLogger("OpenIPC.Web.Auth") + .LogWarning("bad_origin rejected: Origin={Origin} Host={Host} Path={Path}", + ctx.Request.Headers.Origin.ToString(), ctx.Request.Host.Value, ctx.Request.Path.Value); await WriteError(ctx, StatusCodes.Status403Forbidden, "bad_origin"); return; } @@ -123,7 +128,10 @@ private static bool IsMutation(string method) => private static bool OriginAllowed(HttpRequest request) { var origin = request.Headers.Origin.ToString(); - if (string.IsNullOrEmpty(origin)) + // Missing or opaque ("null") origin: a sandboxed/privacy context or a + // native client. SameSite=Strict on the session cookie already blocks a + // real cross-site attacker, so don't false-reject these. + if (string.IsNullOrEmpty(origin) || string.Equals(origin, "null", StringComparison.OrdinalIgnoreCase)) return true; if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri)) return false; diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index aaaa610..3f192a9 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -130,7 +130,10 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) var headers = context.Response.Headers; headers["X-Content-Type-Options"] = "nosniff"; headers["X-Frame-Options"] = "DENY"; - headers["Referrer-Policy"] = "no-referrer"; + // Not "no-referrer" — that makes Chrome send "Origin: null" on same-site + // form POSTs, which then trips our own Origin check. This still withholds + // the referrer cross-origin while keeping the Origin header intact. + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; await next(context); }); From c23b99a41872508c2af3dede65720ab0fa68f2bd Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:32:42 +0300 Subject: [PATCH 12/33] feat(web): camera CRUD from the browser (Phase 20 slice 20.5) - CameraEditor page (/app/cameras/new + /{id}/edit) with a full server-rendered form - Form-post create/update/delete under /app/cameras/*, reusing validation + directory service - Cameras list gains Add / Edit / Delete (confirm) actions; edit keeps creds when blank --- src/OpenIPC.Viewer.Web/Api/CameraFormApi.cs | 83 ++++++++++++++++ .../Components/Pages/CameraEditor.razor | 98 +++++++++++++++++++ .../Components/Pages/Cameras.razor | 14 ++- src/OpenIPC.Viewer.Web/WebServer.cs | 1 + 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/CameraFormApi.cs create mode 100644 src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor diff --git a/src/OpenIPC.Viewer.Web/Api/CameraFormApi.cs b/src/OpenIPC.Viewer.Web/Api/CameraFormApi.cs new file mode 100644 index 0000000..1b9ad4c --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/CameraFormApi.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Services; +using static OpenIPC.Viewer.Web.Api.ApiHelpers; + +namespace OpenIPC.Viewer.Web.Api; + +// Server-rendered form-post handlers for the camera editor UI. Plain HTML forms +// post here (distinct paths from the page routes to avoid the Blazor endpoint +// also matching POST); we validate, call the directory service, and redirect. +// Origin-checked via UseWebAuth; antiforgery disabled (same rationale as login). +public static class CameraFormApi +{ + public static void MapCameraFormEndpoints(this WebApplication app) + { + app.MapPost("/app/cameras/create", async (HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) return BackendUnavailable(); + + var form = await ctx.Request.ReadFormAsync(ct); + if (!FromForm(form).TryValidate(out var valid, out _)) + return Results.Redirect("/app/cameras/new?error=1"); + + var id = await dir.AddAsync(valid.ToNew(), ct); + Audit(ctx, "camera.create", id); + return Results.Redirect("/app/cameras"); + }).DisableAntiforgery(); + + app.MapPost("/app/cameras/{id}/update", async (string id, HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) return BackendUnavailable(); + if (!Guid.TryParse(id, out var guid)) return ValidationError("invalid camera id"); + + var form = await ctx.Request.ReadFormAsync(ct); + if (!FromForm(form).TryValidate(out var valid, out _)) + return Results.Redirect($"/app/cameras/{id}/edit?error=1"); + + try { await dir.UpdateAsync(new CameraId(guid), valid.ToUpdate(), ct); } + catch (InvalidOperationException) { return NotFound(); } + + Audit(ctx, "camera.update", new CameraId(guid)); + return Results.Redirect("/app/cameras"); + }).DisableAntiforgery(); + + app.MapPost("/app/cameras/{id}/delete", async (string id, HttpContext ctx, CancellationToken ct) => + { + var dir = ctx.RequestServices.GetService(); + if (dir is null) return BackendUnavailable(); + if (!Guid.TryParse(id, out var guid)) return ValidationError("invalid camera id"); + + var cameraId = new CameraId(guid); + if (await dir.GetAsync(cameraId, ct) is not null) + { + await dir.RemoveAsync(cameraId, ct); + Audit(ctx, "camera.delete", cameraId); + } + return Results.Redirect("/app/cameras"); + }).DisableAntiforgery(); + } + + private static CameraWriteRequest FromForm(IFormCollection f) => new( + Name: f["name"], + Host: f["host"], + HttpPort: ParseInt(f["httpPort"]), + OnvifPort: ParseInt(f["onvifPort"]), + RtspMain: f["rtspMain"], + RtspSub: NullIfEmpty(f["rtspSub"]), + GroupId: ParseInt(f["groupId"]), + Username: NullIfEmpty(f["username"]), + Password: NullIfEmpty(f["password"]), + StreamQuality: NullIfEmpty(f["streamQuality"])); + + private static int? ParseInt(string? s) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; + + private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor b/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor new file mode 100644 index 0000000..ab1823f --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor @@ -0,0 +1,98 @@ +@page "/app/cameras/new" +@page "/app/cameras/{Id}/edit" +@using OpenIPC.Viewer.Core.Entities +@using OpenIPC.Viewer.Core.Persistence +@using OpenIPC.Viewer.Web.Api +@using OpenIPC.Viewer.Web.Auth +@inject SessionStore Sessions +@inject ICameraRepository CameraRepo +@inject IGroupRepository GroupRepo +@inject NavigationManager Nav + +
+ OpenIPC Viewer + Cameras +
+ +
+

@(IsEdit ? "Edit camera" : "Add camera")

+ @if (Error is not null) + { +

Please check the fields — name, host and a valid RTSP URL are required.

+ } + +
+
+ + + + +
+ + +
+ + +
+ + +
+
+ + Cancel +
+
+ +
+ +@code { + [Parameter] + public string? Id { get; set; } + + [SupplyParameterFromQuery(Name = "error")] + public string? Error { get; set; } + + [CascadingParameter] + public HttpContext? Http { get; set; } + + private bool IsEdit => Id is not null; + private string Action => IsEdit ? $"/app/cameras/{Id}/update" : "/app/cameras/create"; + + private CameraDto? _cam; + private List<(int Id, string Name)> _groups = new(); + + protected override async Task OnInitializedAsync() + { + var token = Http is not null ? AuthApi.CookieToken(Http) : null; + if (token is null || Sessions.Validate(token) is null) + { + Nav.NavigateTo("/app/login", forceLoad: false); + return; + } + + var ct = Http!.RequestAborted; + _groups = (await GroupRepo.GetAllAsync(ct)).Select(g => (g.Id.Value, g.Name)).ToList(); + + if (IsEdit && Guid.TryParse(Id, out var guid)) + { + var camera = await CameraRepo.GetAsync(new CameraId(guid), ct); + if (camera is not null) + _cam = CameraDto.From(camera, null); + } + } +} diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor index f2ac730..caac5cb 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor @@ -19,7 +19,10 @@
-

Cameras (@(_cameras?.Count ?? 0))

+

+ Cameras (@(_cameras?.Count ?? 0)) + + Add camera +

@if (_cameras is null) { @@ -43,7 +46,14 @@
- + } diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 3f192a9..264b228 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -170,6 +170,7 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) app.MapGroupEndpoints(); app.MapLiveEndpoints(); app.MapUiEndpoints(); + app.MapCameraFormEndpoints(); app.MapRazorComponents(); app.MapGet("/", () => Results.Redirect("/app")); From 147df72544ddc146451317785982ee4c5c0ddb53 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:44:29 +0300 Subject: [PATCH 13/33] feat(web): H.265 fallback via on-demand H.264 transcode (Phase 20 slice 20.4) - Live WS honours ?transcode=1: re-encodes to H.264 with libopenh264 (LGPL build has no libx264) - Client detects hvcC / unsupported codec and reconnects asking for transcode - H.264 stays on cheap copy; transcode CPU only for codecs the browser can't play - Verified: transcode WS delivers valid H.264 fMP4 (avc1.42C01F, constrained baseline) --- src/OpenIPC.Viewer.Web/Api/LiveApi.cs | 39 +++++---- src/OpenIPC.Viewer.Web/Components/App.razor | 88 +++++++++++++-------- 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs index 64c868b..665b55c 100644 --- a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.WebSockets; @@ -52,9 +53,13 @@ public static void MapLiveEndpoints(this WebApplication app) var credentials = await dir.GetCredentialsAsync(camera.Id, ct); var rtspUrl = BuildRtspUrl(camera.RtspMainUri, credentials); + // The browser reconnects with ?transcode=1 when it can't play the + // source codec over MSE (e.g. H.265). Then we re-encode to H.264. + var transcode = ctx.Request.Query.ContainsKey("transcode"); + var socket = await ctx.WebSockets.AcceptWebSocketAsync(); var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Live"); - await PumpAsync(socket, rtspUrl, logger, ct); + await PumpAsync(socket, rtspUrl, transcode, logger, ct); }); } @@ -71,12 +76,12 @@ private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials) }.Uri.ToString(); } - private static async Task PumpAsync(WebSocket socket, string rtspUrl, ILogger logger, CancellationToken ct) + private static async Task PumpAsync(WebSocket socket, string rtspUrl, bool transcode, ILogger logger, CancellationToken ct) { Process? proc = null; try { - proc = StartFfmpeg(rtspUrl); + proc = StartFfmpeg(rtspUrl, transcode); _ = DrainStderrAsync(proc, logger); var stdout = proc.StandardOutput.BaseStream; @@ -107,7 +112,7 @@ await socket.SendAsync( } } - private static Process StartFfmpeg(string rtspUrl) + private static Process StartFfmpeg(string rtspUrl, bool transcode) { var psi = new ProcessStartInfo(ResolveFfmpeg()) { @@ -116,21 +121,23 @@ private static Process StartFfmpeg(string rtspUrl) UseShellExecute = false, CreateNoWindow = true, }; - foreach (var arg in new[] + + var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl, "-an" }; + if (transcode) { - "-rtsp_transport", "tcp", - "-fflags", "nobuffer", - "-i", rtspUrl, - "-an", - "-c:v", "copy", - "-f", "mp4", - "-movflags", "+frag_keyframe+empty_moov+default_base_moof", - "-flush_packets", "1", - "pipe:1", - }) + // Software H.264 (libopenh264 — the LGPL build has no libx264). Output + // is Constrained Baseline, which every MSE browser accepts. CPU cost is + // real; this path only runs for codecs the browser can't play directly. + args.AddRange(new[] { "-c:v", "libopenh264", "-b:v", "2M", "-g", "30", "-pix_fmt", "yuv420p" }); + } + else { - psi.ArgumentList.Add(arg); + args.AddRange(new[] { "-c:v", "copy" }); } + args.AddRange(new[] { "-f", "mp4", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-flush_packets", "1", "pipe:1" }); + + foreach (var arg in args) + psi.ArgumentList.Add(arg); return Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch ffmpeg"); } diff --git a/src/OpenIPC.Viewer.Web/Components/App.razor b/src/OpenIPC.Viewer.Web/Components/App.razor index 03d99fb..f66e36d 100644 --- a/src/OpenIPC.Viewer.Web/Components/App.razor +++ b/src/OpenIPC.Viewer.Web/Components/App.razor @@ -53,47 +53,71 @@ const say = onStatus || function () { }; if (!('MediaSource' in window)) { say('no MediaSource'); return; } const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - const ws = new WebSocket(`${proto}://${location.host}/api/v1/cameras/${id}/live`); - ws.binaryType = 'arraybuffer'; - const ms = new MediaSource(); - video.src = URL.createObjectURL(ms); - let sb = null, queue = [], pending = [], codec = null, msOpen = false; - ms.addEventListener('sourceopen', () => { msOpen = true; setup(); }); - say('connecting'); - ws.onopen = () => say('live'); - ws.onclose = () => say('ended'); - ws.onerror = () => say('error'); - ws.onmessage = e => { - const b = new Uint8Array(e.data); - if (!sb) { pending.push(b); if (codec === null) codec = findCodec(pending); setup(); return; } - queue.push(b); pump(); - }; - function setup() { - if (sb || !msOpen || !codec) return; - const mime = `video/mp4; codecs="${codec}"`; - if (!MediaSource.isTypeSupported(mime)) { say('unsupported ' + codec); return; } - sb = ms.addSourceBuffer(mime); sb.mode = 'segments'; - sb.addEventListener('updateend', pump); - for (const x of pending) queue.push(x); pending = []; pump(); - } - function pump() { - if (!sb || sb.updating || !queue.length) return; - try { sb.appendBuffer(queue.shift()); } catch (err) { say('buffer error'); } - trim(); - } - function trim() { - try { if (sb && !sb.updating && sb.buffered.length && video.currentTime > 30) sb.remove(0, video.currentTime - 15); } catch { } + let active = null; + connect(false); + + // transcode=false → copy remux (H.264). If the source turns out to + // be H.265 (or otherwise unplayable), reconnect once asking the + // server to re-encode to H.264. + function connect(transcode) { + const url = `${proto}://${location.host}/api/v1/cameras/${id}/live${transcode ? '?transcode=1' : ''}`; + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + active = ws; + const ms = new MediaSource(); + video.src = URL.createObjectURL(ms); + let sb = null, queue = [], pending = [], codec = null, msOpen = false, switching = false; + ms.addEventListener('sourceopen', () => { msOpen = true; setup(); }); + say(transcode ? 'transcoding…' : 'connecting'); + ws.onopen = () => say(transcode ? 'live · transcoded' : 'live'); + ws.onclose = () => { if (!switching) say('ended'); }; + ws.onerror = () => say('error'); + ws.onmessage = e => { + const b = new Uint8Array(e.data); + if (!sb) { + pending.push(b); + if (codec === null) codec = findCodec(pending); + if (codec === 'hevc' && !transcode) { switching = true; try { ws.close(); } catch { } connect(true); return; } + setup(); return; + } + queue.push(b); pump(); + }; + function setup() { + if (sb || !msOpen || !codec || codec === 'hevc') return; + const mime = `video/mp4; codecs="${codec}"`; + if (!MediaSource.isTypeSupported(mime)) { + if (!transcode) { switching = true; try { ws.close(); } catch { } connect(true); } + else say('unsupported ' + codec); + return; + } + sb = ms.addSourceBuffer(mime); sb.mode = 'segments'; + sb.addEventListener('updateend', pump); + for (const x of pending) queue.push(x); pending = []; pump(); + } + function pump() { + if (!sb || sb.updating || !queue.length) return; + try { sb.appendBuffer(queue.shift()); } catch (err) { say('buffer error'); } + trim(); + } + function trim() { + try { if (sb && !sb.updating && sb.buffered.length && video.currentTime > 30) sb.remove(0, video.currentTime - 15); } catch { } + } } + + // avcC → H.264 codec string; hvcC → 'hevc' sentinel (trigger transcode). function findCodec(chunks) { let t = 0; for (const c of chunks) t += c.length; const d = new Uint8Array(t); let o = 0; for (const c of chunks) { d.set(c, o); o += c.length; } - for (let i = 0; i + 8 < d.length; i++) + for (let i = 0; i + 8 < d.length; i++) { if (d[i] === 0x61 && d[i + 1] === 0x76 && d[i + 2] === 0x63 && d[i + 3] === 0x43) return 'avc1.' + hx(d[i + 5]) + hx(d[i + 6]) + hx(d[i + 7]); + if (d[i] === 0x68 && d[i + 1] === 0x76 && d[i + 2] === 0x63 && d[i + 3] === 0x43) + return 'hevc'; + } return null; } function hx(n) { return n.toString(16).padStart(2, '0').toUpperCase(); } - return { stop() { try { ws.close(); } catch { } } }; + return { stop() { try { if (active) active.close(); } catch { } } }; } return { start }; })(); From 85076b19adf655f6c6831a72254d9766bffe0b9e Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 01:52:14 +0300 Subject: [PATCH 14/33] feat(web): fan-out one ffmpeg session per camera to N viewers (Phase 20 slice 20.4) - LiveStreamHub: shared per-(camera,mode) ffmpeg; parses MP4 boxes, caches the init segment, broadcasts whole fragments so late joiners never get a partial - Per-subscriber bounded channel (drop-oldest) so a slow viewer can't stall others - Refcounted: ffmpeg starts on first viewer, stops when the last leaves - LiveApi slimmed to subscribe + pump; LiveFfmpeg holds the process helpers - Verified: 2 viewers of one camera share a single ffmpeg; cleaned up on leave --- src/OpenIPC.Viewer.Web/Api/LiveApi.cs | 137 ++-------- src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs | 82 ++++++ src/OpenIPC.Viewer.Web/Api/LiveStreamHub.cs | 270 +++++++++++++++++++ src/OpenIPC.Viewer.Web/Backend/WebBackend.cs | 3 + 4 files changed, 377 insertions(+), 115 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs create mode 100644 src/OpenIPC.Viewer.Web/Api/LiveStreamHub.cs diff --git a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs index 665b55c..b0af938 100644 --- a/src/OpenIPC.Viewer.Web/Api/LiveApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/LiveApi.cs @@ -1,24 +1,18 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using OpenIPC.Viewer.Core.Entities; using OpenIPC.Viewer.Core.Services; namespace OpenIPC.Viewer.Web.Api; -// Live video over WebSocket (Phase 20 §20.4, spike). ffmpeg remuxes the camera's -// RTSP H.264 into fragmented MP4 (no re-encode) straight to stdout, and we pump -// those bytes to the browser, which plays them via Media Source Extensions. -// H.265 cameras won't play in most browsers over MSE — a MJPEG fallback and/or -// transcode is future work. +// Live video over WebSocket (Phase 20 §20.4). The heavy lifting (ffmpeg remux/ +// transcode, box parsing, fan-out) is in LiveStreamHub; this endpoint resolves +// the camera, taps the shared stream, and pumps its bytes to the socket. public static class LiveApi { public static void MapLiveEndpoints(this WebApplication app) @@ -32,7 +26,8 @@ public static void MapLiveEndpoints(this WebApplication app) } var dir = ctx.RequestServices.GetService(); - if (dir is null) + var hub = ctx.RequestServices.GetService(); + if (dir is null || hub is null) { ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; return; @@ -52,58 +47,31 @@ public static void MapLiveEndpoints(this WebApplication app) var credentials = await dir.GetCredentialsAsync(camera.Id, ct); var rtspUrl = BuildRtspUrl(camera.RtspMainUri, credentials); - // The browser reconnects with ?transcode=1 when it can't play the // source codec over MSE (e.g. H.265). Then we re-encode to H.264. var transcode = ctx.Request.Query.ContainsKey("transcode"); var socket = await ctx.WebSockets.AcceptWebSocketAsync(); - var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Live"); - await PumpAsync(socket, rtspUrl, transcode, logger, ct); + using var subscription = hub.Subscribe(id, transcode, rtspUrl); + await PumpAsync(socket, subscription, ct); }); } - // Injects credentials into the RTSP URL for ffmpeg (they live in the secrets - // store, not the entity's URI). Never surfaced back to any client. - private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials) - { - if (credentials is null || string.IsNullOrEmpty(credentials.Username)) - return baseUri.ToString(); - return new UriBuilder(baseUri) - { - UserName = credentials.Username, - Password = credentials.Password, - }.Uri.ToString(); - } - - private static async Task PumpAsync(WebSocket socket, string rtspUrl, bool transcode, ILogger logger, CancellationToken ct) + private static async Task PumpAsync(WebSocket socket, LiveSubscription subscription, CancellationToken ct) { - Process? proc = null; try { - proc = StartFfmpeg(rtspUrl, transcode); - _ = DrainStderrAsync(proc, logger); - - var stdout = proc.StandardOutput.BaseStream; - var buffer = new byte[32 * 1024]; - int read; - while (socket.State == WebSocketState.Open && - (read = await stdout.ReadAsync(buffer, ct)) > 0) + await foreach (var chunk in subscription.Reader.ReadAllAsync(ct).ConfigureAwait(false)) { - await socket.SendAsync( - buffer.AsMemory(0, read), WebSocketMessageType.Binary, endOfMessage: true, ct); + if (socket.State != WebSocketState.Open) + break; + await socket.SendAsync(chunk, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false); } } catch (OperationCanceledException) { } - catch (Exception ex) - { - // A closed socket makes SendAsync throw — that's the normal "viewer - // navigated away" path, so keep it at debug. - logger.LogDebug(ex, "live pump ended for {Rtsp}", Redact(rtspUrl)); - } + catch (Exception) { /* socket closed by the viewer — normal */ } finally { - KillQuietly(proc); if (socket.State == WebSocketState.Open) { try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stream ended", CancellationToken.None); } @@ -112,77 +80,16 @@ await socket.SendAsync( } } - private static Process StartFfmpeg(string rtspUrl, bool transcode) - { - var psi = new ProcessStartInfo(ResolveFfmpeg()) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl, "-an" }; - if (transcode) - { - // Software H.264 (libopenh264 — the LGPL build has no libx264). Output - // is Constrained Baseline, which every MSE browser accepts. CPU cost is - // real; this path only runs for codecs the browser can't play directly. - args.AddRange(new[] { "-c:v", "libopenh264", "-b:v", "2M", "-g", "30", "-pix_fmt", "yuv420p" }); - } - else - { - args.AddRange(new[] { "-c:v", "copy" }); - } - args.AddRange(new[] { "-f", "mp4", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-flush_packets", "1", "pipe:1" }); - - foreach (var arg in args) - psi.ArgumentList.Add(arg); - return Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch ffmpeg"); - } - - private static async Task DrainStderrAsync(Process proc, ILogger logger) - { - try - { - string? line; - while ((line = await proc.StandardError.ReadLineAsync()) is not null) - logger.LogTrace("ffmpeg: {Line}", line); - } - catch { /* process gone */ } - } - - private static void KillQuietly(Process? proc) - { - try - { - if (proc is { HasExited: false }) - proc.Kill(entireProcessTree: true); - } - catch { /* already gone */ } - proc?.Dispose(); - } - - // Same bundled-then-PATH resolution as FfmpegSubprocessRecorder. - private static string ResolveFfmpeg() + // Injects credentials into the RTSP URL for ffmpeg (they live in the secrets + // store, not the entity's URI). Never surfaced back to any client. + private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials) { - string? rid = null; - var exe = "ffmpeg"; - if (OperatingSystem.IsWindows()) { rid = "win-x64"; exe = "ffmpeg.exe"; } - else if (OperatingSystem.IsLinux()) { rid = "linux-x64"; } - else if (OperatingSystem.IsMacOS()) { rid = "osx-x64"; } - - if (rid is not null) + if (credentials is null || string.IsNullOrEmpty(credentials.Username)) + return baseUri.ToString(); + return new UriBuilder(baseUri) { - var bundled = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", exe); - if (File.Exists(bundled)) - return bundled; - } - return "ffmpeg"; + UserName = credentials.Username, + Password = credentials.Password, + }.Uri.ToString(); } - - private static string Redact(string rtspUrl) => - Uri.TryCreate(rtspUrl, UriKind.Absolute, out var u) && !string.IsNullOrEmpty(u.UserInfo) - ? rtspUrl.Replace(u.UserInfo + "@", "***@", StringComparison.Ordinal) - : rtspUrl; } diff --git a/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs new file mode 100644 index 0000000..e5b2109 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenIPC.Viewer.Web.Api; + +// Spawns and manages the ffmpeg process that remuxes/transcodes a camera's RTSP +// into fragmented MP4 on stdout. Shared by the fan-out hub. +internal static class LiveFfmpeg +{ + public static Process Start(string rtspUrl, bool transcode) + { + var psi = new ProcessStartInfo(Resolve()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl, "-an" }; + if (transcode) + { + // Software H.264 (libopenh264 — the LGPL build has no libx264). Output + // is Constrained Baseline, which every MSE browser accepts. Only used + // for codecs the browser can't play directly (e.g. H.265). + args.AddRange(new[] { "-c:v", "libopenh264", "-b:v", "2M", "-g", "30", "-pix_fmt", "yuv420p" }); + } + else + { + args.AddRange(new[] { "-c:v", "copy" }); + } + args.AddRange(new[] { "-f", "mp4", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-flush_packets", "1", "pipe:1" }); + + foreach (var arg in args) + psi.ArgumentList.Add(arg); + return Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch ffmpeg"); + } + + public static async Task DrainStderrAsync(Process proc, ILogger logger) + { + try + { + string? line; + while ((line = await proc.StandardError.ReadLineAsync()) is not null) + logger.LogTrace("ffmpeg: {Line}", line); + } + catch { /* process gone */ } + } + + public static void Kill(Process? proc) + { + try + { + if (proc is { HasExited: false }) + proc.Kill(entireProcessTree: true); + } + catch { /* already gone */ } + proc?.Dispose(); + } + + // Same bundled-then-PATH resolution as FfmpegSubprocessRecorder. + private static string Resolve() + { + string? rid = null; + var exe = "ffmpeg"; + if (OperatingSystem.IsWindows()) { rid = "win-x64"; exe = "ffmpeg.exe"; } + else if (OperatingSystem.IsLinux()) { rid = "linux-x64"; } + else if (OperatingSystem.IsMacOS()) { rid = "osx-x64"; } + + if (rid is not null) + { + var bundled = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", exe); + if (File.Exists(bundled)) + return bundled; + } + return "ffmpeg"; + } +} diff --git a/src/OpenIPC.Viewer.Web/Api/LiveStreamHub.cs b/src/OpenIPC.Viewer.Web/Api/LiveStreamHub.cs new file mode 100644 index 0000000..cafa69e --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/LiveStreamHub.cs @@ -0,0 +1,270 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenIPC.Viewer.Web.Api; + +// Fan-out for live video: one ffmpeg session per (camera, mode) shared by all +// viewers, instead of a process + RTSP connection each. A grid of the same +// camera, or several people watching, cost one remux. +// +// MSE needs the fMP4 init segment (ftyp+moov) before any media, and every media +// fragment (moof+mdat) must arrive whole. So the hub parses ffmpeg's output into +// boxes: it caches the init, then broadcasts complete fragments. A late joiner +// gets the cached init, then whole fragments from the next boundary — never a +// partial one. +public sealed class LiveStreamHub +{ + private readonly ILoggerFactory _loggerFactory; + private readonly object _gate = new(); + private readonly Dictionary _streams = new(StringComparer.Ordinal); + + public LiveStreamHub(ILoggerFactory loggerFactory) => _loggerFactory = loggerFactory; + + public LiveSubscription Subscribe(string cameraId, bool transcode, string rtspUrl) + { + lock (_gate) + { + var key = $"{cameraId}|{(transcode ? "t" : "c")}"; + if (!_streams.TryGetValue(key, out var stream)) + { + stream = new CameraStream( + rtspUrl, transcode, + _loggerFactory.CreateLogger("OpenIPC.Web.Live"), + onEmpty: () => { lock (_gate) _streams.Remove(key); }); + _streams[key] = stream; + stream.Start(); + } + return stream.AddSubscriber(); + } + } + + private sealed class CameraStream + { + private readonly string _rtspUrl; + private readonly bool _transcode; + private readonly ILogger _logger; + private readonly Action _onEmpty; + private readonly object _lock = new(); + private readonly HashSet> _subs = new(); + private byte[]? _init; + private Process? _proc; + private CancellationTokenSource? _cts; + private bool _stopped; + + public CameraStream(string rtspUrl, bool transcode, ILogger logger, Action onEmpty) + { + _rtspUrl = rtspUrl; + _transcode = transcode; + _logger = logger; + _onEmpty = onEmpty; + } + + public void Start() + { + _cts = new CancellationTokenSource(); + _proc = LiveFfmpeg.Start(_rtspUrl, _transcode); + _ = LiveFfmpeg.DrainStderrAsync(_proc, _logger); + _ = Task.Run(() => ReadLoopAsync(_cts.Token)); + } + + public LiveSubscription AddSubscriber() + { + // Bounded + drop-oldest: a slow viewer drops fragments (brief gap) but + // never stalls the shared reader or the other viewers. + var ch = Channel.CreateBounded(new BoundedChannelOptions(64) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + lock (_lock) + { + _subs.Add(ch); + if (_init is not null) + ch.Writer.TryWrite(_init); + } + return new LiveSubscription(ch.Reader, () => Remove(ch)); + } + + private void Remove(Channel ch) + { + bool empty; + lock (_lock) + { + _subs.Remove(ch); + ch.Writer.TryComplete(); + empty = _subs.Count == 0; + } + if (empty) + Stop(); + } + + private void Stop() + { + lock (_lock) + { + if (_stopped) return; + _stopped = true; + } + try { _cts?.Cancel(); } catch { /* best effort */ } + LiveFfmpeg.Kill(_proc); + _onEmpty(); + } + + private async Task ReadLoopAsync(CancellationToken ct) + { + try + { + var stdout = _proc!.StandardOutput.BaseStream; + var initParts = new List(); + var initDone = false; + MemoryStream? fragment = null; + + while (!ct.IsCancellationRequested) + { + var box = await ReadBoxAsync(stdout, ct).ConfigureAwait(false); + if (box is null) + break; + + if (!initDone) + { + if (box.Type == "moof") + { + PublishInit(initParts); + initDone = true; + fragment = new MemoryStream(); + fragment.Write(box.Bytes); + } + else + { + initParts.Add(box.Bytes); + } + } + else if (box.Type == "moof") + { + fragment = new MemoryStream(); + fragment.Write(box.Bytes); + } + else + { + fragment?.Write(box.Bytes); + if (box.Type == "mdat" && fragment is not null) + { + Broadcast(fragment.ToArray()); + fragment = null; + } + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { _logger.LogDebug(ex, "live read loop ended"); } + finally { Stop(); } + } + + private void PublishInit(List parts) + { + var total = 0; + foreach (var p in parts) total += p.Length; + var init = new byte[total]; + var offset = 0; + foreach (var p in parts) { Array.Copy(p, 0, init, offset, p.Length); offset += p.Length; } + + lock (_lock) + { + _init = init; + foreach (var s in _subs) + s.Writer.TryWrite(init); + } + } + + private void Broadcast(byte[] fragment) + { + lock (_lock) + { + foreach (var s in _subs) + s.Writer.TryWrite(fragment); + } + } + + private sealed record Box(string Type, byte[] Bytes); + + private static async Task ReadBoxAsync(Stream stream, CancellationToken ct) + { + var header = await ReadExactAsync(stream, 8, ct).ConfigureAwait(false); + if (header is null) + return null; + + long size = BinaryPrimitives.ReadUInt32BigEndian(header); + var type = Encoding.ASCII.GetString(header, 4, 4); + + if (size == 1) + { + var ext = await ReadExactAsync(stream, 8, ct).ConfigureAwait(false); + if (ext is null) return null; + size = BinaryPrimitives.ReadInt64BigEndian(ext); + if (size < 16 || size > int.MaxValue) return null; + var body = await ReadExactAsync(stream, (int)size - 16, ct).ConfigureAwait(false); + if (body is null) return null; + var full = new byte[size]; + Array.Copy(header, 0, full, 0, 8); + Array.Copy(ext, 0, full, 8, 8); + Array.Copy(body, 0, full, 16, body.Length); + return new Box(type, full); + } + + if (size is < 8 or > int.MaxValue) + return null; + var rest = await ReadExactAsync(stream, (int)size - 8, ct).ConfigureAwait(false); + if (rest is null) return null; + var boxBytes = new byte[size]; + Array.Copy(header, 0, boxBytes, 0, 8); + Array.Copy(rest, 0, boxBytes, 8, rest.Length); + return new Box(type, boxBytes); + } + + private static async Task ReadExactAsync(Stream stream, int count, CancellationToken ct) + { + var buffer = new byte[count]; + var offset = 0; + while (offset < count) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset), ct).ConfigureAwait(false); + if (read == 0) + return null; + offset += read; + } + return buffer; + } + } +} + +// A single viewer's tap on a shared camera stream. Reader yields the init +// segment first, then whole fragments. Dispose to leave (the shared ffmpeg stops +// when the last viewer disposes). +public sealed class LiveSubscription : IDisposable +{ + private readonly Action _dispose; + private int _disposed; + + public LiveSubscription(ChannelReader reader, Action dispose) + { + Reader = reader; + _dispose = dispose; + } + + public ChannelReader Reader { get; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + _dispose(); + } +} diff --git a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs index 2a73278..0eaeea9 100644 --- a/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs +++ b/src/OpenIPC.Viewer.Web/Backend/WebBackend.cs @@ -2,6 +2,7 @@ using OpenIPC.Viewer.Core.Persistence; using OpenIPC.Viewer.Core.Services; using OpenIPC.Viewer.Infrastructure.Persistence; +using OpenIPC.Viewer.Web.Api; namespace OpenIPC.Viewer.Web.Backend; @@ -24,6 +25,8 @@ public static IServiceCollection AddWebBackend(this IServiceCollection services, // which the platform host (Desktop) registers alongside this call; the // optional settings/layout deps default to null when absent. services.AddSingleton(); + // Fan-out for live video: one shared ffmpeg session per (camera, mode). + services.AddSingleton(); return services; } } From 42aae3735e67ffdee30a8f919091daabd9c3553a Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 02:03:52 +0300 Subject: [PATCH 15/33] feat(web): RU/EN localization for the web UI (Phase 20 slice 20.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebStrings (EN/RU) + scoped WebLocalizer (lang cookie, else Accept-Language) - /app/lang/{code} switcher (cookie) + EN·RU links in headers - All pages (login, cameras, grid, editor, live) use @L["key"] - Desktop Localizer stays in the App layer; web keeps its own small dictionary --- src/OpenIPC.Viewer.Web/Api/UiApi.cs | 18 +++ .../Components/Pages/CameraEditor.razor | 33 +++--- .../Components/Pages/Cameras.razor | 26 ++-- .../Components/Pages/Grid.razor | 6 +- .../Components/Pages/Live.razor | 5 +- .../Components/Pages/Login.razor | 15 ++- .../Components/_Imports.razor | 1 + .../Localization/WebLocalizer.cs | 34 ++++++ .../Localization/WebStrings.cs | 111 ++++++++++++++++++ src/OpenIPC.Viewer.Web/WebServer.cs | 3 + 10 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Localization/WebLocalizer.cs create mode 100644 src/OpenIPC.Viewer.Web/Localization/WebStrings.cs diff --git a/src/OpenIPC.Viewer.Web/Api/UiApi.cs b/src/OpenIPC.Viewer.Web/Api/UiApi.cs index e7625fa..c359adf 100644 --- a/src/OpenIPC.Viewer.Web/Api/UiApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/UiApi.cs @@ -1,7 +1,9 @@ +using System; using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using OpenIPC.Viewer.Web.Auth; +using OpenIPC.Viewer.Web.Localization; namespace OpenIPC.Viewer.Web.Api; @@ -35,5 +37,21 @@ public static void MapUiEndpoints(this WebApplication app) AuthApi.ClearSessionCookie(ctx); return Results.Redirect("/app/login"); }).DisableAntiforgery(); + + // UI language switch: persist the choice in a cookie, return to the page. + app.MapGet("/app/lang/{code}", (string code, HttpContext ctx) => + { + if (code is "ru" or "en") + { + ctx.Response.Cookies.Append(WebLocalizer.LanguageCookie, code, new CookieOptions + { + Path = "/", + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromDays(365), + }); + } + var back = ctx.Request.Headers.Referer.ToString(); + return Results.Redirect(string.IsNullOrEmpty(back) ? "/app/cameras" : back); + }); } } diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor b/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor index ab1823f..2762b0f 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/CameraEditor.razor @@ -8,39 +8,40 @@ @inject ICameraRepository CameraRepo @inject IGroupRepository GroupRepo @inject NavigationManager Nav +@inject WebLocalizer L
OpenIPC Viewer - Cameras + @L["Nav.Cameras"]
-

@(IsEdit ? "Edit camera" : "Add camera")

+

@(IsEdit ? L["Editor.EditTitle"] : L["Editor.AddTitle"])

@if (Error is not null) { -

Please check the fields — name, host and a valid RTSP URL are required.

+

@L["Editor.Error"]

}
- - - - + + + +
- - + +
-
diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor index caac5cb..e6627b2 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Cameras.razor @@ -6,37 +6,39 @@ @inject ICameraRepository CameraRepo @inject IGroupRepository GroupRepo @inject NavigationManager Nav +@inject WebLocalizer L
OpenIPC Viewer - Grid + @L["Nav.Grid"] @_user + EN · RU
- +

- Cameras (@(_cameras?.Count ?? 0)) - + Add camera + @L["Cameras.Title"] (@(_cameras?.Count ?? 0)) + @L["Cameras.Add"]

@if (_cameras is null) { -

Loading…

+

@L["Common.Loading"]

} else if (_cameras.Count == 0) { -

No cameras yet.

+

@L["Cameras.Empty"]

} else {
NameHostGroupRTSPGrid
NameHostGroupGrid
@c.Name @c.Host @(c.GroupName ?? "—")@c.RtspMain @if (c.IncludedInGrid) { yes } else { no }▶ Live
@c.Host @(c.GroupName ?? "—") @if (c.IncludedInGrid) { yes } else { no }▶ Live + ▶ Live + Edit +
+ +
+
- + @foreach (var c in _cameras) @@ -45,13 +47,13 @@ - + diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor index c47354e..3e0a4b2 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Grid.razor @@ -5,13 +5,15 @@ @inject SessionStore Sessions @inject ICameraRepository CameraRepo @inject NavigationManager Nav +@inject WebLocalizer L
OpenIPC Viewer - Cameras + @L["Nav.Cameras"] @_user -
+ EN · RU +
diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor index b49f406..1cb68da 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Live.razor @@ -1,8 +1,9 @@ @page "/app/cameras/{Id}/live" +@inject WebLocalizer L
-

← Cameras

-

Live

+

@L["Live.Back"]

+

@L["Live.Title"]

diff --git a/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor b/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor index 584f874..79d19ff 100644 --- a/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor +++ b/src/OpenIPC.Viewer.Web/Components/Pages/Login.razor @@ -1,21 +1,26 @@ @page "/app/login" +@inject WebLocalizer L

OpenIPC Viewer

-

Sign in to the web console

+

@L["Login.Subtitle"]

- - - + + +
@if (Error is not null) { -

Invalid username or password.

+

@L["Login.Error"]

} + +

+ EN · RU +

@code { diff --git a/src/OpenIPC.Viewer.Web/Components/_Imports.razor b/src/OpenIPC.Viewer.Web/Components/_Imports.razor index d7bbcbc..aeb22db 100644 --- a/src/OpenIPC.Viewer.Web/Components/_Imports.razor +++ b/src/OpenIPC.Viewer.Web/Components/_Imports.razor @@ -6,3 +6,4 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using OpenIPC.Viewer.Web.Components +@using OpenIPC.Viewer.Web.Localization diff --git a/src/OpenIPC.Viewer.Web/Localization/WebLocalizer.cs b/src/OpenIPC.Viewer.Web/Localization/WebLocalizer.cs new file mode 100644 index 0000000..31c97ff --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Localization/WebLocalizer.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace OpenIPC.Viewer.Web.Localization; + +// Per-request UI language: the `lang` cookie wins (set by the switcher), else the +// browser's Accept-Language. Scoped so each server-rendered request resolves once. +public sealed class WebLocalizer +{ + public const string LanguageCookie = "lang"; + + private readonly IReadOnlyDictionary _dict; + + public WebLocalizer(IHttpContextAccessor accessor) + { + Language = Resolve(accessor.HttpContext); + _dict = Language == "ru" ? WebStrings.Ru : WebStrings.En; + } + + public string Language { get; } + + public string this[string key] => _dict.TryGetValue(key, out var value) ? value : key; + + private static string Resolve(HttpContext? ctx) + { + if (ctx is null) + return "en"; + if (ctx.Request.Cookies.TryGetValue(LanguageCookie, out var c) && (c == "ru" || c == "en")) + return c; + var accept = ctx.Request.Headers.AcceptLanguage.ToString(); + return accept.Contains("ru", StringComparison.OrdinalIgnoreCase) ? "ru" : "en"; + } +} diff --git a/src/OpenIPC.Viewer.Web/Localization/WebStrings.cs b/src/OpenIPC.Viewer.Web/Localization/WebStrings.cs new file mode 100644 index 0000000..8399569 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Localization/WebStrings.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +namespace OpenIPC.Viewer.Web.Localization; + +// EN/RU strings for the web UI. Deliberately a small, self-contained set — the +// desktop Localizer lives in the Avalonia App layer, which the headless server +// must not reference. (A future shared-in-Core localizer could unify them.) +internal static class WebStrings +{ + public static readonly IReadOnlyDictionary En = new Dictionary + { + ["Login.Subtitle"] = "Sign in to the web console", + ["Login.Username"] = "Username", + ["Login.Password"] = "Password", + ["Login.SignIn"] = "Sign in", + ["Login.Error"] = "Invalid username or password.", + + ["Nav.Cameras"] = "Cameras", + ["Nav.Grid"] = "Grid", + ["Nav.SignOut"] = "Sign out", + + ["Cameras.Title"] = "Cameras", + ["Cameras.Add"] = "+ Add camera", + ["Cameras.Empty"] = "No cameras yet.", + ["Common.Loading"] = "Loading…", + ["Cameras.Th.Name"] = "Name", + ["Cameras.Th.Host"] = "Host", + ["Cameras.Th.Group"] = "Group", + ["Cameras.Th.Grid"] = "Grid", + ["Cameras.Live"] = "▶ Live", + ["Cameras.Edit"] = "Edit", + ["Cameras.Delete"] = "Delete", + ["Cameras.DeleteConfirm"] = "Delete this camera?", + ["Common.Yes"] = "yes", + ["Common.No"] = "no", + + ["Editor.AddTitle"] = "Add camera", + ["Editor.EditTitle"] = "Edit camera", + ["Editor.Error"] = "Please check the fields — name, host and a valid RTSP URL are required.", + ["Editor.Name"] = "Name", + ["Editor.Host"] = "Host", + ["Editor.RtspMain"] = "RTSP main URL", + ["Editor.RtspSub"] = "RTSP sub URL (optional)", + ["Editor.HttpPort"] = "HTTP port", + ["Editor.OnvifPort"] = "ONVIF port", + ["Editor.Group"] = "Group", + ["Editor.NoGroup"] = "(no group)", + ["Editor.Quality"] = "Stream quality", + ["Editor.Username"] = "Username", + ["Editor.Password"] = "Password", + ["Editor.KeepBlank"] = "leave blank to keep", + ["Editor.Optional"] = "optional", + ["Editor.Save"] = "Save", + ["Editor.Add"] = "Add camera", + ["Editor.Cancel"] = "Cancel", + + ["Live.Back"] = "← Cameras", + ["Live.Title"] = "Live", + }; + + public static readonly IReadOnlyDictionary Ru = new Dictionary + { + ["Login.Subtitle"] = "Вход в веб-консоль", + ["Login.Username"] = "Логин", + ["Login.Password"] = "Пароль", + ["Login.SignIn"] = "Войти", + ["Login.Error"] = "Неверный логин или пароль.", + + ["Nav.Cameras"] = "Камеры", + ["Nav.Grid"] = "Сетка", + ["Nav.SignOut"] = "Выход", + + ["Cameras.Title"] = "Камеры", + ["Cameras.Add"] = "+ Добавить камеру", + ["Cameras.Empty"] = "Камер пока нет.", + ["Common.Loading"] = "Загрузка…", + ["Cameras.Th.Name"] = "Имя", + ["Cameras.Th.Host"] = "Хост", + ["Cameras.Th.Group"] = "Группа", + ["Cameras.Th.Grid"] = "Сетка", + ["Cameras.Live"] = "▶ Эфир", + ["Cameras.Edit"] = "Изменить", + ["Cameras.Delete"] = "Удалить", + ["Cameras.DeleteConfirm"] = "Удалить камеру?", + ["Common.Yes"] = "да", + ["Common.No"] = "нет", + + ["Editor.AddTitle"] = "Добавить камеру", + ["Editor.EditTitle"] = "Изменить камеру", + ["Editor.Error"] = "Проверьте поля — нужны имя, хост и корректный RTSP URL.", + ["Editor.Name"] = "Имя", + ["Editor.Host"] = "Хост", + ["Editor.RtspMain"] = "RTSP main URL", + ["Editor.RtspSub"] = "RTSP sub URL (необязательно)", + ["Editor.HttpPort"] = "HTTP-порт", + ["Editor.OnvifPort"] = "ONVIF-порт", + ["Editor.Group"] = "Группа", + ["Editor.NoGroup"] = "(без группы)", + ["Editor.Quality"] = "Качество потока", + ["Editor.Username"] = "Логин", + ["Editor.Password"] = "Пароль", + ["Editor.KeepBlank"] = "пусто = не менять", + ["Editor.Optional"] = "необязательно", + ["Editor.Save"] = "Сохранить", + ["Editor.Add"] = "Добавить камеру", + ["Editor.Cancel"] = "Отмена", + + ["Live.Back"] = "← Камеры", + ["Live.Title"] = "Эфир", + }; +} diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index 264b228..9b4fa3d 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -11,6 +11,7 @@ using OpenIPC.Viewer.Web.Api; using OpenIPC.Viewer.Web.Auth; using OpenIPC.Viewer.Web.Components; +using OpenIPC.Viewer.Web.Localization; namespace OpenIPC.Viewer.Web; @@ -44,6 +45,8 @@ public static async Task RunAsync( var builder = WebApplication.CreateBuilder(); ConfigureAuthServices(builder, authOptions ?? new WebAuthOptions()); builder.Services.AddRazorComponents(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); configureBackend?.Invoke(builder.Services); var app = builder.Build(); From e487a7077e3102ba55088ab5305630b330a7b62c Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 02:16:06 +0300 Subject: [PATCH 16/33] feat(web): match the desktop Console skin + left sidebar (Phase 20 polish) - Port desktop design tokens (Colors/Theme.axaml) to CSS variables: indigo accent, dark surfaces, text scale, radii, 8px grid, Inter stack - Replace top headers with a left NavSidebar (icon nav, user + lang + logout) - All pages use the .shell layout; login is a centered card --- src/OpenIPC.Viewer.Web/Components/App.razor | 108 +++++++++++++----- .../Components/NavSidebar.razor | 32 ++++++ .../Components/Pages/CameraEditor.razor | 15 ++- .../Components/Pages/Cameras.razor | 17 +-- .../Components/Pages/Grid.razor | 17 +-- .../Components/Pages/Live.razor | 25 +++- .../Components/Pages/Login.razor | 37 +++--- 7 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 src/OpenIPC.Viewer.Web/Components/NavSidebar.razor diff --git a/src/OpenIPC.Viewer.Web/Components/App.razor b/src/OpenIPC.Viewer.Web/Components/App.razor index f66e36d..26d9b08 100644 --- a/src/OpenIPC.Viewer.Web/Components/App.razor +++ b/src/OpenIPC.Viewer.Web/Components/App.razor @@ -7,41 +7,89 @@ OpenIPC Viewer + + diff --git a/src/OpenIPC.Viewer.Web.Client/package-lock.json b/src/OpenIPC.Viewer.Web.Client/package-lock.json new file mode 100644 index 0000000..15b53d2 --- /dev/null +++ b/src/OpenIPC.Viewer.Web.Client/package-lock.json @@ -0,0 +1,3501 @@ +{ + "name": "openipc-viewer-web-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openipc-viewer-web-client", + "version": "0.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.8.1", + "@typescript-eslint/parser": "^8.8.1", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^8.57.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.12", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.44", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", + "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "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.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "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/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "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==", + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/OpenIPC.Viewer.Web.Client/package.json b/src/OpenIPC.Viewer.Web.Client/package.json new file mode 100644 index 0000000..86799e9 --- /dev/null +++ b/src/OpenIPC.Viewer.Web.Client/package.json @@ -0,0 +1,31 @@ +{ + "name": "openipc-viewer-web-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.8.1", + "@typescript-eslint/parser": "^8.8.1", + "@vitejs/plugin-react": "^4.3.2", + "eslint": "^8.57.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.12", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/src/OpenIPC.Viewer.Web.Client/src/App.tsx b/src/OpenIPC.Viewer.Web.Client/src/App.tsx new file mode 100644 index 0000000..d49ccaf --- /dev/null +++ b/src/OpenIPC.Viewer.Web.Client/src/App.tsx @@ -0,0 +1,31 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useAuth } from './auth' +import { Shell } from './components/Shell' +import { Login } from './pages/Login' +import { Cameras } from './pages/Cameras' +import { Grid } from './pages/Grid' +import { Groups } from './pages/Groups' +import { System } from './pages/System' + +// The whole app is client-routed: navigating between these never reloads the +// page, and — crucially — the Grid's
NameHostGroupGrid
@L["Cameras.Th.Name"]@L["Cameras.Th.Host"]@L["Cameras.Th.Group"]@L["Cameras.Th.Grid"]
@c.Name @c.Host @(c.GroupName ?? "—")@if (c.IncludedInGrid) { yes } else { no }@if (c.IncludedInGrid) { @L["Common.Yes"] } else { @L["Common.No"] } - ▶ Live - Edit + @L["Cameras.Live"] + @L["Cameras.Edit"]
- + onsubmit="return confirm('@L["Cameras.DeleteConfirm"]')"> +
+ + + + + + + + + + {cameras.map((c) => ( + + + + + + + + ))} + +
{t('Cameras.Th.Name')}{t('Cameras.Th.Host')}{t('Cameras.Th.Group')}{t('Cameras.Th.Grid')} +
{c.name}{c.host}{c.groupName ?? '—'} + + {c.includedInGrid ? t('Common.Yes') : t('Common.No')} + + + {t('Cameras.Live')}{' '} + {' '} + +
+ )} + + {editing !== undefined && ( + setEditing(undefined)} + onSaved={onSaved} + /> + )} + + ) +} diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx new file mode 100644 index 0000000..01bfdc4 --- /dev/null +++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx @@ -0,0 +1,92 @@ +import { memo, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api, type CameraDto } from '../api' +import { useI18n } from '../i18n' +import { useLiveTile } from '../hooks/useLiveTile' + +type Size = 1 | 4 | 9 + +// A single live tile. Memoized and keyed by camera id in the grid so that +// changing the layout size (1<->4<->9) NEVER remounts a tile that stays on +// screen — React matches it by key and only moves the DOM node. That is the +// whole app-like point of Phase 21: the
+ Text="OpenIPC.Web: Node not found — skipping React SPA build; the server will run API-only (no web UI). Install Node and rebuild, or ignore for backend-only work." /> - + +
diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index c9d13d4..fd78432 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -11,18 +11,17 @@ using OpenIPC.Viewer.Core.Persistence; using OpenIPC.Viewer.Web.Api; using OpenIPC.Viewer.Web.Auth; -using OpenIPC.Viewer.Web.Components; -using OpenIPC.Viewer.Web.Localization; namespace OpenIPC.Viewer.Web; -// The embedded ASP.NET Core (Kestrel) host. +// The embedded ASP.NET Core (Kestrel) host: process lifecycle, safe bind +// defaults, the auth pipeline, the /api/v1 surface, and the embedded React SPA. // -// Phase 20 slice A (foundation): process lifecycle + health/version endpoints -// and safe bind defaults only. The camera/API/live-video surface, auth, and the -// shared backend composition land in later slices (§20.2–20.5). Keeping this -// slice backend-free is deliberate — it proves the headless host boots and -// serves before the data layer is wired in. +// The backend is composed by the caller (Desktop head / --server-only) and is +// optional: without it the host still boots and serves health/version, and the +// data endpoints answer 503. The Phase 20 server-rendered Razor UI it used to +// carry was removed once the SPA reached parity — the API is now the only +// contract, and the SPA is just its first client. public static class WebServer { // Version string surfaced by /healthz and /api/v1/version. Prefers the @@ -45,9 +44,6 @@ public static async Task RunAsync( { var builder = WebApplication.CreateBuilder(); ConfigureAuthServices(builder, authOptions ?? new WebAuthOptions()); - builder.Services.AddRazorComponents(); - builder.Services.AddHttpContextAccessor(); - builder.Services.AddScoped(); configureBackend?.Invoke(builder.Services); var app = builder.Build(); @@ -145,15 +141,17 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) // wwwroot/). Hashed assets under /assets are immutable; the entry // index.html is the client-routing fallback registered below. No auth // here: the bundles are public, the API behind them is guarded. Null when - // the front-end wasn't built (glob empty) — then only Razor /app serves. + // the front-end wasn't built (glob empty) — then the host serves the API + // only, which is all a headless/API deployment needs. var spa = TryCreateSpaFileProvider(); if (spa is not null) app.UseStaticFiles(new StaticFileOptions { FileProvider = spa }); app.UseRateLimiter(); // Origin check on mutations + bearer-token guard over protected API paths. + // (No UseAntiforgery: the Razor UI it served is gone, and the JSON API is + // covered by the Origin check plus SameSite=Strict on the session cookie.) app.UseWebAuth(); - app.UseAntiforgery(); // Liveness probe — touches no backend, so it answers even before the // data layer exists. Public (no auth) for --server-only smoke checks and, @@ -183,21 +181,27 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) app.MapGroupEndpoints(); app.MapLayoutEndpoints(); app.MapLiveEndpoints(); - app.MapUiEndpoints(); - app.MapCameraFormEndpoints(); + app.MapPtzEndpoints(); app.MapSystemEndpoints(); - // Razor pages stay mounted under /app as the transitional fallback until - // the React SPA reaches full parity (then they're removed in a later commit). - app.MapRazorComponents(); // Client-side routes (/, /cameras, /grid, …) resolve to the SPA entry; - // /api, /app, /healthz and static assets are matched before this. If the - // SPA hasn't been built (no embedded index.html), this 404s and /app still - // serves the Razor UI — a clean transitional state. + // real endpoints and static assets are matched before this. Without an + // embedded index.html (front-end not built) this 404s and the host is an + // API-only server. if (spa is not null) { app.MapFallback(async ctx => { + // An unmatched API path is a 404, not a page: otherwise a typo'd + // or removed endpoint answers "200 text/html" and a client only + // discovers the mistake when JSON.parse chokes on a doctype. + if (IsApiPath(ctx.Request.Path)) + { + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + await ctx.Response.WriteAsJsonAsync(new { error = "not_found" }); + return; + } + var index = spa.GetFileInfo("index.html"); if (!index.Exists) { @@ -210,6 +214,10 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) } } + // Paths that belong to the server, never to the client router. + private static bool IsApiPath(PathString path) => + path.StartsWithSegments("/api") || path.StartsWithSegments("/healthz"); + // The embedded SPA served at web root. Returns null when the wwwroot glob was // empty at build time (no manifest), so a backend-only build still runs. private static IFileProvider? TryCreateSpaFileProvider() diff --git a/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs b/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs index 19e7cce..09301e9 100644 --- a/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs +++ b/tests/OpenIPC.Viewer.Web.Tests/CameraDtoTests.cs @@ -49,6 +49,16 @@ public void From_ExposesHasCredentialsInsteadOfSecretRefs() Assert.True(dto.HasCredentials); } + // The SPA shows the PTZ pad on PtzReady, not HasPtz: without a probed media + // profile every /ptz/* call would answer 409. + [Fact] + public void PtzReady_RequiresBothFlagAndProfileToken() + { + Assert.False(CameraDto.From(SampleCamera() with { HasPtz = false }, null).PtzReady); + Assert.False(CameraDto.From(SampleCamera() with { HasPtz = true, OnvifProfileToken = null }, null).PtzReady); + Assert.True(CameraDto.From(SampleCamera() with { HasPtz = true }, null).PtzReady); + } + [Fact] public void SerializedDto_LeaksNoSecret() { From ac20c2a012fa41bd920104add2a58331a0b790f1 Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 17:07:52 +0300 Subject: [PATCH 28/33] web: bundle Inter, paginate layouts - self-host Inter from the inter-ui package (rsms' own); the full variable file, not the latin subset, which drops Cyrillic - grid pages a layout the way the desktop does: page = gridSize^2 cells, only the current page gets live tiles - the editor now edits the current page and splices it back between the pages before and after, instead of always editing the first one --- .../package-lock.json | 11 +++ src/OpenIPC.Viewer.Web.Client/package.json | 1 + .../src/pages/Grid.tsx | 79 ++++++++++++++++--- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/OpenIPC.Viewer.Web.Client/package-lock.json b/src/OpenIPC.Viewer.Web.Client/package-lock.json index 15b53d2..12fef78 100644 --- a/src/OpenIPC.Viewer.Web.Client/package-lock.json +++ b/src/OpenIPC.Viewer.Web.Client/package-lock.json @@ -22,6 +22,7 @@ "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.12", + "inter-ui": "^4.1.1", "typescript": "^5.6.2", "vite": "^5.4.8" } @@ -2554,6 +2555,16 @@ "dev": true, "license": "ISC" }, + "node_modules/inter-ui": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/inter-ui/-/inter-ui-4.1.1.tgz", + "integrity": "sha512-451h0J29HyOmA+JXgSi/6M12tL7ZCZ8arYKZUXiOXTJpJbAKqJvFh3k5SiV3x7tKe0C0KyrKUUiQIvvZ2PQDcA==", + "dev": true, + "license": "OFL-1.1", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", diff --git a/src/OpenIPC.Viewer.Web.Client/package.json b/src/OpenIPC.Viewer.Web.Client/package.json index 86799e9..fb1e998 100644 --- a/src/OpenIPC.Viewer.Web.Client/package.json +++ b/src/OpenIPC.Viewer.Web.Client/package.json @@ -25,6 +25,7 @@ "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.12", + "inter-ui": "^4.1.1", "typescript": "^5.6.2", "vite": "^5.4.8" } diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx index 7284e6c..bb46a80 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Grid.tsx @@ -12,11 +12,17 @@ const SIDES = [1, 2, 3, 4, 5] // The Live grid, driven by saved layouts (name + grid size + ordered camera // tiles). Switching between layouts keeps shared cameras playing. An edit mode // assigns a camera to each cell and changes the grid size. +// +// A layout may hold more cameras than the grid has cells (the desktop's 82-camera +// "Default" is one), so the tile list is paginated exactly like the desktop head: +// one page = gridSize² cells, page count = ceil(tiles / pageSize). Only the +// current page gets live tiles — flipping pages tears the old sockets down. export function Grid() { const { t } = useI18n() const [cameras, setCameras] = useState(null) const [layouts, setLayouts] = useState(null) const [activeId, setActiveId] = useState(null) + const [page, setPage] = useState(0) const [editing, setEditing] = useState(false) const [draftSize, setDraftSize] = useState(4) const [draftTiles, setDraftTiles] = useState<(string | null)[]>([]) @@ -46,6 +52,13 @@ export function Grid() { const active = layouts?.find((l) => l.id === activeId) ?? null + // Pagination of the active layout. Clamped rather than corrected in state, so + // a layout that shrank under us (another client edited it) still renders. + const pageSize = active ? active.gridSize * active.gridSize : 1 + const pageCount = active ? Math.max(1, Math.ceil(active.tiles.length / pageSize)) : 1 + const safePage = Math.min(page, pageCount - 1) + const pageStart = safePage * pageSize + async function reloadLayouts(): Promise { const ls = await api.layouts() ls.sort((a, b) => a.sortOrder - b.sortOrder) @@ -58,7 +71,7 @@ export function Grid() { const startEdit = () => { if (!active) return setDraftSize(active.gridSize) - setDraftTiles(Array.from({ length: active.gridSize * active.gridSize }, (_, i) => active.tiles[i] ?? null)) + setDraftTiles(Array.from({ length: pageSize }, (_, i) => active.tiles[pageStart + i] ?? null)) setEditing(true) } const changeDraftSize = (n: number) => { @@ -76,15 +89,19 @@ export function Grid() { setBusy(true) try { if (draftSize !== active.gridSize) await api.updateLayout(active.id, { gridSize: draftSize }) - // The editor only shows the first page (gridSize² cells). Desktop layouts - // can hold more cameras than that (paginated), so preserve everything beyond - // the edited page instead of truncating it. Tiles are a compact ordered - // list — drop empty cells, keep order, then re-append the untouched tail. + // The editor shows one page; everything outside it must survive untouched, + // so splice the edited cells back between the pages before and after. + // Tiles are a compact ordered list (no holes), so emptied cells are dropped + // and the tail slides up — that's the model, not a bug. const edited = draftTiles.filter((x): x is string => !!x) - const tail = active.tiles.slice(active.gridSize * active.gridSize) - await api.setLayoutTiles(active.id, [...edited, ...tail]) + const head = active.tiles.slice(0, pageStart) + const tail = active.tiles.slice(pageStart + pageSize) + await api.setLayoutTiles(active.id, [...head, ...edited, ...tail]) await reloadLayouts() setEditing(false) + // A different grid size re-cuts every page boundary; the old page index + // would land somewhere arbitrary, so go back to the first page. + if (draftSize !== active.gridSize) setPage(0) } finally { setBusy(false) } @@ -95,6 +112,7 @@ export function Grid() { const created = await api.createLayout(name, 2) await reloadLayouts() setActiveId(created.id) + setPage(0) } const renameLayout = async (name: string) => { setDialog(null) @@ -108,6 +126,7 @@ export function Grid() { await api.deleteLayout(active.id) setEditing(false) setActiveId(null) + setPage(0) await reloadLayouts() } @@ -125,6 +144,7 @@ export function Grid() { onClick={() => { setEditing(false) setActiveId(l.id) + setPage(0) }} > {l.name} @@ -166,6 +186,12 @@ export function Grid() { ) : !active ? (

{t('Grid.NoLayouts')}

) : editing ? ( + <> + {pageCount > 1 && ( +

+ {t('Grid.EditingPage')} {safePage + 1} / {pageCount} +

+ )}
{draftTiles.map((camId, i) => (
@@ -180,13 +206,40 @@ export function Grid() {
))}
+ ) : ( -
- {Array.from({ length: active.gridSize * active.gridSize }, (_, i) => { - const cam = active.tiles[i] ? camById.get(active.tiles[i]) : undefined - return cam ? : - })} -
+ <> +
+ {Array.from({ length: pageSize }, (_, i) => { + const id = active.tiles[pageStart + i] + const cam = id ? camById.get(id) : undefined + return cam ? : + })} +
+ {pageCount > 1 && ( +
+ + {Array.from({ length: pageCount }, (_, i) => ( + + ))} + +
+ )} + )} {dialog === 'create' && ( From 6e41d0472ebcdbd4f281c8fde1023333af94528f Mon Sep 17 00:00:00 2001 From: keyldev Date: Tue, 21 Jul 2026 17:07:53 +0300 Subject: [PATCH 29/33] web: keep video playing across route changes - live sessions move into a pool above the router: a tile borrows a