diff --git a/.dockerignore b/.dockerignore index a883e89c..9223d5b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,4 +28,5 @@ LICENSE CONTRIBUTING.md dist .git +migrations/ config/ \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d22c300..61315015 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,9 @@ jobs: - name: Create database index.ts run: echo 'export * from "./sqlite";' > server/db/index.ts + - name: Create build file + run: echo 'export const build = 'oss' as any;' > server/build.ts + - name: Generate database migrations run: npm run db:sqlite:generate diff --git a/.gitignore b/.gitignore index 95b1b9be..6a533ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ next-env.d.ts *-audit.json migrations tsconfig.tsbuildinfo -config/config.yml +config/config.saas.yml +config/config.oss.yml +config/config.enterprise.yml +config/privateConfig.yml config/postgres config/postgres* config/openapi.yaml @@ -44,3 +47,4 @@ server/build.ts postgres/ dynamic/ certificates/ +*.mmdb diff --git a/Dockerfile b/Dockerfile index 996ef057..00b76f2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,6 @@ COPY ./cli/wrapper.sh /usr/local/bin/pangctl RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs COPY server/db/names.json ./dist/names.json - COPY public ./public CMD ["npm", "run", "start"] diff --git a/LICENSE b/LICENSE index 0ad25db4..bae61364 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,34 @@ +Copyright (c) 2025 Fossorial, Inc. + +Portions of this software are licensed as follows: + +* All files that include a header specifying they are licensed under the + "Fossorial Commercial License" are governed by the Fossorial Commercial + License terms. The specific terms applicable to each customer depend on the + commercial license tier agreed upon in writing with Fossorial, Inc. + Unauthorized use, copying, modification, or distribution is strictly + prohibited. + +* All files that include a header specifying they are licensed under the GNU + Affero General Public License, Version 3 ("AGPL-3"), are governed by the + AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However, + these files are also available under the Fossorial Commercial License if a + separate commercial license agreement has been executed between the customer + and Fossorial, Inc. + +* All files without a license header are, by default, licensed under the GNU + Affero General Public License, Version 3 (AGPL-3). These files may also be + made available under the Fossorial Commercial License upon agreement with + Fossorial, Inc. + +* All third-party components included in this repository are licensed under + their respective original licenses, as provided by their authors. + +Please consult the header of each individual file to determine the applicable +license. For AGPL-3 licensed files, dual-licensing under the Fossorial +Commercial License is available subject to written agreement with Fossorial, +Inc. + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 diff --git a/bruno/API Keys/Create API Key.bru b/bruno/API Keys/Create API Key.bru new file mode 100644 index 00000000..009b4b04 --- /dev/null +++ b/bruno/API Keys/Create API Key.bru @@ -0,0 +1,17 @@ +meta { + name: Create API Key + type: http + seq: 1 +} + +put { + url: http://localhost:3000/api/v1/api-key + body: json + auth: inherit +} + +body:json { + { + "isRoot": true + } +} diff --git a/bruno/API Keys/Delete API Key.bru b/bruno/API Keys/Delete API Key.bru new file mode 100644 index 00000000..9285f788 --- /dev/null +++ b/bruno/API Keys/Delete API Key.bru @@ -0,0 +1,11 @@ +meta { + name: Delete API Key + type: http + seq: 2 +} + +delete { + url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj + body: none + auth: inherit +} diff --git a/bruno/API Keys/List API Key Actions.bru b/bruno/API Keys/List API Key Actions.bru new file mode 100644 index 00000000..ae5b721e --- /dev/null +++ b/bruno/API Keys/List API Key Actions.bru @@ -0,0 +1,11 @@ +meta { + name: List API Key Actions + type: http + seq: 6 +} + +get { + url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions + body: none + auth: inherit +} diff --git a/bruno/API Keys/List Org API Keys.bru b/bruno/API Keys/List Org API Keys.bru new file mode 100644 index 00000000..468e964b --- /dev/null +++ b/bruno/API Keys/List Org API Keys.bru @@ -0,0 +1,11 @@ +meta { + name: List Org API Keys + type: http + seq: 4 +} + +get { + url: http://localhost:3000/api/v1/org/home-lab/api-keys + body: none + auth: inherit +} diff --git a/bruno/API Keys/List Root API Keys.bru b/bruno/API Keys/List Root API Keys.bru new file mode 100644 index 00000000..8ef31b68 --- /dev/null +++ b/bruno/API Keys/List Root API Keys.bru @@ -0,0 +1,11 @@ +meta { + name: List Root API Keys + type: http + seq: 3 +} + +get { + url: http://localhost:3000/api/v1/root/api-keys + body: none + auth: inherit +} diff --git a/bruno/API Keys/Set API Key Actions.bru b/bruno/API Keys/Set API Key Actions.bru new file mode 100644 index 00000000..54a35c43 --- /dev/null +++ b/bruno/API Keys/Set API Key Actions.bru @@ -0,0 +1,17 @@ +meta { + name: Set API Key Actions + type: http + seq: 5 +} + +post { + url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions + body: json + auth: inherit +} + +body:json { + { + "actionIds": ["listSites"] + } +} diff --git a/bruno/API Keys/Set API Key Orgs.bru b/bruno/API Keys/Set API Key Orgs.bru new file mode 100644 index 00000000..3f0676c5 --- /dev/null +++ b/bruno/API Keys/Set API Key Orgs.bru @@ -0,0 +1,17 @@ +meta { + name: Set API Key Orgs + type: http + seq: 7 +} + +post { + url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs + body: json + auth: inherit +} + +body:json { + { + "orgIds": ["home-lab"] + } +} diff --git a/bruno/API Keys/folder.bru b/bruno/API Keys/folder.bru new file mode 100644 index 00000000..bb8cd5c7 --- /dev/null +++ b/bruno/API Keys/folder.bru @@ -0,0 +1,3 @@ +meta { + name: API Keys +} diff --git a/bruno/Auth/login.bru b/bruno/Auth/login.bru index 3825a252..2b88066b 100644 --- a/bruno/Auth/login.bru +++ b/bruno/Auth/login.bru @@ -5,14 +5,14 @@ meta { } post { - url: http://localhost:3000/api/v1/auth/login + url: http://localhost:4000/api/v1/auth/login body: json auth: none } body:json { { - "email": "admin@fosrl.io", + "email": "owen@fossorial.io", "password": "Password123!" } } diff --git a/bruno/Auth/logout.bru b/bruno/Auth/logout.bru index 7dd134cc..623cd47f 100644 --- a/bruno/Auth/logout.bru +++ b/bruno/Auth/logout.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/api/v1/auth/logout + url: http://localhost:4000/api/v1/auth/logout body: none auth: none } diff --git a/bruno/IDP/Create OIDC Provider.bru b/bruno/IDP/Create OIDC Provider.bru new file mode 100644 index 00000000..23e807cf --- /dev/null +++ b/bruno/IDP/Create OIDC Provider.bru @@ -0,0 +1,22 @@ +meta { + name: Create OIDC Provider + type: http + seq: 1 +} + +put { + url: http://localhost:3000/api/v1/org/home-lab/idp/oidc + body: json + auth: inherit +} + +body:json { + { + "clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys", + "clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3", + "authUrl": "http://localhost:9000/application/o/authorize/", + "tokenUrl": "http://localhost:9000/application/o/token/", + "scopes": ["email", "openid", "profile"], + "userIdentifier": "email" + } +} diff --git a/bruno/IDP/Generate OIDC URL.bru b/bruno/IDP/Generate OIDC URL.bru new file mode 100644 index 00000000..90443096 --- /dev/null +++ b/bruno/IDP/Generate OIDC URL.bru @@ -0,0 +1,11 @@ +meta { + name: Generate OIDC URL + type: http + seq: 2 +} + +get { + url: http://localhost:3000/api/v1 + body: none + auth: inherit +} diff --git a/bruno/IDP/folder.bru b/bruno/IDP/folder.bru new file mode 100644 index 00000000..fc136915 --- /dev/null +++ b/bruno/IDP/folder.bru @@ -0,0 +1,3 @@ +meta { + name: IDP +} diff --git a/bruno/Internal/Traefik Config.bru b/bruno/Internal/Traefik Config.bru new file mode 100644 index 00000000..9fc1c1dc --- /dev/null +++ b/bruno/Internal/Traefik Config.bru @@ -0,0 +1,11 @@ +meta { + name: Traefik Config + type: http + seq: 1 +} + +get { + url: http://localhost:3001/api/v1/traefik-config + body: none + auth: inherit +} diff --git a/bruno/Internal/folder.bru b/bruno/Internal/folder.bru new file mode 100644 index 00000000..702931ec --- /dev/null +++ b/bruno/Internal/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Internal +} diff --git a/bruno/Remote Exit Node/createRemoteExitNode.bru b/bruno/Remote Exit Node/createRemoteExitNode.bru new file mode 100644 index 00000000..1c749a31 --- /dev/null +++ b/bruno/Remote Exit Node/createRemoteExitNode.bru @@ -0,0 +1,11 @@ +meta { + name: createRemoteExitNode + type: http + seq: 1 +} + +put { + url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node + body: none + auth: none +} diff --git a/bruno/Test.bru b/bruno/Test.bru new file mode 100644 index 00000000..16286ec8 --- /dev/null +++ b/bruno/Test.bru @@ -0,0 +1,11 @@ +meta { + name: Test + type: http + seq: 2 +} + +get { + url: http://localhost:3000/api/v1 + body: none + auth: inherit +} diff --git a/bruno/bruno.json b/bruno/bruno.json index f19d936a..f0ed66b3 100644 --- a/bruno/bruno.json +++ b/bruno/bruno.json @@ -1,6 +1,6 @@ { "version": "1", - "name": "Pangolin", + "name": "Pangolin Saas", "type": "collection", "ignore": [ "node_modules", diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 00000000..9ae2a1e6 --- /dev/null +++ b/config/config.yml @@ -0,0 +1,39 @@ +app: + dashboard_url: "https://pangolin.internal" + log_level: debug +server: + cors: + origins: + - "https://pangolin.internal" + methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "PATCH" + allowed_headers: + - "X-CSRF-Token" + - "Content-Type" + credentials: true + secret: 1b5f55122e7611f0bf624bafe52c91daadsfjhksd234 +gerbil: + base_endpoint: pangolin.fosrl.io +flags: + require_email_verification: true + disable_signup_without_invite: false + disable_user_create_org: true + allow_base_domain_resources: false + disable_local_sites: false + disable_basic_wireguard_sites: true + enable_integration_api: true + disable_config_managed_domains: true + hide_supporter_key: true + enable_redis: true + enable_clients: true + allow_raw_resources: true +email: + smtp_host: "email-smtp.us-east-1.amazonaws.com" + smtp_port: 587 + smtp_user: "AKIATFBMPNE6PKLOK4MK" + smtp_pass: "BHStM/Nz9B9Crt3YePtsVDnjEp4MZmXqoQvZXWk0MQTC" + no_reply: no-reply@fossorial.io diff --git a/docker-compose.pg.yml b/docker-compose.pgr.yml similarity index 70% rename from docker-compose.pg.yml rename to docker-compose.pgr.yml index ee50d328..2a45f129 100644 --- a/docker-compose.pg.yml +++ b/docker-compose.pgr.yml @@ -11,4 +11,11 @@ services: - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 + restart: no + + redis: + image: redis:latest # Use the latest Redis image + container_name: dev_redis # Name your Redis container + ports: + - "6379:6379" # Map host port 6379 to container port 6379 restart: no diff --git a/docker-compose.t.yml b/docker-compose.t.yml deleted file mode 100644 index 1c7716dd..00000000 --- a/docker-compose.t.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: pangolin -services: - gerbil: - image: gerbil - container_name: gerbil - network_mode: host - restart: unless-stopped - command: - - --reachableAt=http://localhost:3003 - - --generateAndSaveKeyTo=/var/config/key - - --remoteConfig=http://localhost:3001/api/v1/ - - --sni-port=443 - volumes: - - ./config/:/var/config - cap_add: - - NET_ADMIN - - SYS_MODULE - - traefik: - image: docker.io/traefik:v3.4.1 - container_name: traefik - restart: unless-stopped - network_mode: host - command: - - --configFile=/etc/traefik/traefik_config.yml - volumes: - - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs - - ./certificates:/var/certificates:ro - - ./dynamic:/var/dynamic:ro - diff --git a/drizzle.pg.config.ts b/drizzle.pg.config.ts index 4d1f1e43..2b10d2af 100644 --- a/drizzle.pg.config.ts +++ b/drizzle.pg.config.ts @@ -1,9 +1,20 @@ import { defineConfig } from "drizzle-kit"; import path from "path"; +import { build } from "@server/build"; + +let schema; +if (build === "oss") { + schema = [path.join("server", "db", "pg", "schema.ts")]; +} else { + schema = [ + path.join("server", "db", "pg", "schema.ts"), + path.join("server", "db", "pg", "privateSchema.ts") + ]; +} export default defineConfig({ dialect: "postgresql", - schema: [path.join("server", "db", "pg", "schema.ts")], + schema: schema, out: path.join("server", "migrations"), verbose: true, dbCredentials: { diff --git a/drizzle.sqlite.config.ts b/drizzle.sqlite.config.ts index 94574a89..25bbe7f3 100644 --- a/drizzle.sqlite.config.ts +++ b/drizzle.sqlite.config.ts @@ -1,10 +1,21 @@ +import { build } from "@server/build"; import { APP_PATH } from "@server/lib/consts"; import { defineConfig } from "drizzle-kit"; import path from "path"; +let schema; +if (build === "oss") { + schema = [path.join("server", "db", "sqlite", "schema.ts")]; +} else { + schema = [ + path.join("server", "db", "sqlite", "schema.ts"), + path.join("server", "db", "sqlite", "privateSchema.ts") + ]; +} + export default defineConfig({ dialect: "sqlite", - schema: path.join("server", "db", "sqlite", "schema.ts"), + schema: schema, out: path.join("server", "migrations"), verbose: true, dbCredentials: { diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 70bce4dd..91f4a46a 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -168,6 +168,9 @@ "siteSelect": "Vybrat lokalitu", "siteSearch": "Hledat lokalitu", "siteNotFound": "Nebyla nalezena žádná lokalita.", + "selectCountry": "Vyberte zemi", + "searchCountries": "Hledat země...", + "noCountryFound": "Nebyla nalezena žádná země.", "siteSelectionDescription": "Tato lokalita poskytne připojení k cíli.", "resourceType": "Typ zdroje", "resourceTypeDescription": "Určete, jak chcete přistupovat ke svému zdroji", @@ -1257,6 +1260,48 @@ "domainPickerSubdomain": "Subdoména: {subdomain}", "domainPickerNamespace": "Jmenný prostor: {namespace}", "domainPickerShowMore": "Zobrazit více", + "regionSelectorTitle": "Vybrat region", + "regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.", + "regionSelectorPlaceholder": "Vyberte region", + "regionSelectorComingSoon": "Již brzy", + "billingLoadingSubscription": "Načítání odběru...", + "billingFreeTier": "Volná úroveň", + "billingWarningOverLimit": "Upozornění: Překročili jste jeden nebo více omezení používání. Vaše stránky se nepřipojí dokud nezměníte předplatné nebo neupravíte své používání.", + "billingUsageLimitsOverview": "Přehled omezení použití", + "billingMonitorUsage": "Sledujte vaše využití pomocí nastavených limitů. Pokud potřebujete zvýšit limity, kontaktujte nás prosím support@fossorial.io.", + "billingDataUsage": "Využití dat", + "billingOnlineTime": "Stránka online čas", + "billingUsers": "Aktivní uživatelé", + "billingDomains": "Aktivní domény", + "billingRemoteExitNodes": "Aktivní Samostatně hostované uzly", + "billingNoLimitConfigured": "Žádný limit nenastaven", + "billingEstimatedPeriod": "Odhadované období fakturace", + "billingIncludedUsage": "Zahrnuto využití", + "billingIncludedUsageDescription": "Využití zahrnované s aktuálním plánem předplatného", + "billingFreeTierIncludedUsage": "Povolenky bezplatné úrovně využití", + "billingIncluded": "zahrnuto", + "billingEstimatedTotal": "Odhadovaný celkem:", + "billingNotes": "Poznámky", + "billingEstimateNote": "Toto je odhad založený na aktuálním využití.", + "billingActualChargesMayVary": "Skutečné náklady se mohou lišit.", + "billingBilledAtEnd": "Budete účtováni na konci fakturační doby.", + "billingModifySubscription": "Upravit předplatné", + "billingStartSubscription": "Začít předplatné", + "billingRecurringCharge": "Opakované nabití", + "billingManageSubscriptionSettings": "Spravovat nastavení a nastavení předplatného", + "billingNoActiveSubscription": "Nemáte aktivní předplatné. Začněte předplatné, abyste zvýšili omezení používání.", + "billingFailedToLoadSubscription": "Nepodařilo se načíst odběr", + "billingFailedToLoadUsage": "Nepodařilo se načíst využití", + "billingFailedToGetCheckoutUrl": "Nepodařilo se získat adresu URL pokladny", + "billingPleaseTryAgainLater": "Zkuste to prosím znovu později.", + "billingCheckoutError": "Chyba pokladny", + "billingFailedToGetPortalUrl": "Nepodařilo se získat URL portálu", + "billingPortalError": "Chyba portálu", + "billingDataUsageInfo": "Pokud jste připojeni k cloudu, jsou vám účtována všechna data přenášená prostřednictvím zabezpečených tunelů. To zahrnuje příchozí i odchozí provoz na všech vašich stránkách. Jakmile dosáhnete svého limitu, vaše stránky se odpojí, dokud neaktualizujete svůj tarif nebo nezmenšíte jeho používání. Data nejsou nabírána při používání uzlů.", + "billingOnlineTimeInfo": "Platíte na základě toho, jak dlouho budou vaše stránky připojeny k cloudu. Například, 44,640 minut se rovná jedné stránce 24/7 po celý měsíc. Jakmile dosáhnete svého limitu, vaše stránky se odpojí, dokud neaktualizujete svůj tarif nebo nezkrátíte jeho používání. Čas není vybírán při používání uzlů.", + "billingUsersInfo": "Obdrželi jste platbu za každého uživatele ve vaší organizaci. Fakturace je počítána denně na základě počtu aktivních uživatelských účtů ve vašem org.", + "billingDomainInfo": "Platba je účtována za každou doménu ve vaší organizaci. Fakturace je počítána denně na základě počtu aktivních doménových účtů na Vašem org.", + "billingRemoteExitNodesInfo": "Za každý spravovaný uzel ve vaší organizaci se vám účtuje denně. Fakturace je počítána na základě počtu aktivních spravovaných uzlů ve vašem org.", "domainNotFound": "Doména nenalezena", "domainNotFoundDescription": "Tento dokument je zakázán, protože doména již neexistuje náš systém. Nastavte prosím novou doménu pro tento dokument.", "failed": "Selhalo", @@ -1320,6 +1365,7 @@ "createDomainDnsPropagationDescription": "Změna DNS může trvat nějakou dobu, než se šíří po internetu. To může trvat kdekoli od několika minut do 48 hodin v závislosti na poskytovateli DNS a nastavení TTL.", "resourcePortRequired": "Pro neHTTP zdroje je vyžadováno číslo portu", "resourcePortNotAllowed": "Číslo portu by nemělo být nastaveno pro HTTP zdroje", + "billingPricingCalculatorLink": "Cenová kalkulačka", "signUpTerms": { "IAgreeToThe": "Souhlasím s", "termsOfService": "podmínky služby", @@ -1368,6 +1414,41 @@ "addNewTarget": "Add New Target", "targetsList": "Seznam cílů", "targetErrorDuplicateTargetFound": "Byl nalezen duplicitní cíl", + "healthCheckHealthy": "Zdravé", + "healthCheckUnhealthy": "Nezdravé", + "healthCheckUnknown": "Neznámý", + "healthCheck": "Kontrola stavu", + "configureHealthCheck": "Konfigurace kontroly stavu", + "configureHealthCheckDescription": "Nastavit sledování zdravotního stavu pro {target}", + "enableHealthChecks": "Povolit kontrolu stavu", + "enableHealthChecksDescription": "Sledujte zdraví tohoto cíle. V případě potřeby můžete sledovat jiný cílový bod, než je cíl.", + "healthScheme": "Způsob", + "healthSelectScheme": "Vybrat metodu", + "healthCheckPath": "Cesta", + "healthHostname": "IP / Hostitel", + "healthPort": "Přístav", + "healthCheckPathDescription": "Cesta ke kontrole zdravotního stavu.", + "healthyIntervalSeconds": "Interval zdraví", + "unhealthyIntervalSeconds": "Nezdravý interval", + "IntervalSeconds": "Interval zdraví", + "timeoutSeconds": "Časový limit", + "timeIsInSeconds": "Čas je v sekundách", + "retryAttempts": "Opakovat pokusy", + "expectedResponseCodes": "Očekávané kódy odezvy", + "expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.", + "customHeaders": "Vlastní záhlaví", + "customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota", + "headersValidationError": "Headers must be in the format: Header-Name: value.", + "saveHealthCheck": "Uložit kontrolu stavu", + "healthCheckSaved": "Kontrola stavu uložena", + "healthCheckSavedDescription": "Nastavení kontroly stavu bylo úspěšně uloženo", + "healthCheckError": "Chyba kontroly stavu", + "healthCheckErrorDescription": "Došlo k chybě při ukládání konfigurace kontroly stavu", + "healthCheckPathRequired": "Je vyžadována cesta kontroly stavu", + "healthCheckMethodRequired": "HTTP metoda je povinná", + "healthCheckIntervalMin": "Interval kontroly musí být nejméně 5 sekund", + "healthCheckTimeoutMin": "Časový limit musí být nejméně 1 sekunda", + "healthCheckRetryMin": "Pokusy opakovat musí být alespoň 1", "httpMethod": "HTTP metoda", "selectHttpMethod": "Vyberte HTTP metodu", "domainPickerSubdomainLabel": "Subdoména", @@ -1381,6 +1462,7 @@ "domainPickerEnterSubdomainToSearch": "Zadejte subdoménu pro hledání a výběr z dostupných domén zdarma.", "domainPickerFreeDomains": "Volné domény", "domainPickerSearchForAvailableDomains": "Hledat dostupné domény", + "domainPickerNotWorkSelfHosted": "Poznámka: Poskytnuté domény nejsou momentálně k dispozici pro vlastní hostované instance.", "resourceDomain": "Doména", "resourceEditDomain": "Upravit doménu", "siteName": "Název webu", @@ -1463,6 +1545,72 @@ "autoLoginError": "Automatická chyba přihlášení", "autoLoginErrorNoRedirectUrl": "Od poskytovatele identity nebyla obdržena žádná adresa URL.", "autoLoginErrorGeneratingUrl": "Nepodařilo se vygenerovat ověřovací URL.", + "remoteExitNodeManageRemoteExitNodes": "Spravovat vlastní hostování", + "remoteExitNodeDescription": "Spravujte uzly pro rozšíření připojení k síti", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Hledat uzly...", + "remoteExitNodeAdd": "Přidat uzel", + "remoteExitNodeErrorDelete": "Chyba při odstraňování uzlu", + "remoteExitNodeQuestionRemove": "Jste si jisti, že chcete odstranit uzel {selectedNode} z organizace?", + "remoteExitNodeMessageRemove": "Po odstranění uzel již nebude přístupný.", + "remoteExitNodeMessageConfirm": "Pro potvrzení zadejte název uzlu níže.", + "remoteExitNodeConfirmDelete": "Potvrdit odstranění uzlu", + "remoteExitNodeDelete": "Odstranit uzel", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Vytvořit uzel", + "description": "Vytvořit nový uzel pro rozšíření síťového připojení", + "viewAllButton": "Zobrazit všechny uzly", + "strategy": { + "title": "Strategie tvorby", + "description": "Vyberte pro manuální konfiguraci vašeho uzlu nebo vygenerujte nové přihlašovací údaje.", + "adopt": { + "title": "Přijmout uzel", + "description": "Zvolte tuto možnost, pokud již máte přihlašovací údaje k uzlu." + }, + "generate": { + "title": "Generovat klíče", + "description": "Vyberte tuto možnost, pokud chcete vygenerovat nové klíče pro uzel" + } + }, + "adopt": { + "title": "Přijmout existující uzel", + "description": "Zadejte přihlašovací údaje existujícího uzlu, který chcete přijmout", + "nodeIdLabel": "ID uzlu", + "nodeIdDescription": "ID existujícího uzlu, který chcete přijmout", + "secretLabel": "Tajný klíč", + "secretDescription": "Tajný klíč existujícího uzlu", + "submitButton": "Přijmout uzel" + }, + "generate": { + "title": "Vygenerovaná pověření", + "description": "Použijte tyto generované přihlašovací údaje pro nastavení vašeho uzlu", + "nodeIdTitle": "ID uzlu", + "secretTitle": "Tajný klíč", + "saveCredentialsTitle": "Přidat přihlašovací údaje do konfigurace", + "saveCredentialsDescription": "Přidejte tyto přihlašovací údaje do vlastního konfiguračního souboru Pangolin uzlu pro dokončení připojení.", + "submitButton": "Vytvořit uzel" + }, + "validation": { + "adoptRequired": "ID uzlu a tajný klíč jsou vyžadovány při přijetí existujícího uzlu" + }, + "errors": { + "loadDefaultsFailed": "Nepodařilo se načíst výchozí hodnoty", + "defaultsNotLoaded": "Výchozí hodnoty nebyly načteny", + "createFailed": "Nepodařilo se vytvořit uzel" + }, + "success": { + "created": "Uzel byl úspěšně vytvořen" + } + }, + "remoteExitNodeSelection": "Výběr uzlu", + "remoteExitNodeSelectionDescription": "Vyberte uzel pro směrování provozu přes tuto lokální stránku", + "remoteExitNodeRequired": "Pro lokální stránky musí být vybrán uzel", + "noRemoteExitNodesAvailable": "Nejsou k dispozici žádné uzly", + "noRemoteExitNodesAvailableDescription": "Pro tuto organizaci nejsou k dispozici žádné uzly. Nejprve vytvořte uzel pro použití lokálních stránek.", + "exitNode": "Ukončit uzel", + "country": "L 343, 22.12.2009, s. 1).", + "rulesMatchCountry": "Aktuálně založené na zdrojové IP adrese", "managedSelfHosted": { "title": "Spravované vlastní hostování", "description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami", @@ -1501,11 +1649,51 @@ }, "internationaldomaindetected": "Zjištěna mezinárodní doména", "willbestoredas": "Bude uloženo jako:", + "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí, když je povoleno automatické poskytnutí služby.", + "selectRole": "Vyberte roli", + "roleMappingExpression": "Výraz", + "selectRolePlaceholder": "Vyberte roli", + "selectRoleDescription": "Vyberte roli pro přiřazení všem uživatelům od tohoto poskytovatele identity", + "roleMappingExpressionDescription": "Zadejte výraz JMESPath pro získání informací o roli z ID token", + "idpTenantIdRequired": "ID nájemce je povinné", + "invalidValue": "Neplatná hodnota", + "idpTypeLabel": "Typ poskytovatele identity", + "roleMappingExpressionPlaceholder": "např. obsahuje(skupiny, 'admin') && 'Admin' || 'Member'", + "idpGoogleConfiguration": "Konfigurace Google", + "idpGoogleConfigurationDescription": "Konfigurace přihlašovacích údajů Google OAuth2", + "idpGoogleClientIdDescription": "Vaše ID klienta Google OAuth2", + "idpGoogleClientSecretDescription": "Tajný klíč klienta Google OAuth2", + "idpAzureConfiguration": "Nastavení Azure Entra ID", + "idpAzureConfigurationDescription": "Nastavte vaše Azure Entra ID OAuth2", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "vaše-tenant-id", + "idpAzureTenantIdDescription": "Vaše Azure nájemce ID (nalezeno v přehledu Azure Active Directory – Azure)", + "idpAzureClientIdDescription": "Vaše ID registrace aplikace Azure", + "idpAzureClientSecretDescription": "Tajný klíč registrace aplikace Azure", + "idpGoogleTitle": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpGoogleConfigurationTitle": "Konfigurace Google", + "idpAzureConfigurationTitle": "Nastavení Azure Entra ID", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Vaše ID registrace aplikace Azure", + "idpAzureClientSecretDescription2": "Tajný klíč registrace aplikace Azure", + "subnet": "Podsíť", + "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", + "authPage": "Auth stránka", + "authPageDescription": "Konfigurace autentizační stránky vaší organizace", + "authPageDomain": "Doména ověření stránky", + "noDomainSet": "Není nastavena žádná doména", + "changeDomain": "Změnit doménu", + "selectDomain": "Vybrat doménu", + "restartCertificate": "Restartovat certifikát", + "editAuthPageDomain": "Upravit doménu autentizační stránky", + "setAuthPageDomain": "Nastavit doménu autentické stránky", + "failedToFetchCertificate": "Nepodařilo se načíst certifikát", + "failedToRestartCertificate": "Restartování certifikátu se nezdařilo", + "addDomainToEnableCustomAuthPages": "Přidejte doménu pro povolení vlastních ověřovacích stránek pro vaši organizaci", + "selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace", "idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Vlastní záhlaví", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Záhlaví musí být ve formátu: název záhlaví: hodnota.", "domainPickerProvidedDomain": "Poskytnutá doména", "domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména", "domainPickerVerified": "Ověřeno", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nemohl být platný pro {domain}.", "domainPickerSubdomainSanitized": "Upravená subdoména", "domainPickerSubdomainCorrected": "\"{sub}\" bylo opraveno na \"{sanitized}\"", + "orgAuthSignInTitle": "Přihlaste se do vaší organizace", + "orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity", + "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", + "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", + "subscriptionRequiredToUse": "Pro použití této funkce je vyžadováno předplatné.", + "idpDisabled": "Poskytovatelé identit jsou zakázáni.", + "orgAuthPageDisabled": "Ověřovací stránka organizace je zakázána.", + "domainRestartedDescription": "Ověření domény bylo úspěšně restartováno", "resourceAddEntrypointsEditFile": "Upravit soubor: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Upravit soubor: docker-compose.yml", "emailVerificationRequired": "Je vyžadováno ověření e-mailu. Přihlaste se znovu pomocí {dashboardUrl}/auth/login dokončete tento krok. Poté se vraťte zde.", - "twoFactorSetupRequired": "Je vyžadováno nastavení dvoufaktorového ověřování. Přihlaste se znovu pomocí {dashboardUrl}/autentizace/přihlášení dokončí tento krok. Poté se vraťte zde.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Je vyžadováno nastavení dvoufaktorového ověřování. Přihlaste se znovu pomocí {dashboardUrl}/autentizace/přihlášení dokončí tento krok. Poté se vraťte zde." } diff --git a/messages/de-DE.json b/messages/de-DE.json index 7eb1e1d3..f58a0b86 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -168,6 +168,9 @@ "siteSelect": "Standort auswählen", "siteSearch": "Standorte durchsuchen", "siteNotFound": "Keinen Standort gefunden.", + "selectCountry": "Land auswählen", + "searchCountries": "Länder suchen...", + "noCountryFound": "Kein Land gefunden.", "siteSelectionDescription": "Dieser Standort wird die Verbindung zum Ziel herstellen.", "resourceType": "Ressourcentyp", "resourceTypeDescription": "Legen Sie fest, wie Sie auf Ihre Ressource zugreifen möchten", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Verbunden", "idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.", "idpErrorNotFound": "IdP nicht gefunden", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Ungültige Einladung", "inviteInvalidDescription": "Der Einladungslink ist ungültig.", "inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer", @@ -1139,8 +1140,8 @@ "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", - "sidebarClients": "Clients (Beta)", - "sidebarDomains": "Domains", + "sidebarClients": "Kunden (Beta)", + "sidebarDomains": "Domänen", "enableDockerSocket": "Docker Blaupause aktivieren", "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", "enableDockerSocketLink": "Mehr erfahren", @@ -1188,7 +1189,7 @@ "certificateStatus": "Zertifikatsstatus", "loading": "Laden", "restart": "Neustart", - "domains": "Domains", + "domains": "Domänen", "domainsDescription": "Domains für Ihre Organisation verwalten", "domainsSearch": "Domains durchsuchen...", "domainAdd": "Domain hinzufügen", @@ -1201,7 +1202,7 @@ "domainMessageConfirm": "Um zu bestätigen, geben Sie bitte den Domainnamen unten ein.", "domainConfirmDelete": "Domain-Löschung bestätigen", "domainDelete": "Domain löschen", - "domain": "Domain", + "domain": "Domäne", "selectDomainTypeNsName": "Domain-Delegation (NS)", "selectDomainTypeNsDescription": "Diese Domain und alle ihre Subdomains. Verwenden Sie dies, wenn Sie eine gesamte Domainzone kontrollieren möchten.", "selectDomainTypeCnameName": "Einzelne Domain (CNAME)", @@ -1241,7 +1242,7 @@ "sidebarExpand": "Erweitern", "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", - "domainPickerEnterDomain": "Domain", + "domainPickerEnterDomain": "Domäne", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mehr anzeigen", + "regionSelectorTitle": "Region auswählen", + "regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.", + "regionSelectorPlaceholder": "Wähle eine Region", + "regionSelectorComingSoon": "Kommt bald", + "billingLoadingSubscription": "Abonnement wird geladen...", + "billingFreeTier": "Kostenlose Stufe", + "billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Webseiten werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.", + "billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen", + "billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@fossorial.io.", + "billingDataUsage": "Datenverbrauch", + "billingOnlineTime": "Online-Zeit der Seite", + "billingUsers": "Aktive Benutzer", + "billingDomains": "Aktive Domänen", + "billingRemoteExitNodes": "Aktive selbstgehostete Nodes", + "billingNoLimitConfigured": "Kein Limit konfiguriert", + "billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum", + "billingIncludedUsage": "Inklusive Nutzung", + "billingIncludedUsageDescription": "Nutzung, die in Ihrem aktuellen Abonnementplan enthalten ist", + "billingFreeTierIncludedUsage": "Nutzungskontingente der kostenlosen Stufe", + "billingIncluded": "inbegriffen", + "billingEstimatedTotal": "Geschätzte Gesamtsumme:", + "billingNotes": "Notizen", + "billingEstimateNote": "Dies ist eine Schätzung basierend auf Ihrem aktuellen Verbrauch.", + "billingActualChargesMayVary": "Tatsächliche Kosten können variieren.", + "billingBilledAtEnd": "Sie werden am Ende des Abrechnungszeitraums in Rechnung gestellt.", + "billingModifySubscription": "Abonnement ändern", + "billingStartSubscription": "Abonnement starten", + "billingRecurringCharge": "Wiederkehrende Kosten", + "billingManageSubscriptionSettings": "Verwalten Sie Ihre Abonnement-Einstellungen und Präferenzen", + "billingNoActiveSubscription": "Sie haben kein aktives Abonnement. Starten Sie Ihr Abonnement, um Nutzungslimits zu erhöhen.", + "billingFailedToLoadSubscription": "Fehler beim Laden des Abonnements", + "billingFailedToLoadUsage": "Fehler beim Laden der Nutzung", + "billingFailedToGetCheckoutUrl": "Fehler beim Abrufen der Checkout-URL", + "billingPleaseTryAgainLater": "Bitte versuchen Sie es später noch einmal.", + "billingCheckoutError": "Checkout-Fehler", + "billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL", + "billingPortalError": "Portalfehler", + "billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.", + "billingOnlineTimeInfo": "Sie werden belastet, abhängig davon, wie lange Ihre Seiten mit der Cloud verbunden bleiben. Zum Beispiel 44.640 Minuten entspricht einer Site, die 24 Stunden am Tag des Monats läuft. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Die Zeit wird nicht belastet, wenn Sie Knoten verwenden.", + "billingUsersInfo": "Ihnen wird für jeden Benutzer in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven Benutzerkonten in Ihrer Organisation.", + "billingDomainInfo": "Ihnen wird für jede Domäne in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven Domänenkonten in Ihrer Organisation.", + "billingRemoteExitNodesInfo": "Ihnen wird für jeden verwalteten Node in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven verwalteten Nodes in Ihrer Organisation.", "domainNotFound": "Domain nicht gefunden", "domainNotFoundDescription": "Diese Ressource ist deaktiviert, weil die Domain nicht mehr in unserem System existiert. Bitte setzen Sie eine neue Domain für diese Ressource.", "failed": "Fehlgeschlagen", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Es kann einige Zeit dauern, bis DNS-Änderungen im Internet verbreitet werden. Dies kann je nach Ihrem DNS-Provider und den TTL-Einstellungen von einigen Minuten bis zu 48 Stunden dauern.", "resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich", "resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden", + "billingPricingCalculatorLink": "Preisrechner", "signUpTerms": { "IAgreeToThe": "Ich stimme den", "termsOfService": "Nutzungsbedingungen zu", @@ -1327,7 +1371,7 @@ "privacyPolicy": "Datenschutzrichtlinie" }, "siteRequired": "Standort ist erforderlich.", - "olmTunnel": "Olm Tunnel", + "olmTunnel": "Olm-Tunnel", "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", "clientDefaultsNotFound": "Kundenvorgaben nicht gefunden", @@ -1368,6 +1412,41 @@ "addNewTarget": "Neues Ziel hinzufügen", "targetsList": "Ziel-Liste", "targetErrorDuplicateTargetFound": "Doppeltes Ziel gefunden", + "healthCheckHealthy": "Gesund", + "healthCheckUnhealthy": "Ungesund", + "healthCheckUnknown": "Unbekannt", + "healthCheck": "Gesundheits-Check", + "configureHealthCheck": "Gesundheits-Check konfigurieren", + "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", + "enableHealthChecks": "Gesundheits-Checks aktivieren", + "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", + "healthScheme": "Methode", + "healthSelectScheme": "Methode auswählen", + "healthCheckPath": "Pfad", + "healthHostname": "IP / Host", + "healthPort": "Port", + "healthCheckPathDescription": "Der Pfad zum Überprüfen des Gesundheitszustands.", + "healthyIntervalSeconds": "Gesunder Intervall", + "unhealthyIntervalSeconds": "Ungesunder Intervall", + "IntervalSeconds": "Gesunder Intervall", + "timeoutSeconds": "Timeout", + "timeIsInSeconds": "Zeit ist in Sekunden", + "retryAttempts": "Wiederholungsversuche", + "expectedResponseCodes": "Erwartete Antwortcodes", + "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", + "customHeaders": "Eigene Kopfzeilen", + "customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert", + "headersValidationError": "Header müssen im Format Header-Name: Wert sein.", + "saveHealthCheck": "Gesundheits-Check speichern", + "healthCheckSaved": "Gesundheits-Check gespeichert", + "healthCheckSavedDescription": "Die Konfiguration des Gesundheits-Checks wurde erfolgreich gespeichert", + "healthCheckError": "Fehler beim Gesundheits-Check", + "healthCheckErrorDescription": "Beim Speichern der Gesundheits-Check-Konfiguration ist ein Fehler aufgetreten", + "healthCheckPathRequired": "Gesundheits-Check-Pfad ist erforderlich", + "healthCheckMethodRequired": "HTTP-Methode ist erforderlich", + "healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen", + "healthCheckTimeoutMin": "Timeout muss mindestens 1 Sekunde betragen", + "healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen", "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", @@ -1381,7 +1460,8 @@ "domainPickerEnterSubdomainToSearch": "Geben Sie eine Subdomain ein, um verfügbare freie Domains zu suchen und auszuwählen.", "domainPickerFreeDomains": "Freie Domains", "domainPickerSearchForAvailableDomains": "Verfügbare Domains suchen", - "resourceDomain": "Domain", + "domainPickerNotWorkSelfHosted": "Hinweis: Kostenlose bereitgestellte Domains sind derzeit nicht für selbstgehostete Instanzen verfügbar.", + "resourceDomain": "Domäne", "resourceEditDomain": "Domain bearbeiten", "siteName": "Site-Name", "proxyPort": "Port", @@ -1463,6 +1543,72 @@ "autoLoginError": "Fehler bei der automatischen Anmeldung", "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", + "remoteExitNodeManageRemoteExitNodes": "Selbst-Hosted verwalten", + "remoteExitNodeDescription": "Knoten verwalten, um die Netzwerkverbindung zu erweitern", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Knoten suchen...", + "remoteExitNodeAdd": "Knoten hinzufügen", + "remoteExitNodeErrorDelete": "Fehler beim Löschen des Knotens", + "remoteExitNodeQuestionRemove": "Sind Sie sicher, dass Sie den Knoten {selectedNode} aus der Organisation entfernen möchten?", + "remoteExitNodeMessageRemove": "Einmal entfernt, wird der Knoten nicht mehr zugänglich sein.", + "remoteExitNodeMessageConfirm": "Um zu bestätigen, geben Sie bitte den Namen des Knotens unten ein.", + "remoteExitNodeConfirmDelete": "Löschknoten bestätigen", + "remoteExitNodeDelete": "Knoten löschen", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Knoten erstellen", + "description": "Erstellen Sie einen neuen Knoten, um Ihre Netzwerkverbindung zu erweitern", + "viewAllButton": "Alle Knoten anzeigen", + "strategy": { + "title": "Erstellungsstrategie", + "description": "Wählen Sie diese Option, um Ihren Knoten manuell zu konfigurieren oder neue Zugangsdaten zu generieren.", + "adopt": { + "title": "Node übernehmen", + "description": "Wählen Sie dies, wenn Sie bereits die Anmeldedaten für den Knoten haben." + }, + "generate": { + "title": "Schlüssel generieren", + "description": "Wählen Sie dies, wenn Sie neue Schlüssel für den Knoten generieren möchten" + } + }, + "adopt": { + "title": "Vorhandenen Node übernehmen", + "description": "Geben Sie die Zugangsdaten des vorhandenen Knotens ein, den Sie übernehmen möchten", + "nodeIdLabel": "Knoten-ID", + "nodeIdDescription": "Die ID des vorhandenen Knotens, den Sie übernehmen möchten", + "secretLabel": "Geheimnis", + "secretDescription": "Der geheime Schlüssel des vorhandenen Knotens", + "submitButton": "Node übernehmen" + }, + "generate": { + "title": "Generierte Anmeldedaten", + "description": "Verwenden Sie diese generierten Anmeldeinformationen, um Ihren Knoten zu konfigurieren", + "nodeIdTitle": "Knoten-ID", + "secretTitle": "Geheimnis", + "saveCredentialsTitle": "Anmeldedaten zur Konfiguration hinzufügen", + "saveCredentialsDescription": "Fügen Sie diese Anmeldedaten zu Ihrer selbst-gehosteten Pangolin Node-Konfigurationsdatei hinzu, um die Verbindung abzuschließen.", + "submitButton": "Knoten erstellen" + }, + "validation": { + "adoptRequired": "Knoten-ID und Geheimnis sind erforderlich, wenn ein existierender Knoten angenommen wird" + }, + "errors": { + "loadDefaultsFailed": "Fehler beim Laden der Standardeinstellungen", + "defaultsNotLoaded": "Standardeinstellungen nicht geladen", + "createFailed": "Knoten konnte nicht erstellt werden" + }, + "success": { + "created": "Knoten erfolgreich erstellt" + } + }, + "remoteExitNodeSelection": "Knotenauswahl", + "remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll", + "remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein", + "noRemoteExitNodesAvailable": "Keine Knoten verfügbar", + "noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Sites zu verwenden.", + "exitNode": "Exit-Node", + "country": "Land", + "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", "managedSelfHosted": { "title": "Verwaltetes Selbsthosted", "description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Internationale Domain erkannt", "willbestoredas": "Wird gespeichert als:", + "roleMappingDescription": "Legen Sie fest, wie den Benutzern Rollen zugewiesen werden, wenn sie sich anmelden, wenn Auto Provision aktiviert ist.", + "selectRole": "Wählen Sie eine Rolle", + "roleMappingExpression": "Ausdruck", + "selectRolePlaceholder": "Rolle auswählen", + "selectRoleDescription": "Wählen Sie eine Rolle aus, die allen Benutzern von diesem Identitätsprovider zugewiesen werden soll", + "roleMappingExpressionDescription": "Geben Sie einen JMESPath-Ausdruck ein, um Rolleninformationen aus dem ID-Token zu extrahieren", + "idpTenantIdRequired": "Mandant ID ist erforderlich", + "invalidValue": "Ungültiger Wert", + "idpTypeLabel": "Identitätsanbietertyp", + "roleMappingExpressionPlaceholder": "z. B. enthalten(Gruppen, 'admin') && 'Admin' || 'Mitglied'", + "idpGoogleConfiguration": "Google-Konfiguration", + "idpGoogleConfigurationDescription": "Konfigurieren Sie Ihre Google OAuth2 Zugangsdaten", + "idpGoogleClientIdDescription": "Ihre Google OAuth2 Client-ID", + "idpGoogleClientSecretDescription": "Ihr Google OAuth2 Client Secret", + "idpAzureConfiguration": "Azure Entra ID Konfiguration", + "idpAzureConfigurationDescription": "Konfigurieren Sie Ihre Azure Entra ID OAuth2 Zugangsdaten", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "deine Mandant-ID", + "idpAzureTenantIdDescription": "Ihre Azure Mieter-ID (gefunden in Azure Active Directory Übersicht)", + "idpAzureClientIdDescription": "Ihre Azure App Registration Client ID", + "idpAzureClientSecretDescription": "Ihr Azure App Registration Client Secret", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google-Konfiguration", + "idpAzureConfigurationTitle": "Azure Entra ID Konfiguration", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Ihre Azure App Registration Client ID", + "idpAzureClientSecretDescription2": "Ihr Azure App Registration Client Secret", "idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Eigene Kopfzeilen", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Header müssen im Format Header-Name: Wert sein.", + "subnet": "Subnetz", + "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", + "authPage": "Auth Seite", + "authPageDescription": "Konfigurieren Sie die Auth-Seite für Ihre Organisation", + "authPageDomain": "Domain der Auth Seite", + "noDomainSet": "Keine Domäne gesetzt", + "changeDomain": "Domain ändern", + "selectDomain": "Domain auswählen", + "restartCertificate": "Zertifikat neu starten", + "editAuthPageDomain": "Auth Page Domain bearbeiten", + "setAuthPageDomain": "Domain der Auth Seite festlegen", + "failedToFetchCertificate": "Zertifikat konnte nicht abgerufen werden", + "failedToRestartCertificate": "Zertifikat konnte nicht neu gestartet werden", + "addDomainToEnableCustomAuthPages": "Fügen Sie eine Domain hinzu, um benutzerdefinierte Authentifizierungsseiten für Ihre Organisation zu aktivieren", + "selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation", "domainPickerProvidedDomain": "Angegebene Domain", "domainPickerFreeProvidedDomain": "Kostenlose Domain", "domainPickerVerified": "Verifiziert", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.", "domainPickerSubdomainSanitized": "Subdomain bereinigt", "domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"", + "orgAuthSignInTitle": "Bei Ihrer Organisation anmelden", + "orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren", + "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", + "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", + "subscriptionRequiredToUse": "Um diese Funktion nutzen zu können, ist ein Abonnement erforderlich.", + "idpDisabled": "Identitätsanbieter sind deaktiviert.", + "orgAuthPageDisabled": "Organisations-Authentifizierungsseite ist deaktiviert.", + "domainRestartedDescription": "Domain-Verifizierung erfolgreich neu gestartet", "resourceAddEntrypointsEditFile": "Datei bearbeiten: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml", "emailVerificationRequired": "E-Mail-Verifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Kommen Sie dann wieder hierher.", - "twoFactorSetupRequired": "Die Zwei-Faktor-Authentifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Dann kommen Sie hierher zurück.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Die Zwei-Faktor-Authentifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Dann kommen Sie hierher zurück." } diff --git a/messages/en-US.json b/messages/en-US.json index f4e063ce..6783e974 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,3 +1,4 @@ + { "setupCreate": "Create your organization, site, and resources", "setupNewOrg": "New Organization", @@ -94,9 +95,9 @@ "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", "siteWg": "Basic WireGuard", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", - "siteWgDescriptionSaas": "Use any WireGuard client to establish a tunnel. Manual NAT setup required. ONLY WORKS ON SELF HOSTED NODES", + "siteWgDescriptionSaas": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteLocalDescription": "Local resources only. No tunneling.", - "siteLocalDescriptionSaas": "Local resources only. No tunneling. ONLY WORKS ON SELF HOSTED NODES", + "siteLocalDescriptionSaas": "Local resources only. No tunneling.", "siteSeeAll": "See All Sites", "siteTunnelDescription": "Determine how you want to connect to your site", "siteNewtCredentials": "Newt Credentials", @@ -159,7 +160,7 @@ "resourceHTTP": "HTTPS Resource", "resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.", "resourceRaw": "Raw TCP/UDP Resource", - "resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.", + "resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number. This only works when sites are connected to nodes.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", "resourceSeeAll": "See All Resources", @@ -168,6 +169,9 @@ "siteSelect": "Select site", "siteSearch": "Search site", "siteNotFound": "No site found.", + "selectCountry": "Select country", + "searchCountries": "Search countries...", + "noCountryFound": "No country found.", "siteSelectionDescription": "This site will provide connectivity to the target.", "resourceType": "Resource Type", "resourceTypeDescription": "Determine how you want to access your resource", @@ -914,8 +918,6 @@ "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", @@ -1257,6 +1259,48 @@ "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Show More", + "regionSelectorTitle": "Select Region", + "regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.", + "regionSelectorPlaceholder": "Choose a region", + "regionSelectorComingSoon": "Coming Soon", + "billingLoadingSubscription": "Loading subscription...", + "billingFreeTier": "Free Tier", + "billingWarningOverLimit": "Warning: You have exceeded one or more usage limits. Your sites will not connect until you modify your subscription or adjust your usage.", + "billingUsageLimitsOverview": "Usage Limits Overview", + "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@fossorial.io.", + "billingDataUsage": "Data Usage", + "billingOnlineTime": "Site Online Time", + "billingUsers": "Active Users", + "billingDomains": "Active Domains", + "billingRemoteExitNodes": "Active Self-hosted Nodes", + "billingNoLimitConfigured": "No limit configured", + "billingEstimatedPeriod": "Estimated Billing Period", + "billingIncludedUsage": "Included Usage", + "billingIncludedUsageDescription": "Usage included with your current subscription plan", + "billingFreeTierIncludedUsage": "Free tier usage allowances", + "billingIncluded": "included", + "billingEstimatedTotal": "Estimated Total:", + "billingNotes": "Notes", + "billingEstimateNote": "This is an estimate based on your current usage.", + "billingActualChargesMayVary": "Actual charges may vary.", + "billingBilledAtEnd": "You will be billed at the end of the billing period.", + "billingModifySubscription": "Modify Subscription", + "billingStartSubscription": "Start Subscription", + "billingRecurringCharge": "Recurring Charge", + "billingManageSubscriptionSettings": "Manage your subscription settings and preferences", + "billingNoActiveSubscription": "You don't have an active subscription. Start your subscription to increase usage limits.", + "billingFailedToLoadSubscription": "Failed to load subscription", + "billingFailedToLoadUsage": "Failed to load usage", + "billingFailedToGetCheckoutUrl": "Failed to get checkout URL", + "billingPleaseTryAgainLater": "Please try again later.", + "billingCheckoutError": "Checkout Error", + "billingFailedToGetPortalUrl": "Failed to get portal URL", + "billingPortalError": "Portal Error", + "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", + "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", + "billingUsersInfo": "You're charged for each user in your organization. Billing is calculated daily based on the number of active user accounts in your org.", + "billingDomainInfo": "You're charged for each domain in your organization. Billing is calculated daily based on the number of active domain accounts in your org.", + "billingRemoteExitNodesInfo": "You're charged for each managed Node in your organization. Billing is calculated daily based on the number of active managed Nodes in your org.", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", @@ -1320,6 +1364,7 @@ "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", "resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources", + "billingPricingCalculatorLink": "Pricing Calculator", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", @@ -1368,6 +1413,41 @@ "addNewTarget": "Add New Target", "targetsList": "Targets List", "targetErrorDuplicateTargetFound": "Duplicate target found", + "healthCheckHealthy": "Healthy", + "healthCheckUnhealthy": "Unhealthy", + "healthCheckUnknown": "Unknown", + "healthCheck": "Health Check", + "configureHealthCheck": "Configure Health Check", + "configureHealthCheckDescription": "Set up health monitoring for {target}", + "enableHealthChecks": "Enable Health Checks", + "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", + "healthScheme": "Method", + "healthSelectScheme": "Select Method", + "healthCheckPath": "Path", + "healthHostname": "IP / Host", + "healthPort": "Port", + "healthCheckPathDescription": "The path to check for health status.", + "healthyIntervalSeconds": "Healthy Interval", + "unhealthyIntervalSeconds": "Unhealthy Interval", + "IntervalSeconds": "Healthy Interval", + "timeoutSeconds": "Timeout", + "timeIsInSeconds": "Time is in seconds", + "retryAttempts": "Retry Attempts", + "expectedResponseCodes": "Expected Response Codes", + "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", + "customHeaders": "Custom Headers", + "customHeadersDescription": "Headers new line separated: Header-Name: value", + "headersValidationError": "Headers must be in the format: Header-Name: value", + "saveHealthCheck": "Save Health Check", + "healthCheckSaved": "Health Check Saved", + "healthCheckSavedDescription": "Health check configuration has been saved successfully", + "healthCheckError": "Health Check Error", + "healthCheckErrorDescription": "An error occurred while saving the health check configuration", + "healthCheckPathRequired": "Health check path is required", + "healthCheckMethodRequired": "HTTP method is required", + "healthCheckIntervalMin": "Check interval must be at least 5 seconds", + "healthCheckTimeoutMin": "Timeout must be at least 1 second", + "healthCheckRetryMin": "Retry attempts must be at least 1", "httpMethod": "HTTP Method", "selectHttpMethod": "Select HTTP method", "domainPickerSubdomainLabel": "Subdomain", @@ -1381,6 +1461,7 @@ "domainPickerEnterSubdomainToSearch": "Enter a subdomain to search and select from available free domains.", "domainPickerFreeDomains": "Free Domains", "domainPickerSearchForAvailableDomains": "Search for available domains", + "domainPickerNotWorkSelfHosted": "Note: Free provided domains are not available for self-hosted instances right now.", "resourceDomain": "Domain", "resourceEditDomain": "Edit Domain", "siteName": "Site Name", @@ -1463,6 +1544,72 @@ "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "remoteExitNodeManageRemoteExitNodes": "Manage Self-Hosted", + "remoteExitNodeDescription": "Manage nodes to extend your network connectivity", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Search nodes...", + "remoteExitNodeAdd": "Add Node", + "remoteExitNodeErrorDelete": "Error deleting node", + "remoteExitNodeQuestionRemove": "Are you sure you want to remove the node {selectedNode} from the organization?", + "remoteExitNodeMessageRemove": "Once removed, the node will no longer be accessible.", + "remoteExitNodeMessageConfirm": "To confirm, please type the name of the node below.", + "remoteExitNodeConfirmDelete": "Confirm Delete Node", + "remoteExitNodeDelete": "Delete Node", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Create Node", + "description": "Create a new node to extend your network connectivity", + "viewAllButton": "View All Nodes", + "strategy": { + "title": "Creation Strategy", + "description": "Choose this to manually configure your node or generate new credentials.", + "adopt": { + "title": "Adopt Node", + "description": "Choose this if you already have the credentials for the node." + }, + "generate": { + "title": "Generate Keys", + "description": "Choose this if you want to generate new keys for the node" + } + }, + "adopt": { + "title": "Adopt Existing Node", + "description": "Enter the credentials of the existing node you want to adopt", + "nodeIdLabel": "Node ID", + "nodeIdDescription": "The ID of the existing node you want to adopt", + "secretLabel": "Secret", + "secretDescription": "The secret key of the existing node", + "submitButton": "Adopt Node" + }, + "generate": { + "title": "Generated Credentials", + "description": "Use these generated credentials to configure your node", + "nodeIdTitle": "Node ID", + "secretTitle": "Secret", + "saveCredentialsTitle": "Add Credentials to Config", + "saveCredentialsDescription": "Add these credentials to your self-hosted Pangolin node configuration file to complete the connection.", + "submitButton": "Create Node" + }, + "validation": { + "adoptRequired": "Node ID and Secret are required when adopting an existing node" + }, + "errors": { + "loadDefaultsFailed": "Failed to load defaults", + "defaultsNotLoaded": "Defaults not loaded", + "createFailed": "Failed to create node" + }, + "success": { + "created": "Node created successfully" + } + }, + "remoteExitNodeSelection": "Node Selection", + "remoteExitNodeSelectionDescription": "Select a node to route traffic through for this local site", + "remoteExitNodeRequired": "A node must be selected for local sites", + "noRemoteExitNodesAvailable": "No Nodes Available", + "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", + "exitNode": "Exit Node", + "country": "Country", + "rulesMatchCountry": "Currently based on source IP", "managedSelfHosted": { "title": "Managed Self-Hosted", "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", @@ -1501,11 +1648,53 @@ }, "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", + "roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", + "selectRole": "Select a Role", + "roleMappingExpression": "Expression", + "selectRolePlaceholder": "Choose a role", + "selectRoleDescription": "Select a role to assign to all users from this identity provider", + "roleMappingExpressionDescription": "Enter a JMESPath expression to extract role information from the ID token", + "idpTenantIdRequired": "Tenant ID is required", + "invalidValue": "Invalid value", + "idpTypeLabel": "Identity Provider Type", + "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", + "idpGoogleConfiguration": "Google Configuration", + "idpGoogleConfigurationDescription": "Configure your Google OAuth2 credentials", + "idpGoogleClientIdDescription": "Your Google OAuth2 Client ID", + "idpGoogleClientSecretDescription": "Your Google OAuth2 Client Secret", + "idpAzureConfiguration": "Azure Entra ID Configuration", + "idpAzureConfigurationDescription": "Configure your Azure Entra ID OAuth2 credentials", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "your-tenant-id", + "idpAzureTenantIdDescription": "Your Azure tenant ID (found in Azure Active Directory overview)", + "idpAzureClientIdDescription": "Your Azure App Registration Client ID", + "idpAzureClientSecretDescription": "Your Azure App Registration Client Secret", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google Configuration", + "idpAzureConfigurationTitle": "Azure Entra ID Configuration", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Your Azure App Registration Client ID", + "idpAzureClientSecretDescription2": "Your Azure App Registration Client Secret", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Custom Headers", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Headers must be in the format: Header-Name: value.", + "subnet": "Subnet", + "subnetDescription": "The subnet for this organization's network configuration.", + "authPage": "Auth Page", + "authPageDescription": "Configure the auth page for your organization", + "authPageDomain": "Auth Page Domain", + "noDomainSet": "No domain set", + "changeDomain": "Change Domain", + "selectDomain": "Select Domain", + "restartCertificate": "Restart Certificate", + "editAuthPageDomain": "Edit Auth Page Domain", + "setAuthPageDomain": "Set Auth Page Domain", + "failedToFetchCertificate": "Failed to fetch certificate", + "failedToRestartCertificate": "Failed to restart certificate", + "addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for your organization", + "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerVerified": "Verified", @@ -1519,10 +1708,21 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", "domainPickerSubdomainSanitized": "Subdomain sanitized", "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", + "orgAuthSignInTitle": "Sign in to your organization", + "orgAuthChooseIdpDescription": "Choose your identity provider to continue", + "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", + "orgAuthSignInWithPangolin": "Sign in with Pangolin", + "subscriptionRequiredToUse": "A subscription is required to use this feature.", + "idpDisabled": "Identity providers are disabled.", + "orgAuthPageDisabled": "Organization auth page is disabled.", + "domainRestartedDescription": "Domain verification restarted successfully", "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", + "authPageUpdated": "Auth page updated successfully", + "healthCheckNotAvailable": "Local", "rewritePath": "Rewrite Path", "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." } diff --git a/messages/es-ES.json b/messages/es-ES.json index 0070a03e..d48e77b5 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -67,7 +67,7 @@ "siteDocker": "Expandir para detalles de despliegue de Docker", "toggle": "Cambiar", "dockerCompose": "Componer Docker", - "dockerRun": "Docker Run", + "dockerRun": "Ejecutar Docker", "siteLearnLocal": "Los sitios locales no tienen túnel, aprender más", "siteConfirmCopy": "He copiado la configuración", "searchSitesProgress": "Buscar sitios...", @@ -168,6 +168,9 @@ "siteSelect": "Seleccionar sitio", "siteSearch": "Buscar sitio", "siteNotFound": "Sitio no encontrado.", + "selectCountry": "Seleccionar país", + "searchCountries": "Buscar países...", + "noCountryFound": "Ningún país encontrado.", "siteSelectionDescription": "Este sitio proporcionará conectividad al objetivo.", "resourceType": "Tipo de recurso", "resourceTypeDescription": "Determina cómo quieres acceder a tu recurso", @@ -814,7 +817,7 @@ "redirectUrl": "URL de redirección", "redirectUrlAbout": "Acerca de la URL de redirección", "redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración de tu proveedor de identidad.", - "pangolinAuth": "Auth - Pangolin", + "pangolinAuth": "Autenticación - Pangolin", "verificationCodeLengthRequirements": "Tu código de verificación debe tener 8 caracteres.", "errorOccurred": "Se ha producido un error", "emailErrorVerify": "No se pudo verificar el email:", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.", "idpErrorNotFound": "IdP no encontrado", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Invitación inválida", "inviteInvalidDescription": "El enlace de invitación no es válido.", "inviteErrorWrongUser": "La invitación no es para este usuario", @@ -1219,7 +1220,7 @@ "billing": "Facturación", "orgBillingDescription": "Gestiona tu información de facturación y suscripciones", "github": "GitHub", - "pangolinHosted": "Pangolin Hosted", + "pangolinHosted": "Pangolin Alojado", "fossorial": "Fossorial", "completeAccountSetup": "Completar configuración de cuenta", "completeAccountSetupDescription": "Establece tu contraseña para comenzar", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Subdominio: {subdomain}", "domainPickerNamespace": "Espacio de nombres: {namespace}", "domainPickerShowMore": "Mostrar más", + "regionSelectorTitle": "Seleccionar Región", + "regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.", + "regionSelectorPlaceholder": "Elige una región", + "regionSelectorComingSoon": "Próximamente", + "billingLoadingSubscription": "Cargando suscripción...", + "billingFreeTier": "Nivel Gratis", + "billingWarningOverLimit": "Advertencia: Has excedido uno o más límites de uso. Tus sitios no se conectarán hasta que modifiques tu suscripción o ajustes tu uso.", + "billingUsageLimitsOverview": "Descripción general de los límites de uso", + "billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@fossorial.io.", + "billingDataUsage": "Uso de datos", + "billingOnlineTime": "Tiempo en línea del sitio", + "billingUsers": "Usuarios activos", + "billingDomains": "Dominios activos", + "billingRemoteExitNodes": "Nodos autogestionados activos", + "billingNoLimitConfigured": "No se ha configurado ningún límite", + "billingEstimatedPeriod": "Período de facturación estimado", + "billingIncludedUsage": "Uso incluido", + "billingIncludedUsageDescription": "Uso incluido con su plan de suscripción actual", + "billingFreeTierIncludedUsage": "Permisos de uso del nivel gratuito", + "billingIncluded": "incluido", + "billingEstimatedTotal": "Total Estimado:", + "billingNotes": "Notas", + "billingEstimateNote": "Esta es una estimación basada en tu uso actual.", + "billingActualChargesMayVary": "Los cargos reales pueden variar.", + "billingBilledAtEnd": "Se te facturará al final del período de facturación.", + "billingModifySubscription": "Modificar Suscripción", + "billingStartSubscription": "Iniciar Suscripción", + "billingRecurringCharge": "Cargo Recurrente", + "billingManageSubscriptionSettings": "Administra la configuración y preferencias de tu suscripción", + "billingNoActiveSubscription": "No tienes una suscripción activa. Inicia tu suscripción para aumentar los límites de uso.", + "billingFailedToLoadSubscription": "Error al cargar la suscripción", + "billingFailedToLoadUsage": "Error al cargar el uso", + "billingFailedToGetCheckoutUrl": "Error al obtener la URL de pago", + "billingPleaseTryAgainLater": "Por favor, inténtelo de nuevo más tarde.", + "billingCheckoutError": "Error de pago", + "billingFailedToGetPortalUrl": "Error al obtener la URL del portal", + "billingPortalError": "Error del portal", + "billingDataUsageInfo": "Se le cobran todos los datos transferidos a través de sus túneles seguros cuando se conectan a la nube. Esto incluye tanto tráfico entrante como saliente a través de todos sus sitios. Cuando alcance su límite, sus sitios se desconectarán hasta que actualice su plan o reduzca el uso. Los datos no se cargan cuando se usan nodos.", + "billingOnlineTimeInfo": "Se te cobrará en función del tiempo que tus sitios permanezcan conectados a la nube. Por ejemplo, 44.640 minutos equivale a un sitio que funciona 24/7 durante un mes completo. Cuando alcance su límite, sus sitios se desconectarán hasta que mejore su plan o reduzca el uso. No se cargará el tiempo al usar nodos.", + "billingUsersInfo": "Se te cobra por cada usuario en tu organización. La facturación se calcula diariamente según la cantidad de cuentas de usuario activas en tu organización.", + "billingDomainInfo": "Se te cobra por cada dominio en tu organización. La facturación se calcula diariamente según la cantidad de cuentas de dominio activas en tu organización.", + "billingRemoteExitNodesInfo": "Se te cobra por cada nodo gestionado en tu organización. La facturación se calcula diariamente según la cantidad de nodos gestionados activos en tu organización.", "domainNotFound": "Dominio no encontrado", "domainNotFoundDescription": "Este recurso está deshabilitado porque el dominio ya no existe en nuestro sistema. Por favor, establece un nuevo dominio para este recurso.", "failed": "Fallido", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Los cambios de DNS pueden tardar un tiempo en propagarse a través de internet. Esto puede tardar desde unos pocos minutos hasta 48 horas, dependiendo de tu proveedor de DNS y la configuración de TTL.", "resourcePortRequired": "Se requiere número de puerto para recursos no HTTP", "resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP", + "billingPricingCalculatorLink": "Calculadora de Precios", "signUpTerms": { "IAgreeToThe": "Estoy de acuerdo con los", "termsOfService": "términos del servicio", @@ -1368,6 +1412,41 @@ "addNewTarget": "Agregar nuevo destino", "targetsList": "Lista de destinos", "targetErrorDuplicateTargetFound": "Se encontró un destino duplicado", + "healthCheckHealthy": "Saludable", + "healthCheckUnhealthy": "No saludable", + "healthCheckUnknown": "Desconocido", + "healthCheck": "Chequeo de salud", + "configureHealthCheck": "Configurar Chequeo de Salud", + "configureHealthCheckDescription": "Configura la monitorización de salud para {target}", + "enableHealthChecks": "Activar Chequeos de Salud", + "enableHealthChecksDescription": "Controlar la salud de este objetivo. Puedes supervisar un punto final diferente al objetivo si es necesario.", + "healthScheme": "Método", + "healthSelectScheme": "Seleccionar método", + "healthCheckPath": "Ruta", + "healthHostname": "IP / Host", + "healthPort": "Puerto", + "healthCheckPathDescription": "La ruta para comprobar el estado de salud.", + "healthyIntervalSeconds": "Intervalo Saludable", + "unhealthyIntervalSeconds": "Intervalo No Saludable", + "IntervalSeconds": "Intervalo Saludable", + "timeoutSeconds": "Tiempo de Espera", + "timeIsInSeconds": "El tiempo está en segundos", + "retryAttempts": "Intentos de Reintento", + "expectedResponseCodes": "Códigos de respuesta esperados", + "expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.", + "customHeaders": "Cabeceras personalizadas", + "customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor", + "headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.", + "saveHealthCheck": "Guardar Chequeo de Salud", + "healthCheckSaved": "Chequeo de Salud Guardado", + "healthCheckSavedDescription": "La configuración del chequeo de salud se ha guardado correctamente", + "healthCheckError": "Error en el Chequeo de Salud", + "healthCheckErrorDescription": "Ocurrió un error al guardar la configuración del chequeo de salud", + "healthCheckPathRequired": "Se requiere la ruta del chequeo de salud", + "healthCheckMethodRequired": "Se requiere el método HTTP", + "healthCheckIntervalMin": "El intervalo de comprobación debe ser de al menos 5 segundos", + "healthCheckTimeoutMin": "El tiempo de espera debe ser de al menos 1 segundo", + "healthCheckRetryMin": "Los intentos de reintento deben ser de al menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Seleccionar método HTTP", "domainPickerSubdomainLabel": "Subdominio", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Ingrese un subdominio para buscar y seleccionar entre dominios gratuitos disponibles.", "domainPickerFreeDomains": "Dominios gratuitos", "domainPickerSearchForAvailableDomains": "Buscar dominios disponibles", + "domainPickerNotWorkSelfHosted": "Nota: Los dominios gratuitos proporcionados no están disponibles para instancias autogestionadas por ahora.", "resourceDomain": "Dominio", "resourceEditDomain": "Editar dominio", "siteName": "Nombre del sitio", @@ -1463,6 +1543,72 @@ "autoLoginError": "Error de inicio de sesión automático", "autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.", "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.", + "remoteExitNodeManageRemoteExitNodes": "Administrar Nodos Autogestionados", + "remoteExitNodeDescription": "Administrar nodos para extender la conectividad de red", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Buscar nodos...", + "remoteExitNodeAdd": "Añadir Nodo", + "remoteExitNodeErrorDelete": "Error al eliminar el nodo", + "remoteExitNodeQuestionRemove": "¿Está seguro de que desea eliminar el nodo {selectedNode} de la organización?", + "remoteExitNodeMessageRemove": "Una vez eliminado, el nodo ya no será accesible.", + "remoteExitNodeMessageConfirm": "Para confirmar, por favor escriba el nombre del nodo a continuación.", + "remoteExitNodeConfirmDelete": "Confirmar eliminar nodo", + "remoteExitNodeDelete": "Eliminar Nodo", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Crear Nodo", + "description": "Crear un nuevo nodo para extender la conectividad de red", + "viewAllButton": "Ver todos los nodos", + "strategy": { + "title": "Estrategia de Creación", + "description": "Elija esto para configurar manualmente su nodo o generar nuevas credenciales.", + "adopt": { + "title": "Adoptar Nodo", + "description": "Elija esto si ya tiene las credenciales para el nodo." + }, + "generate": { + "title": "Generar Claves", + "description": "Elija esto si desea generar nuevas claves para el nodo" + } + }, + "adopt": { + "title": "Adoptar Nodo Existente", + "description": "Introduzca las credenciales del nodo existente que desea adoptar", + "nodeIdLabel": "ID del nodo", + "nodeIdDescription": "El ID del nodo existente que desea adoptar", + "secretLabel": "Secreto", + "secretDescription": "La clave secreta del nodo existente", + "submitButton": "Adoptar Nodo" + }, + "generate": { + "title": "Credenciales Generadas", + "description": "Utilice estas credenciales generadas para configurar su nodo", + "nodeIdTitle": "ID del nodo", + "secretTitle": "Secreto", + "saveCredentialsTitle": "Agregar Credenciales a la Configuración", + "saveCredentialsDescription": "Agrega estas credenciales a tu archivo de configuración del nodo Pangolin autogestionado para completar la conexión.", + "submitButton": "Crear Nodo" + }, + "validation": { + "adoptRequired": "El ID del nodo y el secreto son necesarios al adoptar un nodo existente" + }, + "errors": { + "loadDefaultsFailed": "Falló al cargar los valores predeterminados", + "defaultsNotLoaded": "Valores predeterminados no cargados", + "createFailed": "Error al crear el nodo" + }, + "success": { + "created": "Nodo creado correctamente" + } + }, + "remoteExitNodeSelection": "Selección de nodo", + "remoteExitNodeSelectionDescription": "Seleccione un nodo a través del cual enrutar el tráfico para este sitio local", + "remoteExitNodeRequired": "Un nodo debe ser seleccionado para sitios locales", + "noRemoteExitNodesAvailable": "No hay nodos disponibles", + "noRemoteExitNodesAvailableDescription": "No hay nodos disponibles para esta organización. Crea un nodo primero para usar sitios locales.", + "exitNode": "Nodo de Salida", + "country": "País", + "rulesMatchCountry": "Actualmente basado en IP de origen", "managedSelfHosted": { "title": "Autogestionado", "description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Dominio Internacional detectado", "willbestoredas": "Se almacenará como:", + "roleMappingDescription": "Determinar cómo se asignan los roles a los usuarios cuando se registran cuando está habilitada la provisión automática.", + "selectRole": "Seleccione un rol", + "roleMappingExpression": "Expresión", + "selectRolePlaceholder": "Elija un rol", + "selectRoleDescription": "Seleccione un rol para asignar a todos los usuarios de este proveedor de identidad", + "roleMappingExpressionDescription": "Introduzca una expresión JMESPath para extraer información de rol del token de ID", + "idpTenantIdRequired": "El ID del cliente es obligatorio", + "invalidValue": "Valor inválido", + "idpTypeLabel": "Tipo de proveedor de identidad", + "roleMappingExpressionPlaceholder": "e.g., contiene(grupos, 'administrador') && 'administrador' || 'miembro'", + "idpGoogleConfiguration": "Configuración de Google", + "idpGoogleConfigurationDescription": "Configura tus credenciales de Google OAuth2", + "idpGoogleClientIdDescription": "Tu ID de cliente de Google OAuth2", + "idpGoogleClientSecretDescription": "Tu secreto de cliente de Google OAuth2", + "idpAzureConfiguration": "Configuración de Azure Entra ID", + "idpAzureConfigurationDescription": "Configure sus credenciales de Azure Entra ID OAuth2", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "su-inquilino-id", + "idpAzureTenantIdDescription": "Su ID de inquilino de Azure (encontrado en el resumen de Azure Active Directory)", + "idpAzureClientIdDescription": "Tu ID de Cliente de Registro de Azure App", + "idpAzureClientSecretDescription": "Tu Azure App Registro Cliente secreto", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Configuración de Google", + "idpAzureConfigurationTitle": "Configuración de Azure Entra ID", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Tu ID de Cliente de Registro de Azure App", + "idpAzureClientSecretDescription2": "Tu Azure App Registro Cliente secreto", "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Cabeceras personalizadas", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.", + "subnet": "Subred", + "subnetDescription": "La subred para la configuración de red de esta organización.", + "authPage": "Página Auth", + "authPageDescription": "Configurar la página de autenticación de su organización", + "authPageDomain": "Auth Page Domain", + "noDomainSet": "Ningún dominio establecido", + "changeDomain": "Cambiar dominio", + "selectDomain": "Seleccionar dominio", + "restartCertificate": "Reiniciar certificado", + "editAuthPageDomain": "Editar dominio Auth Page", + "setAuthPageDomain": "Establecer dominio Auth Page", + "failedToFetchCertificate": "Error al obtener el certificado", + "failedToRestartCertificate": "Error al reiniciar el certificado", + "addDomainToEnableCustomAuthPages": "Añadir un dominio para habilitar páginas de autenticación personalizadas para su organización", + "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", "domainPickerVerified": "Verificado", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.", "domainPickerSubdomainSanitized": "Subdominio saneado", "domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"", + "orgAuthSignInTitle": "Inicia sesión en tu organización", + "orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", + "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", + "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", + "subscriptionRequiredToUse": "Se requiere una suscripción para utilizar esta función.", + "idpDisabled": "Los proveedores de identidad están deshabilitados.", + "orgAuthPageDisabled": "La página de autenticación de la organización está deshabilitada.", + "domainRestartedDescription": "Verificación de dominio reiniciada con éxito", "resourceAddEntrypointsEditFile": "Editar archivo: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Editar archivo: docker-compose.yml", "emailVerificationRequired": "Se requiere verificación de correo electrónico. Por favor, inicie sesión de nuevo a través de {dashboardUrl}/auth/login complete este paso. Luego, vuelva aquí.", - "twoFactorSetupRequired": "La configuración de autenticación de doble factor es requerida. Por favor, inicia sesión de nuevo a través de {dashboardUrl}/auth/login completa este paso. Luego, vuelve aquí.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "La configuración de autenticación de doble factor es requerida. Por favor, inicia sesión de nuevo a través de {dashboardUrl}/auth/login completa este paso. Luego, vuelve aquí." } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 3fd3e2df..00da9036 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -168,6 +168,9 @@ "siteSelect": "Sélectionner un site", "siteSearch": "Chercher un site", "siteNotFound": "Aucun site trouvé.", + "selectCountry": "Sélectionnez un pays", + "searchCountries": "Recherchez des pays...", + "noCountryFound": "Aucun pays trouvé.", "siteSelectionDescription": "Ce site fournira la connectivité à la cible.", "resourceType": "Type de ressource", "resourceTypeDescription": "Déterminer comment vous voulez accéder à votre ressource", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Connecté", "idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.", "idpErrorNotFound": "IdP introuvable", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Invitation invalide", "inviteInvalidDescription": "Le lien d'invitation n'est pas valide.", "inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Sous-domaine : {subdomain}", "domainPickerNamespace": "Espace de noms : {namespace}", "domainPickerShowMore": "Afficher plus", + "regionSelectorTitle": "Sélectionner Région", + "regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.", + "regionSelectorPlaceholder": "Choisissez une région", + "regionSelectorComingSoon": "Bientôt disponible", + "billingLoadingSubscription": "Chargement de l'abonnement...", + "billingFreeTier": "Niveau gratuit", + "billingWarningOverLimit": "Attention : Vous avez dépassé une ou plusieurs limites d'utilisation. Vos sites ne se connecteront pas tant que vous n'avez pas modifié votre abonnement ou ajusté votre utilisation.", + "billingUsageLimitsOverview": "Vue d'ensemble des limites d'utilisation", + "billingMonitorUsage": "Surveillez votre consommation par rapport aux limites configurées. Si vous avez besoin d'une augmentation des limites, veuillez nous contacter à support@fossorial.io.", + "billingDataUsage": "Utilisation des données", + "billingOnlineTime": "Temps en ligne du site", + "billingUsers": "Utilisateurs actifs", + "billingDomains": "Domaines actifs", + "billingRemoteExitNodes": "Nœuds auto-hébergés actifs", + "billingNoLimitConfigured": "Aucune limite configurée", + "billingEstimatedPeriod": "Période de facturation estimée", + "billingIncludedUsage": "Utilisation incluse", + "billingIncludedUsageDescription": "Utilisation incluse dans votre plan d'abonnement actuel", + "billingFreeTierIncludedUsage": "Tolérances d'utilisation du niveau gratuit", + "billingIncluded": "inclus", + "billingEstimatedTotal": "Total estimé :", + "billingNotes": "Notes", + "billingEstimateNote": "Ceci est une estimation basée sur votre utilisation actuelle.", + "billingActualChargesMayVary": "Les frais réels peuvent varier.", + "billingBilledAtEnd": "Vous serez facturé à la fin de la période de facturation.", + "billingModifySubscription": "Modifier l'abonnement", + "billingStartSubscription": "Démarrer l'abonnement", + "billingRecurringCharge": "Frais récurrents", + "billingManageSubscriptionSettings": "Gérez les paramètres et préférences de votre abonnement", + "billingNoActiveSubscription": "Vous n'avez pas d'abonnement actif. Commencez votre abonnement pour augmenter les limites d'utilisation.", + "billingFailedToLoadSubscription": "Échec du chargement de l'abonnement", + "billingFailedToLoadUsage": "Échec du chargement de l'utilisation", + "billingFailedToGetCheckoutUrl": "Échec pour obtenir l'URL de paiement", + "billingPleaseTryAgainLater": "Veuillez réessayer plus tard.", + "billingCheckoutError": "Erreur de paiement", + "billingFailedToGetPortalUrl": "Échec pour obtenir l'URL du portail", + "billingPortalError": "Erreur du portail", + "billingDataUsageInfo": "Vous êtes facturé pour toutes les données transférées via vos tunnels sécurisés lorsque vous êtes connecté au cloud. Cela inclut le trafic entrant et sortant sur tous vos sites. Lorsque vous atteignez votre limite, vos sites se déconnecteront jusqu'à ce que vous mettiez à niveau votre plan ou réduisiez l'utilisation. Les données ne sont pas facturées lors de l'utilisation de nœuds.", + "billingOnlineTimeInfo": "Vous êtes facturé en fonction de la durée de connexion de vos sites au cloud. Par exemple, 44 640 minutes équivaut à un site fonctionnant 24/7 pendant un mois complet. Lorsque vous atteignez votre limite, vos sites se déconnecteront jusqu'à ce que vous mettiez à niveau votre forfait ou réduisiez votre consommation. Le temps n'est pas facturé lors de l'utilisation de nœuds.", + "billingUsersInfo": "Vous êtes facturé pour chaque utilisateur dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de comptes utilisateurs actifs dans votre organisation.", + "billingDomainInfo": "Vous êtes facturé pour chaque domaine dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de comptes de domaine actifs dans votre organisation.", + "billingRemoteExitNodesInfo": "Vous êtes facturé pour chaque nœud géré dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de nœuds gérés actifs dans votre organisation.", "domainNotFound": "Domaine introuvable", "domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.", "failed": "Échec", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.", "resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP", "resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP", + "billingPricingCalculatorLink": "Calculateur de prix", "signUpTerms": { "IAgreeToThe": "Je suis d'accord avec", "termsOfService": "les conditions d'utilisation", @@ -1368,6 +1412,41 @@ "addNewTarget": "Ajouter une nouvelle cible", "targetsList": "Liste des cibles", "targetErrorDuplicateTargetFound": "Cible en double trouvée", + "healthCheckHealthy": "Sain", + "healthCheckUnhealthy": "En mauvaise santé", + "healthCheckUnknown": "Inconnu", + "healthCheck": "Vérification de l'état de santé", + "configureHealthCheck": "Configurer la vérification de l'état de santé", + "configureHealthCheckDescription": "Configurer la surveillance de la santé pour {target}", + "enableHealthChecks": "Activer les vérifications de santé", + "enableHealthChecksDescription": "Surveiller la vie de cette cible. Vous pouvez surveiller un point de terminaison différent de la cible si nécessaire.", + "healthScheme": "Méthode", + "healthSelectScheme": "Sélectionnez la méthode", + "healthCheckPath": "Chemin d'accès", + "healthHostname": "IP / Hôte", + "healthPort": "Port", + "healthCheckPathDescription": "Le chemin à vérifier pour le statut de santé.", + "healthyIntervalSeconds": "Intervalle sain", + "unhealthyIntervalSeconds": "Intervalle en mauvaise santé", + "IntervalSeconds": "Intervalle sain", + "timeoutSeconds": "Délai", + "timeIsInSeconds": "Le temps est exprimé en secondes", + "retryAttempts": "Tentatives de réessai", + "expectedResponseCodes": "Codes de réponse attendus", + "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", + "customHeaders": "En-têtes personnalisés", + "customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur", + "headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.", + "saveHealthCheck": "Sauvegarder la vérification de l'état de santé", + "healthCheckSaved": "Vérification de l'état de santé enregistrée", + "healthCheckSavedDescription": "La configuration de la vérification de l'état de santé a été enregistrée avec succès", + "healthCheckError": "Erreur de vérification de l'état de santé", + "healthCheckErrorDescription": "Une erreur s'est produite lors de l'enregistrement de la configuration de la vérification de l'état de santé", + "healthCheckPathRequired": "Le chemin de vérification de l'état de santé est requis", + "healthCheckMethodRequired": "La méthode HTTP est requise", + "healthCheckIntervalMin": "L'intervalle de vérification doit être d'au moins 5 secondes", + "healthCheckTimeoutMin": "Le délai doit être d'au moins 1 seconde", + "healthCheckRetryMin": "Les tentatives de réessai doivent être d'au moins 1", "httpMethod": "Méthode HTTP", "selectHttpMethod": "Sélectionnez la méthode HTTP", "domainPickerSubdomainLabel": "Sous-domaine", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Entrez un sous-domaine pour rechercher et sélectionner parmi les domaines gratuits disponibles.", "domainPickerFreeDomains": "Domaines gratuits", "domainPickerSearchForAvailableDomains": "Rechercher des domaines disponibles", + "domainPickerNotWorkSelfHosted": "Remarque : Les domaines fournis gratuitement ne sont pas disponibles pour les instances auto-hébergées pour le moment.", "resourceDomain": "Domaine", "resourceEditDomain": "Modifier le domaine", "siteName": "Nom du site", @@ -1463,6 +1543,72 @@ "autoLoginError": "Erreur de connexion automatique", "autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.", "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.", + "remoteExitNodeManageRemoteExitNodes": "Gérer auto-hébergé", + "remoteExitNodeDescription": "Gérer les nœuds pour étendre votre connectivité réseau", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Rechercher des nœuds...", + "remoteExitNodeAdd": "Ajouter un noeud", + "remoteExitNodeErrorDelete": "Erreur lors de la suppression du noeud", + "remoteExitNodeQuestionRemove": "Êtes-vous sûr de vouloir supprimer le noeud {selectedNode} de l'organisation ?", + "remoteExitNodeMessageRemove": "Une fois supprimé, le noeud ne sera plus accessible.", + "remoteExitNodeMessageConfirm": "Pour confirmer, veuillez saisir le nom du noeud ci-dessous.", + "remoteExitNodeConfirmDelete": "Confirmer la suppression du noeud", + "remoteExitNodeDelete": "Supprimer le noeud", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Créer un noeud", + "description": "Créer un nouveau nœud pour étendre votre connectivité réseau", + "viewAllButton": "Voir tous les nœuds", + "strategy": { + "title": "Stratégie de création", + "description": "Choisissez ceci pour configurer manuellement votre nœud ou générer de nouveaux identifiants.", + "adopt": { + "title": "Adopter un nœud", + "description": "Choisissez ceci si vous avez déjà les identifiants pour le noeud." + }, + "generate": { + "title": "Générer des clés", + "description": "Choisissez ceci si vous voulez générer de nouvelles clés pour le noeud" + } + }, + "adopt": { + "title": "Adopter un nœud existant", + "description": "Entrez les identifiants du noeud existant que vous souhaitez adopter", + "nodeIdLabel": "Nœud ID", + "nodeIdDescription": "L'ID du noeud existant que vous voulez adopter", + "secretLabel": "Secret", + "secretDescription": "La clé secrète du noeud existant", + "submitButton": "Noeud d'Adopt" + }, + "generate": { + "title": "Informations d'identification générées", + "description": "Utilisez ces identifiants générés pour configurer votre noeud", + "nodeIdTitle": "Nœud ID", + "secretTitle": "Secret", + "saveCredentialsTitle": "Ajouter des identifiants à la config", + "saveCredentialsDescription": "Ajoutez ces informations d'identification à votre fichier de configuration du nœud Pangolin auto-hébergé pour compléter la connexion.", + "submitButton": "Créer un noeud" + }, + "validation": { + "adoptRequired": "ID de nœud et secret sont requis lors de l'adoption d'un noeud existant" + }, + "errors": { + "loadDefaultsFailed": "Échec du chargement des valeurs par défaut", + "defaultsNotLoaded": "Valeurs par défaut non chargées", + "createFailed": "Impossible de créer le noeud" + }, + "success": { + "created": "Noeud créé avec succès" + } + }, + "remoteExitNodeSelection": "Sélection du noeud", + "remoteExitNodeSelectionDescription": "Sélectionnez un nœud pour acheminer le trafic pour ce site local", + "remoteExitNodeRequired": "Un noeud doit être sélectionné pour les sites locaux", + "noRemoteExitNodesAvailable": "Aucun noeud disponible", + "noRemoteExitNodesAvailableDescription": "Aucun noeud n'est disponible pour cette organisation. Créez d'abord un noeud pour utiliser des sites locaux.", + "exitNode": "Nœud de sortie", + "country": "Pays", + "rulesMatchCountry": "Actuellement basé sur l'IP source", "managedSelfHosted": { "title": "Gestion autonome", "description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Domaine international détecté", "willbestoredas": "Sera stocké comme :", + "roleMappingDescription": "Détermine comment les rôles sont assignés aux utilisateurs lorsqu'ils se connectent lorsque la fourniture automatique est activée.", + "selectRole": "Sélectionnez un rôle", + "roleMappingExpression": "Expression", + "selectRolePlaceholder": "Choisir un rôle", + "selectRoleDescription": "Sélectionnez un rôle à assigner à tous les utilisateurs de ce fournisseur d'identité", + "roleMappingExpressionDescription": "Entrez une expression JMESPath pour extraire les informations du rôle du jeton ID", + "idpTenantIdRequired": "L'ID du locataire est requis", + "invalidValue": "Valeur non valide", + "idpTypeLabel": "Type de fournisseur d'identité", + "roleMappingExpressionPlaceholder": "ex: contenu(groupes) && 'admin' || 'membre'", + "idpGoogleConfiguration": "Configuration Google", + "idpGoogleConfigurationDescription": "Configurer vos identifiants Google OAuth2", + "idpGoogleClientIdDescription": "Votre identifiant client Google OAuth2", + "idpGoogleClientSecretDescription": "Votre secret client Google OAuth2", + "idpAzureConfiguration": "Configuration de l'entra ID Azure", + "idpAzureConfigurationDescription": "Configurer vos identifiants OAuth2 Azure Entra", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "votre-locataire-id", + "idpAzureTenantIdDescription": "Votre ID de locataire Azure (trouvé dans l'aperçu Azure Active Directory)", + "idpAzureClientIdDescription": "Votre ID client d'enregistrement de l'application Azure", + "idpAzureClientSecretDescription": "Le secret de votre client d'enregistrement Azure App", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Configuration Google", + "idpAzureConfigurationTitle": "Configuration de l'entra ID Azure", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Votre ID client d'enregistrement de l'application Azure", + "idpAzureClientSecretDescription2": "Le secret de votre client d'enregistrement Azure App", "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "En-têtes personnalisés", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.", + "subnet": "Sous-réseau", + "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", + "authPage": "Page d'authentification", + "authPageDescription": "Configurer la page d'authentification de votre organisation", + "authPageDomain": "Domaine de la page d'authentification", + "noDomainSet": "Aucun domaine défini", + "changeDomain": "Changer de domaine", + "selectDomain": "Sélectionner un domaine", + "restartCertificate": "Redémarrer le certificat", + "editAuthPageDomain": "Modifier le domaine de la page d'authentification", + "setAuthPageDomain": "Définir le domaine de la page d'authentification", + "failedToFetchCertificate": "Impossible de récupérer le certificat", + "failedToRestartCertificate": "Échec du redémarrage du certificat", + "addDomainToEnableCustomAuthPages": "Ajouter un domaine pour activer les pages d'authentification personnalisées pour votre organisation", + "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "domainPickerProvidedDomain": "Domaine fourni", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", "domainPickerVerified": "Vérifié", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.", "domainPickerSubdomainSanitized": "Sous-domaine nettoyé", "domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"", + "orgAuthSignInTitle": "Connectez-vous à votre organisation", + "orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer", + "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", + "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", + "subscriptionRequiredToUse": "Un abonnement est requis pour utiliser cette fonctionnalité.", + "idpDisabled": "Les fournisseurs d'identité sont désactivés.", + "orgAuthPageDisabled": "La page d'authentification de l'organisation est désactivée.", + "domainRestartedDescription": "La vérification du domaine a été redémarrée avec succès", "resourceAddEntrypointsEditFile": "Modifier le fichier : config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml", "emailVerificationRequired": "La vérification de l'e-mail est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", - "twoFactorSetupRequired": "La configuration d'authentification à deux facteurs est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "La configuration d'authentification à deux facteurs est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici." } diff --git a/messages/it-IT.json b/messages/it-IT.json index 683e3fad..10286f7d 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -36,8 +36,8 @@ "viewSettings": "Visualizza impostazioni", "delete": "Elimina", "name": "Nome", - "online": "Online", - "offline": "Offline", + "online": "In linea", + "offline": "Non in linea", "site": "Sito", "dataIn": "Dati In", "dataOut": "Dati Fuori", @@ -168,6 +168,9 @@ "siteSelect": "Seleziona sito", "siteSearch": "Cerca sito", "siteNotFound": "Nessun sito trovato.", + "selectCountry": "Seleziona paese", + "searchCountries": "Cerca paesi...", + "noCountryFound": "Nessun paese trovato.", "siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.", "resourceType": "Tipo Di Risorsa", "resourceTypeDescription": "Determina come vuoi accedere alla tua risorsa", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Connesso", "idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.", "idpErrorNotFound": "IdP non trovato", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Invito Non Valido", "inviteInvalidDescription": "Il link di invito non è valido.", "inviteErrorWrongUser": "L'invito non è per questo utente", @@ -1220,7 +1221,7 @@ "orgBillingDescription": "Gestisci le tue informazioni di fatturazione e abbonamenti", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", - "fossorial": "Fossorial", + "fossorial": "Fossoriale", "completeAccountSetup": "Completa la Configurazione dell'Account", "completeAccountSetupDescription": "Imposta la tua password per iniziare", "accountSetupSent": "Invieremo un codice di configurazione dell'account a questo indirizzo email.", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Sottodominio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostra Altro", + "regionSelectorTitle": "Seleziona regione", + "regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.", + "regionSelectorPlaceholder": "Scegli una regione", + "regionSelectorComingSoon": "Prossimamente", + "billingLoadingSubscription": "Caricamento abbonamento...", + "billingFreeTier": "Piano Gratuito", + "billingWarningOverLimit": "Avviso: Hai superato uno o più limiti di utilizzo. I tuoi siti non si connetteranno finché non modifichi il tuo abbonamento o non adegui il tuo utilizzo.", + "billingUsageLimitsOverview": "Panoramica dei Limiti di Utilizzo", + "billingMonitorUsage": "Monitora il tuo utilizzo rispetto ai limiti configurati. Se hai bisogno di aumentare i limiti, contattaci all'indirizzo support@fossorial.io.", + "billingDataUsage": "Utilizzo dei Dati", + "billingOnlineTime": "Tempo Online del Sito", + "billingUsers": "Utenti Attivi", + "billingDomains": "Domini Attivi", + "billingRemoteExitNodes": "Nodi Self-hosted Attivi", + "billingNoLimitConfigured": "Nessun limite configurato", + "billingEstimatedPeriod": "Periodo di Fatturazione Stimato", + "billingIncludedUsage": "Utilizzo Incluso", + "billingIncludedUsageDescription": "Utilizzo incluso nel tuo piano di abbonamento corrente", + "billingFreeTierIncludedUsage": "Elenchi di utilizzi inclusi nel piano gratuito", + "billingIncluded": "incluso", + "billingEstimatedTotal": "Totale Stimato:", + "billingNotes": "Note", + "billingEstimateNote": "Questa è una stima basata sul tuo utilizzo attuale.", + "billingActualChargesMayVary": "I costi effettivi possono variare.", + "billingBilledAtEnd": "Sarai fatturato alla fine del periodo di fatturazione.", + "billingModifySubscription": "Modifica Abbonamento", + "billingStartSubscription": "Inizia Abbonamento", + "billingRecurringCharge": "Addebito Ricorrente", + "billingManageSubscriptionSettings": "Gestisci impostazioni e preferenze dell'abbonamento", + "billingNoActiveSubscription": "Non hai un abbonamento attivo. Avvia il tuo abbonamento per aumentare i limiti di utilizzo.", + "billingFailedToLoadSubscription": "Caricamento abbonamento fallito", + "billingFailedToLoadUsage": "Caricamento utilizzo fallito", + "billingFailedToGetCheckoutUrl": "Errore durante l'ottenimento dell'URL di pagamento", + "billingPleaseTryAgainLater": "Per favore, riprova più tardi.", + "billingCheckoutError": "Errore di Pagamento", + "billingFailedToGetPortalUrl": "Errore durante l'ottenimento dell'URL del portale", + "billingPortalError": "Errore del Portale", + "billingDataUsageInfo": "Hai addebitato tutti i dati trasferiti attraverso i tunnel sicuri quando sei connesso al cloud. Questo include sia il traffico in entrata e in uscita attraverso tutti i siti. Quando si raggiunge il limite, i siti si disconnetteranno fino a quando non si aggiorna il piano o si riduce l'utilizzo. I dati non vengono caricati quando si utilizzano nodi.", + "billingOnlineTimeInfo": "Ti viene addebitato in base al tempo in cui i tuoi siti rimangono connessi al cloud. Ad esempio, 44,640 minuti è uguale a un sito in esecuzione 24/7 per un mese intero. Quando raggiungi il tuo limite, i tuoi siti si disconnetteranno fino a quando non aggiorni il tuo piano o riduci l'utilizzo. Il tempo non viene caricato quando si usano i nodi.", + "billingUsersInfo": "Sei addebitato per ogni utente nella tua organizzazione. La fatturazione viene calcolata giornalmente in base al numero di account utente attivi nella tua organizzazione.", + "billingDomainInfo": "Sei addebitato per ogni dominio nella tua organizzazione. La fatturazione viene calcolata giornalmente in base al numero di account dominio attivi nella tua organizzazione.", + "billingRemoteExitNodesInfo": "Sei addebitato per ogni nodo gestito nella tua organizzazione. La fatturazione viene calcolata giornalmente in base al numero di nodi gestiti attivi nella tua organizzazione.", "domainNotFound": "Domini Non Trovati", "domainNotFoundDescription": "Questa risorsa è disabilitata perché il dominio non esiste più nel nostro sistema. Si prega di impostare un nuovo dominio per questa risorsa.", "failed": "Fallito", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Le modifiche DNS possono richiedere del tempo per propagarsi in Internet. Questo può richiedere da pochi minuti a 48 ore, a seconda del tuo provider DNS e delle impostazioni TTL.", "resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP", "resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP", + "billingPricingCalculatorLink": "Calcolatore di Prezzi", "signUpTerms": { "IAgreeToThe": "Accetto i", "termsOfService": "termini di servizio", @@ -1327,7 +1371,7 @@ "privacyPolicy": "informativa sulla privacy" }, "siteRequired": "Il sito è richiesto.", - "olmTunnel": "Olm Tunnel", + "olmTunnel": "Tunnel Olm", "olmTunnelDescription": "Usa Olm per la connettività client", "errorCreatingClient": "Errore nella creazione del client", "clientDefaultsNotFound": "Impostazioni predefinite del client non trovate", @@ -1368,6 +1412,41 @@ "addNewTarget": "Aggiungi Nuovo Target", "targetsList": "Elenco dei Target", "targetErrorDuplicateTargetFound": "Target duplicato trovato", + "healthCheckHealthy": "Sano", + "healthCheckUnhealthy": "Non Sano", + "healthCheckUnknown": "Sconosciuto", + "healthCheck": "Controllo Salute", + "configureHealthCheck": "Configura Controllo Salute", + "configureHealthCheckDescription": "Imposta il monitoraggio della salute per {target}", + "enableHealthChecks": "Abilita i Controlli di Salute", + "enableHealthChecksDescription": "Monitorare lo stato di salute di questo obiettivo. Se necessario, è possibile monitorare un endpoint diverso da quello del bersaglio.", + "healthScheme": "Metodo", + "healthSelectScheme": "Seleziona Metodo", + "healthCheckPath": "Percorso", + "healthHostname": "IP / Host", + "healthPort": "Porta", + "healthCheckPathDescription": "Percorso per verificare lo stato di salute.", + "healthyIntervalSeconds": "Intervallo Sano", + "unhealthyIntervalSeconds": "Intervallo Non Sano", + "IntervalSeconds": "Intervallo Sano", + "timeoutSeconds": "Timeout", + "timeIsInSeconds": "Il tempo è in secondi", + "retryAttempts": "Tentativi di Riprova", + "expectedResponseCodes": "Codici di Risposta Attesi", + "expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.", + "customHeaders": "Intestazioni Personalizzate", + "customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore", + "headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.", + "saveHealthCheck": "Salva Controllo Salute", + "healthCheckSaved": "Controllo Salute Salvato", + "healthCheckSavedDescription": "La configurazione del controllo salute è stata salvata con successo", + "healthCheckError": "Errore Controllo Salute", + "healthCheckErrorDescription": "Si è verificato un errore durante il salvataggio della configurazione del controllo salute.", + "healthCheckPathRequired": "Il percorso del controllo salute è richiesto", + "healthCheckMethodRequired": "Metodo HTTP richiesto", + "healthCheckIntervalMin": "L'intervallo del controllo deve essere almeno di 5 secondi", + "healthCheckTimeoutMin": "Il timeout deve essere di almeno 1 secondo", + "healthCheckRetryMin": "I tentativi di riprova devono essere almeno 1", "httpMethod": "Metodo HTTP", "selectHttpMethod": "Seleziona metodo HTTP", "domainPickerSubdomainLabel": "Sottodominio", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Inserisci un sottodominio per cercare e selezionare dai domini gratuiti disponibili.", "domainPickerFreeDomains": "Domini Gratuiti", "domainPickerSearchForAvailableDomains": "Cerca domini disponibili", + "domainPickerNotWorkSelfHosted": "Nota: I domini forniti gratuitamente non sono disponibili per le istanze self-hosted al momento.", "resourceDomain": "Dominio", "resourceEditDomain": "Modifica Dominio", "siteName": "Nome del Sito", @@ -1463,6 +1543,72 @@ "autoLoginError": "Errore di Accesso Automatico", "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.", + "remoteExitNodeManageRemoteExitNodes": "Gestisci Self-Hosted", + "remoteExitNodeDescription": "Gestisci i nodi per estendere la connettività di rete", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Cerca nodi...", + "remoteExitNodeAdd": "Aggiungi Nodo", + "remoteExitNodeErrorDelete": "Errore nell'eliminare il nodo", + "remoteExitNodeQuestionRemove": "Sei sicuro di voler rimuovere il nodo {selectedNode} dall'organizzazione?", + "remoteExitNodeMessageRemove": "Una volta rimosso, il nodo non sarà più accessibile.", + "remoteExitNodeMessageConfirm": "Per confermare, digita il nome del nodo qui sotto.", + "remoteExitNodeConfirmDelete": "Conferma Eliminazione Nodo", + "remoteExitNodeDelete": "Elimina Nodo", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Crea Nodo", + "description": "Crea un nuovo nodo per estendere la connettività di rete", + "viewAllButton": "Visualizza Tutti I Nodi", + "strategy": { + "title": "Strategia di Creazione", + "description": "Scegli questa opzione per configurare manualmente il nodo o generare nuove credenziali.", + "adopt": { + "title": "Adotta Nodo", + "description": "Scegli questo se hai già le credenziali per il nodo." + }, + "generate": { + "title": "Genera Chiavi", + "description": "Scegli questa opzione se vuoi generare nuove chiavi per il nodo" + } + }, + "adopt": { + "title": "Adotta Nodo Esistente", + "description": "Inserisci le credenziali del nodo esistente che vuoi adottare", + "nodeIdLabel": "ID Nodo", + "nodeIdDescription": "L'ID del nodo esistente che si desidera adottare", + "secretLabel": "Segreto", + "secretDescription": "La chiave segreta del nodo esistente", + "submitButton": "Adotta Nodo" + }, + "generate": { + "title": "Credenziali Generate", + "description": "Usa queste credenziali generate per configurare il nodo", + "nodeIdTitle": "ID Nodo", + "secretTitle": "Segreto", + "saveCredentialsTitle": "Aggiungi Credenziali alla Configurazione", + "saveCredentialsDescription": "Aggiungi queste credenziali al tuo file di configurazione del nodo self-hosted Pangolin per completare la connessione.", + "submitButton": "Crea Nodo" + }, + "validation": { + "adoptRequired": "L'ID del nodo e il segreto sono necessari quando si adotta un nodo esistente" + }, + "errors": { + "loadDefaultsFailed": "Caricamento impostazioni predefinite fallito", + "defaultsNotLoaded": "Impostazioni predefinite non caricate", + "createFailed": "Impossibile creare il nodo" + }, + "success": { + "created": "Nodo creato con successo" + } + }, + "remoteExitNodeSelection": "Selezione Nodo", + "remoteExitNodeSelectionDescription": "Seleziona un nodo per instradare il traffico per questo sito locale", + "remoteExitNodeRequired": "Un nodo deve essere selezionato per i siti locali", + "noRemoteExitNodesAvailable": "Nessun Nodo Disponibile", + "noRemoteExitNodesAvailableDescription": "Non ci sono nodi disponibili per questa organizzazione. Crea un nodo prima per usare i siti locali.", + "exitNode": "Nodo di Uscita", + "country": "Paese", + "rulesMatchCountry": "Attualmente basato sull'IP di origine", "managedSelfHosted": { "title": "Gestito Auto-Ospitato", "description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Dominio Internazionale Rilevato", "willbestoredas": "Verrà conservato come:", + "roleMappingDescription": "Determinare come i ruoli sono assegnati agli utenti quando accedono quando è abilitata la fornitura automatica.", + "selectRole": "Seleziona un ruolo", + "roleMappingExpression": "Espressione", + "selectRolePlaceholder": "Scegli un ruolo", + "selectRoleDescription": "Seleziona un ruolo da assegnare a tutti gli utenti da questo provider di identità", + "roleMappingExpressionDescription": "Inserire un'espressione JMESPath per estrarre le informazioni sul ruolo dal token ID", + "idpTenantIdRequired": "L'ID dell'inquilino è obbligatorio", + "invalidValue": "Valore non valido", + "idpTypeLabel": "Tipo Provider Identità", + "roleMappingExpressionPlaceholder": "es. contiene(gruppi, 'admin') && 'Admin' 'Membro'", + "idpGoogleConfiguration": "Configurazione Google", + "idpGoogleConfigurationDescription": "Configura le tue credenziali di Google OAuth2", + "idpGoogleClientIdDescription": "Il Tuo Client Id Google OAuth2", + "idpGoogleClientSecretDescription": "Il Tuo Client Google OAuth2 Secret", + "idpAzureConfiguration": "Configurazione Azure Entra ID", + "idpAzureConfigurationDescription": "Configura le credenziali OAuth2 di Azure Entra ID", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "iltuo-inquilino-id", + "idpAzureTenantIdDescription": "Il tuo ID del tenant Azure (trovato nella panoramica di Azure Active Directory)", + "idpAzureClientIdDescription": "Il Tuo Id Client Registrazione App Azure", + "idpAzureClientSecretDescription": "Il Tuo Client Di Registrazione App Azure Secret", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Configurazione Google", + "idpAzureConfigurationTitle": "Configurazione Azure Entra ID", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Il Tuo Id Client Registrazione App Azure", + "idpAzureClientSecretDescription2": "Il Tuo Client Di Registrazione App Azure Secret", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Intestazioni Personalizzate", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.", + "subnet": "Sottorete", + "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", + "authPage": "Pagina Autenticazione", + "authPageDescription": "Configura la pagina di autenticazione per la tua organizzazione", + "authPageDomain": "Dominio Pagina Auth", + "noDomainSet": "Nessun dominio impostato", + "changeDomain": "Cambia Dominio", + "selectDomain": "Seleziona Dominio", + "restartCertificate": "Riavvia Certificato", + "editAuthPageDomain": "Modifica Dominio Pagina Auth", + "setAuthPageDomain": "Imposta Dominio Pagina Autenticazione", + "failedToFetchCertificate": "Recupero del certificato non riuscito", + "failedToRestartCertificate": "Riavvio del certificato non riuscito", + "addDomainToEnableCustomAuthPages": "Aggiungi un dominio per abilitare le pagine di autenticazione personalizzate per la tua organizzazione", + "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "domainPickerProvidedDomain": "Dominio Fornito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", "domainPickerVerified": "Verificato", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.", "domainPickerSubdomainSanitized": "Sottodominio igienizzato", "domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"", + "orgAuthSignInTitle": "Accedi alla tua organizzazione", + "orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare", + "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", + "orgAuthSignInWithPangolin": "Accedi con Pangolino", + "subscriptionRequiredToUse": "Per utilizzare questa funzionalità è necessario un abbonamento.", + "idpDisabled": "I provider di identità sono disabilitati.", + "orgAuthPageDisabled": "La pagina di autenticazione dell'organizzazione è disabilitata.", + "domainRestartedDescription": "Verifica del dominio riavviata con successo", "resourceAddEntrypointsEditFile": "Modifica file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Modifica file: docker-compose.yml", "emailVerificationRequired": "Verifica via email. Effettua nuovamente il login via {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", - "twoFactorSetupRequired": "È richiesta la configurazione di autenticazione a due fattori. Effettua nuovamente l'accesso tramite {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "È richiesta la configurazione di autenticazione a due fattori. Effettua nuovamente l'accesso tramite {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui." } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 30865f1e..4684eacb 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -168,6 +168,9 @@ "siteSelect": "사이트 선택", "siteSearch": "사이트 검색", "siteNotFound": "사이트를 찾을 수 없습니다.", + "selectCountry": "국가 선택하기", + "searchCountries": "국가 검색...", + "noCountryFound": "국가를 찾을 수 없습니다.", "siteSelectionDescription": "이 사이트는 대상에 대한 연결을 제공합니다.", "resourceType": "리소스 유형", "resourceTypeDescription": "리소스에 접근하는 방법을 결정하세요", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "연결됨", "idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.", "idpErrorNotFound": "IdP를 찾을 수 없습니다.", - "idpGoogleAlt": "구글", - "idpAzureAlt": "애저", "inviteInvalid": "유효하지 않은 초대", "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", "inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "서브도메인: {subdomain}", "domainPickerNamespace": "이름 공간: {namespace}", "domainPickerShowMore": "더보기", + "regionSelectorTitle": "지역 선택", + "regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.", + "regionSelectorPlaceholder": "지역 선택", + "regionSelectorComingSoon": "곧 출시 예정", + "billingLoadingSubscription": "구독 불러오는 중...", + "billingFreeTier": "무료 티어", + "billingWarningOverLimit": "경고: 하나 이상의 사용 한도를 초과했습니다. 구독을 수정하거나 사용량을 조정하기 전까지 사이트는 연결되지 않습니다.", + "billingUsageLimitsOverview": "사용 한도 개요", + "billingMonitorUsage": "설정된 한도에 대한 사용량을 모니터링합니다. 한도를 늘려야 하는 경우 support@fossorial.io로 연락하십시오.", + "billingDataUsage": "데이터 사용량", + "billingOnlineTime": "사이트 온라인 시간", + "billingUsers": "활성 사용자", + "billingDomains": "활성 도메인", + "billingRemoteExitNodes": "활성 자체 호스팅 노드", + "billingNoLimitConfigured": "구성된 한도가 없습니다.", + "billingEstimatedPeriod": "예상 청구 기간", + "billingIncludedUsage": "포함 사용량", + "billingIncludedUsageDescription": "현재 구독 계획에 포함된 사용량", + "billingFreeTierIncludedUsage": "무료 티어 사용 허용량", + "billingIncluded": "포함됨", + "billingEstimatedTotal": "예상 총액:", + "billingNotes": "노트", + "billingEstimateNote": "현재 사용량을 기반으로 한 추정치입니다.", + "billingActualChargesMayVary": "실제 청구 금액은 다를 수 있습니다.", + "billingBilledAtEnd": "청구 기간이 끝난 후 청구됩니다.", + "billingModifySubscription": "구독 수정", + "billingStartSubscription": "구독 시작", + "billingRecurringCharge": "반복 요금", + "billingManageSubscriptionSettings": "구독 설정 및 기본 설정을 관리합니다", + "billingNoActiveSubscription": "활성 구독이 없습니다. 사용 한도를 늘리려면 구독을 시작하십시오.", + "billingFailedToLoadSubscription": "구독을 불러오는 데 실패했습니다.", + "billingFailedToLoadUsage": "사용량을 불러오는 데 실패했습니다.", + "billingFailedToGetCheckoutUrl": "체크아웃 URL을 가져오는 데 실패했습니다.", + "billingPleaseTryAgainLater": "나중에 다시 시도하십시오.", + "billingCheckoutError": "체크아웃 오류", + "billingFailedToGetPortalUrl": "포털 URL을 가져오는 데 실패했습니다.", + "billingPortalError": "포털 오류", + "billingDataUsageInfo": "클라우드에 연결할 때 보안 터널을 통해 전송된 모든 데이터에 대해 비용이 청구됩니다. 여기에는 모든 사이트의 들어오고 나가는 트래픽이 포함됩니다. 사용량 한도에 도달하면 플랜을 업그레이드하거나 사용량을 줄일 때까지 사이트가 연결 해제됩니다. 노드를 사용하는 경우 데이터는 요금이 청구되지 않습니다.", + "billingOnlineTimeInfo": "사이트가 클라우드에 연결된 시간에 따라 요금이 청구됩니다. 예를 들어, 44,640분은 사이트가 한 달 내내 24시간 작동하는 것과 같습니다. 사용량 한도에 도달하면 플랜을 업그레이드하거나 사용량을 줄일 때까지 사이트가 연결 해제됩니다. 노드를 사용할 때 시간은 요금이 청구되지 않습니다.", + "billingUsersInfo": "조직의 사용자마다 요금이 청구됩니다. 청구는 조직의 활성 사용자 계정 수에 따라 매일 계산됩니다.", + "billingDomainInfo": "조직의 도메인마다 요금이 청구됩니다. 청구는 조직의 활성 도메인 계정 수에 따라 매일 계산됩니다.", + "billingRemoteExitNodesInfo": "조직의 관리 노드마다 요금이 청구됩니다. 청구는 조직의 활성 관리 노드 수에 따라 매일 계산됩니다.", "domainNotFound": "도메인을 찾을 수 없습니다", "domainNotFoundDescription": "이 리소스는 도메인이 더 이상 시스템에 존재하지 않아 비활성화되었습니다. 이 리소스에 대한 새 도메인을 설정하세요.", "failed": "실패", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.", "resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다", "resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요", + "billingPricingCalculatorLink": "가격 계산기", "signUpTerms": { "IAgreeToThe": "동의합니다", "termsOfService": "서비스 약관", @@ -1368,6 +1412,41 @@ "addNewTarget": "새 대상 추가", "targetsList": "대상 목록", "targetErrorDuplicateTargetFound": "중복 대상 발견", + "healthCheckHealthy": "정상", + "healthCheckUnhealthy": "비정상", + "healthCheckUnknown": "알 수 없음", + "healthCheck": "상태 확인", + "configureHealthCheck": "상태 확인 설정", + "configureHealthCheckDescription": "{target}에 대한 상태 모니터링 설정", + "enableHealthChecks": "상태 확인 활성화", + "enableHealthChecksDescription": "이 대상을 모니터링하여 건강 상태를 확인하세요. 필요에 따라 대상과 다른 엔드포인트를 모니터링할 수 있습니다.", + "healthScheme": "방법", + "healthSelectScheme": "방법 선택", + "healthCheckPath": "경로", + "healthHostname": "IP / 호스트", + "healthPort": "포트", + "healthCheckPathDescription": "상태 확인을 위한 경로입니다.", + "healthyIntervalSeconds": "정상 간격", + "unhealthyIntervalSeconds": "비정상 간격", + "IntervalSeconds": "정상 간격", + "timeoutSeconds": "시간 초과", + "timeIsInSeconds": "시간은 초 단위입니다", + "retryAttempts": "재시도 횟수", + "expectedResponseCodes": "예상 응답 코드", + "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", + "customHeaders": "사용자 정의 헤더", + "customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", + "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", + "saveHealthCheck": "상태 확인 저장", + "healthCheckSaved": "상태 확인이 저장되었습니다.", + "healthCheckSavedDescription": "상태 확인 구성이 성공적으로 저장되었습니다", + "healthCheckError": "상태 확인 오류", + "healthCheckErrorDescription": "상태 확인 구성을 저장하는 동안 오류가 발생했습니다", + "healthCheckPathRequired": "상태 확인 경로는 필수입니다.", + "healthCheckMethodRequired": "HTTP 방법은 필수입니다.", + "healthCheckIntervalMin": "확인 간격은 최소 5초여야 합니다.", + "healthCheckTimeoutMin": "시간 초과는 최소 1초여야 합니다.", + "healthCheckRetryMin": "재시도 횟수는 최소 1회여야 합니다.", "httpMethod": "HTTP 메소드", "selectHttpMethod": "HTTP 메소드 선택", "domainPickerSubdomainLabel": "서브도메인", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "사용 가능한 무료 도메인에서 검색 및 선택할 서브도메인 입력.", "domainPickerFreeDomains": "무료 도메인", "domainPickerSearchForAvailableDomains": "사용 가능한 도메인 검색", + "domainPickerNotWorkSelfHosted": "참고: 무료 제공 도메인은 현재 자체 호스팅 인스턴스에 사용할 수 없습니다.", "resourceDomain": "도메인", "resourceEditDomain": "도메인 수정", "siteName": "사이트 이름", @@ -1463,6 +1543,72 @@ "autoLoginError": "자동 로그인 오류", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.", + "remoteExitNodeManageRemoteExitNodes": "관리 자체 호스팅", + "remoteExitNodeDescription": "네트워크 연결성을 확장하기 위해 노드를 관리하세요", + "remoteExitNodes": "노드", + "searchRemoteExitNodes": "노드 검색...", + "remoteExitNodeAdd": "노드 추가", + "remoteExitNodeErrorDelete": "노드 삭제 오류", + "remoteExitNodeQuestionRemove": "조직에서 노드 {selectedNode}를 제거하시겠습니까?", + "remoteExitNodeMessageRemove": "한 번 제거되면 더 이상 노드에 접근할 수 없습니다.", + "remoteExitNodeMessageConfirm": "확인을 위해 아래에 노드 이름을 입력해 주세요.", + "remoteExitNodeConfirmDelete": "노드 삭제 확인", + "remoteExitNodeDelete": "노드 삭제", + "sidebarRemoteExitNodes": "노드", + "remoteExitNodeCreate": { + "title": "노드 생성", + "description": "네트워크 연결성을 확장하기 위해 새 노드를 생성하세요", + "viewAllButton": "모든 노드 보기", + "strategy": { + "title": "생성 전략", + "description": "노드를 직접 구성하거나 새 자격 증명을 생성하려면 이것을 선택하세요.", + "adopt": { + "title": "노드 채택", + "description": "이미 노드의 자격 증명이 있는 경우 이것을 선택하세요." + }, + "generate": { + "title": "키 생성", + "description": "노드에 대한 새 키를 생성하려면 이것을 선택하세요" + } + }, + "adopt": { + "title": "기존 노드 채택", + "description": "채택하려는 기존 노드의 자격 증명을 입력하세요", + "nodeIdLabel": "노드 ID", + "nodeIdDescription": "채택하려는 기존 노드의 ID", + "secretLabel": "비밀", + "secretDescription": "기존 노드의 비밀 키", + "submitButton": "노드 채택" + }, + "generate": { + "title": "생성된 자격 증명", + "description": "생성된 자격 증명을 사용하여 노드를 구성하세요", + "nodeIdTitle": "노드 ID", + "secretTitle": "비밀", + "saveCredentialsTitle": "구성에 자격 증명 추가", + "saveCredentialsDescription": "연결을 완료하려면 이러한 자격 증명을 자체 호스팅 Pangolin 노드 구성 파일에 추가하십시오.", + "submitButton": "노드 생성" + }, + "validation": { + "adoptRequired": "기존 노드를 채택하려면 노드 ID와 비밀 키가 필요합니다" + }, + "errors": { + "loadDefaultsFailed": "기본값 로드 실패", + "defaultsNotLoaded": "기본값 로드되지 않음", + "createFailed": "노드 생성 실패" + }, + "success": { + "created": "노드가 성공적으로 생성되었습니다" + } + }, + "remoteExitNodeSelection": "노드 선택", + "remoteExitNodeSelectionDescription": "이 로컬 사이트에서 트래픽을 라우팅할 노드를 선택하세요", + "remoteExitNodeRequired": "로컬 사이트에 노드를 선택해야 합니다", + "noRemoteExitNodesAvailable": "사용 가능한 노드가 없습니다", + "noRemoteExitNodesAvailableDescription": "이 조직에 사용 가능한 노드가 없습니다. 로컬 사이트를 사용하려면 먼저 노드를 생성하세요.", + "exitNode": "종단 노드", + "country": "국가", + "rulesMatchCountry": "현재 소스 IP를 기반으로 합니다", "managedSelfHosted": { "title": "관리 자체 호스팅", "description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "국제 도메인 감지됨", "willbestoredas": "다음으로 저장됩니다:", + "roleMappingDescription": "자동 프로비저닝이 활성화되면 사용자가 로그인할 때 역할이 할당되는 방법을 결정합니다.", + "selectRole": "역할 선택", + "roleMappingExpression": "표현식", + "selectRolePlaceholder": "역할 선택", + "selectRoleDescription": "이 신원 공급자로부터 모든 사용자에게 할당할 역할을 선택하십시오.", + "roleMappingExpressionDescription": "ID 토큰에서 역할 정보를 추출하기 위한 JMESPath 표현식을 입력하세요.", + "idpTenantIdRequired": "테넌트 ID가 필요합니다", + "invalidValue": "잘못된 값", + "idpTypeLabel": "신원 공급자 유형", + "roleMappingExpressionPlaceholder": "예: contains(groups, 'admin') && 'Admin' || 'Member'", + "idpGoogleConfiguration": "Google 구성", + "idpGoogleConfigurationDescription": "Google OAuth2 자격 증명을 구성합니다.", + "idpGoogleClientIdDescription": "Google OAuth2 클라이언트 ID", + "idpGoogleClientSecretDescription": "Google OAuth2 클라이언트 비밀", + "idpAzureConfiguration": "Azure Entra ID 구성", + "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 자격 증명을 구성합니다.", + "idpTenantId": "테넌트 ID", + "idpTenantIdPlaceholder": "your-tenant-id", + "idpAzureTenantIdDescription": "Azure 액티브 디렉터리 개요에서 찾은 Azure 테넌트 ID", + "idpAzureClientIdDescription": "Azure 앱 등록 클라이언트 ID", + "idpAzureClientSecretDescription": "Azure 앱 등록 클라이언트 비밀", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "구글", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "애저", + "idpGoogleConfigurationTitle": "Google 구성", + "idpAzureConfigurationTitle": "Azure Entra ID 구성", + "idpTenantIdLabel": "테넌트 ID", + "idpAzureClientIdDescription2": "Azure 앱 등록 클라이언트 ID", + "idpAzureClientSecretDescription2": "Azure 앱 등록 클라이언트 비밀", "idpGoogleDescription": "Google OAuth2/OIDC 공급자", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", - "customHeaders": "사용자 정의 헤더", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", + "subnet": "서브넷", + "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", + "authPage": "인증 페이지", + "authPageDescription": "조직에 대한 인증 페이지를 구성합니다.", + "authPageDomain": "인증 페이지 도메인", + "noDomainSet": "도메인 설정 없음", + "changeDomain": "도메인 변경", + "selectDomain": "도메인 선택", + "restartCertificate": "인증서 재시작", + "editAuthPageDomain": "인증 페이지 도메인 편집", + "setAuthPageDomain": "인증 페이지 도메인 설정", + "failedToFetchCertificate": "인증서 가져오기 실패", + "failedToRestartCertificate": "인증서 재시작 실패", + "addDomainToEnableCustomAuthPages": "조직의 맞춤 인증 페이지를 활성화하려면 도메인을 추가하세요.", + "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "domainPickerProvidedDomain": "제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인", "domainPickerVerified": "검증됨", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.", "domainPickerSubdomainSanitized": "하위 도메인 정리됨", "domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다", + "orgAuthSignInTitle": "조직에 로그인", + "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", + "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", + "orgAuthSignInWithPangolin": "Pangolin으로 로그인", + "subscriptionRequiredToUse": "이 기능을 사용하려면 구독이 필요합니다.", + "idpDisabled": "신원 공급자가 비활성화되었습니다.", + "orgAuthPageDisabled": "조직 인증 페이지가 비활성화되었습니다.", + "domainRestartedDescription": "도메인 인증이 성공적으로 재시작되었습니다.", "resourceAddEntrypointsEditFile": "파일 편집: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "파일 편집: docker-compose.yml", "emailVerificationRequired": "이메일 인증이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", - "twoFactorSetupRequired": "이중 인증 설정이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "이중 인증 설정이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요." } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index fc0cfb0b..686a6f4d 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -118,7 +118,7 @@ "usageExamples": "Brukseksempler", "tokenId": "Token-ID", "requestHeades": "Request Headers", - "queryParameter": "Query Parameter", + "queryParameter": "Forespørsel Params", "importantNote": "Viktig merknad", "shareImportantDescription": "Av sikkerhetsgrunner anbefales det å bruke headere fremfor query parametere der det er mulig, da query parametere kan logges i serverlogger eller nettleserhistorikk.", "token": "Token", @@ -168,6 +168,9 @@ "siteSelect": "Velg område", "siteSearch": "Søk i område", "siteNotFound": "Ingen område funnet.", + "selectCountry": "Velg land", + "searchCountries": "Søk land...", + "noCountryFound": "Ingen land funnet.", "siteSelectionDescription": "Dette området vil gi tilkobling til mål.", "resourceType": "Ressurstype", "resourceTypeDescription": "Bestem hvordan du vil få tilgang til ressursen din", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Tilkoblet", "idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.", "idpErrorNotFound": "IdP ikke funnet", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Ugyldig invitasjon", "inviteInvalidDescription": "Invitasjonslenken er ugyldig.", "inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Underdomene: {subdomain}", "domainPickerNamespace": "Navnerom: {namespace}", "domainPickerShowMore": "Vis mer", + "regionSelectorTitle": "Velg Region", + "regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.", + "regionSelectorPlaceholder": "Velg en region", + "regionSelectorComingSoon": "Kommer snart", + "billingLoadingSubscription": "Laster abonnement...", + "billingFreeTier": "Gratis nivå", + "billingWarningOverLimit": "Advarsel: Du har overskredet en eller flere bruksgrenser. Nettstedene dine vil ikke koble til før du endrer abonnementet ditt eller justerer bruken.", + "billingUsageLimitsOverview": "Oversikt over bruksgrenser", + "billingMonitorUsage": "Overvåk bruken din i forhold til konfigurerte grenser. Hvis du trenger økte grenser, vennligst kontakt support@fossorial.io.", + "billingDataUsage": "Databruk", + "billingOnlineTime": "Online tid for nettsteder", + "billingUsers": "Aktive brukere", + "billingDomains": "Aktive domener", + "billingRemoteExitNodes": "Aktive selvstyrte noder", + "billingNoLimitConfigured": "Ingen grense konfigurert", + "billingEstimatedPeriod": "Estimert faktureringsperiode", + "billingIncludedUsage": "Inkludert Bruk", + "billingIncludedUsageDescription": "Bruk inkludert i din nåværende abonnementsplan", + "billingFreeTierIncludedUsage": "Gratis nivå bruksgrenser", + "billingIncluded": "inkludert", + "billingEstimatedTotal": "Estimert Totalt:", + "billingNotes": "Notater", + "billingEstimateNote": "Dette er et estimat basert på din nåværende bruk.", + "billingActualChargesMayVary": "Faktiske kostnader kan variere.", + "billingBilledAtEnd": "Du vil bli fakturert ved slutten av faktureringsperioden.", + "billingModifySubscription": "Endre abonnement", + "billingStartSubscription": "Start abonnement", + "billingRecurringCharge": "Innkommende Avgift", + "billingManageSubscriptionSettings": "Administrer abonnementsinnstillinger og preferanser", + "billingNoActiveSubscription": "Du har ikke et aktivt abonnement. Start abonnementet ditt for å øke bruksgrensene.", + "billingFailedToLoadSubscription": "Klarte ikke å laste abonnement", + "billingFailedToLoadUsage": "Klarte ikke å laste bruksdata", + "billingFailedToGetCheckoutUrl": "Mislyktes å få betalingslenke", + "billingPleaseTryAgainLater": "Vennligst prøv igjen senere.", + "billingCheckoutError": "Kasserror", + "billingFailedToGetPortalUrl": "Mislyktes å hente portal URL", + "billingPortalError": "Portal Error", + "billingDataUsageInfo": "Du er ladet for all data som overføres gjennom dine sikre tunneler når du er koblet til skyen. Dette inkluderer både innkommende og utgående trafikk på alle dine nettsteder. Når du når grensen din, vil sidene koble fra til du oppgraderer planen eller reduserer bruken. Data belastes ikke ved bruk av EK-grupper.", + "billingOnlineTimeInfo": "Du er ladet på hvor lenge sidene dine forblir koblet til skyen. For eksempel tilsvarer 44,640 minutter ett nettsted som går 24/7 i en hel måned. Når du når grensen din, vil sidene koble fra til du oppgraderer planen eller reduserer bruken. Tid belastes ikke når du bruker noder.", + "billingUsersInfo": "Du belastes for hver bruker i organisasjonen din. Faktureringen beregnes daglig basert på antall aktive brukerkontoer i organisasjonen din.", + "billingDomainInfo": "Du belastes for hvert domene i organisasjonen din. Faktureringen beregnes daglig basert på antall aktive domenekontoer i organisasjonen din.", + "billingRemoteExitNodesInfo": "Du belastes for hver styrt node i organisasjonen din. Faktureringen beregnes daglig basert på antall aktive styrte noder i organisasjonen din.", "domainNotFound": "Domene ikke funnet", "domainNotFoundDescription": "Denne ressursen er deaktivert fordi domenet ikke lenger eksisterer i systemet vårt. Vennligst angi et nytt domene for denne ressursen.", "failed": "Mislyktes", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "DNS-endringer kan ta litt tid å propagere over internett. Dette kan ta fra noen få minutter til 48 timer, avhengig av din DNS-leverandør og TTL-innstillinger.", "resourcePortRequired": "Portnummer er påkrevd for ikke-HTTP-ressurser", "resourcePortNotAllowed": "Portnummer skal ikke angis for HTTP-ressurser", + "billingPricingCalculatorLink": "Pris Kalkulator", "signUpTerms": { "IAgreeToThe": "Jeg godtar", "termsOfService": "brukervilkårene", @@ -1368,6 +1412,41 @@ "addNewTarget": "Legg til nytt mål", "targetsList": "Liste over mål", "targetErrorDuplicateTargetFound": "Duplikat av mål funnet", + "healthCheckHealthy": "Sunn", + "healthCheckUnhealthy": "Usunn", + "healthCheckUnknown": "Ukjent", + "healthCheck": "Helsekontroll", + "configureHealthCheck": "Konfigurer Helsekontroll", + "configureHealthCheckDescription": "Sett opp helsekontroll for {target}", + "enableHealthChecks": "Aktiver Helsekontroller", + "enableHealthChecksDescription": "Overvåk helsen til dette målet. Du kan overvåke et annet endepunkt enn målet hvis nødvendig.", + "healthScheme": "Metode", + "healthSelectScheme": "Velg metode", + "healthCheckPath": "Sti", + "healthHostname": "IP / Vert", + "healthPort": "Port", + "healthCheckPathDescription": "Stien for å sjekke helsestatus.", + "healthyIntervalSeconds": "Sunt intervall", + "unhealthyIntervalSeconds": "Usunt intervall", + "IntervalSeconds": "Sunt intervall", + "timeoutSeconds": "Timeout", + "timeIsInSeconds": "Tid er i sekunder", + "retryAttempts": "Forsøk på nytt", + "expectedResponseCodes": "Forventede svarkoder", + "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.", + "customHeaders": "Egendefinerte topptekster", + "customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi", + "headersValidationError": "Topptekst må være i formatet: header-navn: verdi.", + "saveHealthCheck": "Lagre Helsekontroll", + "healthCheckSaved": "Helsekontroll Lagret", + "healthCheckSavedDescription": "Helsekontrollkonfigurasjonen ble lagret", + "healthCheckError": "Helsekontrollfeil", + "healthCheckErrorDescription": "Det oppstod en feil under lagring av helsekontrollkonfigurasjonen", + "healthCheckPathRequired": "Helsekontrollsti er påkrevd", + "healthCheckMethodRequired": "HTTP-metode er påkrevd", + "healthCheckIntervalMin": "Sjekkeintervallet må være minst 5 sekunder", + "healthCheckTimeoutMin": "Timeout må være minst 1 sekund", + "healthCheckRetryMin": "Forsøk på nytt må være minst 1", "httpMethod": "HTTP-metode", "selectHttpMethod": "Velg HTTP-metode", "domainPickerSubdomainLabel": "Underdomene", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Skriv inn et underdomene for å søke og velge blant tilgjengelige gratis domener.", "domainPickerFreeDomains": "Gratis domener", "domainPickerSearchForAvailableDomains": "Søk etter tilgjengelige domener", + "domainPickerNotWorkSelfHosted": "Merk: Gratis tilbudte domener er ikke tilgjengelig for selv-hostede instanser akkurat nå.", "resourceDomain": "Domene", "resourceEditDomain": "Rediger domene", "siteName": "Områdenavn", @@ -1463,6 +1543,72 @@ "autoLoginError": "Feil ved automatisk innlogging", "autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.", "autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.", + "remoteExitNodeManageRemoteExitNodes": "Administrer Selv-Hostet", + "remoteExitNodeDescription": "Administrer noder for å forlenge nettverkstilkoblingen din", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Søk noder...", + "remoteExitNodeAdd": "Legg til Node", + "remoteExitNodeErrorDelete": "Feil ved sletting av node", + "remoteExitNodeQuestionRemove": "Er du sikker på at du vil fjerne noden {selectedNode} fra organisasjonen?", + "remoteExitNodeMessageRemove": "Når noden er fjernet, vil ikke lenger være tilgjengelig.", + "remoteExitNodeMessageConfirm": "For å bekrefte, skriv inn navnet på noden nedenfor.", + "remoteExitNodeConfirmDelete": "Bekreft sletting av Node", + "remoteExitNodeDelete": "Slett Node", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Opprett node", + "description": "Opprett en ny node for å utvide nettverkstilkoblingen din", + "viewAllButton": "Vis alle koder", + "strategy": { + "title": "Opprettelsesstrategi", + "description": "Velg denne for manuelt å konfigurere noden eller generere nye legitimasjoner.", + "adopt": { + "title": "Adopter Node", + "description": "Velg dette hvis du allerede har legitimasjon til noden." + }, + "generate": { + "title": "Generer Nøkler", + "description": "Velg denne hvis du vil generere nye nøkler for noden" + } + }, + "adopt": { + "title": "Adopter Eksisterende Node", + "description": "Skriv inn opplysningene til den eksisterende noden du vil adoptere", + "nodeIdLabel": "Node ID", + "nodeIdDescription": "ID-en til den eksisterende noden du vil adoptere", + "secretLabel": "Sikkerhetsnøkkel", + "secretDescription": "Den hemmelige nøkkelen til en eksisterende node", + "submitButton": "Adopt Node" + }, + "generate": { + "title": "Genererte Legitimasjoner", + "description": "Bruk disse genererte opplysningene for å konfigurere noden din", + "nodeIdTitle": "Node ID", + "secretTitle": "Sikkerhet", + "saveCredentialsTitle": "Legg til Legitimasjoner til Config", + "saveCredentialsDescription": "Legg til disse legitimasjonene i din selv-hostede Pangolin node-konfigurasjonsfil for å fullføre koblingen.", + "submitButton": "Opprett node" + }, + "validation": { + "adoptRequired": "Node ID og Secret er påkrevd når du adopterer en eksisterende node" + }, + "errors": { + "loadDefaultsFailed": "Feil ved lasting av standarder", + "defaultsNotLoaded": "Standarder ikke lastet", + "createFailed": "Kan ikke opprette node" + }, + "success": { + "created": "Node opprettet" + } + }, + "remoteExitNodeSelection": "Noden utvalg", + "remoteExitNodeSelectionDescription": "Velg en node for å sende trafikk gjennom for dette lokale nettstedet", + "remoteExitNodeRequired": "En node må velges for lokale nettsteder", + "noRemoteExitNodesAvailable": "Ingen noder tilgjengelig", + "noRemoteExitNodesAvailableDescription": "Ingen noder er tilgjengelige for denne organisasjonen. Opprett en node først for å bruke lokale nettsteder.", + "exitNode": "Utgangsnode", + "country": "Land", + "rulesMatchCountry": "For tiden basert på kilde IP", "managedSelfHosted": { "title": "Administrert selv-hostet", "description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Internasjonalt domene oppdaget", "willbestoredas": "Vil bli lagret som:", + "roleMappingDescription": "Bestem hvordan roller tilordnes brukere når innloggingen er aktivert når autog-rapportering er aktivert.", + "selectRole": "Velg en rolle", + "roleMappingExpression": "Uttrykk", + "selectRolePlaceholder": "Velg en rolle", + "selectRoleDescription": "Velg en rolle å tilordne alle brukere fra denne identitet leverandøren", + "roleMappingExpressionDescription": "Skriv inn et JMESPath uttrykk for å hente rolleinformasjon fra ID-nøkkelen", + "idpTenantIdRequired": "Bedriftens ID kreves", + "invalidValue": "Ugyldig verdi", + "idpTypeLabel": "Identitet leverandør type", + "roleMappingExpressionPlaceholder": "F.eks. inneholder(grupper, 'admin') && 'Admin' ⋅'Medlem'", + "idpGoogleConfiguration": "Google Konfigurasjon", + "idpGoogleConfigurationDescription": "Konfigurer din Google OAuth2 legitimasjon", + "idpGoogleClientIdDescription": "Din Google OAuth2-klient-ID", + "idpGoogleClientSecretDescription": "Google OAuth2-klienten din hemmelig", + "idpAzureConfiguration": "Azure Entra ID konfigurasjon", + "idpAzureConfigurationDescription": "Konfigurere din Azure Entra ID OAuth2 legitimasjon", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "din-tenant-id", + "idpAzureTenantIdDescription": "Din Azure leie-ID (funnet i Azure Active Directory-oversikten)", + "idpAzureClientIdDescription": "Din Azure App registrerings klient-ID", + "idpAzureClientSecretDescription": "Din Azure App registrerings klient hemmelig", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google Konfigurasjon", + "idpAzureConfigurationTitle": "Azure Entra ID konfigurasjon", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Din Azure App registrerings klient-ID", + "idpAzureClientSecretDescription2": "Din Azure App registrerings klient hemmelig", "idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Egendefinerte topptekster", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Topptekst må være i formatet: header-navn: verdi.", + "subnet": "Subnett", + "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", + "authPage": "Autentiseringsside", + "authPageDescription": "Konfigurer autoriseringssiden for din organisasjon", + "authPageDomain": "Autentiseringsside domene", + "noDomainSet": "Ingen domene valgt", + "changeDomain": "Endre domene", + "selectDomain": "Velg domene", + "restartCertificate": "Omstart sertifikat", + "editAuthPageDomain": "Rediger auth sidedomene", + "setAuthPageDomain": "Angi autoriseringsside domene", + "failedToFetchCertificate": "Kunne ikke hente sertifikat", + "failedToRestartCertificate": "Kan ikke starte sertifikat", + "addDomainToEnableCustomAuthPages": "Legg til et domene for å aktivere egendefinerte autentiseringssider for organisasjonen din", + "selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside", "domainPickerProvidedDomain": "Gitt domene", "domainPickerFreeProvidedDomain": "Gratis oppgitt domene", "domainPickerVerified": "Bekreftet", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.", "domainPickerSubdomainSanitized": "Underdomenet som ble sanivert", "domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"", + "orgAuthSignInTitle": "Logg inn på din organisasjon", + "orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", + "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", + "orgAuthSignInWithPangolin": "Logg inn med Pangolin", + "subscriptionRequiredToUse": "Et abonnement er påkrevd for å bruke denne funksjonen.", + "idpDisabled": "Identitetsleverandører er deaktivert.", + "orgAuthPageDisabled": "Informasjons-siden for organisasjon er deaktivert.", + "domainRestartedDescription": "Domene-verifiseringen ble startet på nytt", "resourceAddEntrypointsEditFile": "Rediger fil: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Rediger fil: docker-compose.yml", "emailVerificationRequired": "E-postbekreftelse er nødvendig. Logg inn på nytt via {dashboardUrl}/auth/login og fullfør dette trinnet. Kom deretter tilbake her.", - "twoFactorSetupRequired": "To-faktor autentiseringsoppsett er nødvendig. Vennligst logg inn igjen via {dashboardUrl}/auth/login og fullfør dette steget. Kom deretter tilbake her.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "To-faktor autentiseringsoppsett er nødvendig. Vennligst logg inn igjen via {dashboardUrl}/auth/login og fullfør dette steget. Kom deretter tilbake her." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index ada6f687..12744579 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -10,7 +10,7 @@ "setupErrorIdentifier": "Organisatie-ID is al in gebruik. Kies een andere.", "componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.", "componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.", - "welcome": "Welkom bij Pangolin!", + "welcome": "Welkom bij Pangolin", "welcomeTo": "Welkom bij", "componentsCreateOrg": "Maak een Organisatie", "componentsMember": "Je bent lid van {count, plural, =0 {geen organisatie} one {één organisatie} other {# organisaties}}.", @@ -22,7 +22,7 @@ "inviteErrorUser": "Het spijt ons, maar de uitnodiging die u probeert te gebruiken is niet voor deze gebruiker.", "inviteLoginUser": "Controleer of je bent aangemeld als de juiste gebruiker.", "inviteErrorNoUser": "Het spijt ons, maar de uitnodiging die u probeert te gebruiken is niet voor een bestaande gebruiker.", - "inviteCreateUser": "U moet eerst een account aanmaken.", + "inviteCreateUser": "U moet eerst een account aanmaken", "goHome": "Ga naar huis", "inviteLogInOtherUser": "Log in als een andere gebruiker", "createAnAccount": "Account aanmaken", @@ -38,12 +38,12 @@ "name": "Naam", "online": "Online", "offline": "Offline", - "site": "Referentie", - "dataIn": "Dataverbruik inkomend", - "dataOut": "Dataverbruik uitgaand", + "site": "Website", + "dataIn": "Gegevens in", + "dataOut": "Data Uit", "connectionType": "Type verbinding", "tunnelType": "Tunnel type", - "local": "Lokaal", + "local": "lokaal", "edit": "Bewerken", "siteConfirmDelete": "Verwijderen van site bevestigen", "siteDelete": "Site verwijderen", @@ -55,7 +55,7 @@ "siteCreate": "Site maken", "siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden", "siteCreateDescription": "Maak een nieuwe site aan om verbinding te maken met uw bronnen", - "close": "Sluiten", + "close": "Afsluiten", "siteErrorCreate": "Fout bij maken site", "siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden", "siteErrorCreateDefaults": "Standaardinstellingen niet gevonden", @@ -90,21 +90,21 @@ "siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteSettingDescription": "Configureer de instellingen op uw site", "siteSetting": "{siteName} instellingen", - "siteNewtTunnel": "Newttunnel (Aanbevolen)", + "siteNewtTunnel": "Nieuwstunnel (Aanbevolen)", "siteNewtTunnelDescription": "Gemakkelijkste manier om een ingangspunt in uw netwerk te maken. Geen extra opzet.", "siteWg": "Basis WireGuard", "siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.", "siteWgDescriptionSaas": "Gebruik elke WireGuard-client om een tunnel op te zetten. Handmatige NAT-instelling vereist. WERKT ALLEEN OP SELF HOSTED NODES", "siteLocalDescription": "Alleen lokale bronnen. Geen tunneling.", "siteLocalDescriptionSaas": "Alleen lokale bronnen. Geen tunneling. WERKT ALLEEN OP SELF HOSTED NODES", - "siteSeeAll": "Alle sites bekijken", + "siteSeeAll": "Alle werkruimtes bekijken", "siteTunnelDescription": "Bepaal hoe u verbinding wilt maken met uw site", "siteNewtCredentials": "Nieuwste aanmeldgegevens", "siteNewtCredentialsDescription": "Dit is hoe Newt zich zal verifiëren met de server", "siteCredentialsSave": "Uw referenties opslaan", "siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "siteInfo": "Site informatie", - "status": "Status", + "status": "status", "shareTitle": "Beheer deellinks", "shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot uw bronnen te verlenen", "shareSearch": "Zoek share links...", @@ -146,19 +146,19 @@ "never": "Nooit", "shareErrorSelectResource": "Selecteer een bron", "resourceTitle": "Bronnen beheren", - "resourceDescription": "Veilige proxy's voor uw privéapplicaties maken", + "resourceDescription": "Veilige proxy's voor uw privé applicaties maken", "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", "authentication": "Authenticatie", "protected": "Beschermd", - "notProtected": "Niet beveiligd", + "notProtected": "Niet beschermd", "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceMessageConfirm": "Om te bevestigen, typ de naam van de bron hieronder.", "resourceQuestionRemove": "Weet u zeker dat u de resource {selectedResource} uit de organisatie wilt verwijderen?", "resourceHTTP": "HTTPS bron", "resourceHTTPDescription": "Proxy verzoeken aan uw app via HTTPS via een subdomein of basisdomein.", - "resourceRaw": "TCP/UDP bron", + "resourceRaw": "Ruwe TCP/UDP bron", "resourceRawDescription": "Proxy verzoeken naar je app via TCP/UDP met behulp van een poortnummer.", "resourceCreate": "Bron maken", "resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken", @@ -168,6 +168,9 @@ "siteSelect": "Selecteer site", "siteSearch": "Zoek site", "siteNotFound": "Geen site gevonden.", + "selectCountry": "Selecteer land", + "searchCountries": "Zoek landen...", + "noCountryFound": "Geen land gevonden.", "siteSelectionDescription": "Deze site zal connectiviteit met het doelwit bieden.", "resourceType": "Type bron", "resourceTypeDescription": "Bepaal hoe u toegang wilt krijgen tot uw bron", @@ -183,7 +186,7 @@ "protocolSelect": "Selecteer een protocol", "resourcePortNumber": "Nummer van poort", "resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.", - "cancel": "Annuleren", + "cancel": "annuleren", "resourceConfig": "Configuratie tekstbouwstenen", "resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om je TCP/UDP-bron in te stellen", "resourceAddEntrypoints": "Traefik: Entrypoints toevoegen", @@ -212,7 +215,7 @@ "saveGeneralSettings": "Algemene instellingen opslaan", "saveSettings": "Instellingen opslaan", "orgDangerZone": "Gevaarlijke zone", - "orgDangerZoneDescription": "Deze instantie verwijderen is onomkeerbaar. Bevestig alstublieft dat u wilt doorgaan.", + "orgDangerZoneDescription": "Als u deze instantie verwijdert, is er geen weg terug. Wees het alstublieft zeker.", "orgDelete": "Verwijder organisatie", "orgDeleteConfirm": "Bevestig Verwijderen Organisatie", "orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.", @@ -265,7 +268,7 @@ "apiKeysGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", "apiKeysList": "Uw API-sleutel", "apiKeysSave": "Uw API-sleutel opslaan", - "apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", + "apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plek.", "apiKeysInfo": "Uw API-sleutel is:", "apiKeysConfirmCopy": "Ik heb de API-sleutel gekopieerd", "generate": "Genereren", @@ -501,7 +504,7 @@ "targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.", "methodSelect": "Selecteer methode", "targetSubmit": "Doelwit toevoegen", - "targetNoOne": "Geen doel toegevoegd. Voeg deze toe via dit formulier.", + "targetNoOne": "Geen doelwitten. Voeg een doel toe via het formulier.", "targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.", "targetsSubmit": "Doelstellingen opslaan", "proxyAdditional": "Extra Proxy-instellingen", @@ -572,7 +575,7 @@ "domainsErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de domeinen", "none": "geen", "unknown": "onbekend", - "resources": "Bronnen", + "resources": "Hulpmiddelen", "resourcesDescription": "Bronnen zijn proxies voor applicaties die op uw privénetwerk worden uitgevoerd. Maak een bron aan voor elke HTTP/HTTPS of onbewerkte TCP/UDP-service op uw privénetwerk. Elke bron moet verbonden zijn met een site om private, beveiligde verbinding mogelijk te maken via een versleutelde WireGuard tunnel.", "resourcesWireGuardConnect": "Beveiligde verbinding met WireGuard versleuteling", "resourcesMultipleAuthenticationMethods": "Meerdere verificatiemethoden configureren", @@ -598,7 +601,7 @@ "newtId": "Newt-ID", "newtSecretKey": "Nieuwe geheime sleutel", "architecture": "Architectuur", - "sites": "Sites", + "sites": "Werkruimtes", "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je moet je interne bronnen aanspreken met behulp van de peer IP.", "siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients", "siteWgManualConfigurationRequired": "Handmatige configuratie vereist", @@ -727,22 +730,22 @@ "idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider {name} permanent wilt verwijderen?", "idpMessageRemove": "Dit zal de identiteitsprovider en alle bijbehorende configuraties verwijderen. Gebruikers die via deze provider authenticeren, kunnen niet langer inloggen.", "idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.", - "idpConfirmDelete": "Bevestig verwijderen identiteit provider", - "idpDelete": "Identiteit provider verwijderen", - "idp": "Identiteitsproviders", - "idpSearch": "Identiteitsproviders zoeken...", - "idpAdd": "Identiteit provider toevoegen", - "idpClientIdRequired": "Client ID is vereist.", - "idpClientSecretRequired": "Client geheim is vereist.", - "idpErrorAuthUrlInvalid": "Authenticatie URL moet een geldige URL zijn.", - "idpErrorTokenUrlInvalid": "Token URL moet een geldige URL zijn.", + "idpConfirmDelete": "Bevestig verwijderen Identity Provider", + "idpDelete": "Identity Provider verwijderen", + "idp": "Identiteit aanbieders", + "idpSearch": "Identiteitsaanbieders zoeken...", + "idpAdd": "Identity Provider toevoegen", + "idpClientIdRequired": "Client-ID is vereist.", + "idpClientSecretRequired": "Clientgeheim is vereist.", + "idpErrorAuthUrlInvalid": "Authenticatie-URL moet een geldige URL zijn.", + "idpErrorTokenUrlInvalid": "Token-URL moet een geldige URL zijn.", "idpPathRequired": "ID-pad is vereist.", "idpScopeRequired": "Toepassingsgebieden zijn vereist.", - "idpOidcDescription": "Een OpenID Connect identiteitsprovider configureren", - "idpCreatedDescription": "Identiteitsprovider succesvol aangemaakt", - "idpCreate": "Identiteitsprovider aanmaken", - "idpCreateDescription": "Een nieuwe identiteitsprovider voor authenticatie configureren", - "idpSeeAll": "Zie alle Identiteitsproviders", + "idpOidcDescription": "Een OpenID Connect identity provider configureren", + "idpCreatedDescription": "Identity provider succesvol aangemaakt", + "idpCreate": "Identity Provider aanmaken", + "idpCreateDescription": "Een nieuwe identiteitsprovider voor gebruikersauthenticatie configureren", + "idpSeeAll": "Zie alle identiteitsaanbieders", "idpSettingsDescription": "Configureer de basisinformatie voor uw identiteitsprovider", "idpDisplayName": "Een weergavenaam voor deze identiteitsprovider", "idpAutoProvisionUsers": "Auto Provisie Gebruikers", @@ -752,10 +755,10 @@ "idpTypeDescription": "Selecteer het type identiteitsprovider dat u wilt configureren", "idpOidcConfigure": "OAuth2/OIDC configuratie", "idpOidcConfigureDescription": "Configureer de eindpunten van de OAuth2/OIDC provider en referenties", - "idpClientId": "Client ID", - "idpClientIdDescription": "De OAuth2 client ID van uw identiteitsprovider", - "idpClientSecret": "Client Secret", - "idpClientSecretDescription": "Het OAuth2 Client Secret van je identiteitsprovider", + "idpClientId": "Klant ID", + "idpClientIdDescription": "De OAuth2-client-ID van uw identiteitsprovider", + "idpClientSecret": "Clientgeheim", + "idpClientSecretDescription": "Het OAuth2-clientgeheim van je identiteitsprovider", "idpAuthUrl": "URL autorisatie", "idpAuthUrlDescription": "De URL voor autorisatie OAuth2", "idpTokenUrl": "URL token", @@ -801,7 +804,7 @@ "defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.", "defaultMappingsSubmit": "Standaard toewijzingen opslaan", "orgPoliciesEdit": "Organisatie beleid bewerken", - "org": "Organisatie", + "org": "Rekening", "orgSelect": "Selecteer organisatie", "orgSearch": "Zoek in org", "orgNotFound": "Geen org gevonden.", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Verbonden", "idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.", "idpErrorNotFound": "IdP niet gevonden", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Ongeldige uitnodiging", "inviteInvalidDescription": "Uitnodigingslink is ongeldig.", "inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker", @@ -924,7 +925,7 @@ "inviteErrorExpired": "De uitnodiging is mogelijk verlopen", "inviteErrorRevoked": "De uitnodiging is mogelijk ingetrokken", "inviteErrorTypo": "Er kan een typefout zijn in de uitnodigingslink", - "pangolinSetup": "Setup - Pangolin", + "pangolinSetup": "Instellen - Pangolin", "orgNameRequired": "Organisatienaam is vereist", "orgIdRequired": "Organisatie-ID is vereist", "orgErrorCreate": "Fout opgetreden tijdens het aanmaken org", @@ -976,10 +977,10 @@ "supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!", "githubUsername": "GitHub-gebruikersnaam", "supportKeyInput": "Supporter Sleutel", - "supportKeyBuy": "Koop supportersleutel", + "supportKeyBuy": "Koop Supportersleutel", "logoutError": "Fout bij uitloggen", "signingAs": "Ingelogd als", - "serverAdmin": "Server beheer", + "serverAdmin": "Server Beheerder", "managedSelfhosted": "Beheerde Self-Hosted", "otpEnable": "Twee-factor inschakelen", "otpDisable": "Tweestapsverificatie uitschakelen", @@ -994,12 +995,12 @@ "actionGetUser": "Gebruiker ophalen", "actionGetOrgUser": "Krijg organisatie-gebruiker", "actionListOrgDomains": "Lijst organisatie domeinen", - "actionCreateSite": "Site maken", + "actionCreateSite": "Site aanmaken", "actionDeleteSite": "Site verwijderen", "actionGetSite": "Site ophalen", "actionListSites": "Sites weergeven", "actionApplyBlueprint": "Blauwdruk toepassen", - "setupToken": "Setup Token", + "setupToken": "Instel Token", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", "actionUpdateSite": "Site bijwerken", @@ -1128,7 +1129,7 @@ "sidebarOverview": "Overzicht.", "sidebarHome": "Startpagina", "sidebarSites": "Werkruimtes", - "sidebarResources": "Bronnen", + "sidebarResources": "Hulpmiddelen", "sidebarAccessControl": "Toegangs controle", "sidebarUsers": "Gebruikers", "sidebarInvitations": "Uitnodigingen", @@ -1255,8 +1256,50 @@ "domainPickerOrganizationDomains": "Organisatiedomeinen", "domainPickerProvidedDomains": "Aangeboden domeinen", "domainPickerSubdomain": "Subdomein: {subdomain}", - "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerNamespace": "Naamruimte: {namespace}", "domainPickerShowMore": "Meer weergeven", + "regionSelectorTitle": "Selecteer Regio", + "regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.", + "regionSelectorPlaceholder": "Kies een regio", + "regionSelectorComingSoon": "Komt binnenkort", + "billingLoadingSubscription": "Abonnement laden...", + "billingFreeTier": "Gratis Niveau", + "billingWarningOverLimit": "Waarschuwing: U hebt een of meer gebruikslimieten overschreden. Uw sites maken geen verbinding totdat u uw abonnement aanpast of uw gebruik aanpast.", + "billingUsageLimitsOverview": "Overzicht gebruikslimieten", + "billingMonitorUsage": "Houd uw gebruik in de gaten ten opzichte van de ingestelde limieten. Als u verhoogde limieten nodig heeft, neem dan contact met ons op support@fossorial.io.", + "billingDataUsage": "Gegevensgebruik", + "billingOnlineTime": "Site Online Tijd", + "billingUsers": "Actieve Gebruikers", + "billingDomains": "Actieve Domeinen", + "billingRemoteExitNodes": "Actieve Zelfgehoste Nodes", + "billingNoLimitConfigured": "Geen limiet ingesteld", + "billingEstimatedPeriod": "Geschatte Facturatie Periode", + "billingIncludedUsage": "Opgenomen Gebruik", + "billingIncludedUsageDescription": "Gebruik inbegrepen in uw huidige abonnementsplan", + "billingFreeTierIncludedUsage": "Gratis niveau gebruikstoelagen", + "billingIncluded": "inbegrepen", + "billingEstimatedTotal": "Geschat Totaal:", + "billingNotes": "Notities", + "billingEstimateNote": "Dit is een schatting gebaseerd op uw huidige gebruik.", + "billingActualChargesMayVary": "Facturering kan variëren.", + "billingBilledAtEnd": "U wordt aan het einde van de factureringsperiode gefactureerd.", + "billingModifySubscription": "Abonnementsaanpassing", + "billingStartSubscription": "Abonnement Starten", + "billingRecurringCharge": "Terugkerende Kosten", + "billingManageSubscriptionSettings": "Beheer uw abonnementsinstellingen en voorkeuren", + "billingNoActiveSubscription": "U heeft geen actief abonnement. Start uw abonnement om gebruikslimieten te verhogen.", + "billingFailedToLoadSubscription": "Fout bij laden van abonnement", + "billingFailedToLoadUsage": "Niet gelukt om gebruik te laden", + "billingFailedToGetCheckoutUrl": "Niet gelukt om checkout URL te krijgen", + "billingPleaseTryAgainLater": "Probeer het later opnieuw.", + "billingCheckoutError": "Checkout Fout", + "billingFailedToGetPortalUrl": "Niet gelukt om portal URL te krijgen", + "billingPortalError": "Portal Fout", + "billingDataUsageInfo": "U bent in rekening gebracht voor alle gegevens die via uw beveiligde tunnels via de cloud worden verzonden. Dit omvat zowel inkomende als uitgaande verkeer over al uw sites. Wanneer u uw limiet bereikt zullen uw sites de verbinding verbreken totdat u uw abonnement upgradet of het gebruik vermindert. Gegevens worden niet in rekening gebracht bij het gebruik van knooppunten.", + "billingOnlineTimeInfo": "U wordt in rekening gebracht op basis van hoe lang uw sites verbonden blijven met de cloud. Bijvoorbeeld 44,640 minuten is gelijk aan één site met 24/7 voor een volledige maand. Wanneer u uw limiet bereikt, zal de verbinding tussen uw sites worden verbroken totdat u een upgrade van uw abonnement uitvoert of het gebruik vermindert. Tijd wordt niet belast bij het gebruik van knooppunten.", + "billingUsersInfo": "U wordt gefactureerd voor elke gebruiker in uw organisatie. Facturering wordt dagelijks berekend op basis van het aantal actieve gebruikersaccounts in uw organisatie.", + "billingDomainInfo": "U wordt gefactureerd voor elk domein in uw organisatie. Facturering wordt dagelijks berekend op basis van het aantal actieve domeinaccounts in uw organisatie.", + "billingRemoteExitNodesInfo": "U wordt gefactureerd voor elke beheerde Node in uw organisatie. Facturering wordt dagelijks berekend op basis van het aantal actieve beheerde Nodes in uw organisatie.", "domainNotFound": "Domein niet gevonden", "domainNotFoundDescription": "Deze bron is uitgeschakeld omdat het domein niet langer in ons systeem bestaat. Stel een nieuw domein in voor deze bron.", "failed": "Mislukt", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "DNS-wijzigingen kunnen enige tijd duren om over het internet te worden verspreid. Dit kan enkele minuten tot 48 uur duren, afhankelijk van je DNS-provider en TTL-instellingen.", "resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen", "resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen", + "billingPricingCalculatorLink": "Prijs Calculator", "signUpTerms": { "IAgreeToThe": "Ik ga akkoord met de", "termsOfService": "servicevoorwaarden", @@ -1368,6 +1412,41 @@ "addNewTarget": "Voeg nieuw doelwit toe", "targetsList": "Lijst met doelen", "targetErrorDuplicateTargetFound": "Dubbel doelwit gevonden", + "healthCheckHealthy": "Gezond", + "healthCheckUnhealthy": "Ongezond", + "healthCheckUnknown": "Onbekend", + "healthCheck": "Gezondheidscontrole", + "configureHealthCheck": "Configureer Gezondheidscontrole", + "configureHealthCheckDescription": "Stel gezondheid monitor voor {target} in", + "enableHealthChecks": "Inschakelen Gezondheidscontroles", + "enableHealthChecksDescription": "Controleer de gezondheid van dit doel. U kunt een ander eindpunt monitoren dan het doel indien vereist.", + "healthScheme": "Methode", + "healthSelectScheme": "Selecteer methode", + "healthCheckPath": "Pad", + "healthHostname": "IP / Host", + "healthPort": "Poort", + "healthCheckPathDescription": "Het pad om de gezondheid status te controleren.", + "healthyIntervalSeconds": "Gezonde Interval", + "unhealthyIntervalSeconds": "Ongezonde Interval", + "IntervalSeconds": "Gezonde Interval", + "timeoutSeconds": "Timeout", + "timeIsInSeconds": "Tijd is in seconden", + "retryAttempts": "Herhaal Pogingen", + "expectedResponseCodes": "Verwachte Reactiecodes", + "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", + "customHeaders": "Aangepaste headers", + "customHeadersDescription": "Kopregeleinde: Header-Naam: waarde", + "headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.", + "saveHealthCheck": "Opslaan Gezondheidscontrole", + "healthCheckSaved": "Gezondheidscontrole Opgeslagen", + "healthCheckSavedDescription": "Gezondheidscontrole configuratie succesvol opgeslagen", + "healthCheckError": "Gezondheidscontrole Fout", + "healthCheckErrorDescription": "Er is een fout opgetreden bij het opslaan van de configuratie van de gezondheidscontrole.", + "healthCheckPathRequired": "Gezondheidscontrole pad is vereist", + "healthCheckMethodRequired": "HTTP methode is vereist", + "healthCheckIntervalMin": "Controle interval moet minimaal 5 seconden zijn", + "healthCheckTimeoutMin": "Timeout moet minimaal 1 seconde zijn", + "healthCheckRetryMin": "Herhaal pogingen moet minimaal 1 zijn", "httpMethod": "HTTP-methode", "selectHttpMethod": "Selecteer HTTP-methode", "domainPickerSubdomainLabel": "Subdomein", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Voer een subdomein in om te zoeken en te selecteren uit beschikbare gratis domeinen.", "domainPickerFreeDomains": "Gratis Domeinen", "domainPickerSearchForAvailableDomains": "Zoek naar beschikbare domeinen", + "domainPickerNotWorkSelfHosted": "Opmerking: Gratis aangeboden domeinen zijn momenteel niet beschikbaar voor zelf-gehoste instanties.", "resourceDomain": "Domein", "resourceEditDomain": "Domein bewerken", "siteName": "Site Naam", @@ -1463,6 +1543,72 @@ "autoLoginError": "Auto Login Fout", "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.", + "remoteExitNodeManageRemoteExitNodes": "Beheer Zelf-Gehoste", + "remoteExitNodeDescription": "Beheer knooppunten om uw netwerkverbinding uit te breiden", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Knooppunten zoeken...", + "remoteExitNodeAdd": "Voeg node toe", + "remoteExitNodeErrorDelete": "Fout bij verwijderen node", + "remoteExitNodeQuestionRemove": "Weet u zeker dat u het node {selectedNode} uit de organisatie wilt verwijderen?", + "remoteExitNodeMessageRemove": "Eenmaal verwijderd, zal het knooppunt niet langer toegankelijk zijn.", + "remoteExitNodeMessageConfirm": "Om te bevestigen, typ de naam van het knooppunt hieronder.", + "remoteExitNodeConfirmDelete": "Bevestig verwijderen node", + "remoteExitNodeDelete": "Knoop verwijderen", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Maak node", + "description": "Maak een nieuwe node aan om uw netwerkverbinding uit te breiden", + "viewAllButton": "Alle nodes weergeven", + "strategy": { + "title": "Creatie Strategie", + "description": "Kies dit om uw node handmatig te configureren of nieuwe referenties te genereren.", + "adopt": { + "title": "Adopteer Node", + "description": "Kies dit als u al de referenties voor deze node heeft" + }, + "generate": { + "title": "Genereer Sleutels", + "description": "Kies dit als u nieuwe sleutels voor het knooppunt wilt genereren" + } + }, + "adopt": { + "title": "Adopteer Bestaande Node", + "description": "Voer de referenties in van het bestaande knooppunt dat u wilt adopteren", + "nodeIdLabel": "Knooppunt ID", + "nodeIdDescription": "De ID van het knooppunt dat u wilt adopteren", + "secretLabel": "Geheim", + "secretDescription": "De geheime sleutel van de bestaande node", + "submitButton": "Knooppunt adopteren" + }, + "generate": { + "title": "Gegeneerde Inloggegevens", + "description": "Gebruik deze gegenereerde inloggegevens om uw node te configureren", + "nodeIdTitle": "Knooppunt ID", + "secretTitle": "Geheim", + "saveCredentialsTitle": "Voeg Inloggegevens toe aan Config", + "saveCredentialsDescription": "Voeg deze inloggegevens toe aan uw zelf-gehoste Pangolin-node configuratiebestand om de verbinding te voltooien.", + "submitButton": "Maak node" + }, + "validation": { + "adoptRequired": "Node ID en Secret zijn verplicht bij het overnemen van een bestaand knooppunt" + }, + "errors": { + "loadDefaultsFailed": "Niet gelukt om standaarden te laden", + "defaultsNotLoaded": "Standaarden niet geladen", + "createFailed": "Fout bij het maken van node" + }, + "success": { + "created": "Node succesvol aangemaakt" + } + }, + "remoteExitNodeSelection": "Knooppunt selectie", + "remoteExitNodeSelectionDescription": "Selecteer een node om het verkeer door te leiden voor deze lokale site", + "remoteExitNodeRequired": "Een node moet worden geselecteerd voor lokale sites", + "noRemoteExitNodesAvailable": "Geen knooppunten beschikbaar", + "noRemoteExitNodesAvailableDescription": "Er zijn geen knooppunten beschikbaar voor deze organisatie. Maak eerst een knooppunt aan om lokale sites te gebruiken.", + "exitNode": "Exit Node", + "country": "Land", + "rulesMatchCountry": "Momenteel gebaseerd op bron IP", "managedSelfHosted": { "title": "Beheerde Self-Hosted", "description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Internationaal Domein Gedetecteerd", "willbestoredas": "Zal worden opgeslagen als:", + "roleMappingDescription": "Bepaal hoe rollen worden toegewezen aan gebruikers wanneer ze inloggen wanneer Auto Provision is ingeschakeld.", + "selectRole": "Selecteer een rol", + "roleMappingExpression": "Expressie", + "selectRolePlaceholder": "Kies een rol", + "selectRoleDescription": "Selecteer een rol om toe te wijzen aan alle gebruikers van deze identiteitsprovider", + "roleMappingExpressionDescription": "Voer een JMESPath expressie in om rolinformatie van de ID-token te extraheren", + "idpTenantIdRequired": "Tenant ID is vereist", + "invalidValue": "Ongeldige waarde", + "idpTypeLabel": "Identiteit provider type", + "roleMappingExpressionPlaceholder": "bijvoorbeeld bevat (groepen, 'admin') && 'Admin' ½ 'Member'", + "idpGoogleConfiguration": "Google Configuratie", + "idpGoogleConfigurationDescription": "Configureer uw Google OAuth2-referenties", + "idpGoogleClientIdDescription": "Uw Google OAuth2-client-ID", + "idpGoogleClientSecretDescription": "Uw Google OAuth2 Clientgeheim", + "idpAzureConfiguration": "Azure Entra ID configuratie", + "idpAzureConfigurationDescription": "Configureer uw Azure Entra ID OAuth2 referenties", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "jouw-tenant-id", + "idpAzureTenantIdDescription": "Uw Azure tenant ID (gevonden in Azure Active Directory overzicht)", + "idpAzureClientIdDescription": "Uw Azure App registratie Client ID", + "idpAzureClientSecretDescription": "Uw Azure App registratie Client Secret", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google Configuratie", + "idpAzureConfigurationTitle": "Azure Entra ID configuratie", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Uw Azure App registratie Client ID", + "idpAzureClientSecretDescription2": "Uw Azure App registratie Client Secret", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Aangepaste headers", - "customHeadersDescription": "Voeg aangepaste headers toe die met proxyverzoeken worden meegestuurd. Gebruik één regel per header in het formaat 'Header-naam: waarde'", - "headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.", + "subnet": "Subnet", + "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", + "authPage": "Authenticatie pagina", + "authPageDescription": "De autorisatiepagina voor uw organisatie configureren", + "authPageDomain": "Authenticatie pagina domein", + "noDomainSet": "Geen domein ingesteld", + "changeDomain": "Domein wijzigen", + "selectDomain": "Domein selecteren", + "restartCertificate": "Certificaat opnieuw starten", + "editAuthPageDomain": "Authenticatiepagina domein bewerken", + "setAuthPageDomain": "Authenticatiepagina domein instellen", + "failedToFetchCertificate": "Certificaat ophalen mislukt", + "failedToRestartCertificate": "Kon certificaat niet opnieuw opstarten", + "addDomainToEnableCustomAuthPages": "Voeg een domein toe om aangepaste authenticatiepagina's voor uw organisatie in te schakelen", + "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "domainPickerProvidedDomain": "Opgegeven domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", "domainPickerVerified": "Geverifieerd", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.", "domainPickerSubdomainSanitized": "Subdomein gesaniseerd", "domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"", + "orgAuthSignInTitle": "Meld je aan bij je organisatie", + "orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan", + "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", + "orgAuthSignInWithPangolin": "Log in met Pangolin", + "subscriptionRequiredToUse": "Een abonnement is vereist om deze functie te gebruiken.", + "idpDisabled": "Identiteitsaanbieders zijn uitgeschakeld.", + "orgAuthPageDisabled": "Pagina voor organisatie-authenticatie is uitgeschakeld.", + "domainRestartedDescription": "Domeinverificatie met succes opnieuw gestart", "resourceAddEntrypointsEditFile": "Bestand bewerken: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml", "emailVerificationRequired": "E-mail verificatie is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", - "twoFactorSetupRequired": "Tweestapsverificatie instellen is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Tweestapsverificatie instellen is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug." } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index cd341974..ffe77bb8 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -168,6 +168,9 @@ "siteSelect": "Wybierz witrynę", "siteSearch": "Szukaj witryny", "siteNotFound": "Nie znaleziono witryny.", + "selectCountry": "Wybierz kraj", + "searchCountries": "Szukaj krajów...", + "noCountryFound": "Nie znaleziono kraju.", "siteSelectionDescription": "Ta strona zapewni połączenie z celem.", "resourceType": "Typ zasobu", "resourceTypeDescription": "Określ jak chcesz uzyskać dostęp do swojego zasobu", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Połączono", "idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.", "idpErrorNotFound": "Nie znaleziono IdP", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Nieprawidłowe zaproszenie", "inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.", "inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika", @@ -1155,7 +1156,7 @@ "containerLabels": "Etykiety", "containerLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "containerLabelsTitle": "Etykiety kontenera", - "containerLabelEmpty": "", + "containerLabelEmpty": "", "containerPorts": "Porty", "containerPortsMore": "+{count} więcej", "containerActions": "Akcje", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Subdomena: {subdomain}", "domainPickerNamespace": "Przestrzeń nazw: {namespace}", "domainPickerShowMore": "Pokaż więcej", + "regionSelectorTitle": "Wybierz region", + "regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.", + "regionSelectorPlaceholder": "Wybierz region", + "regionSelectorComingSoon": "Wkrótce dostępne", + "billingLoadingSubscription": "Ładowanie subskrypcji...", + "billingFreeTier": "Darmowy pakiet", + "billingWarningOverLimit": "Ostrzeżenie: Przekroczyłeś jeden lub więcej limitów użytkowania. Twoje witryny nie połączą się, dopóki nie zmienisz subskrypcji lub nie dostosujesz użytkowania.", + "billingUsageLimitsOverview": "Przegląd Limitów Użytkowania", + "billingMonitorUsage": "Monitoruj swoje wykorzystanie w porównaniu do skonfigurowanych limitów. Jeśli potrzebujesz zwiększenia limitów, skontaktuj się z nami pod adresem support@fossorial.io.", + "billingDataUsage": "Użycie danych", + "billingOnlineTime": "Czas Online Strony", + "billingUsers": "Aktywni użytkownicy", + "billingDomains": "Aktywne domeny", + "billingRemoteExitNodes": "Aktywne samodzielnie-hostowane węzły", + "billingNoLimitConfigured": "Nie skonfigurowano limitu", + "billingEstimatedPeriod": "Szacowany Okres Rozliczeniowy", + "billingIncludedUsage": "Zawarte użycie", + "billingIncludedUsageDescription": "Użycie zawarte w obecnym planie subskrypcji", + "billingFreeTierIncludedUsage": "Limity użycia dla darmowego pakietu", + "billingIncluded": "zawarte", + "billingEstimatedTotal": "Szacowana Całkowita:", + "billingNotes": "Notatki", + "billingEstimateNote": "To jest szacunkowe, oparte na Twoim obecnym użyciu.", + "billingActualChargesMayVary": "Rzeczywiste opłaty mogą się różnić.", + "billingBilledAtEnd": "Zostaniesz obciążony na koniec okresu rozliczeniowego.", + "billingModifySubscription": "Modyfikuj Subskrypcję", + "billingStartSubscription": "Rozpocznij Subskrypcję", + "billingRecurringCharge": "Opłata Cyklowa", + "billingManageSubscriptionSettings": "Zarządzaj ustawieniami i preferencjami subskrypcji", + "billingNoActiveSubscription": "Nie masz aktywnej subskrypcji. Rozpocznij subskrypcję, aby zwiększyć limity użytkowania.", + "billingFailedToLoadSubscription": "Nie udało się załadować subskrypcji", + "billingFailedToLoadUsage": "Nie udało się załadować użycia", + "billingFailedToGetCheckoutUrl": "Nie udało się uzyskać adresu URL zakupu", + "billingPleaseTryAgainLater": "Spróbuj ponownie później.", + "billingCheckoutError": "Błąd przy kasie", + "billingFailedToGetPortalUrl": "Nie udało się uzyskać adresu URL portalu", + "billingPortalError": "Błąd Portalu", + "billingDataUsageInfo": "Jesteś obciążony za wszystkie dane przesyłane przez bezpieczne tunele, gdy jesteś podłączony do chmury. Obejmuje to zarówno ruch przychodzący, jak i wychodzący we wszystkich Twoich witrynach. Gdy osiągniesz swój limit, twoje strony zostaną rozłączone, dopóki nie zaktualizujesz planu lub nie ograniczysz użycia. Dane nie będą naliczane przy użyciu węzłów.", + "billingOnlineTimeInfo": "Opłata zależy od tego, jak długo twoje strony pozostają połączone z chmurą. Na przykład 44,640 minut oznacza jedną stronę działającą 24/7 przez cały miesiąc. Kiedy osiągniesz swój limit, twoje strony zostaną rozłączone, dopóki nie zaktualizujesz planu lub nie zmniejsz jego wykorzystania. Czas nie będzie naliczany przy użyciu węzłów.", + "billingUsersInfo": "Jesteś obciążany za każdego użytkownika w twojej organizacji. Rozliczenia są obliczane codziennie na podstawie liczby aktywnych kont użytkowników w twojej organizacji.", + "billingDomainInfo": "Jesteś obciążany za każdą domenę w twojej organizacji. Rozliczenia są obliczane codziennie na podstawie liczby aktywnych kont domen w twojej organizacji.", + "billingRemoteExitNodesInfo": "Jesteś obciążany za każdy zarządzany węzeł w twojej organizacji. Rozliczenia są obliczane codziennie na podstawie liczby aktywnych zarządzanych węzłów w twojej organizacji.", "domainNotFound": "Nie znaleziono domeny", "domainNotFoundDescription": "Zasób jest wyłączony, ponieważ domena nie istnieje już w naszym systemie. Proszę ustawić nową domenę dla tego zasobu.", "failed": "Niepowodzenie", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Zmiany DNS mogą zająć trochę czasu na rozpropagowanie się w Internecie. Może to potrwać od kilku minut do 48 godzin, w zależności od dostawcy DNS i ustawień TTL.", "resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP", "resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP", + "billingPricingCalculatorLink": "Kalkulator Cen", "signUpTerms": { "IAgreeToThe": "Zgadzam się z", "termsOfService": "warunkami usługi", @@ -1368,6 +1412,41 @@ "addNewTarget": "Dodaj nowy cel", "targetsList": "Lista celów", "targetErrorDuplicateTargetFound": "Znaleziono duplikat celu", + "healthCheckHealthy": "Zdrowy", + "healthCheckUnhealthy": "Niezdrowy", + "healthCheckUnknown": "Nieznany", + "healthCheck": "Kontrola Zdrowia", + "configureHealthCheck": "Skonfiguruj Kontrolę Zdrowia", + "configureHealthCheckDescription": "Skonfiguruj monitorowanie zdrowia dla {target}", + "enableHealthChecks": "Włącz Kontrole Zdrowia", + "enableHealthChecksDescription": "Monitoruj zdrowie tego celu. Możesz monitorować inny punkt końcowy niż docelowy w razie potrzeby.", + "healthScheme": "Metoda", + "healthSelectScheme": "Wybierz metodę", + "healthCheckPath": "Ścieżka", + "healthHostname": "IP / Host", + "healthPort": "Port", + "healthCheckPathDescription": "Ścieżka do sprawdzania stanu zdrowia.", + "healthyIntervalSeconds": "Interwał Zdrowy", + "unhealthyIntervalSeconds": "Interwał Niezdrowy", + "IntervalSeconds": "Interwał Zdrowy", + "timeoutSeconds": "Limit Czasu", + "timeIsInSeconds": "Czas w sekundach", + "retryAttempts": "Próby Ponowienia", + "expectedResponseCodes": "Oczekiwane Kody Odpowiedzi", + "expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.", + "customHeaders": "Niestandardowe nagłówki", + "customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość", + "headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.", + "saveHealthCheck": "Zapisz Kontrolę Zdrowia", + "healthCheckSaved": "Kontrola Zdrowia Zapisana", + "healthCheckSavedDescription": "Konfiguracja kontroli zdrowia została zapisana pomyślnie", + "healthCheckError": "Błąd Kontroli Zdrowia", + "healthCheckErrorDescription": "Wystąpił błąd podczas zapisywania konfiguracji kontroli zdrowia", + "healthCheckPathRequired": "Ścieżka kontroli zdrowia jest wymagana", + "healthCheckMethodRequired": "Metoda HTTP jest wymagana", + "healthCheckIntervalMin": "Interwał sprawdzania musi wynosić co najmniej 5 sekund", + "healthCheckTimeoutMin": "Limit czasu musi wynosić co najmniej 1 sekundę", + "healthCheckRetryMin": "Liczba prób ponowienia musi wynosić co najmniej 1", "httpMethod": "Metoda HTTP", "selectHttpMethod": "Wybierz metodę HTTP", "domainPickerSubdomainLabel": "Poddomena", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Wprowadź poddomenę, aby wyszukać i wybrać z dostępnych darmowych domen.", "domainPickerFreeDomains": "Darmowe domeny", "domainPickerSearchForAvailableDomains": "Szukaj dostępnych domen", + "domainPickerNotWorkSelfHosted": "Uwaga: Darmowe domeny nie są obecnie dostępne dla instancji samodzielnie-hostowanych.", "resourceDomain": "Domena", "resourceEditDomain": "Edytuj domenę", "siteName": "Nazwa strony", @@ -1463,6 +1543,72 @@ "autoLoginError": "Błąd automatycznego logowania", "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.", + "remoteExitNodeManageRemoteExitNodes": "Zarządzaj Samodzielnie-Hostingowane", + "remoteExitNodeDescription": "Zarządzaj węzłami w celu rozszerzenia połączenia z siecią", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Szukaj węzłów...", + "remoteExitNodeAdd": "Dodaj węzeł", + "remoteExitNodeErrorDelete": "Błąd podczas usuwania węzła", + "remoteExitNodeQuestionRemove": "Czy na pewno chcesz usunąć węzeł {selectedNode} z organizacji?", + "remoteExitNodeMessageRemove": "Po usunięciu, węzeł nie będzie już dostępny.", + "remoteExitNodeMessageConfirm": "Aby potwierdzić, wpisz nazwę węzła poniżej.", + "remoteExitNodeConfirmDelete": "Potwierdź usunięcie węzła", + "remoteExitNodeDelete": "Usuń węzeł", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Utwórz węzeł", + "description": "Utwórz nowy węzeł, aby rozszerzyć połączenie z siecią", + "viewAllButton": "Zobacz wszystkie węzły", + "strategy": { + "title": "Strategia Tworzenia", + "description": "Wybierz to, aby ręcznie skonfigurować węzeł lub wygenerować nowe poświadczenia.", + "adopt": { + "title": "Zaadoptuj Węzeł", + "description": "Wybierz to, jeśli masz już dane logowania dla węzła." + }, + "generate": { + "title": "Generuj Klucze", + "description": "Wybierz to, jeśli chcesz wygenerować nowe klucze dla węzła" + } + }, + "adopt": { + "title": "Zaadoptuj Istniejący Węzeł", + "description": "Wprowadź dane logowania istniejącego węzła, który chcesz przyjąć", + "nodeIdLabel": "ID węzła", + "nodeIdDescription": "ID istniejącego węzła, który chcesz przyjąć", + "secretLabel": "Sekret", + "secretDescription": "Sekretny klucz istniejącego węzła", + "submitButton": "Przyjmij węzeł" + }, + "generate": { + "title": "Wygenerowane Poświadczenia", + "description": "Użyj tych danych logowania, aby skonfigurować węzeł", + "nodeIdTitle": "ID węzła", + "secretTitle": "Sekret", + "saveCredentialsTitle": "Dodaj Poświadczenia do Konfiguracji", + "saveCredentialsDescription": "Dodaj te poświadczenia do pliku konfiguracyjnego swojego samodzielnie-hostowanego węzła Pangolin, aby zakończyć połączenie.", + "submitButton": "Utwórz węzeł" + }, + "validation": { + "adoptRequired": "Identyfikator węzła i sekret są wymagane podczas przyjmowania istniejącego węzła" + }, + "errors": { + "loadDefaultsFailed": "Nie udało się załadować domyślnych ustawień", + "defaultsNotLoaded": "Domyślne ustawienia nie zostały załadowane", + "createFailed": "Nie udało się utworzyć węzła" + }, + "success": { + "created": "Węzeł utworzony pomyślnie" + } + }, + "remoteExitNodeSelection": "Wybór węzła", + "remoteExitNodeSelectionDescription": "Wybierz węzeł do przekierowania ruchu dla tej lokalnej witryny", + "remoteExitNodeRequired": "Węzeł musi być wybrany dla lokalnych witryn", + "noRemoteExitNodesAvailable": "Brak dostępnych węzłów", + "noRemoteExitNodesAvailableDescription": "Węzły nie są dostępne dla tej organizacji. Utwórz węzeł, aby używać lokalnych witryn.", + "exitNode": "Węzeł Wyjściowy", + "country": "Kraj", + "rulesMatchCountry": "Obecnie bazuje na adresie IP źródła", "managedSelfHosted": { "title": "Zarządzane Samodzielnie-Hostingowane", "description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Wykryto międzynarodową domenę", "willbestoredas": "Będą przechowywane jako:", + "roleMappingDescription": "Określ jak role są przypisywane do użytkowników podczas logowania się, gdy automatyczne świadczenie jest włączone.", + "selectRole": "Wybierz rolę", + "roleMappingExpression": "Wyrażenie", + "selectRolePlaceholder": "Wybierz rolę", + "selectRoleDescription": "Wybierz rolę do przypisania wszystkim użytkownikom od tego dostawcy tożsamości", + "roleMappingExpressionDescription": "Wprowadź wyrażenie JMESŚcieżki, aby wyodrębnić informacje o roli z tokenu ID", + "idpTenantIdRequired": "ID lokatora jest wymagane", + "invalidValue": "Nieprawidłowa wartość", + "idpTypeLabel": "Typ dostawcy tożsamości", + "roleMappingExpressionPlaceholder": "np. zawiera(grupy, 'admin') && 'Admin' || 'Członek'", + "idpGoogleConfiguration": "Konfiguracja Google", + "idpGoogleConfigurationDescription": "Skonfiguruj swoje poświadczenia Google OAuth2", + "idpGoogleClientIdDescription": "Twój identyfikator klienta Google OAuth2", + "idpGoogleClientSecretDescription": "Twój klucz klienta Google OAuth2", + "idpAzureConfiguration": "Konfiguracja Azure Entra ID", + "idpAzureConfigurationDescription": "Skonfiguruj swoje dane logowania OAuth2 Azure Entra", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "twoj-lokator", + "idpAzureTenantIdDescription": "Twój identyfikator dzierżawcy Azure (znaleziony w Podglądzie Azure Active Directory", + "idpAzureClientIdDescription": "Twój identyfikator klienta rejestracji aplikacji Azure", + "idpAzureClientSecretDescription": "Klucz tajny Twojego klienta rejestracji aplikacji Azure", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Konfiguracja Google", + "idpAzureConfigurationTitle": "Konfiguracja Azure Entra ID", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Twój identyfikator klienta rejestracji aplikacji Azure", + "idpAzureClientSecretDescription2": "Klucz tajny Twojego klienta rejestracji aplikacji Azure", "idpGoogleDescription": "Dostawca Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Niestandardowe nagłówki", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.", + "subnet": "Podsieć", + "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", + "authPage": "Strona uwierzytelniania", + "authPageDescription": "Skonfiguruj stronę uwierzytelniania dla swojej organizacji", + "authPageDomain": "Domena strony uwierzytelniania", + "noDomainSet": "Nie ustawiono domeny", + "changeDomain": "Zmień domenę", + "selectDomain": "Wybierz domenę", + "restartCertificate": "Uruchom ponownie certyfikat", + "editAuthPageDomain": "Edytuj domenę strony uwierzytelniania", + "setAuthPageDomain": "Ustaw domenę strony uwierzytelniania", + "failedToFetchCertificate": "Nie udało się pobrać certyfikatu", + "failedToRestartCertificate": "Nie udało się ponownie uruchomić certyfikatu", + "addDomainToEnableCustomAuthPages": "Dodaj domenę, aby włączyć niestandardowe strony uwierzytelniania dla Twojej organizacji", + "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "domainPickerProvidedDomain": "Dostarczona domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", "domainPickerVerified": "Zweryfikowano", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.", "domainPickerSubdomainSanitized": "Poddomena oczyszczona", "domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"", + "orgAuthSignInTitle": "Zaloguj się do swojej organizacji", + "orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", + "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", + "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", + "subscriptionRequiredToUse": "Do korzystania z tej funkcji wymagana jest subskrypcja.", + "idpDisabled": "Dostawcy tożsamości są wyłączeni", + "orgAuthPageDisabled": "Strona autoryzacji organizacji jest wyłączona.", + "domainRestartedDescription": "Weryfikacja domeny zrestartowana pomyślnie", "resourceAddEntrypointsEditFile": "Edytuj plik: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml", "emailVerificationRequired": "Weryfikacja adresu e-mail jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login zakończył ten krok. Następnie wróć tutaj.", - "twoFactorSetupRequired": "Konfiguracja uwierzytelniania dwuskładnikowego jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login dokończ ten krok. Następnie wróć tutaj.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Konfiguracja uwierzytelniania dwuskładnikowego jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login dokończ ten krok. Następnie wróć tutaj." } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index a6667a9c..151ee73f 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -8,25 +8,25 @@ "orgId": "ID da organização", "setupIdentifierMessage": "Este é o identificador exclusivo para sua organização. Isso é separado do nome de exibição.", "setupErrorIdentifier": "O ID da organização já existe. Por favor, escolha um diferente.", - "componentsErrorNoMemberCreate": "Você não é atualmente um membro de nenhuma organização. Crie uma organização para começar.", - "componentsErrorNoMember": "Você não é atualmente um membro de nenhuma organização.", + "componentsErrorNoMemberCreate": "Não é atualmente um membro de nenhuma organização. Crie uma organização para começar.", + "componentsErrorNoMember": "Não é atualmente um membro de nenhuma organização.", "welcome": "Bem-vindo ao Pangolin", "welcomeTo": "Bem-vindo ao", "componentsCreateOrg": "Criar uma organização", - "componentsMember": "Você é membro de {count, plural, =0 {nenhuma organização} one {uma organização} other {# organizações}}.", + "componentsMember": "É membro de {count, plural, =0 {nenhuma organização} one {uma organização} other {# organizações}}.", "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", - "dismiss": "Descartar", + "dismiss": "Rejeitar", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", "componentsSupporterMessage": "Obrigado por apoiar o Pangolin como um {tier}!", - "inviteErrorNotValid": "Desculpe, mas parece que o convite que você está tentando acessar não foi aceito ou não é mais válido.", - "inviteErrorUser": "Lamentamos, mas parece que o convite que você está tentando acessar não é para este usuário.", - "inviteLoginUser": "Verifique se você está logado como o usuário correto.", - "inviteErrorNoUser": "Desculpe, mas parece que o convite que você está tentando acessar não é para um usuário que existe.", + "inviteErrorNotValid": "Desculpe, mas parece que o convite que está a tentar aceder não foi aceito ou não é mais válido.", + "inviteErrorUser": "Lamentamos, mas parece que o convite que está a tentar aceder não é para este utilizador.", + "inviteLoginUser": "Verifique se você está logado como o utilizador correto.", + "inviteErrorNoUser": "Desculpe, mas parece que o convite que está a tentar aceder não é para um utilizador que existe.", "inviteCreateUser": "Por favor, crie uma conta primeiro.", - "goHome": "Ir para casa", - "inviteLogInOtherUser": "Fazer login como um usuário diferente", + "goHome": "Voltar ao inicio", + "inviteLogInOtherUser": "Fazer login como um utilizador diferente", "createAnAccount": "Crie uma conta", - "inviteNotAccepted": "Convite não aceito", + "inviteNotAccepted": "Convite não aceite", "authCreateAccount": "Crie uma conta para começar", "authNoAccount": "Não possui uma conta?", "email": "e-mail", @@ -34,23 +34,23 @@ "confirmPassword": "Confirmar senha", "createAccount": "Criar conta", "viewSettings": "Visualizar configurações", - "delete": "excluir", + "delete": "apagar", "name": "Nome:", "online": "Disponível", "offline": "Desconectado", "site": "site", - "dataIn": "Dados em", + "dataIn": "Dados de entrada", "dataOut": "Dados de saída", "connectionType": "Tipo de conexão", "tunnelType": "Tipo de túnel", "local": "Localização", "edit": "Alterar", - "siteConfirmDelete": "Confirmar exclusão do site", + "siteConfirmDelete": "Confirmar que pretende apagar o site", "siteDelete": "Excluir site", "siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todos os recursos e alvos associados ao site também serão removidos.", "siteMessageConfirm": "Para confirmar, por favor, digite o nome do site abaixo.", "siteQuestionRemove": "Você tem certeza que deseja remover o site {selectedSite} da organização?", - "siteManageSites": "Gerenciar sites", + "siteManageSites": "Gerir sites", "siteDescription": "Permitir conectividade à sua rede através de túneis seguros", "siteCreate": "Criar site", "siteCreateDescription2": "Siga os passos abaixo para criar e conectar um novo site", @@ -79,10 +79,10 @@ "operatingSystem": "Sistema operacional", "commands": "Comandos", "recommended": "Recomendados", - "siteNewtDescription": "Para a melhor experiência do usuário, utilize Novo. Ele usa o WireGuard sob o capuz e permite que você aborde seus recursos privados através dos endereços LAN em sua rede privada do painel do Pangolin.", + "siteNewtDescription": "Para a melhor experiência do utilizador, utilize Novo. Ele usa o WireGuard sob o capuz e permite que você aborde seus recursos privados através dos endereços LAN em sua rede privada do painel do Pangolin.", "siteRunsInDocker": "Executa no Docker", "siteRunsInShell": "Executa na shell no macOS, Linux e Windows", - "siteErrorDelete": "Erro ao excluir site", + "siteErrorDelete": "Erro ao apagar site", "siteErrorUpdate": "Falha ao atualizar site", "siteErrorUpdateDescription": "Ocorreu um erro ao atualizar o site.", "siteUpdated": "Site atualizado", @@ -105,12 +105,12 @@ "siteCredentialsSaveDescription": "Você só será capaz de ver esta vez. Certifique-se de copiá-lo para um lugar seguro.", "siteInfo": "Informações do Site", "status": "SItuação", - "shareTitle": "Gerenciar links de compartilhamento", + "shareTitle": "Gerir links partilhados", "shareDescription": "Criar links compartilháveis para conceder acesso temporário ou permanente aos seus recursos", "shareSearch": "Pesquisar links de compartilhamento...", "shareCreate": "Criar Link de Compartilhamento", - "shareErrorDelete": "Falha ao excluir o link", - "shareErrorDeleteMessage": "Ocorreu um erro ao excluir o link", + "shareErrorDelete": "Falha ao apagar o link", + "shareErrorDeleteMessage": "Ocorreu um erro ao apagar o link", "shareDeleted": "Link excluído", "shareDeletedDescription": "O link foi eliminado", "shareTokenDescription": "Seu token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.", @@ -127,13 +127,13 @@ "shareErrorFetchResourceDescription": "Ocorreu um erro ao obter os recursos", "shareErrorCreate": "Falha ao criar link de compartilhamento", "shareErrorCreateDescription": "Ocorreu um erro ao criar o link de compartilhamento", - "shareCreateDescription": "Qualquer um com este link pode acessar o recurso", + "shareCreateDescription": "Qualquer um com este link pode aceder o recurso", "shareTitleOptional": "Título (opcional)", "expireIn": "Expira em", "neverExpire": "Nunca expirar", - "shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os usuários que usaram este link perderão acesso ao recurso.", + "shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os utilizadores que usaram este link perderão acesso ao recurso.", "shareSeeOnce": "Você só poderá ver este link uma vez. Certifique-se de copiá-lo.", - "shareAccessHint": "Qualquer um com este link pode acessar o recurso. Compartilhe com cuidado.", + "shareAccessHint": "Qualquer um com este link pode aceder o recurso. Compartilhe com cuidado.", "shareTokenUsage": "Ver Uso do Token de Acesso", "createLink": "Criar Link", "resourcesNotFound": "Nenhum recurso encontrado", @@ -145,11 +145,11 @@ "expires": "Expira", "never": "nunca", "shareErrorSelectResource": "Por favor, selecione um recurso", - "resourceTitle": "Gerenciar Recursos", + "resourceTitle": "Gerir Recursos", "resourceDescription": "Crie proxies seguros para seus aplicativos privados", "resourcesSearch": "Procurar recursos...", "resourceAdd": "Adicionar Recurso", - "resourceErrorDelte": "Erro ao excluir recurso", + "resourceErrorDelte": "Erro ao apagar recurso", "authentication": "Autenticação", "protected": "Protegido", "notProtected": "Não Protegido", @@ -168,9 +168,12 @@ "siteSelect": "Selecionar site", "siteSearch": "Procurar no site", "siteNotFound": "Nenhum site encontrado.", + "selectCountry": "Selecionar país", + "searchCountries": "Buscar países...", + "noCountryFound": "Nenhum país encontrado.", "siteSelectionDescription": "Este site fornecerá conectividade ao destino.", "resourceType": "Tipo de Recurso", - "resourceTypeDescription": "Determine como você deseja acessar seu recurso", + "resourceTypeDescription": "Determine como você deseja aceder seu recurso", "resourceHTTPSSettings": "Configurações de HTTPS", "resourceHTTPSSettingsDescription": "Configure como seu recurso será acessado por HTTPS", "domainType": "Tipo de domínio", @@ -192,7 +195,7 @@ "resourceBack": "Voltar aos recursos", "resourceGoTo": "Ir para o Recurso", "resourceDelete": "Excluir Recurso", - "resourceDeleteConfirm": "Confirmar exclusão de recurso", + "resourceDeleteConfirm": "Confirmar que pretende apagar o recurso", "visibility": "Visibilidade", "enabled": "Ativado", "disabled": "Desabilitado", @@ -208,14 +211,14 @@ "passToAuth": "Passar para Autenticação", "orgSettingsDescription": "Configurar as configurações gerais da sua organização", "orgGeneralSettings": "Configurações da organização", - "orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização", - "saveGeneralSettings": "Salvar configurações gerais", - "saveSettings": "Salvar Configurações", + "orgGeneralSettingsDescription": "Gerir os detalhes e a configuração da sua organização", + "saveGeneralSettings": "Guardar configurações gerais", + "saveSettings": "Guardar Configurações", "orgDangerZone": "Zona de Perigo", "orgDangerZoneDescription": "Uma vez que você exclui esta organização, não há volta. Por favor, tenha certeza.", "orgDelete": "Excluir Organização", - "orgDeleteConfirm": "Confirmar exclusão da organização", - "orgMessageRemove": "Esta ação é irreversível e excluirá todos os dados associados.", + "orgDeleteConfirm": "Confirmar que pretende apagar a organização", + "orgMessageRemove": "Esta ação é irreversível e apagará todos os dados associados.", "orgMessageConfirm": "Para confirmar, digite o nome da organização abaixo.", "orgQuestionRemove": "Tem certeza que deseja remover a organização {selectedOrg}?", "orgUpdated": "Organização atualizada", @@ -224,29 +227,29 @@ "orgErrorUpdateMessage": "Ocorreu um erro ao atualizar a organização.", "orgErrorFetch": "Falha ao buscar organizações", "orgErrorFetchMessage": "Ocorreu um erro ao listar suas organizações", - "orgErrorDelete": "Falha ao excluir organização", - "orgErrorDeleteMessage": "Ocorreu um erro ao excluir a organização.", + "orgErrorDelete": "Falha ao apagar organização", + "orgErrorDeleteMessage": "Ocorreu um erro ao apagar a organização.", "orgDeleted": "Organização excluída", "orgDeletedMessage": "A organização e seus dados foram excluídos.", "orgMissing": "ID da Organização Ausente", "orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.", - "accessUsersManage": "Gerenciar Usuários", - "accessUsersDescription": "Convidar usuários e adicioná-los a funções para gerenciar o acesso à sua organização", - "accessUsersSearch": "Procurar usuários...", + "accessUsersManage": "Gerir Utilizadores", + "accessUsersDescription": "Convidar utilizadores e adicioná-los a funções para gerir o acesso à sua organização", + "accessUsersSearch": "Procurar utilizadores...", "accessUserCreate": "Criar Usuário", - "accessUserRemove": "Remover usuário", + "accessUserRemove": "Remover utilizador", "username": "Usuário:", "identityProvider": "Provedor de Identidade", "role": "Funções", "nameRequired": "O nome é obrigatório", - "accessRolesManage": "Gerenciar Funções", - "accessRolesDescription": "Configurar funções para gerenciar o acesso à sua organização", + "accessRolesManage": "Gerir Funções", + "accessRolesDescription": "Configurar funções para gerir o acesso à sua organização", "accessRolesSearch": "Pesquisar funções...", "accessRolesAdd": "Adicionar função", "accessRoleDelete": "Excluir Papel", "description": "Descrição:", "inviteTitle": "Convites Abertos", - "inviteDescription": "Gerencie seus convites para outros usuários", + "inviteDescription": "Gerir seus convites para outros utilizadores", "inviteSearch": "Procurar convites...", "minutes": "minutos", "hours": "horas", @@ -264,7 +267,7 @@ "apiKeysGeneralSettings": "Permissões", "apiKeysGeneralSettingsDescription": "Determine o que esta chave API pode fazer", "apiKeysList": "Sua Chave API", - "apiKeysSave": "Salvar Sua Chave API", + "apiKeysSave": "Guardar Sua Chave API", "apiKeysSaveDescription": "Você só poderá ver isto uma vez. Certifique-se de copiá-la para um local seguro.", "apiKeysInfo": "Sua chave API é:", "apiKeysConfirmCopy": "Eu copiei a chave API", @@ -277,33 +280,33 @@ "apiKeysPermissionsUpdatedDescription": "As permissões foram atualizadas.", "apiKeysPermissionsGeneralSettings": "Permissões", "apiKeysPermissionsGeneralSettingsDescription": "Determine o que esta chave API pode fazer", - "apiKeysPermissionsSave": "Salvar Permissões", + "apiKeysPermissionsSave": "Guardar Permissões", "apiKeysPermissionsTitle": "Permissões", "apiKeys": "Chaves API", "searchApiKeys": "Pesquisar chaves API...", "apiKeysAdd": "Gerar Chave API", - "apiKeysErrorDelete": "Erro ao excluir chave API", - "apiKeysErrorDeleteMessage": "Erro ao excluir chave API", + "apiKeysErrorDelete": "Erro ao apagar chave API", + "apiKeysErrorDeleteMessage": "Erro ao apagar chave API", "apiKeysQuestionRemove": "Tem certeza que deseja remover a chave API {selectedApiKey} da organização?", "apiKeysMessageRemove": "Uma vez removida, a chave API não poderá mais ser utilizada.", "apiKeysMessageConfirm": "Para confirmar, por favor digite o nome da chave API abaixo.", "apiKeysDeleteConfirm": "Confirmar Exclusão da Chave API", "apiKeysDelete": "Excluir Chave API", - "apiKeysManage": "Gerenciar Chaves API", + "apiKeysManage": "Gerir Chaves API", "apiKeysDescription": "As chaves API são usadas para autenticar com a API de integração", "apiKeysSettings": "Configurações de {apiKeyName}", - "userTitle": "Gerenciar Todos os Usuários", - "userDescription": "Visualizar e gerenciar todos os usuários no sistema", + "userTitle": "Gerir Todos os Utilizadores", + "userDescription": "Visualizar e gerir todos os utilizadores no sistema", "userAbount": "Sobre a Gestão de Usuário", - "userAbountDescription": "Esta tabela exibe todos os objetos root do usuário. Cada usuário pode pertencer a várias organizações. Remover um usuário de uma organização não exclui seu objeto de usuário raiz - ele permanecerá no sistema. Para remover completamente um usuário do sistema, você deve excluir seu objeto raiz usando a ação de excluir nesta tabela.", - "userServer": "Usuários do Servidor", - "userSearch": "Pesquisar usuários do servidor...", - "userErrorDelete": "Erro ao excluir usuário", + "userAbountDescription": "Esta tabela exibe todos os objetos root do utilizador. Cada utilizador pode pertencer a várias organizações. Remover um utilizador de uma organização não exclui seu objeto de utilizador raiz - ele permanecerá no sistema. Para remover completamente um utilizador do sistema, você deve apagar seu objeto raiz usando a ação de apagar nesta tabela.", + "userServer": "Utilizadores do Servidor", + "userSearch": "Pesquisar utilizadores do servidor...", + "userErrorDelete": "Erro ao apagar utilizador", "userDeleteConfirm": "Confirmar Exclusão do Usuário", - "userDeleteServer": "Excluir usuário do servidor", - "userMessageRemove": "O usuário será removido de todas as organizações e será completamente removido do servidor.", - "userMessageConfirm": "Para confirmar, por favor digite o nome do usuário abaixo.", - "userQuestionRemove": "Tem certeza que deseja excluir o {selectedUser} permanentemente do servidor?", + "userDeleteServer": "Excluir utilizador do servidor", + "userMessageRemove": "O utilizador será removido de todas as organizações e será completamente removido do servidor.", + "userMessageConfirm": "Para confirmar, por favor digite o nome do utilizador abaixo.", + "userQuestionRemove": "Tem certeza que deseja apagar o {selectedUser} permanentemente do servidor?", "licenseKey": "Chave de Licença", "valid": "Válido", "numberOfSites": "Número de sites", @@ -314,8 +317,8 @@ "licenseTermsAgree": "Você deve concordar com os termos da licença", "licenseErrorKeyLoad": "Falha ao carregar chaves de licença", "licenseErrorKeyLoadDescription": "Ocorreu um erro ao carregar a chave da licença.", - "licenseErrorKeyDelete": "Falha ao excluir chave de licença", - "licenseErrorKeyDeleteDescription": "Ocorreu um erro ao excluir a chave de licença.", + "licenseErrorKeyDelete": "Falha ao apagar chave de licença", + "licenseErrorKeyDeleteDescription": "Ocorreu um erro ao apagar a chave de licença.", "licenseKeyDeleted": "Chave da licença excluída", "licenseKeyDeletedDescription": "A chave da licença foi excluída.", "licenseErrorKeyActivate": "Falha ao ativar a chave de licença", @@ -336,13 +339,13 @@ "fossorialLicense": "Ver Termos e Condições de Assinatura e Licença Fossorial", "licenseMessageRemove": "Isto irá remover a chave da licença e todas as permissões associadas concedidas por ela.", "licenseMessageConfirm": "Para confirmar, por favor, digite a chave de licença abaixo.", - "licenseQuestionRemove": "Tem certeza que deseja excluir a chave de licença {selectedKey}?", + "licenseQuestionRemove": "Tem certeza que deseja apagar a chave de licença {selectedKey}?", "licenseKeyDelete": "Excluir Chave de Licença", - "licenseKeyDeleteConfirm": "Confirmar exclusão da chave de licença", - "licenseTitle": "Gerenciar Status da Licença", - "licenseTitleDescription": "Visualizar e gerenciar chaves de licença no sistema", + "licenseKeyDeleteConfirm": "Confirmar que pretende apagar a chave de licença", + "licenseTitle": "Gerir Status da Licença", + "licenseTitleDescription": "Visualizar e gerir chaves de licença no sistema", "licenseHost": "Licença do host", - "licenseHostDescription": "Gerenciar a chave de licença principal do host.", + "licenseHostDescription": "Gerir a chave de licença principal do host.", "licensedNot": "Não Licenciado", "hostId": "ID do host", "licenseReckeckAll": "Verifique novamente todas as chaves", @@ -370,37 +373,37 @@ "inviteRemoved": "Convite removido", "inviteRemovedDescription": "O convite para {email} foi removido.", "inviteQuestionRemove": "Tem certeza de que deseja remover o convite {email}?", - "inviteMessageRemove": "Uma vez removido, este convite não será mais válido. Você sempre pode convidar o usuário novamente mais tarde.", + "inviteMessageRemove": "Uma vez removido, este convite não será mais válido. Você sempre pode convidar o utilizador novamente mais tarde.", "inviteMessageConfirm": "Para confirmar, digite o endereço de e-mail do convite abaixo.", "inviteQuestionRegenerate": "Tem certeza que deseja regenerar o convite{email, plural, ='' {}, other { para #}}? Isso irá revogar o convite anterior.", "inviteRemoveConfirm": "Confirmar Remoção do Convite", "inviteRegenerated": "Convite Regenerado", "inviteSent": "Um novo convite foi enviado para {email}.", - "inviteSentEmail": "Enviar notificação por e-mail ao usuário", + "inviteSentEmail": "Enviar notificação por e-mail ao utilizador", "inviteGenerate": "Um novo convite foi gerado para {email}.", "inviteDuplicateError": "Convite Duplicado", - "inviteDuplicateErrorDescription": "Já existe um convite para este usuário.", + "inviteDuplicateErrorDescription": "Já existe um convite para este utilizador.", "inviteRateLimitError": "Limite de Taxa Excedido", - "inviteRateLimitErrorDescription": "Você excedeu o limite de 3 regenerações por hora. Por favor, tente novamente mais tarde.", + "inviteRateLimitErrorDescription": "Excedeu o limite de 3 regenerações por hora. Por favor, tente novamente mais tarde.", "inviteRegenerateError": "Falha ao Regenerar Convite", "inviteRegenerateErrorDescription": "Ocorreu um erro ao regenerar o convite.", "inviteValidityPeriod": "Período de Validade", "inviteValidityPeriodSelect": "Selecione o período de validade", - "inviteRegenerateMessage": "O convite foi regenerado. O usuário deve acessar o link abaixo para aceitar o convite.", + "inviteRegenerateMessage": "O convite foi regenerado. O utilizador deve aceder o link abaixo para aceitar o convite.", "inviteRegenerateButton": "Regenerar", "expiresAt": "Expira em", "accessRoleUnknown": "Função Desconhecida", "placeholder": "Espaço reservado", - "userErrorOrgRemove": "Falha ao remover usuário", - "userErrorOrgRemoveDescription": "Ocorreu um erro ao remover o usuário.", + "userErrorOrgRemove": "Falha ao remover utilizador", + "userErrorOrgRemoveDescription": "Ocorreu um erro ao remover o utilizador.", "userOrgRemoved": "Usuário removido", - "userOrgRemovedDescription": "O usuário {email} foi removido da organização.", + "userOrgRemovedDescription": "O utilizador {email} foi removido da organização.", "userQuestionOrgRemove": "Tem certeza que deseja remover {email} da organização?", - "userMessageOrgRemove": "Uma vez removido, este usuário não terá mais acesso à organização. Você sempre pode reconvidá-lo depois, mas eles precisarão aceitar o convite novamente.", - "userMessageOrgConfirm": "Para confirmar, digite o nome do usuário abaixo.", + "userMessageOrgRemove": "Uma vez removido, este utilizador não terá mais acesso à organização. Você sempre pode reconvidá-lo depois, mas eles precisarão aceitar o convite novamente.", + "userMessageOrgConfirm": "Para confirmar, digite o nome do utilizador abaixo.", "userRemoveOrgConfirm": "Confirmar Remoção do Usuário", "userRemoveOrg": "Remover Usuário da Organização", - "users": "Usuários", + "users": "Utilizadores", "accessRoleMember": "Membro", "accessRoleOwner": "Proprietário", "userConfirmed": "Confirmado", @@ -408,7 +411,7 @@ "emailInvalid": "Endereço de email inválido", "inviteValidityDuration": "Por favor, selecione uma duração", "accessRoleSelectPlease": "Por favor, selecione uma função", - "usernameRequired": "Nome de usuário é obrigatório", + "usernameRequired": "Nome de utilizador é obrigatório", "idpSelectPlease": "Por favor, selecione um provedor de identidade", "idpGenericOidc": "Provedor genérico OAuth2/OIDC.", "accessRoleErrorFetch": "Falha ao buscar funções", @@ -416,51 +419,51 @@ "idpErrorFetch": "Falha ao buscar provedores de identidade", "idpErrorFetchDescription": "Ocorreu um erro ao buscar provedores de identidade", "userErrorExists": "Usuário já existe", - "userErrorExistsDescription": "Este usuário já é membro da organização.", - "inviteError": "Falha ao convidar usuário", - "inviteErrorDescription": "Ocorreu um erro ao convidar o usuário", + "userErrorExistsDescription": "Este utilizador já é membro da organização.", + "inviteError": "Falha ao convidar utilizador", + "inviteErrorDescription": "Ocorreu um erro ao convidar o utilizador", "userInvited": "Usuário convidado", - "userInvitedDescription": "O usuário foi convidado com sucesso.", - "userErrorCreate": "Falha ao criar usuário", - "userErrorCreateDescription": "Ocorreu um erro ao criar o usuário", + "userInvitedDescription": "O utilizador foi convidado com sucesso.", + "userErrorCreate": "Falha ao criar utilizador", + "userErrorCreateDescription": "Ocorreu um erro ao criar o utilizador", "userCreated": "Usuário criado", - "userCreatedDescription": "O usuário foi criado com sucesso.", + "userCreatedDescription": "O utilizador foi criado com sucesso.", "userTypeInternal": "Usuário Interno", - "userTypeInternalDescription": "Convidar um usuário para se juntar à sua organização diretamente.", + "userTypeInternalDescription": "Convidar um utilizador para se juntar à sua organização diretamente.", "userTypeExternal": "Usuário Externo", - "userTypeExternalDescription": "Criar um usuário com um provedor de identidade externo.", - "accessUserCreateDescription": "Siga os passos abaixo para criar um novo usuário", - "userSeeAll": "Ver Todos os Usuários", + "userTypeExternalDescription": "Criar um utilizador com um provedor de identidade externo.", + "accessUserCreateDescription": "Siga os passos abaixo para criar um novo utilizador", + "userSeeAll": "Ver Todos os Utilizadores", "userTypeTitle": "Tipo de Usuário", - "userTypeDescription": "Determine como você deseja criar o usuário", + "userTypeDescription": "Determine como você deseja criar o utilizador", "userSettings": "Informações do Usuário", - "userSettingsDescription": "Insira os detalhes para o novo usuário", - "inviteEmailSent": "Enviar e-mail de convite para o usuário", + "userSettingsDescription": "Insira os detalhes para o novo utilizador", + "inviteEmailSent": "Enviar e-mail de convite para o utilizador", "inviteValid": "Válido Por", "selectDuration": "Selecionar duração", "accessRoleSelect": "Selecionar função", - "inviteEmailSentDescription": "Um e-mail foi enviado ao usuário com o link de acesso abaixo. Eles devem acessar o link para aceitar o convite.", - "inviteSentDescription": "O usuário foi convidado. Eles devem acessar o link abaixo para aceitar o convite.", + "inviteEmailSentDescription": "Um e-mail foi enviado ao utilizador com o link de acesso abaixo. Eles devem aceder ao link para aceitar o convite.", + "inviteSentDescription": "O utilizador foi convidado. Eles devem aceder ao link abaixo para aceitar o convite.", "inviteExpiresIn": "O convite expirará em {days, plural, one {# dia} other {# dias}}.", "idpTitle": "Informações Gerais", - "idpSelect": "Selecione o provedor de identidade para o usuário externo", - "idpNotConfigured": "Nenhum provedor de identidade está configurado. Configure um provedor de identidade antes de criar usuários externos.", - "usernameUniq": "Isto deve corresponder ao nome de usuário único que existe no provedor de identidade selecionado.", + "idpSelect": "Selecione o provedor de identidade para o utilizador externo", + "idpNotConfigured": "Nenhum provedor de identidade está configurado. Configure um provedor de identidade antes de criar utilizadores externos.", + "usernameUniq": "Isto deve corresponder ao nome de utilizador único que existe no provedor de identidade selecionado.", "emailOptional": "E-mail (Opcional)", "nameOptional": "Nome (Opcional)", - "accessControls": "Controles de Acesso", - "userDescription2": "Gerenciar as configurações deste usuário", - "accessRoleErrorAdd": "Falha ao adicionar usuário à função", - "accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.", + "accessControls": "Controlos de Acesso", + "userDescription2": "Gerir as configurações deste utilizador", + "accessRoleErrorAdd": "Falha ao adicionar utilizador à função", + "accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar utilizador à função.", "userSaved": "Usuário salvo", - "userSavedDescription": "O usuário foi atualizado.", + "userSavedDescription": "O utilizador foi atualizado.", "autoProvisioned": "Auto provisionado", - "autoProvisionedDescription": "Permitir que este usuário seja gerenciado automaticamente pelo provedor de identidade", - "accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização", - "accessControlsSubmit": "Salvar Controles de Acesso", + "autoProvisionedDescription": "Permitir que este utilizador seja gerido automaticamente pelo provedor de identidade", + "accessControlsDescription": "Gerir o que este utilizador pode aceder e fazer na organização", + "accessControlsSubmit": "Guardar Controlos de Acesso", "roles": "Funções", - "accessUsersRoles": "Gerenciar Usuários e Funções", - "accessUsersRolesDescription": "Convide usuários e adicione-os a funções para gerenciar o acesso à sua organização", + "accessUsersRoles": "Gerir Utilizadores e Funções", + "accessUsersRolesDescription": "Convide utilizadores e adicione-os a funções para gerir o acesso à sua organização", "key": "Chave", "createdAt": "Criado Em", "proxyErrorInvalidHeader": "Valor do cabeçalho Host personalizado inválido. Use o formato de nome de domínio ou salve vazio para remover o cabeçalho Host personalizado.", @@ -494,7 +497,7 @@ "targetTlsSettingsAdvanced": "Configurações TLS Avançadas", "targetTlsSni": "Nome do Servidor TLS (SNI)", "targetTlsSniDescription": "O Nome do Servidor TLS para usar para SNI. Deixe vazio para usar o padrão.", - "targetTlsSubmit": "Salvar Configurações", + "targetTlsSubmit": "Guardar Configurações", "targets": "Configuração de Alvos", "targetsDescription": "Configure alvos para rotear tráfego para seus serviços de backend", "targetStickySessions": "Ativar Sessões Persistentes", @@ -503,12 +506,12 @@ "targetSubmit": "Adicionar Alvo", "targetNoOne": "Sem alvos. Adicione um alvo usando o formulário.", "targetNoOneDescription": "Adicionar mais de um alvo acima habilitará o balanceamento de carga.", - "targetsSubmit": "Salvar Alvos", + "targetsSubmit": "Guardar Alvos", "proxyAdditional": "Configurações Adicionais de Proxy", "proxyAdditionalDescription": "Configure como seu recurso lida com configurações de proxy", "proxyCustomHeader": "Cabeçalho Host Personalizado", "proxyCustomHeaderDescription": "O cabeçalho host para definir ao fazer proxy de requisições. Deixe vazio para usar o padrão.", - "proxyAdditionalSubmit": "Salvar Configurações de Proxy", + "proxyAdditionalSubmit": "Guardar Configurações de Proxy", "subnetMaskErrorInvalid": "Máscara de subnet inválida. Deve estar entre 0 e 32.", "ipAddressErrorInvalidFormat": "Formato de endereço IP inválido", "ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido", @@ -561,7 +564,7 @@ "ruleSubmit": "Adicionar Regra", "rulesNoOne": "Sem regras. Adicione uma regra usando o formulário.", "rulesOrder": "As regras são avaliadas por prioridade em ordem ascendente.", - "rulesSubmit": "Salvar Regras", + "rulesSubmit": "Guardar Regras", "resourceErrorCreate": "Erro ao criar recurso", "resourceErrorCreateDescription": "Ocorreu um erro ao criar o recurso", "resourceErrorCreateMessage": "Erro ao criar recurso:", @@ -576,7 +579,7 @@ "resourcesDescription": "Recursos são proxies para aplicações executando em sua rede privada. Crie um recurso para qualquer serviço HTTP/HTTPS ou TCP/UDP bruto em sua rede privada. Cada recurso deve estar conectado a um site para habilitar conectividade privada e segura através de um túnel WireGuard criptografado.", "resourcesWireGuardConnect": "Conectividade segura com criptografia WireGuard", "resourcesMultipleAuthenticationMethods": "Configure múltiplos métodos de autenticação", - "resourcesUsersRolesAccess": "Controle de acesso baseado em usuários e funções", + "resourcesUsersRolesAccess": "Controle de acesso baseado em utilizadores e funções", "resourcesErrorUpdate": "Falha ao alternar recurso", "resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "access": "Acesso", @@ -606,7 +609,7 @@ "pangolinSettings": "Configurações - Pangolin", "accessRoleYour": "Sua função:", "accessRoleSelect2": "Selecionar uma função", - "accessUserSelect": "Selecionar um usuário", + "accessUserSelect": "Selecionar um utilizador", "otpEmailEnter": "Digite um e-mail", "otpEmailEnterDescription": "Pressione enter para adicionar um e-mail após digitá-lo no campo de entrada.", "otpEmailErrorInvalid": "Endereço de e-mail inválido. O caractere curinga (*) deve ser a parte local inteira.", @@ -616,8 +619,8 @@ "otpEmailTitleDescription": "Requer autenticação baseada em e-mail para acesso ao recurso", "otpEmailWhitelist": "Lista de E-mails Permitidos", "otpEmailWhitelistList": "E-mails na Lista Permitida", - "otpEmailWhitelistListDescription": "Apenas usuários com estes endereços de e-mail poderão acessar este recurso. Eles serão solicitados a inserir uma senha única enviada para seu e-mail. Caracteres curinga (*@example.com) podem ser usados para permitir qualquer endereço de e-mail de um domínio.", - "otpEmailWhitelistSave": "Salvar Lista Permitida", + "otpEmailWhitelistListDescription": "Apenas utilizadores com estes endereços de e-mail poderão aceder este recurso. Eles serão solicitados a inserir uma senha única enviada para seu e-mail. Caracteres curinga (*@example.com) podem ser usados para permitir qualquer endereço de e-mail de um domínio.", + "otpEmailWhitelistSave": "Guardar Lista Permitida", "passwordAdd": "Adicionar Senha", "passwordRemove": "Remover Senha", "pincodeAdd": "Adicionar Código PIN", @@ -657,14 +660,14 @@ "resourcePincodeSetupDescription": "O código PIN do recurso foi definido com sucesso", "resourcePincodeSetupTitle": "Definir Código PIN", "resourcePincodeSetupTitleDescription": "Defina um código PIN para proteger este recurso", - "resourceRoleDescription": "Administradores sempre podem acessar este recurso.", - "resourceUsersRoles": "Usuários e Funções", - "resourceUsersRolesDescription": "Configure quais usuários e funções podem visitar este recurso", - "resourceUsersRolesSubmit": "Salvar Usuários e Funções", + "resourceRoleDescription": "Administradores sempre podem aceder este recurso.", + "resourceUsersRoles": "Utilizadores e Funções", + "resourceUsersRolesDescription": "Configure quais utilizadores e funções podem visitar este recurso", + "resourceUsersRolesSubmit": "Guardar Utilizadores e Funções", "resourceWhitelistSave": "Salvo com sucesso", "resourceWhitelistSaveDescription": "As configurações da lista permitida foram salvas", "ssoUse": "Usar SSO da Plataforma", - "ssoUseDescription": "Os usuários existentes só precisarão fazer login uma vez para todos os recursos que tiverem isso habilitado.", + "ssoUseDescription": "Os utilizadores existentes só precisarão fazer login uma vez para todos os recursos que tiverem isso habilitado.", "proxyErrorInvalidPort": "Número da porta inválido", "subdomainErrorInvalid": "Subdomínio inválido", "domainErrorFetch": "Erro ao buscar domínios", @@ -690,7 +693,7 @@ "siteDestination": "Site de Destino", "searchSites": "Pesquisar sites", "accessRoleCreate": "Criar Função", - "accessRoleCreateDescription": "Crie uma nova função para agrupar usuários e gerenciar suas permissões.", + "accessRoleCreateDescription": "Crie uma nova função para agrupar utilizadores e gerir suas permissões.", "accessRoleCreateSubmit": "Criar Função", "accessRoleCreated": "Função criada", "accessRoleCreatedDescription": "A função foi criada com sucesso.", @@ -700,13 +703,13 @@ "accessRoleErrorRemove": "Falha ao remover função", "accessRoleErrorRemoveDescription": "Ocorreu um erro ao remover a função.", "accessRoleName": "Nome da Função", - "accessRoleQuestionRemove": "Você está prestes a excluir a função {name}. Você não pode desfazer esta ação.", + "accessRoleQuestionRemove": "Você está prestes a apagar a função {name}. Você não pode desfazer esta ação.", "accessRoleRemove": "Remover Função", "accessRoleRemoveDescription": "Remover uma função da organização", "accessRoleRemoveSubmit": "Remover Função", "accessRoleRemoved": "Função removida", "accessRoleRemovedDescription": "A função foi removida com sucesso.", - "accessRoleRequiredRemove": "Antes de excluir esta função, selecione uma nova função para transferir os membros existentes.", + "accessRoleRequiredRemove": "Antes de apagar esta função, selecione uma nova função para transferir os membros existentes.", "manage": "Gerir", "sitesNotFound": "Nenhum site encontrado.", "pangolinServerAdmin": "Administrador do Servidor - Pangolin", @@ -914,12 +917,10 @@ "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.", "idpErrorNotFound": "IdP não encontrado", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Convite Inválido", "inviteInvalidDescription": "O link do convite é inválido.", - "inviteErrorWrongUser": "O convite não é para este usuário", - "inviteErrorUserNotExists": "O usuário não existe. Por favor, crie uma conta primeiro.", + "inviteErrorWrongUser": "O convite não é para este utilizador", + "inviteErrorUserNotExists": "O utilizador não existe. Por favor, crie uma conta primeiro.", "inviteErrorLoginRequired": "Você deve estar logado para aceitar um convite", "inviteErrorExpired": "O convite pode ter expirado", "inviteErrorRevoked": "O convite pode ter sido revogado", @@ -934,7 +935,7 @@ "home": "Início", "accessControl": "Controle de Acesso", "settings": "Configurações", - "usersAll": "Todos os Usuários", + "usersAll": "Todos os Utilizadores", "license": "Licença", "pangolinDashboard": "Painel - Pangolin", "noResults": "Nenhum resultado encontrado.", @@ -987,8 +988,8 @@ "licenseTierProfessionalRequired": "Edição Profissional Necessária", "licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.", "actionGetOrg": "Obter Organização", - "updateOrgUser": "Atualizar usuário Org", - "createOrgUser": "Criar usuário Org", + "updateOrgUser": "Atualizar utilizador Org", + "createOrgUser": "Criar utilizador Org", "actionUpdateOrg": "Atualizar Organização", "actionUpdateUser": "Atualizar Usuário", "actionGetUser": "Obter Usuário", @@ -1135,8 +1136,8 @@ "sidebarRoles": "Papéis", "sidebarShareableLinks": "Links compartilháveis", "sidebarApiKeys": "Chaves API", - "sidebarSettings": "Confirgurações", - "sidebarAllUsers": "Todos os usuários", + "sidebarSettings": "Configurações", + "sidebarAllUsers": "Todos os utilizadores", "sidebarIdentityProviders": "Provedores de identidade", "sidebarLicense": "Tipo:", "sidebarClients": "Clientes (Beta)", @@ -1189,7 +1190,7 @@ "loading": "Carregando", "restart": "Reiniciar", "domains": "Domínios", - "domainsDescription": "Gerencie domínios para sua organização", + "domainsDescription": "Gerir domínios para sua organização", "domainsSearch": "Pesquisar domínios...", "domainAdd": "Adicionar Domínio", "domainAddDescription": "Registre um novo domínio com sua organização", @@ -1217,7 +1218,7 @@ "pending": "Pendente", "sidebarBilling": "Faturamento", "billing": "Faturamento", - "orgBillingDescription": "Gerencie suas informações de faturamento e assinaturas", + "orgBillingDescription": "Gerir suas informações de faturação e assinaturas", "github": "GitHub", "pangolinHosted": "Hospedagem Pangolin", "fossorial": "Fossorial", @@ -1232,7 +1233,7 @@ "completeSetup": "Configuração Completa", "accountSetupSuccess": "Configuração da conta concluída! Bem-vindo ao Pangolin!", "documentation": "Documentação", - "saveAllSettings": "Salvar Todas as Configurações", + "saveAllSettings": "Guardar Todas as Configurações", "settingsUpdated": "Configurações atualizadas", "settingsUpdatedDescription": "Todas as configurações foram atualizadas com sucesso", "settingsErrorUpdate": "Falha ao atualizar configurações", @@ -1257,13 +1258,55 @@ "domainPickerSubdomain": "Subdomínio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostrar Mais", + "regionSelectorTitle": "Selecionar Região", + "regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.", + "regionSelectorPlaceholder": "Escolher uma região", + "regionSelectorComingSoon": "Em breve", + "billingLoadingSubscription": "Carregando assinatura...", + "billingFreeTier": "Plano Gratuito", + "billingWarningOverLimit": "Aviso: Você ultrapassou um ou mais limites de uso. Seus sites não se conectarão até você modificar sua assinatura ou ajustar seu uso.", + "billingUsageLimitsOverview": "Visão Geral dos Limites de Uso", + "billingMonitorUsage": "Monitore seu uso em relação aos limites configurados. Se precisar aumentar esses limites, entre em contato conosco support@fossorial.io.", + "billingDataUsage": "Uso de Dados", + "billingOnlineTime": "Tempo Online do Site", + "billingUsers": "Usuários Ativos", + "billingDomains": "Domínios Ativos", + "billingRemoteExitNodes": "Nodos Auto-Hospedados Ativos", + "billingNoLimitConfigured": "Nenhum limite configurado", + "billingEstimatedPeriod": "Período Estimado de Cobrança", + "billingIncludedUsage": "Uso Incluído", + "billingIncludedUsageDescription": "Uso incluído no seu plano de assinatura atual", + "billingFreeTierIncludedUsage": "Limites de uso do plano gratuito", + "billingIncluded": "incluído", + "billingEstimatedTotal": "Total Estimado:", + "billingNotes": "Notas", + "billingEstimateNote": "Esta é uma estimativa baseada no seu uso atual.", + "billingActualChargesMayVary": "As cobranças reais podem variar.", + "billingBilledAtEnd": "Sua cobrança será feita ao final do período de cobrança.", + "billingModifySubscription": "Modificar Assinatura", + "billingStartSubscription": "Iniciar Assinatura", + "billingRecurringCharge": "Cobrança Recorrente", + "billingManageSubscriptionSettings": "Gerenciar as configurações e preferências da sua assinatura", + "billingNoActiveSubscription": "Você não tem uma assinatura ativa. Inicie sua assinatura para aumentar os limites de uso.", + "billingFailedToLoadSubscription": "Falha ao carregar assinatura", + "billingFailedToLoadUsage": "Falha ao carregar uso", + "billingFailedToGetCheckoutUrl": "Falha ao obter URL de checkout", + "billingPleaseTryAgainLater": "Por favor, tente novamente mais tarde.", + "billingCheckoutError": "Erro de Checkout", + "billingFailedToGetPortalUrl": "Falha ao obter URL do portal", + "billingPortalError": "Erro do Portal", + "billingDataUsageInfo": "Você é cobrado por todos os dados transferidos através de seus túneis seguros quando conectado à nuvem. Isso inclui o tráfego de entrada e saída em todos os seus sites. Quando você atingir o seu limite, seus sites desconectarão até que você atualize seu plano ou reduza o uso. Os dados não serão cobrados ao usar os nós.", + "billingOnlineTimeInfo": "Cobrança de acordo com o tempo em que seus sites permanecem conectados à nuvem. Por exemplo, 44,640 minutos é igual a um site que roda 24/7 para um mês inteiro. Quando você atinge o seu limite, seus sites desconectarão até que você faça o upgrade do seu plano ou reduza o uso. O tempo não é cobrado ao usar nós.", + "billingUsersInfo": "Você será cobrado por cada usuário em sua organização. A cobrança é calculada diariamente com base no número de contas de usuário ativas em sua organização.", + "billingDomainInfo": "Você será cobrado por cada domínio em sua organização. A cobrança é calculada diariamente com base no número de contas de domínio ativas em sua organização.", + "billingRemoteExitNodesInfo": "Você será cobrado por cada Nodo gerenciado em sua organização. A cobrança é calculada diariamente com base no número de Nodos gerenciados ativos em sua organização.", "domainNotFound": "Domínio Não Encontrado", "domainNotFoundDescription": "Este recurso está desativado porque o domínio não existe mais em nosso sistema. Defina um novo domínio para este recurso.", "failed": "Falhou", "createNewOrgDescription": "Crie uma nova organização", "organization": "Organização", "port": "Porta", - "securityKeyManage": "Gerenciar chaves de segurança", + "securityKeyManage": "Gerir chaves de segurança", "securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha", "securityKeyRegister": "Registrar nova chave de segurança", "securityKeyList": "Suas chaves de segurança", @@ -1314,12 +1357,13 @@ "createDomainARecords": "Registros A", "createDomainRecordNumber": "Registrar {number}", "createDomainTxtRecords": "Registros TXT", - "createDomainSaveTheseRecords": "Salvar Esses Registros", + "createDomainSaveTheseRecords": "Guardar Esses Registros", "createDomainSaveTheseRecordsDescription": "Certifique-se de salvar esses registros DNS, pois você não os verá novamente.", "createDomainDnsPropagation": "Propagação DNS", "createDomainDnsPropagationDescription": "Alterações no DNS podem levar algum tempo para se propagar pela internet. Pode levar de alguns minutos a 48 horas, dependendo do seu provedor de DNS e das configurações de TTL.", "resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP", "resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP", + "billingPricingCalculatorLink": "Calculadora de Preços", "signUpTerms": { "IAgreeToThe": "Concordo com", "termsOfService": "os termos de serviço", @@ -1368,6 +1412,41 @@ "addNewTarget": "Adicionar Novo Alvo", "targetsList": "Lista de Alvos", "targetErrorDuplicateTargetFound": "Alvo duplicado encontrado", + "healthCheckHealthy": "Saudável", + "healthCheckUnhealthy": "Não Saudável", + "healthCheckUnknown": "Desconhecido", + "healthCheck": "Verificação de Saúde", + "configureHealthCheck": "Configurar Verificação de Saúde", + "configureHealthCheckDescription": "Configure a monitorização de saúde para {target}", + "enableHealthChecks": "Ativar Verificações de Saúde", + "enableHealthChecksDescription": "Monitore a saúde deste alvo. Você pode monitorar um ponto de extremidade diferente do alvo, se necessário.", + "healthScheme": "Método", + "healthSelectScheme": "Selecione o Método", + "healthCheckPath": "Caminho", + "healthHostname": "IP / Host", + "healthPort": "Porta", + "healthCheckPathDescription": "O caminho para verificar o estado de saúde.", + "healthyIntervalSeconds": "Intervalo Saudável", + "unhealthyIntervalSeconds": "Intervalo Não Saudável", + "IntervalSeconds": "Intervalo Saudável", + "timeoutSeconds": "Tempo Limite", + "timeIsInSeconds": "O tempo está em segundos", + "retryAttempts": "Tentativas de Repetição", + "expectedResponseCodes": "Códigos de Resposta Esperados", + "expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.", + "customHeaders": "Cabeçalhos Personalizados", + "customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor", + "headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.", + "saveHealthCheck": "Salvar Verificação de Saúde", + "healthCheckSaved": "Verificação de Saúde Salva", + "healthCheckSavedDescription": "Configuração de verificação de saúde salva com sucesso", + "healthCheckError": "Erro de Verificação de Saúde", + "healthCheckErrorDescription": "Ocorreu um erro ao salvar a configuração de verificação de saúde", + "healthCheckPathRequired": "O caminho de verificação de saúde é obrigatório", + "healthCheckMethodRequired": "O método HTTP é obrigatório", + "healthCheckIntervalMin": "O intervalo de verificação deve ser de pelo menos 5 segundos", + "healthCheckTimeoutMin": "O tempo limite deve ser de pelo menos 1 segundo", + "healthCheckRetryMin": "As tentativas de repetição devem ser pelo menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Selecionar método HTTP", "domainPickerSubdomainLabel": "Subdomínio", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Digite um subdomínio para buscar e selecionar entre os domínios gratuitos disponíveis.", "domainPickerFreeDomains": "Domínios Gratuitos", "domainPickerSearchForAvailableDomains": "Pesquise por domínios disponíveis", + "domainPickerNotWorkSelfHosted": "Nota: Domínios gratuitos fornecidos não estão disponíveis para instâncias auto-hospedadas no momento.", "resourceDomain": "Domínio", "resourceEditDomain": "Editar Domínio", "siteName": "Nome do Site", @@ -1401,7 +1481,7 @@ "editInternalResourceDialogSitePort": "Porta do Site", "editInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "editInternalResourceDialogCancel": "Cancelar", - "editInternalResourceDialogSaveResource": "Salvar Recurso", + "editInternalResourceDialogSaveResource": "Guardar Recurso", "editInternalResourceDialogSuccess": "Sucesso", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno atualizado com sucesso", "editInternalResourceDialogError": "Erro", @@ -1428,7 +1508,7 @@ "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Porta do Site", - "createInternalResourceDialogSitePortDescription": "Use esta porta para acessar o recurso no site quando conectado com um cliente.", + "createInternalResourceDialogSitePortDescription": "Use esta porta para aceder o recurso no site quando conectado com um cliente.", "createInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "createInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.", "createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.", @@ -1452,7 +1532,7 @@ "siteAddress": "Endereço do Site", "siteAddressDescription": "Especificar o endereço IP do host para que os clientes se conectem. Este é o endereço interno do site na rede Pangolin para os clientes endereçarem. Deve estar dentro da sub-rede da Organização.", "autoLoginExternalIdp": "Login Automático com IDP Externo", - "autoLoginExternalIdpDescription": "Redirecionar imediatamente o usuário para o IDP externo para autenticação.", + "autoLoginExternalIdpDescription": "Redirecionar imediatamente o utilizador para o IDP externo para autenticação.", "selectIdp": "Selecionar IDP", "selectIdpPlaceholder": "Escolher um IDP...", "selectIdpRequired": "Por favor, selecione um IDP quando o login automático estiver ativado.", @@ -1463,6 +1543,72 @@ "autoLoginError": "Erro de Login Automático", "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.", + "remoteExitNodeManageRemoteExitNodes": "Gerenciar Auto-Hospedados", + "remoteExitNodeDescription": "Gerencie os nós para estender sua conectividade de rede", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Buscar nós...", + "remoteExitNodeAdd": "Adicionar node", + "remoteExitNodeErrorDelete": "Erro ao excluir nó", + "remoteExitNodeQuestionRemove": "Tem certeza que deseja remover o nó {selectedNode} da organização?", + "remoteExitNodeMessageRemove": "Uma vez removido, o nó não estará mais acessível.", + "remoteExitNodeMessageConfirm": "Para confirmar, por favor, digite o nome do nó abaixo.", + "remoteExitNodeConfirmDelete": "Confirmar exclusão do nó", + "remoteExitNodeDelete": "Excluir nó", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Criar nó", + "description": "Crie um novo nó para estender sua conectividade de rede", + "viewAllButton": "Ver Todos os Nós", + "strategy": { + "title": "Estratégia de Criação", + "description": "Escolha isto para configurar o seu nó manualmente ou gerar novas credenciais.", + "adopt": { + "title": "Adotar Nodo", + "description": "Escolha isto se você já tem credenciais para o nó." + }, + "generate": { + "title": "Gerar Chaves", + "description": "Escolha esta opção se você quer gerar novas chaves para o nó" + } + }, + "adopt": { + "title": "Adotar Nodo Existente", + "description": "Digite as credenciais do nó existente que deseja adoptar", + "nodeIdLabel": "Nó ID", + "nodeIdDescription": "O ID do nó existente que você deseja adoptar", + "secretLabel": "Chave Secreta", + "secretDescription": "A chave secreta do nó existente", + "submitButton": "Nó Adotado" + }, + "generate": { + "title": "Credenciais Geradas", + "description": "Use estas credenciais geradas para configurar o seu nó", + "nodeIdTitle": "Nó ID", + "secretTitle": "Chave Secreta", + "saveCredentialsTitle": "Adicionar Credenciais à Configuração", + "saveCredentialsDescription": "Adicione essas credenciais ao arquivo de configuração do seu nodo de Pangolin auto-hospedado para completar a conexão.", + "submitButton": "Criar nó" + }, + "validation": { + "adoptRequired": "ID do nó e Segredo são necessários ao adotar um nó existente" + }, + "errors": { + "loadDefaultsFailed": "Falha ao carregar padrões", + "defaultsNotLoaded": "Padrões não carregados", + "createFailed": "Falha ao criar nó" + }, + "success": { + "created": "Nó criado com sucesso" + } + }, + "remoteExitNodeSelection": "Seleção de nó", + "remoteExitNodeSelectionDescription": "Selecione um nó para encaminhar o tráfego para este site local", + "remoteExitNodeRequired": "Um nó deve ser seleccionado para sites locais", + "noRemoteExitNodesAvailable": "Nenhum nó disponível", + "noRemoteExitNodesAvailableDescription": "Nenhum nó está disponível para esta organização. Crie um nó primeiro para usar sites locais.", + "exitNode": "Nodo de Saída", + "country": "País", + "rulesMatchCountry": "Atualmente baseado no IP de origem", "managedSelfHosted": { "title": "Gerenciado Auto-Hospedado", "description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos", @@ -1479,7 +1625,7 @@ }, "benefitLessMaintenance": { "title": "Menos manutenção", - "description": "Sem migrações, backups ou infraestrutura extra para gerenciar. Lidamos com isso na nuvem." + "description": "Sem migrações, backups ou infraestrutura extra para gerir. Lidamos com isso na nuvem." }, "benefitCloudFailover": { "title": "Falha na nuvem", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Domínio Internacional Detectado", "willbestoredas": "Será armazenado como:", + "roleMappingDescription": "Determinar como as funções são atribuídas aos usuários quando eles fazem login quando Auto Provisão está habilitada.", + "selectRole": "Selecione uma função", + "roleMappingExpression": "Expressão", + "selectRolePlaceholder": "Escolha uma função", + "selectRoleDescription": "Selecione uma função para atribuir a todos os usuários deste provedor de identidade", + "roleMappingExpressionDescription": "Insira uma expressão JMESPath para extrair informações da função do token de ID", + "idpTenantIdRequired": "ID do inquilino é necessária", + "invalidValue": "Valor Inválido", + "idpTypeLabel": "Tipo de provedor de identidade", + "roleMappingExpressionPlaceholder": "ex.: Contem (grupos, 'administrador') && 'Administrador' 「'Membro'", + "idpGoogleConfiguration": "Configuração do Google", + "idpGoogleConfigurationDescription": "Configurar suas credenciais do Google OAuth2", + "idpGoogleClientIdDescription": "Seu ID de Cliente OAuth2 do Google", + "idpGoogleClientSecretDescription": "Seu Segredo de Cliente OAuth2 do Google", + "idpAzureConfiguration": "Configuração de ID do Azure Entra", + "idpAzureConfigurationDescription": "Configure as suas credenciais do Azure Entra ID OAuth2", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "seu-tenente-id", + "idpAzureTenantIdDescription": "Seu ID do tenant Azure (encontrado na visão geral do diretório ativo Azure)", + "idpAzureClientIdDescription": "Seu ID de Cliente de Registro do App Azure", + "idpAzureClientSecretDescription": "Seu segredo de cliente de registro de aplicativos Azure", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Configuração do Google", + "idpAzureConfigurationTitle": "Configuração de ID do Azure Entra", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Seu ID de Cliente de Registro do App Azure", + "idpAzureClientSecretDescription2": "Seu segredo de cliente de registro de aplicativos Azure", "idpGoogleDescription": "Provedor Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Cabeçalhos Personalizados", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.", + "subnet": "Sub-rede", + "subnetDescription": "A sub-rede para a configuração de rede dessa organização.", + "authPage": "Página de Autenticação", + "authPageDescription": "Configurar a página de autenticação para sua organização", + "authPageDomain": "Domínio de Página Autenticação", + "noDomainSet": "Nenhum domínio definido", + "changeDomain": "Alterar domínio", + "selectDomain": "Selecionar domínio", + "restartCertificate": "Reiniciar Certificado", + "editAuthPageDomain": "Editar Página de Autenticação", + "setAuthPageDomain": "Definir domínio da página de autenticação", + "failedToFetchCertificate": "Falha ao buscar o certificado", + "failedToRestartCertificate": "Falha ao reiniciar o certificado", + "addDomainToEnableCustomAuthPages": "Adicione um domínio para habilitar páginas de autenticação personalizadas para sua organização", + "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "domainPickerProvidedDomain": "Domínio fornecido", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", "domainPickerVerified": "Verificada", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.", "domainPickerSubdomainSanitized": "Subdomínio banalizado", "domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"", + "orgAuthSignInTitle": "Entrar na sua organização", + "orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar", + "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", + "orgAuthSignInWithPangolin": "Entrar com o Pangolin", + "subscriptionRequiredToUse": "Uma assinatura é necessária para usar esse recurso.", + "idpDisabled": "Provedores de identidade estão desabilitados.", + "orgAuthPageDisabled": "A página de autenticação da organização está desativada.", + "domainRestartedDescription": "Verificação de domínio reiniciado com sucesso", "resourceAddEntrypointsEditFile": "Editar arquivo: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml", "emailVerificationRequired": "Verificação de e-mail é necessária. Por favor, faça login novamente via {dashboardUrl}/auth/login conclui esta etapa. Em seguida, volte aqui.", - "twoFactorSetupRequired": "Configuração de autenticação de dois fatores é necessária. Por favor, entre novamente via {dashboardUrl}/auth/login conclua este passo. Em seguida, volte aqui.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Configuração de autenticação de dois fatores é necessária. Por favor, entre novamente via {dashboardUrl}/auth/login conclua este passo. Em seguida, volte aqui." } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 2ec7764a..290d0215 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -168,6 +168,9 @@ "siteSelect": "Выберите сайт", "siteSearch": "Поиск сайта", "siteNotFound": "Сайт не найден.", + "selectCountry": "Выберите страну", + "searchCountries": "Поиск стран...", + "noCountryFound": "Страна не найдена.", "siteSelectionDescription": "Этот сайт предоставит подключение к цели.", "resourceType": "Тип ресурса", "resourceTypeDescription": "Определите, как вы хотите получать доступ к вашему ресурсу", @@ -236,7 +239,7 @@ "accessUserCreate": "Создать пользователя", "accessUserRemove": "Удалить пользователя", "username": "Имя пользователя", - "identityProvider": "Identity Provider", + "identityProvider": "Поставщик удостоверений", "role": "Роль", "nameRequired": "Имя обязательно", "accessRolesManage": "Управление ролями", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Подключено", "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", "idpErrorNotFound": "IdP не найден", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Недействительное приглашение", "inviteInvalidDescription": "Ссылка на приглашение недействительна.", "inviteErrorWrongUser": "Приглашение не для этого пользователя", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Поддомен: {subdomain}", "domainPickerNamespace": "Пространство имен: {namespace}", "domainPickerShowMore": "Показать еще", + "regionSelectorTitle": "Выберите регион", + "regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.", + "regionSelectorPlaceholder": "Выбор региона", + "regionSelectorComingSoon": "Скоро будет", + "billingLoadingSubscription": "Загрузка подписки...", + "billingFreeTier": "Бесплатный уровень", + "billingWarningOverLimit": "Предупреждение: Вы превысили одну или несколько границ использования. Ваши сайты не подключатся, пока вы не измените подписку или не скорректируете использование.", + "billingUsageLimitsOverview": "Обзор лимитов использования", + "billingMonitorUsage": "Контролируйте использование в соответствии с установленными лимитами. Если вам требуется увеличение лимитов, пожалуйста, свяжитесь с нами support@fossorial.io.", + "billingDataUsage": "Использование данных", + "billingOnlineTime": "Время работы сайта", + "billingUsers": "Активные пользователи", + "billingDomains": "Активные домены", + "billingRemoteExitNodes": "Активные самоуправляемые узлы", + "billingNoLimitConfigured": "Лимит не установлен", + "billingEstimatedPeriod": "Предполагаемый период выставления счетов", + "billingIncludedUsage": "Включенное использование", + "billingIncludedUsageDescription": "Использование, включенное в ваш текущий план подписки", + "billingFreeTierIncludedUsage": "Бесплатное использование ограничений", + "billingIncluded": "включено", + "billingEstimatedTotal": "Предполагаемая сумма:", + "billingNotes": "Заметки", + "billingEstimateNote": "Это приблизительная оценка на основании вашего текущего использования.", + "billingActualChargesMayVary": "Фактические начисления могут отличаться.", + "billingBilledAtEnd": "С вас будет выставлен счет в конце периода выставления счетов.", + "billingModifySubscription": "Изменить подписку", + "billingStartSubscription": "Начать подписку", + "billingRecurringCharge": "Периодический взнос", + "billingManageSubscriptionSettings": "Управляйте настройками и предпочтениями вашей подписки", + "billingNoActiveSubscription": "У вас нет активной подписки. Начните подписку, чтобы увеличить лимиты использования.", + "billingFailedToLoadSubscription": "Не удалось загрузить подписку", + "billingFailedToLoadUsage": "Не удалось загрузить использование", + "billingFailedToGetCheckoutUrl": "Не удалось получить URL-адрес для оплаты", + "billingPleaseTryAgainLater": "Пожалуйста, повторите попытку позже.", + "billingCheckoutError": "Ошибка при оформлении заказа", + "billingFailedToGetPortalUrl": "Не удалось получить URL-адрес портала", + "billingPortalError": "Ошибка портала", + "billingDataUsageInfo": "Вы несете ответственность за все данные, переданные через безопасные туннели при подключении к облаку. Это включает как входящий, так и исходящий трафик на всех ваших сайтах. При достижении лимита ваши сайты будут отключаться до тех пор, пока вы не обновите план или не уменьшите его использование. При использовании узлов не взимается плата.", + "billingOnlineTimeInfo": "Вы тарифицируете на то, как долго ваши сайты будут подключены к облаку. Например, 44 640 минут равны одному сайту, работающему круглосуточно за весь месяц. Когда вы достигните лимита, ваши сайты будут отключаться до тех пор, пока вы не обновите тарифный план или не сократите нагрузку. При использовании узлов не тарифицируется.", + "billingUsersInfo": "С вас взимается плата за каждого пользователя в вашей организации. Оплата рассчитывается ежедневно исходя из количества активных учетных записей пользователей в вашей организации.", + "billingDomainInfo": "С вас взимается плата за каждый домен в вашей организации. Оплата рассчитывается ежедневно исходя из количества активных учетных записей доменов в вашей организации.", + "billingRemoteExitNodesInfo": "С вас взимается плата за каждый управляемый узел в вашей организации. Оплата рассчитывается ежедневно исходя из количества активных управляемых узлов в вашей организации.", "domainNotFound": "Домен не найден", "domainNotFoundDescription": "Этот ресурс отключен, так как домен больше не существует в нашей системе. Пожалуйста, установите новый домен для этого ресурса.", "failed": "Ошибка", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "Изменения DNS могут занять некоторое время для распространения через интернет. Это может занять от нескольких минут до 48 часов в зависимости от вашего DNS провайдера и настроек TTL.", "resourcePortRequired": "Номер порта необходим для не-HTTP ресурсов", "resourcePortNotAllowed": "Номер порта не должен быть установлен для HTTP ресурсов", + "billingPricingCalculatorLink": "Калькулятор расценок", "signUpTerms": { "IAgreeToThe": "Я согласен с", "termsOfService": "условия использования", @@ -1368,6 +1412,41 @@ "addNewTarget": "Добавить новую цель", "targetsList": "Список целей", "targetErrorDuplicateTargetFound": "Обнаружена дублирующаяся цель", + "healthCheckHealthy": "Здоровый", + "healthCheckUnhealthy": "Нездоровый", + "healthCheckUnknown": "Неизвестно", + "healthCheck": "Проверка здоровья", + "configureHealthCheck": "Настроить проверку здоровья", + "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}", + "enableHealthChecks": "Включить проверки здоровья", + "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", + "healthScheme": "Метод", + "healthSelectScheme": "Выберите метод", + "healthCheckPath": "Путь", + "healthHostname": "IP / хост", + "healthPort": "Порт", + "healthCheckPathDescription": "Путь к проверке состояния здоровья.", + "healthyIntervalSeconds": "Интервал здоровых состояний", + "unhealthyIntervalSeconds": "Интервал нездоровых состояний", + "IntervalSeconds": "Интервал здоровых состояний", + "timeoutSeconds": "Тайм-аут", + "timeIsInSeconds": "Время указано в секундах", + "retryAttempts": "Количество попыток повторного запроса", + "expectedResponseCodes": "Ожидаемые коды ответов", + "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", + "customHeaders": "Пользовательские заголовки", + "customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", + "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", + "saveHealthCheck": "Сохранить проверку здоровья", + "healthCheckSaved": "Проверка здоровья сохранена", + "healthCheckSavedDescription": "Конфигурация проверки состояния успешно сохранена", + "healthCheckError": "Ошибка проверки состояния", + "healthCheckErrorDescription": "Произошла ошибка при сохранении конфигурации проверки состояния", + "healthCheckPathRequired": "Требуется путь проверки состояния", + "healthCheckMethodRequired": "Требуется метод HTTP", + "healthCheckIntervalMin": "Интервал проверки должен составлять не менее 5 секунд", + "healthCheckTimeoutMin": "Тайм-аут должен составлять не менее 1 секунды", + "healthCheckRetryMin": "Количество попыток должно быть не менее 1", "httpMethod": "HTTP метод", "selectHttpMethod": "Выберите HTTP метод", "domainPickerSubdomainLabel": "Поддомен", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Введите поддомен для поиска и выбора из доступных свободных доменов.", "domainPickerFreeDomains": "Свободные домены", "domainPickerSearchForAvailableDomains": "Поиск доступных доменов", + "domainPickerNotWorkSelfHosted": "Примечание: бесплатные предоставляемые домены в данный момент недоступны для самоуправляемых экземпляров.", "resourceDomain": "Домен", "resourceEditDomain": "Редактировать домен", "siteName": "Имя сайта", @@ -1463,6 +1543,72 @@ "autoLoginError": "Ошибка автоматического входа", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.", + "remoteExitNodeManageRemoteExitNodes": "Управление самоуправляемым", + "remoteExitNodeDescription": "Управляйте узлами для расширения сетевого подключения", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "Поиск узлов...", + "remoteExitNodeAdd": "Добавить узел", + "remoteExitNodeErrorDelete": "Ошибка удаления узла", + "remoteExitNodeQuestionRemove": "Вы уверены, что хотите удалить узел {selectedNode} из организации?", + "remoteExitNodeMessageRemove": "После удаления узел больше не будет доступен.", + "remoteExitNodeMessageConfirm": "Для подтверждения введите имя узла ниже.", + "remoteExitNodeConfirmDelete": "Подтвердите удаление узла", + "remoteExitNodeDelete": "Удалить узел", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "Создать узел", + "description": "Создайте новый узел, чтобы расширить сетевое подключение", + "viewAllButton": "Все узлы", + "strategy": { + "title": "Стратегия создания", + "description": "Выберите эту опцию для настройки вашего узла или создания новых учетных данных.", + "adopt": { + "title": "Принять узел", + "description": "Выберите это, если у вас уже есть учетные данные для узла." + }, + "generate": { + "title": "Сгенерировать ключи", + "description": "Выберите это, если вы хотите создать новые ключи для узла" + } + }, + "adopt": { + "title": "Принять существующий узел", + "description": "Введите учетные данные существующего узла, который вы хотите принять", + "nodeIdLabel": "ID узла", + "nodeIdDescription": "ID существующего узла, который вы хотите принять", + "secretLabel": "Секретный ключ", + "secretDescription": "Секретный ключ существующего узла", + "submitButton": "Принять узел" + }, + "generate": { + "title": "Сгенерированные учетные данные", + "description": "Используйте эти учётные данные для настройки вашего узла", + "nodeIdTitle": "ID узла", + "secretTitle": "Секретный ключ", + "saveCredentialsTitle": "Добавить учетные данные в конфигурацию", + "saveCredentialsDescription": "Добавьте эти учетные данные в файл конфигурации вашего самоуправляемого узла Pangolin, чтобы завершить подключение.", + "submitButton": "Создать узел" + }, + "validation": { + "adoptRequired": "ID узла и секрет требуются при установке существующего узла" + }, + "errors": { + "loadDefaultsFailed": "Не удалось загрузить параметры по умолчанию", + "defaultsNotLoaded": "Параметры по умолчанию не загружены", + "createFailed": "Не удалось создать узел" + }, + "success": { + "created": "Узел успешно создан" + } + }, + "remoteExitNodeSelection": "Выбор узла", + "remoteExitNodeSelectionDescription": "Выберите узел для маршрутизации трафика для этого локального сайта", + "remoteExitNodeRequired": "Узел должен быть выбран для локальных сайтов", + "noRemoteExitNodesAvailable": "Нет доступных узлов", + "noRemoteExitNodesAvailableDescription": "Для этой организации узлы не доступны. Сначала создайте узел, чтобы использовать локальные сайты.", + "exitNode": "Узел выхода", + "country": "Страна", + "rulesMatchCountry": "В настоящее время основано на исходном IP", "managedSelfHosted": { "title": "Управляемый с самовывоза", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Обнаружен международный домен", "willbestoredas": "Будет храниться как:", + "roleMappingDescription": "Определите, как роли, назначаемые пользователям, когда они войдут в систему автоматического профиля.", + "selectRole": "Выберите роль", + "roleMappingExpression": "Выражение", + "selectRolePlaceholder": "Выберите роль", + "selectRoleDescription": "Выберите роль, чтобы назначить всем пользователям этого поставщика идентификации", + "roleMappingExpressionDescription": "Введите выражение JMESPath, чтобы извлечь информацию о роли из ID токена", + "idpTenantIdRequired": "Требуется ID владельца", + "invalidValue": "Неверное значение", + "idpTypeLabel": "Тип поставщика удостоверений", + "roleMappingExpressionPlaceholder": "например, contains(groups, 'admin') && 'Admin' || 'Member'", + "idpGoogleConfiguration": "Конфигурация Google", + "idpGoogleConfigurationDescription": "Настройка учетных данных Google OAuth2", + "idpGoogleClientIdDescription": "Ваш Google OAuth2 ID клиента", + "idpGoogleClientSecretDescription": "Ваш Google OAuth2 Секрет", + "idpAzureConfiguration": "Конфигурация Azure Entra ID", + "idpAzureConfigurationDescription": "Настройте учетные данные Azure Entra ID OAuth2", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "ваш тенант-id", + "idpAzureTenantIdDescription": "Идентификатор арендатора Azure (найден в обзоре Active Directory Azure)", + "idpAzureClientIdDescription": "Ваш идентификатор клиента Azure App", + "idpAzureClientSecretDescription": "Секрет регистрации клиента Azure App", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Конфигурация Google", + "idpAzureConfigurationTitle": "Конфигурация Azure Entra ID", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "Ваш идентификатор клиента Azure App", + "idpAzureClientSecretDescription2": "Секрет регистрации клиента Azure App", "idpGoogleDescription": "Google OAuth2/OIDC провайдер", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "Пользовательские заголовки", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", + "subnet": "Подсеть", + "subnetDescription": "Подсеть для конфигурации сети этой организации.", + "authPage": "Страница авторизации", + "authPageDescription": "Настройка страницы авторизации для вашей организации", + "authPageDomain": "Домен страницы авторизации", + "noDomainSet": "Домен не установлен", + "changeDomain": "Изменить домен", + "selectDomain": "Выберите домен", + "restartCertificate": "Перезапустить сертификат", + "editAuthPageDomain": "Редактировать домен страницы авторизации", + "setAuthPageDomain": "Установить домен страницы авторизации", + "failedToFetchCertificate": "Не удалось получить сертификат", + "failedToRestartCertificate": "Не удалось перезапустить сертификат", + "addDomainToEnableCustomAuthPages": "Добавьте домен для включения пользовательских страниц аутентификации для вашей организации", + "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "domainPickerProvidedDomain": "Домен предоставлен", "domainPickerFreeProvidedDomain": "Бесплатный домен", "domainPickerVerified": "Подтверждено", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.", "domainPickerSubdomainSanitized": "Субдомен очищен", "domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"", + "orgAuthSignInTitle": "Войдите в свою организацию", + "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", + "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", + "orgAuthSignInWithPangolin": "Войти через Pangolin", + "subscriptionRequiredToUse": "Для использования этой функции требуется подписка.", + "idpDisabled": "Провайдеры идентификации отключены.", + "orgAuthPageDisabled": "Страница авторизации организации отключена.", + "domainRestartedDescription": "Проверка домена успешно перезапущена", "resourceAddEntrypointsEditFile": "Редактировать файл: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml", "emailVerificationRequired": "Требуется подтверждение адреса электронной почты. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", - "twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда." } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index f0a66950..33c37327 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -168,6 +168,9 @@ "siteSelect": "Site seç", "siteSearch": "Site ara", "siteNotFound": "Herhangi bir site bulunamadı.", + "selectCountry": "Ülke Seç", + "searchCountries": "Ülkeleri ara...", + "noCountryFound": "Ülke bulunamadı.", "siteSelectionDescription": "Bu site hedefe bağlantı sağlayacaktır.", "resourceType": "Kaynak Türü", "resourceTypeDescription": "Kaynağınıza nasıl erişmek istediğinizi belirleyin", @@ -814,7 +817,7 @@ "redirectUrl": "Yönlendirme URL'si", "redirectUrlAbout": "Yönlendirme URL'si Hakkında", "redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.", - "pangolinAuth": "Auth - Pangolin", + "pangolinAuth": "Yetkilendirme - Pangolin", "verificationCodeLengthRequirements": "Doğrulama kodunuz 8 karakter olmalıdır.", "errorOccurred": "Bir hata oluştu", "emailErrorVerify": "E-posta doğrulanamadı: ", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "Bağlandı", "idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.", "idpErrorNotFound": "IdP bulunamadı", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "Geçersiz Davet", "inviteInvalidDescription": "Davet bağlantısı geçersiz.", "inviteErrorWrongUser": "Davet bu kullanıcı için değil", @@ -1241,7 +1242,7 @@ "sidebarExpand": "Genişlet", "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", - "domainPickerEnterDomain": "Domain", + "domainPickerEnterDomain": "Alan Adı", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "Alt Alan: {subdomain}", "domainPickerNamespace": "Ad Alanı: {namespace}", "domainPickerShowMore": "Daha Fazla Göster", + "regionSelectorTitle": "Bölge Seç", + "regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.", + "regionSelectorPlaceholder": "Bölge Seçin", + "regionSelectorComingSoon": "Yakında Geliyor", + "billingLoadingSubscription": "Abonelik yükleniyor...", + "billingFreeTier": "Ücretsiz Dilim", + "billingWarningOverLimit": "Uyarı: Bir veya daha fazla kullanım limitini aştınız. Aboneliğinizi değiştirmediğiniz veya kullanımı ayarlamadığınız sürece siteleriniz bağlanmayacaktır.", + "billingUsageLimitsOverview": "Kullanım Limitleri Genel Görünümü", + "billingMonitorUsage": "Kullanımınızı yapılandırılmış limitlerle karşılaştırın. Limitlerin artırılmasına ihtiyacınız varsa, lütfen support@fossorial.io adresinden bizimle iletişime geçin.", + "billingDataUsage": "Veri Kullanımı", + "billingOnlineTime": "Site Çevrimiçi Süresi", + "billingUsers": "Aktif Kullanıcılar", + "billingDomains": "Aktif Alanlar", + "billingRemoteExitNodes": "Aktif Öz-Host Düğümleri", + "billingNoLimitConfigured": "Hiçbir limit yapılandırılmadı", + "billingEstimatedPeriod": "Tahmini Fatura Dönemi", + "billingIncludedUsage": "Dahil Kullanım", + "billingIncludedUsageDescription": "Mevcut abonelik planınıza bağlı kullanım", + "billingFreeTierIncludedUsage": "Ücretsiz dilim kullanım hakları", + "billingIncluded": "dahil", + "billingEstimatedTotal": "Tahmini Toplam:", + "billingNotes": "Notlar", + "billingEstimateNote": "Bu, mevcut kullanımınıza dayalı bir tahmindir.", + "billingActualChargesMayVary": "Asıl ücretler farklılık gösterebilir.", + "billingBilledAtEnd": "Fatura döneminin sonunda fatura düzenlenecektir.", + "billingModifySubscription": "Aboneliği Düzenle", + "billingStartSubscription": "Aboneliği Başlat", + "billingRecurringCharge": "Yinelenen Ücret", + "billingManageSubscriptionSettings": "Abonelik ayarlarınızı ve tercihlerinizi yönetin", + "billingNoActiveSubscription": "Aktif bir aboneliğiniz yok. Kullanım limitlerini artırmak için aboneliğinizi başlatın.", + "billingFailedToLoadSubscription": "Abonelik yüklenemedi", + "billingFailedToLoadUsage": "Kullanım yüklenemedi", + "billingFailedToGetCheckoutUrl": "Ödeme URL'si alınamadı", + "billingPleaseTryAgainLater": "Lütfen daha sonra tekrar deneyin.", + "billingCheckoutError": "Ödeme Hatası", + "billingFailedToGetPortalUrl": "Portal URL'si alınamadı", + "billingPortalError": "Portal Hatası", + "billingDataUsageInfo": "Buluta bağlandığınızda, güvenli tünellerinizden aktarılan tüm verilerden ücret alınırsınız. Bu, tüm sitelerinizdeki gelen ve giden trafiği içerir. Limitinize ulaştığınızda, planınızı yükseltmeli veya kullanımı azaltmalısınız, aksi takdirde siteleriniz bağlantıyı keser. Düğümler kullanırken verilerden ücret alınmaz.", + "billingOnlineTimeInfo": "Sitelerinizin buluta ne kadar süre bağlı kaldığına göre ücretlendirilirsiniz. Örneğin, 44,640 dakika, bir sitenin 24/7 boyunca tam bir ay boyunca çalışması anlamına gelir. Limitinize ulaştığınızda, planınızı yükseltmeyip kullanımı azaltmazsanız siteleriniz bağlantıyı keser. Düğümler kullanırken zamandan ücret alınmaz.", + "billingUsersInfo": "Kuruluşunuzdaki her kullanıcı için ücretlendirilirsiniz. Faturalandırma, hesabınızdaki aktif kullanıcı hesaplarının sayısına göre günlük olarak hesaplanır.", + "billingDomainInfo": "Kuruluşunuzdaki her alan adı için ücretlendirilirsiniz. Faturalandırma, hesabınızdaki aktif alan adları hesaplarının sayısına göre günlük olarak hesaplanır.", + "billingRemoteExitNodesInfo": "Kuruluşunuzdaki her yönetilen Düğüm için ücretlendirilirsiniz. Faturalandırma, hesabınızdaki aktif yönetilen Düğümler sayısına göre günlük olarak hesaplanır.", "domainNotFound": "Alan Adı Bulunamadı", "domainNotFoundDescription": "Bu kaynak devre dışıdır çünkü alan adı sistemimizde artık mevcut değil. Bu kaynak için yeni bir alan adı belirleyin.", "failed": "Başarısız", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "DNS değişikliklerinin internet genelinde yayılması zaman alabilir. DNS sağlayıcınız ve TTL ayarlarına bağlı olarak bu birkaç dakika ile 48 saat arasında değişebilir.", "resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir", "resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı", + "billingPricingCalculatorLink": "Fiyat Hesaplayıcı", "signUpTerms": { "IAgreeToThe": "Kabul ediyorum", "termsOfService": "hizmet şartları", @@ -1368,6 +1412,41 @@ "addNewTarget": "Yeni Hedef Ekle", "targetsList": "Hedefler Listesi", "targetErrorDuplicateTargetFound": "Yinelenen hedef bulundu", + "healthCheckHealthy": "Sağlıklı", + "healthCheckUnhealthy": "Sağlıksız", + "healthCheckUnknown": "Bilinmiyor", + "healthCheck": "Sağlık Kontrolü", + "configureHealthCheck": "Sağlık Kontrolünü Yapılandır", + "configureHealthCheckDescription": "{hedef} için sağlık izleme kurun", + "enableHealthChecks": "Sağlık Kontrollerini Etkinleştir", + "enableHealthChecksDescription": "Bu hedefin sağlığını izleyin. Gerekirse hedef dışındaki bir son noktayı izleyebilirsiniz.", + "healthScheme": "Yöntem", + "healthSelectScheme": "Yöntem Seç", + "healthCheckPath": "Yol", + "healthHostname": "IP / Host", + "healthPort": "Port", + "healthCheckPathDescription": "Sağlık durumunu kontrol etmek için yol.", + "healthyIntervalSeconds": "Sağlıklı Aralık", + "unhealthyIntervalSeconds": "Sağlıksız Aralık", + "IntervalSeconds": "Sağlıklı Aralık", + "timeoutSeconds": "Zaman Aşımı", + "timeIsInSeconds": "Zaman saniye cinsindendir", + "retryAttempts": "Tekrar Deneme Girişimleri", + "expectedResponseCodes": "Beklenen Yanıt Kodları", + "expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.", + "customHeaders": "Özel Başlıklar", + "customHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer", + "headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.", + "saveHealthCheck": "Sağlık Kontrolünü Kaydet", + "healthCheckSaved": "Sağlık Kontrolü Kaydedildi", + "healthCheckSavedDescription": "Sağlık kontrol yapılandırması başarıyla kaydedildi", + "healthCheckError": "Sağlık Kontrol Hatası", + "healthCheckErrorDescription": "Sağlık kontrol yapılandırması kaydedilirken bir hata oluştu", + "healthCheckPathRequired": "Sağlık kontrol yolu gereklidir", + "healthCheckMethodRequired": "HTTP yöntemi gereklidir", + "healthCheckIntervalMin": "Kontrol aralığı en az 5 saniye olmalıdır", + "healthCheckTimeoutMin": "Zaman aşımı en az 1 saniye olmalıdır", + "healthCheckRetryMin": "Tekrar deneme girişimleri en az 1 olmalıdır", "httpMethod": "HTTP Yöntemi", "selectHttpMethod": "HTTP yöntemini seçin", "domainPickerSubdomainLabel": "Alt Alan Adı", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "Mevcut ücretsiz alan adları arasından aramak ve seçmek için bir alt alan adı girin.", "domainPickerFreeDomains": "Ücretsiz Alan Adları", "domainPickerSearchForAvailableDomains": "Mevcut alan adlarını ara", + "domainPickerNotWorkSelfHosted": "Not: Ücretsiz sağlanan alan adları şu anda öz-host edilmiş örnekler için kullanılabilir değildir.", "resourceDomain": "Alan Adı", "resourceEditDomain": "Alan Adını Düzenle", "siteName": "Site Adı", @@ -1463,6 +1543,72 @@ "autoLoginError": "Otomatik Giriş Hatası", "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.", + "remoteExitNodeManageRemoteExitNodes": "Öz-Host Yönetim", + "remoteExitNodeDescription": "Ağ bağlantınızı genişletmek için düğümleri yönetin", + "remoteExitNodes": "Düğümler", + "searchRemoteExitNodes": "Düğüm ara...", + "remoteExitNodeAdd": "Düğüm Ekle", + "remoteExitNodeErrorDelete": "Düğüm silinirken hata oluştu", + "remoteExitNodeQuestionRemove": "{selectedNode} düğümünü organizasyondan kaldırmak istediğinizden emin misiniz?", + "remoteExitNodeMessageRemove": "Kaldırıldığında, düğüm artık erişilebilir olmayacaktır.", + "remoteExitNodeMessageConfirm": "Onaylamak için lütfen aşağıya düğümün adını yazın.", + "remoteExitNodeConfirmDelete": "Düğüm Silmeyi Onayla", + "remoteExitNodeDelete": "Düğümü Sil", + "sidebarRemoteExitNodes": "Düğümler", + "remoteExitNodeCreate": { + "title": "Düğüm Oluştur", + "description": "Ağ bağlantınızı genişletmek için yeni bir düğüm oluşturun", + "viewAllButton": "Tüm Düğümleri Gör", + "strategy": { + "title": "Oluşturma Stratejisi", + "description": "Düğümünüzü manuel olarak yapılandırmak veya yeni kimlik bilgileri oluşturmak için bunu seçin.", + "adopt": { + "title": "Düğüm Benimse", + "description": "Zaten düğüm için kimlik bilgilerine sahipseniz bunu seçin." + }, + "generate": { + "title": "Anahtarları Oluştur", + "description": "Düğüm için yeni anahtarlar oluşturmak istiyorsanız bunu seçin" + } + }, + "adopt": { + "title": "Mevcut Düğümü Benimse", + "description": "Adayacağınız mevcut düğümün kimlik bilgilerini girin", + "nodeIdLabel": "Düğüm ID", + "nodeIdDescription": "Adayacağınız mevcut düğümün ID'si", + "secretLabel": "Gizli", + "secretDescription": "Mevcut düğümün gizli anahtarı", + "submitButton": "Düğümü Benimse" + }, + "generate": { + "title": "Oluşturulan Kimlik Bilgileri", + "description": "Düğümünüzü yapılandırmak için oluşturulan bu kimlik bilgilerini kullanın", + "nodeIdTitle": "Düğüm ID", + "secretTitle": "Gizli", + "saveCredentialsTitle": "Kimlik Bilgilerini Yapılandırmaya Ekle", + "saveCredentialsDescription": "Bağlantıyı tamamlamak için bu kimlik bilgilerini öz-host Pangolin düğüm yapılandırma dosyanıza ekleyin.", + "submitButton": "Düğüm Oluştur" + }, + "validation": { + "adoptRequired": "Mevcut bir düğümü benimserken Düğüm ID ve Gizli anahtar gereklidir" + }, + "errors": { + "loadDefaultsFailed": "Varsayılanlar yüklenemedi", + "defaultsNotLoaded": "Varsayılanlar yüklenmedi", + "createFailed": "Düğüm oluşturulamadı" + }, + "success": { + "created": "Düğüm başarıyla oluşturuldu" + } + }, + "remoteExitNodeSelection": "Düğüm Seçimi", + "remoteExitNodeSelectionDescription": "Yerel site için trafiği yönlendirecek düğümü seçin", + "remoteExitNodeRequired": "Yerel siteler için bir düğüm seçilmelidir", + "noRemoteExitNodesAvailable": "Düğüm Bulunamadı", + "noRemoteExitNodesAvailableDescription": "Bu organizasyon için düğüm mevcut değil. Yerel siteleri kullanmak için önce bir düğüm oluşturun.", + "exitNode": "Çıkış Düğümü", + "country": "Ülke", + "rulesMatchCountry": "Şu anda kaynak IP'ye dayanarak", "managedSelfHosted": { "title": "Yönetilen Self-Hosted", "description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", "willbestoredas": "Şu şekilde depolanacak:", + "roleMappingDescription": "Otomatik Sağlama etkinleştirildiğinde kullanıcıların oturum açarken rollerin nasıl atandığını belirleyin.", + "selectRole": "Bir Rol Seçin", + "roleMappingExpression": "İfade", + "selectRolePlaceholder": "Bir rol seçin", + "selectRoleDescription": "Bu kimlik sağlayıcısından tüm kullanıcılara atanacak bir rol seçin", + "roleMappingExpressionDescription": "Rol bilgilerini ID tokeninden çıkarmak için bir JMESPath ifadesi girin", + "idpTenantIdRequired": "Kiracı Kimliği gereklidir", + "invalidValue": "Geçersiz değer", + "idpTypeLabel": "Kimlik Sağlayıcı Türü", + "roleMappingExpressionPlaceholder": "örn., contains(gruplar, 'yönetici') && 'Yönetici' || 'Üye'", + "idpGoogleConfiguration": "Google Yapılandırması", + "idpGoogleConfigurationDescription": "Google OAuth2 kimlik bilgilerinizi yapılandırın", + "idpGoogleClientIdDescription": "Google OAuth2 İstemci Kimliğiniz", + "idpGoogleClientSecretDescription": "Google OAuth2 İstemci Sırrınız", + "idpAzureConfiguration": "Azure Entra ID Yapılandırması", + "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 kimlik bilgilerinizi yapılandırın", + "idpTenantId": "Kiracı Kimliği", + "idpTenantIdPlaceholder": "kiraci-kimliginiz", + "idpAzureTenantIdDescription": "Azure kiracı kimliğiniz (Azure Active Directory genel bakışında bulunur)", + "idpAzureClientIdDescription": "Azure Uygulama Kaydı İstemci Kimliğiniz", + "idpAzureClientSecretDescription": "Azure Uygulama Kaydı İstemci Sırrınız", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google Yapılandırması", + "idpAzureConfigurationTitle": "Azure Entra ID Yapılandırması", + "idpTenantIdLabel": "Kiracı Kimliği", + "idpAzureClientIdDescription2": "Azure Uygulama Kaydı İstemci Kimliğiniz", + "idpAzureClientSecretDescription2": "Azure Uygulama Kaydı İstemci Sırrınız", "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", - "customHeaders": "Özel Başlıklar", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.", + "subnet": "Alt ağ", + "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", + "authPage": "Yetkilendirme Sayfası", + "authPageDescription": "Kuruluşunuz için yetkilendirme sayfasını yapılandırın", + "authPageDomain": "Yetkilendirme Sayfası Alanı", + "noDomainSet": "Alan belirlenmedi", + "changeDomain": "Alanı Değiştir", + "selectDomain": "Alan Seçin", + "restartCertificate": "Sertifikayı Yenile", + "editAuthPageDomain": "Yetkilendirme Sayfası Alanını Düzenle", + "setAuthPageDomain": "Yetkilendirme Sayfası Alanını Ayarla", + "failedToFetchCertificate": "Sertifika getirilemedi", + "failedToRestartCertificate": "Sertifika yeniden başlatılamadı", + "addDomainToEnableCustomAuthPages": "Kuruluşunuz için özel kimlik doğrulama sayfalarını etkinleştirmek için bir alan ekleyin", + "selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin", "domainPickerProvidedDomain": "Sağlanan Alan Adı", "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", "domainPickerVerified": "Doğrulandı", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.", "domainPickerSubdomainSanitized": "Alt alan adı temizlendi", "domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi", + "orgAuthSignInTitle": "Kuruluşunuza giriş yapın", + "orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin", + "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", + "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", + "subscriptionRequiredToUse": "Bu özelliği kullanmak için abonelik gerekmektedir.", + "idpDisabled": "Kimlik sağlayıcılar devre dışı bırakılmıştır.", + "orgAuthPageDisabled": "Kuruluş kimlik doğrulama sayfası devre dışı bırakılmıştır.", + "domainRestartedDescription": "Alan doğrulaması başarıyla yeniden başlatıldı", "resourceAddEntrypointsEditFile": "Dosyayı düzenle: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml", "emailVerificationRequired": "E-posta doğrulaması gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", - "twoFactorSetupRequired": "İki faktörlü kimlik doğrulama ayarı gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "İki faktörlü kimlik doğrulama ayarı gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün." } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 1e6ddce8..5708f976 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -168,6 +168,9 @@ "siteSelect": "选择站点", "siteSearch": "搜索站点", "siteNotFound": "未找到站点。", + "selectCountry": "选择国家", + "searchCountries": "搜索国家...", + "noCountryFound": "找不到国家。", "siteSelectionDescription": "此站点将为目标提供连接。", "resourceType": "资源类型", "resourceTypeDescription": "确定如何访问您的资源", @@ -595,7 +598,7 @@ "newtErrorFetchReleases": "无法获取版本信息: {err}", "newtErrorFetchLatest": "无法获取最新版信息: {err}", "newtEndpoint": "Newt 端点", - "newtId": "Newt ID", + "newtId": "Newt ID", "newtSecretKey": "Newt 私钥", "architecture": "架构", "sites": "站点", @@ -914,8 +917,6 @@ "idpConnectingToFinished": "已连接", "idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。", "idpErrorNotFound": "找不到 IdP", - "idpGoogleAlt": "Google", - "idpAzureAlt": "Azure", "inviteInvalid": "无效邀请", "inviteInvalidDescription": "邀请链接无效。", "inviteErrorWrongUser": "邀请不是该用户的", @@ -1155,7 +1156,7 @@ "containerLabels": "标签", "containerLabelsCount": "{count, plural, other {# 标签}}", "containerLabelsTitle": "容器标签", - "containerLabelEmpty": "", + "containerLabelEmpty": "<为空>", "containerPorts": "端口", "containerPortsMore": "+{count} 更多", "containerActions": "行动", @@ -1257,6 +1258,48 @@ "domainPickerSubdomain": "子域:{subdomain}", "domainPickerNamespace": "命名空间:{namespace}", "domainPickerShowMore": "显示更多", + "regionSelectorTitle": "选择区域", + "regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。", + "regionSelectorPlaceholder": "选择一个区域", + "regionSelectorComingSoon": "即将推出", + "billingLoadingSubscription": "正在加载订阅...", + "billingFreeTier": "免费层", + "billingWarningOverLimit": "警告:您已超出一个或多个使用限制。在您修改订阅或调整使用情况之前,您的站点将无法连接。", + "billingUsageLimitsOverview": "使用限制概览", + "billingMonitorUsage": "监控您的使用情况以对比已配置的限制。如需提高限制请联系我们 support@fossorial.io。", + "billingDataUsage": "数据使用情况", + "billingOnlineTime": "站点在线时间", + "billingUsers": "活跃用户", + "billingDomains": "活跃域", + "billingRemoteExitNodes": "活跃自托管节点", + "billingNoLimitConfigured": "未配置限制", + "billingEstimatedPeriod": "估计结算周期", + "billingIncludedUsage": "包含的使用量", + "billingIncludedUsageDescription": "您当前订阅计划中包含的使用量", + "billingFreeTierIncludedUsage": "免费层使用额度", + "billingIncluded": "包含", + "billingEstimatedTotal": "预计总额:", + "billingNotes": "备注", + "billingEstimateNote": "这是根据您当前使用情况的估算。", + "billingActualChargesMayVary": "实际费用可能会有变化。", + "billingBilledAtEnd": "您将在结算周期结束时被计费。", + "billingModifySubscription": "修改订阅", + "billingStartSubscription": "开始订阅", + "billingRecurringCharge": "周期性收费", + "billingManageSubscriptionSettings": "管理您的订阅设置和偏好", + "billingNoActiveSubscription": "您没有活跃的订阅。开始订阅以增加使用限制。", + "billingFailedToLoadSubscription": "无法加载订阅", + "billingFailedToLoadUsage": "无法加载使用情况", + "billingFailedToGetCheckoutUrl": "无法获取结账网址", + "billingPleaseTryAgainLater": "请稍后再试。", + "billingCheckoutError": "结账错误", + "billingFailedToGetPortalUrl": "无法获取门户网址", + "billingPortalError": "门户错误", + "billingDataUsageInfo": "当连接到云端时,您将为通过安全隧道传输的所有数据收取费用。 这包括您所有站点的进出流量。 当您达到上限时,您的站点将断开连接,直到您升级计划或减少使用。使用节点时不收取数据。", + "billingOnlineTimeInfo": "您要根据您的网站连接到云端的时间长短收取费用。 例如,44,640分钟等于一个24/7全月运行的网站。 当您达到上限时,您的站点将断开连接,直到您升级计划或减少使用。使用节点时不收取费用。", + "billingUsersInfo": "根据您组织中的活跃用户数量收费。按日计算账单。", + "billingDomainInfo": "根据组织中活跃域的数量收费。按日计算账单。", + "billingRemoteExitNodesInfo": "根据您组织中已管理节点的数量收费。按日计算账单。", "domainNotFound": "域未找到", "domainNotFoundDescription": "此资源已禁用,因为该域不再在我们的系统中存在。请为此资源设置一个新域。", "failed": "失败", @@ -1320,6 +1363,7 @@ "createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。", "resourcePortRequired": "非 HTTP 资源必须输入端口号", "resourcePortNotAllowed": "HTTP 资源不应设置端口号", + "billingPricingCalculatorLink": "价格计算器", "signUpTerms": { "IAgreeToThe": "我同意", "termsOfService": "服务条款", @@ -1346,7 +1390,7 @@ "clientOlmCredentials": "Olm 凭据", "clientOlmCredentialsDescription": "这是 Olm 服务器的身份验证方式", "olmEndpoint": "Olm 端点", - "olmId": "Olm ID", + "olmId": "Olm ID", "olmSecretKey": "Olm 私钥", "clientCredentialsSave": "保存您的凭据", "clientCredentialsSaveDescription": "该信息仅会显示一次,请确保将其复制到安全位置。", @@ -1368,6 +1412,41 @@ "addNewTarget": "添加新目标", "targetsList": "目标列表", "targetErrorDuplicateTargetFound": "找到重复的目标", + "healthCheckHealthy": "正常", + "healthCheckUnhealthy": "不正常", + "healthCheckUnknown": "未知", + "healthCheck": "健康检查", + "configureHealthCheck": "配置健康检查", + "configureHealthCheckDescription": "为 {target} 设置健康监控", + "enableHealthChecks": "启用健康检查", + "enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。", + "healthScheme": "方法", + "healthSelectScheme": "选择方法", + "healthCheckPath": "路径", + "healthHostname": "IP / 主机", + "healthPort": "端口", + "healthCheckPathDescription": "用于检查健康状态的路径。", + "healthyIntervalSeconds": "正常间隔", + "unhealthyIntervalSeconds": "不正常间隔", + "IntervalSeconds": "正常间隔", + "timeoutSeconds": "超时", + "timeIsInSeconds": "时间以秒为单位", + "retryAttempts": "重试次数", + "expectedResponseCodes": "期望响应代码", + "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", + "customHeaders": "自定义标题", + "customHeadersDescription": "头部新行分隔:头部名称:值", + "headersValidationError": "头部必须是格式:头部名称:值。", + "saveHealthCheck": "保存健康检查", + "healthCheckSaved": "健康检查已保存", + "healthCheckSavedDescription": "健康检查配置已成功保存。", + "healthCheckError": "健康检查错误", + "healthCheckErrorDescription": "保存健康检查配置时出错", + "healthCheckPathRequired": "健康检查路径为必填项", + "healthCheckMethodRequired": "HTTP 方法为必填项", + "healthCheckIntervalMin": "检查间隔必须至少为 5 秒", + "healthCheckTimeoutMin": "超时必须至少为 1 秒", + "healthCheckRetryMin": "重试次数必须至少为 1 次", "httpMethod": "HTTP 方法", "selectHttpMethod": "选择 HTTP 方法", "domainPickerSubdomainLabel": "子域名", @@ -1381,6 +1460,7 @@ "domainPickerEnterSubdomainToSearch": "输入一个子域名以搜索并从可用免费域名中选择。", "domainPickerFreeDomains": "免费域名", "domainPickerSearchForAvailableDomains": "搜索可用域名", + "domainPickerNotWorkSelfHosted": "注意:自托管实例当前不提供免费的域名。", "resourceDomain": "域名", "resourceEditDomain": "编辑域名", "siteName": "站点名称", @@ -1463,6 +1543,72 @@ "autoLoginError": "自动登录错误", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。", + "remoteExitNodeManageRemoteExitNodes": "管理自托管", + "remoteExitNodeDescription": "管理节点以扩展您的网络连接", + "remoteExitNodes": "Nodes", + "searchRemoteExitNodes": "搜索节点...", + "remoteExitNodeAdd": "添加节点", + "remoteExitNodeErrorDelete": "删除节点时出错", + "remoteExitNodeQuestionRemove": "您确定要从组织中删除 {selectedNode} 节点吗?", + "remoteExitNodeMessageRemove": "一旦删除,该节点将不再能够访问。", + "remoteExitNodeMessageConfirm": "要确认,请输入以下节点的名称。", + "remoteExitNodeConfirmDelete": "确认删除节点", + "remoteExitNodeDelete": "删除节点", + "sidebarRemoteExitNodes": "Nodes", + "remoteExitNodeCreate": { + "title": "创建节点", + "description": "创建一个新节点来扩展您的网络连接", + "viewAllButton": "查看所有节点", + "strategy": { + "title": "创建策略", + "description": "选择此选项以手动配置您的节点或生成新凭据。", + "adopt": { + "title": "采纳节点", + "description": "如果您已经拥有该节点的凭据,请选择此项。" + }, + "generate": { + "title": "生成密钥", + "description": "如果您想为节点生成新密钥,请选择此选项" + } + }, + "adopt": { + "title": "采纳现有节点", + "description": "输入您想要采用的现有节点的凭据", + "nodeIdLabel": "节点 ID", + "nodeIdDescription": "您想要采用的现有节点的 ID", + "secretLabel": "密钥", + "secretDescription": "现有节点的秘密密钥", + "submitButton": "采用节点" + }, + "generate": { + "title": "生成的凭据", + "description": "使用这些生成的凭据来配置您的节点", + "nodeIdTitle": "节点 ID", + "secretTitle": "密钥", + "saveCredentialsTitle": "将凭据添加到配置中", + "saveCredentialsDescription": "将这些凭据添加到您的自托管 Pangolin 节点配置文件中以完成连接。", + "submitButton": "创建节点" + }, + "validation": { + "adoptRequired": "在通过现有节点时需要节点ID和密钥" + }, + "errors": { + "loadDefaultsFailed": "无法加载默认值", + "defaultsNotLoaded": "默认值未加载", + "createFailed": "创建节点失败" + }, + "success": { + "created": "节点创建成功" + } + }, + "remoteExitNodeSelection": "节点选择", + "remoteExitNodeSelectionDescription": "为此本地站点选择要路由流量的节点", + "remoteExitNodeRequired": "必须为本地站点选择节点", + "noRemoteExitNodesAvailable": "无可用节点", + "noRemoteExitNodesAvailableDescription": "此组织没有可用的节点。首先创建一个节点来使用本地站点。", + "exitNode": "出口节点", + "country": "国家", + "rulesMatchCountry": "当前基于源 IP", "managedSelfHosted": { "title": "托管自托管", "description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器", @@ -1501,11 +1647,53 @@ }, "internationaldomaindetected": "检测到国际域", "willbestoredas": "储存为:", + "roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。", + "selectRole": "选择角色", + "roleMappingExpression": "表达式", + "selectRolePlaceholder": "选择角色", + "selectRoleDescription": "选择一个角色,从此身份提供商分配给所有用户", + "roleMappingExpressionDescription": "输入一个 JMESPath 表达式来从 ID 令牌提取角色信息", + "idpTenantIdRequired": "租户ID是必需的", + "invalidValue": "无效的值", + "idpTypeLabel": "身份提供者类型", + "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", + "idpGoogleConfiguration": "Google 配置", + "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 凭据", + "idpGoogleClientIdDescription": "您的 Google OAuth2 客户端 ID", + "idpGoogleClientSecretDescription": "您的 Google OAuth2 客户端密钥", + "idpAzureConfiguration": "Azure Entra ID 配置", + "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 凭据", + "idpTenantId": "Tenant ID", + "idpTenantIdPlaceholder": "您的租户ID", + "idpAzureTenantIdDescription": "您的 Azure 租户ID (在 Azure Active Directory 概览中发现)", + "idpAzureClientIdDescription": "您的 Azure 应用程序注册客户端 ID", + "idpAzureClientSecretDescription": "您的 Azure 应用程序注册客户端密钥", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google 配置", + "idpAzureConfigurationTitle": "Azure Entra ID 配置", + "idpTenantIdLabel": "Tenant ID", + "idpAzureClientIdDescription2": "您的 Azure 应用程序注册客户端 ID", + "idpAzureClientSecretDescription2": "您的 Azure 应用程序注册客户端密钥", "idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "customHeaders": "自定义标题", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "headersValidationError": "头部必须是格式:头部名称:值。", + "subnet": "子网", + "subnetDescription": "此组织网络配置的子网。", + "authPage": "认证页面", + "authPageDescription": "配置您的组织认证页面", + "authPageDomain": "认证页面域", + "noDomainSet": "没有域设置", + "changeDomain": "更改域", + "selectDomain": "选择域", + "restartCertificate": "重新启动证书", + "editAuthPageDomain": "编辑认证页面域", + "setAuthPageDomain": "设置认证页面域", + "failedToFetchCertificate": "获取证书失败", + "failedToRestartCertificate": "重新启动证书失败", + "addDomainToEnableCustomAuthPages": "为您的组织添加域名以启用自定义认证页面", + "selectDomainForOrgAuthPage": "选择组织认证页面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免费提供的域", "domainPickerVerified": "已验证", @@ -1519,10 +1707,16 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。", "domainPickerSubdomainSanitized": "子域已净化", "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"", + "orgAuthSignInTitle": "登录到您的组织", + "orgAuthChooseIdpDescription": "选择您的身份提供商以继续", + "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", + "orgAuthSignInWithPangolin": "使用 Pangolin 登录", + "subscriptionRequiredToUse": "需要订阅才能使用此功能。", + "idpDisabled": "身份提供者已禁用。", + "orgAuthPageDisabled": "组织认证页面已禁用。", + "domainRestartedDescription": "域验证重新启动成功", "resourceAddEntrypointsEditFile": "编辑文件:config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "编辑文件:docker-compose.yml", "emailVerificationRequired": "需要电子邮件验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", - "twoFactorSetupRequired": "需要设置双因素身份验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", - "rewritePath": "Rewrite Path", - "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." + "twoFactorSetupRequired": "需要设置双因素身份验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。" } diff --git a/package-lock.json b/package-lock.json index 0985af1e..14d08688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", + "@aws-sdk/client-s3": "3.837.0", "@hookform/resolvers": "5.2.2", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", @@ -61,10 +62,12 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", + "ioredis": "5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.544.0", + "maxmind": "5.0.0", "moment": "2.30.1", "next": "15.5.3", "next-intl": "^4.3.9", @@ -83,7 +86,10 @@ "react-hook-form": "7.62.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", + "reodotdev": "^1.0.0", + "resend": "^6.1.1", "semver": "^7.7.2", + "stripe": "18.2.1", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.8", @@ -99,6 +105,7 @@ "devDependencies": { "@dotenvx/dotenvx": "1.51.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@react-email/preview-server": "4.1.0", "@tailwindcss/postcss": "^4.1.13", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.9", @@ -143,6 +150,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asteasolutions/zod-to-openapi": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz", @@ -155,11 +176,87 @@ "zod": "^3.20.2" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -175,7 +272,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -188,7 +284,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -202,7 +297,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -216,7 +310,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -231,7 +324,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -241,7 +333,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -253,7 +344,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -266,7 +356,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -280,7 +369,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -290,51 +378,133 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.837.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.837.0.tgz", + "integrity": "sha512-sBjPPG30HIfNwpzWuajCDf7agb4YAxPFFpsp3kwgptJF8PEi0HzQg64bskquMzjqLC2tXsn5rKtDVpQOvs29MQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.835.0", + "@aws-sdk/credential-provider-node": "3.835.0", + "@aws-sdk/middleware-bucket-endpoint": "3.830.0", + "@aws-sdk/middleware-expect-continue": "3.821.0", + "@aws-sdk/middleware-flexible-checksums": "3.835.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-location-constraint": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-sdk-s3": "3.835.0", + "@aws-sdk/middleware-ssec": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.835.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/signature-v4-multi-region": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.835.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/eventstream-serde-config-resolver": "^4.1.2", + "@smithy/eventstream-serde-node": "^4.0.4", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-blob-browser": "^4.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/hash-stream-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.12", + "@smithy/middleware-retry": "^4.1.13", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.20", + "@smithy/util-defaults-mode-node": "^4.0.20", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.2", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.5", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@aws-sdk/client-sesv2": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.888.0.tgz", - "integrity": "sha512-Zy7AXvj4oVLE5Zkj61qYZxIFgJXbRgTmFJvQ/EqgxE87KPR9+gF5wtC3iqcKEmkqFlWlxWrlhV4K70Vqqj4bZQ==", + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.899.0.tgz", + "integrity": "sha512-aMs3QgB9lWaKKrnx9KhIopoeXLNzI/sqdp5M56j30jlBD4vqdcCzW2OwFAAs26QzUgNKOOSY+iLZcE9DUDdIvg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.888.0", - "@aws-sdk/credential-provider-node": "3.888.0", - "@aws-sdk/middleware-host-header": "3.887.0", - "@aws-sdk/middleware-logger": "3.887.0", - "@aws-sdk/middleware-recursion-detection": "3.887.0", - "@aws-sdk/middleware-user-agent": "3.888.0", - "@aws-sdk/region-config-resolver": "3.887.0", - "@aws-sdk/signature-v4-multi-region": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@aws-sdk/util-endpoints": "3.887.0", - "@aws-sdk/util-user-agent-browser": "3.887.0", - "@aws-sdk/util-user-agent-node": "3.888.0", - "@smithy/config-resolver": "^4.2.1", - "@smithy/core": "^3.11.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/credential-provider-node": "3.899.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.899.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/signature-v4-multi-region": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.899.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.13.0", "@smithy/fetch-http-handler": "^5.2.1", "@smithy/hash-node": "^4.1.1", "@smithy/invalid-dependency": "^4.1.1", "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.1", - "@smithy/middleware-retry": "^4.2.1", + "@smithy/middleware-endpoint": "^4.2.5", + "@smithy/middleware-retry": "^4.3.1", "@smithy/middleware-serde": "^4.1.1", "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/node-http-handler": "^4.2.1", "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.1", - "@smithy/util-defaults-mode-node": "^4.1.1", - "@smithy/util-endpoints": "^3.1.1", + "@smithy/util-defaults-mode-browser": "^4.1.5", + "@smithy/util-defaults-mode-node": "^4.1.5", + "@smithy/util-endpoints": "^3.1.2", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.1", + "@smithy/util-retry": "^4.1.2", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, @@ -342,49 +512,49 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.888.0.tgz", - "integrity": "sha512-8CLy/ehGKUmekjH+VtZJ4w40PqDg3u0K7uPziq/4P8Q7LLgsy8YQoHNbuY4am7JU3HWrqLXJI9aaz1+vPGPoWA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/client-sso": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.899.0.tgz", + "integrity": "sha512-EKz/iiVDv2OC8/3ONcXG3+rhphx9Heh7KXQdsZzsAXGVn6mWtrHQLrWjgONckmK4LrD07y4+5WlJlGkMxSMA5A==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.888.0", - "@aws-sdk/middleware-host-header": "3.887.0", - "@aws-sdk/middleware-logger": "3.887.0", - "@aws-sdk/middleware-recursion-detection": "3.887.0", - "@aws-sdk/middleware-user-agent": "3.888.0", - "@aws-sdk/region-config-resolver": "3.887.0", - "@aws-sdk/types": "3.887.0", - "@aws-sdk/util-endpoints": "3.887.0", - "@aws-sdk/util-user-agent-browser": "3.887.0", - "@aws-sdk/util-user-agent-node": "3.888.0", - "@smithy/config-resolver": "^4.2.1", - "@smithy/core": "^3.11.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.899.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.899.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.13.0", "@smithy/fetch-http-handler": "^5.2.1", "@smithy/hash-node": "^4.1.1", "@smithy/invalid-dependency": "^4.1.1", "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.1", - "@smithy/middleware-retry": "^4.2.1", + "@smithy/middleware-endpoint": "^4.2.5", + "@smithy/middleware-retry": "^4.3.1", "@smithy/middleware-serde": "^4.1.1", "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/node-http-handler": "^4.2.1", "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.1", - "@smithy/util-defaults-mode-node": "^4.1.1", - "@smithy/util-endpoints": "^3.1.1", + "@smithy/util-defaults-mode-browser": "^4.1.5", + "@smithy/util-defaults-mode-node": "^4.1.5", + "@smithy/util-endpoints": "^3.1.2", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.1", + "@smithy/util-retry": "^4.1.2", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, @@ -392,43 +562,41 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.888.0.tgz", - "integrity": "sha512-L3S2FZywACo4lmWv37Y4TbefuPJ1fXWyWwIJ3J4wkPYFJ47mmtUPqThlVrSbdTHkEjnZgJe5cRfxk0qCLsFh1w==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/core": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.899.0.tgz", + "integrity": "sha512-Enp5Zw37xaRlnscyaelaUZNxVqyE3CTS8gjahFbW2bbzVtRD2itHBVgq8A3lvKiFb7Feoxa71aTe0fQ1I6AhQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", - "@aws-sdk/xml-builder": "3.887.0", - "@smithy/core": "^3.11.0", - "@smithy/node-config-provider": "^4.2.1", - "@smithy/property-provider": "^4.0.5", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/xml-builder": "3.894.0", + "@smithy/core": "^3.13.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.6.1", + "@smithy/signature-v4": "^5.2.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-utf8": "^4.1.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.888.0.tgz", - "integrity": "sha512-shPi4AhUKbIk7LugJWvNpeZA8va7e5bOHAEKo89S0Ac8WDZt2OaNzbh/b9l0iSL2eEyte8UgIsYGcFxOwIF1VA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.899.0.tgz", + "integrity": "sha512-wXQ//KQ751EFhUbdfoL/e2ZDaM8l2Cff+hVwFcj32yiZyeCMhnoLRMQk2euAaUOugqPY5V5qesFbHhISbIedtw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/property-provider": "^4.0.5", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -436,46 +604,46 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.888.0.tgz", - "integrity": "sha512-Jvuk6nul0lE7o5qlQutcqlySBHLXOyoPtiwE6zyKbGc7RVl0//h39Lab7zMeY2drMn8xAnIopL4606Fd8JI/Hw==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.899.0.tgz", + "integrity": "sha512-/rRHyJFdnPrupjt/1q/PxaO6O26HFsguVUJSUeMeGUWLy0W8OC3slLFDNh89CgTqnplCyt1aLFMCagRM20HjNQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/types": "3.887.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/types": "3.893.0", "@smithy/fetch-http-handler": "^5.2.1", "@smithy/node-http-handler": "^4.2.1", - "@smithy/property-provider": "^4.0.5", + "@smithy/property-provider": "^4.1.1", "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.1", + "@smithy/util-stream": "^4.3.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.888.0.tgz", - "integrity": "sha512-M82ItvS5yq+tO6ZOV1ruaVs2xOne+v8HW85GFCXnz8pecrzYdgxh6IsVqEbbWruryG/mUGkWMbkBZoEsy4MgyA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.899.0.tgz", + "integrity": "sha512-B8oFNFTDV0j1yiJiqzkC2ybml+theNnmsLrTLBhJbnBLWkxEcmVGKVIMnATW9BUCBhHmEtDiogdNIzSwP8tbMw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/credential-provider-env": "3.888.0", - "@aws-sdk/credential-provider-http": "3.888.0", - "@aws-sdk/credential-provider-process": "3.888.0", - "@aws-sdk/credential-provider-sso": "3.888.0", - "@aws-sdk/credential-provider-web-identity": "3.888.0", - "@aws-sdk/nested-clients": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/credential-provider-env": "3.899.0", + "@aws-sdk/credential-provider-http": "3.899.0", + "@aws-sdk/credential-provider-process": "3.899.0", + "@aws-sdk/credential-provider-sso": "3.899.0", + "@aws-sdk/credential-provider-web-identity": "3.899.0", + "@aws-sdk/nested-clients": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -483,23 +651,23 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.888.0.tgz", - "integrity": "sha512-KCrQh1dCDC8Y+Ap3SZa6S81kHk+p+yAaOQ5jC3dak4zhHW3RCrsGR/jYdemTOgbEGcA6ye51UbhWfrrlMmeJSA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.899.0.tgz", + "integrity": "sha512-nHBnZ2ZCOqTGJ2A9xpVj8iK6+WV+j0JNv3XGEkIuL4mqtGEPJlEex/0mD/hqc1VF8wZzojji2OQ3892m1mUOSA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.888.0", - "@aws-sdk/credential-provider-http": "3.888.0", - "@aws-sdk/credential-provider-ini": "3.888.0", - "@aws-sdk/credential-provider-process": "3.888.0", - "@aws-sdk/credential-provider-sso": "3.888.0", - "@aws-sdk/credential-provider-web-identity": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", + "@aws-sdk/credential-provider-env": "3.899.0", + "@aws-sdk/credential-provider-http": "3.899.0", + "@aws-sdk/credential-provider-ini": "3.899.0", + "@aws-sdk/credential-provider-process": "3.899.0", + "@aws-sdk/credential-provider-sso": "3.899.0", + "@aws-sdk/credential-provider-web-identity": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -507,17 +675,17 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.888.0.tgz", - "integrity": "sha512-+aX6piSukPQ8DUS4JAH344GePg8/+Q1t0+kvSHAZHhYvtQ/1Zek3ySOJWH2TuzTPCafY4nmWLcQcqvU1w9+4Lw==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.899.0.tgz", + "integrity": "sha512-1PWSejKcJQUKBNPIqSHlEW4w8vSjmb+3kNJqCinJybjp5uP5BJgBp6QNcb8Nv30VBM0bn3ajVd76LCq4ZshQAw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -525,19 +693,19 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.888.0.tgz", - "integrity": "sha512-b1ZJji7LJ6E/j1PhFTyvp51in2iCOQ3VP6mj5H6f5OUnqn7efm41iNMoinKr87n0IKZw7qput5ggXVxEdPhouA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.899.0.tgz", + "integrity": "sha512-URlMbo74CAhIGrhzEP2fw5F5Tt6MRUctA8aa88MomlEHCEbJDsMD3nh6qoXxwR3LyvEBFmCWOZ/1TWmAjMsSdA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.888.0", - "@aws-sdk/core": "3.888.0", - "@aws-sdk/token-providers": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", + "@aws-sdk/client-sso": "3.899.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/token-providers": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -545,17 +713,18 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.888.0.tgz", - "integrity": "sha512-7P0QNtsDzMZdmBAaY/vY1BsZHwTGvEz3bsn2bm5VSKFAeMmZqsHK1QeYdNsFjLtegnVh+wodxMq50jqLv3LFlA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.899.0.tgz", + "integrity": "sha512-UEn5o5FMcbeFPRRkJI6VCrgdyR9qsLlGA7+AKCYuYADsKbvJGIIQk6A2oD82vIVvLYD3TtbTLDLsF7haF9mpbw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/nested-clients": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/property-provider": "^4.0.5", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/nested-clients": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -563,14 +732,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.887.0.tgz", - "integrity": "sha512-ulzqXv6NNqdu/kr0sgBYupWmahISHY+azpJidtK6ZwQIC+vBUk9NdZeqQpy7KVhIk2xd4+5Oq9rxapPwPI21CA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.893.0.tgz", + "integrity": "sha512-qL5xYRt80ahDfj9nDYLhpCNkDinEXvjLe/Qen/Y/u12+djrR2MB4DRa6mzBCkLkdXDtf0WAoW2EZsNCfGrmOEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", + "@aws-sdk/types": "3.893.0", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" @@ -579,14 +748,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.887.0.tgz", - "integrity": "sha512-YbbgLI6jKp2qSoAcHnXrQ5jcuc5EYAmGLVFgMVdk8dfCfJLfGGSaOLxF4CXC7QYhO50s+mPPkhBYejCik02Kug==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-logger": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.893.0.tgz", + "integrity": "sha512-ZqzMecjju5zkBquSIfVfCORI/3Mge21nUY4nWaGQy+NUXehqCGG4W7AiVpiHGOcY2cGJa7xeEkYcr2E2U9U0AA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", + "@aws-sdk/types": "3.893.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -594,14 +763,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.887.0.tgz", - "integrity": "sha512-tjrUXFtQnFLo+qwMveq5faxP5MQakoLArXtqieHphSqZTXm21wDJM73hgT4/PQQGTwgYjDKqnqsE1hvk0hcfDw==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.893.0.tgz", + "integrity": "sha512-H7Zotd9zUHQAr/wr3bcWHULYhEeoQrF54artgsoUGIf/9emv6LzY89QUccKIxYd6oHKNTrTyXm9F0ZZrzXNxlg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", + "@aws-sdk/types": "3.893.0", "@aws/lambda-invoke-store": "^0.0.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", @@ -611,25 +780,25 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.888.0.tgz", - "integrity": "sha512-rKOFNfqgqOfrdcLGF8fcO75azWS2aq2ksRHFoIEFru5FJxzu/yDAhY4C2FKiP/X34xeIUS2SbE/gQgrgWHSN2g==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.899.0.tgz", + "integrity": "sha512-/3/EIRSwQ5CNOSTHx96gVGzzmTe46OxcPG5FTgM6i9ZD+K/Q3J/UPGFL5DPzct5fXiSLvD1cGQitWHStVDjOVQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/core": "^3.11.0", - "@smithy/node-config-provider": "^4.2.1", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-arn-parser": "3.893.0", + "@smithy/core": "^3.13.0", + "@smithy/node-config-provider": "^4.2.2", "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.6.1", + "@smithy/signature-v4": "^5.2.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-config-provider": "^4.1.0", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.1", + "@smithy/util-stream": "^4.3.2", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, @@ -637,17 +806,17 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.888.0.tgz", - "integrity": "sha512-ZkcUkoys8AdrNNG7ATjqw2WiXqrhTvT+r4CIK3KhOqIGPHX0p0DQWzqjaIl7ZhSUToKoZ4Ud7MjF795yUr73oA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.899.0.tgz", + "integrity": "sha512-6EsVCC9j1VIyVyLOg+HyO3z9L+c0PEwMiHe3kuocoMf8nkfjSzJfIl6zAtgAXWgP5MKvusTP2SUbS9ezEEHZ+A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@aws-sdk/util-endpoints": "3.887.0", - "@smithy/core": "^3.11.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@smithy/core": "^3.13.0", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" @@ -656,49 +825,49 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.888.0.tgz", - "integrity": "sha512-py4o4RPSGt+uwGvSBzR6S6cCBjS4oTX5F8hrHFHfPCdIOMVjyOBejn820jXkCrcdpSj3Qg1yUZXxsByvxc9Lyg==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/nested-clients": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.899.0.tgz", + "integrity": "sha512-ySXXsFO0RH28VISEqvCuPZ78VAkK45/+OCIJgPvYpcCX9CVs70XSvMPXDI46I49mudJ1s4H3IUKccYSEtA+jaw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.888.0", - "@aws-sdk/middleware-host-header": "3.887.0", - "@aws-sdk/middleware-logger": "3.887.0", - "@aws-sdk/middleware-recursion-detection": "3.887.0", - "@aws-sdk/middleware-user-agent": "3.888.0", - "@aws-sdk/region-config-resolver": "3.887.0", - "@aws-sdk/types": "3.887.0", - "@aws-sdk/util-endpoints": "3.887.0", - "@aws-sdk/util-user-agent-browser": "3.887.0", - "@aws-sdk/util-user-agent-node": "3.888.0", - "@smithy/config-resolver": "^4.2.1", - "@smithy/core": "^3.11.0", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.899.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.899.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.13.0", "@smithy/fetch-http-handler": "^5.2.1", "@smithy/hash-node": "^4.1.1", "@smithy/invalid-dependency": "^4.1.1", "@smithy/middleware-content-length": "^4.1.1", - "@smithy/middleware-endpoint": "^4.2.1", - "@smithy/middleware-retry": "^4.2.1", + "@smithy/middleware-endpoint": "^4.2.5", + "@smithy/middleware-retry": "^4.3.1", "@smithy/middleware-serde": "^4.1.1", "@smithy/middleware-stack": "^4.1.1", - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/node-http-handler": "^4.2.1", "@smithy/protocol-http": "^5.2.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-body-length-node": "^4.1.0", - "@smithy/util-defaults-mode-browser": "^4.1.1", - "@smithy/util-defaults-mode-node": "^4.1.1", - "@smithy/util-endpoints": "^3.1.1", + "@smithy/util-defaults-mode-browser": "^4.1.5", + "@smithy/util-defaults-mode-node": "^4.1.5", + "@smithy/util-endpoints": "^3.1.2", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.1", + "@smithy/util-retry": "^4.1.2", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" }, @@ -706,17 +875,17 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.887.0.tgz", - "integrity": "sha512-VdSMrIqJ3yjJb/fY+YAxrH/lCVv0iL8uA+lbMNfQGtO5tB3Zx6SU9LEpUwBNX8fPK1tUpI65CNE4w42+MY/7Mg==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.893.0.tgz", + "integrity": "sha512-/cJvh3Zsa+Of0Zbg7vl9wp/kZtdb40yk/2+XcroAMVPO9hPvmS9r/UOm6tO7FeX4TtkRFwWaQJiTZTgSdsPY+Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", - "@smithy/node-config-provider": "^4.2.1", + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", - "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-config-provider": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" }, @@ -724,17 +893,17 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.888.0.tgz", - "integrity": "sha512-FmOHUaJzEhqfcpyh0L7HLwYcYopK13Dbmuf+oUyu56/RoeB1nLnltH1VMQVj8v3Am2IwlGR+/JpFyrdkErN+cA==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.899.0.tgz", + "integrity": "sha512-wV51Jogxhd7dI4Q2Y1ASbkwTsRT3G8uwWFDCwl+WaErOQAzofKlV6nFJQlfgjMk4iEn2gFOIWqJ8fMTGShRK/A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.888.0", - "@aws-sdk/types": "3.887.0", + "@aws-sdk/middleware-sdk-s3": "3.899.0", + "@aws-sdk/types": "3.893.0", "@smithy/protocol-http": "^5.2.1", - "@smithy/signature-v4": "^5.1.3", + "@smithy/signature-v4": "^5.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -742,18 +911,18 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.888.0.tgz", - "integrity": "sha512-WA3NF+3W8GEuCMG1WvkDYbB4z10G3O8xuhT7QSjhvLYWQ9CPt3w4VpVIfdqmUn131TCIbhCzD0KN/1VJTjAjyw==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/token-providers": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.899.0.tgz", + "integrity": "sha512-Ovu1nWr8HafYa/7DaUvvPnzM/yDUGDBqaiS7rRzv++F5VwyFY37+z/mHhvRnr+PbNWo8uf22a121SNue5uwP2w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.888.0", - "@aws-sdk/nested-clients": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", + "@aws-sdk/core": "3.899.0", + "@aws-sdk/nested-clients": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -761,10 +930,10 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/types": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.887.0.tgz", - "integrity": "sha512-fmTEJpUhsPsovQ12vZSpVTEP/IaRoJAMBGQXlQNjtCpkBp6Iq3KQDa/HDaPINE+3xxo6XvTdtibsNOd5zJLV9A==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/types": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", + "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -775,10 +944,10 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", - "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz", + "integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -788,59 +957,46 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.887.0.tgz", - "integrity": "sha512-kpegvT53KT33BMeIcGLPA65CQVxLUL/C3gTz9AzlU/SDmeusBHX4nRApAicNzI/ltQ5lxZXbQn18UczzBuwF1w==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-endpoints": { + "version": "3.895.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.895.0.tgz", + "integrity": "sha512-MhxBvWbwxmKknuggO2NeMwOVkHOYL98pZ+1ZRI5YwckoCL3AvISMnPJgfN60ww6AIXHGpkp+HhpFdKOe8RHSEg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.887.0", + "@aws-sdk/types": "3.893.0", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", - "@smithy/util-endpoints": "^3.1.1", + "@smithy/util-endpoints": "^3.1.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.873.0.tgz", - "integrity": "sha512-xcVhZF6svjM5Rj89T1WzkjQmrTF6dpR2UvIHPMTnSZoNe6CixejPZ6f0JJ2kAhO8H+dUHwNBlsUgOTIKiK/Syg==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.893.0.tgz", + "integrity": "sha512-PE9NtbDBW6Kgl1bG6A5fF3EPo168tnkj8TgMcT0sg4xYBWsBpq0bpJZRh+Jm5Bkwiw9IgTCLjEU7mR6xWaMB9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.887.0.tgz", - "integrity": "sha512-X71UmVsYc6ZTH4KU6hA5urOzYowSXc3qvroagJNLJYU1ilgZ529lP4J9XOYfEvTXkLR1hPFSRxa43SrwgelMjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.887.0", + "@aws-sdk/types": "3.893.0", "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.888.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.888.0.tgz", - "integrity": "sha512-rSB3OHyuKXotIGfYEo//9sU0lXAUrTY28SUUnxzOGYuQsAt0XR5iYwBAp+RjV6x8f+Hmtbg0PdCsy1iNAXa0UQ==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.899.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.899.0.tgz", + "integrity": "sha512-CiP0UAVQWLg2+8yciUBzVLaK5Fr7jBQ7wVu+p/O2+nlCOD3E3vtL1KZ1qX/d3OVpVSVaMAdZ9nbyewGV9hvjjg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.888.0", - "@aws-sdk/types": "3.887.0", - "@smithy/node-config-provider": "^4.2.1", + "@aws-sdk/middleware-user-agent": "3.899.0", + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -856,14 +1012,633 @@ } } }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.887.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.887.0.tgz", - "integrity": "sha512-lMwgWK1kNgUhHGfBvO/5uLe7TKhycwOn3eRCqsKPT9aPCx/HWuTlpcQp8oW2pCRGLS7qzcxqpQulcD+bbUL7XQ==", + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/xml-builder": { + "version": "3.894.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.894.0.tgz", + "integrity": "sha512-E6EAMc9dT1a2DOdo4zyOf3fp5+NJ2wI+mcm7RaW1baFIWDwcb99PpvWoV7YEiK7oaBDshuOEGWKUSYXdW+JYgA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.835.0.tgz", + "integrity": "sha512-4J19IcBKU5vL8yw/YWEvbwEGcmCli0rpRyxG53v0K5/3weVPxVBbKfkWcjWVQ4qdxNz2uInfbTde4BRBFxWllQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.835.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.835.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.835.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.12", + "@smithy/middleware-retry": "^4.1.13", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.20", + "@smithy/util-defaults-mode-node": "^4.0.20", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.835.0.tgz", + "integrity": "sha512-7mnf4xbaLI8rkDa+w6fUU48dG6yDuOgLXEPe4Ut3SbMp1ceJBPMozNHbCwkiyHk3HpxZYf8eVy0wXhJMrxZq5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.5.3", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.835.0.tgz", + "integrity": "sha512-U9LFWe7+ephNyekpUbzT7o6SmJTmn6xkrPkE0D7pbLojnPVi/8SZKyjtgQGIsAv+2kFkOCqMOIYUKd/0pE7uew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.835.0.tgz", + "integrity": "sha512-jCdNEsQklil7frDm/BuVKl4ubVoQHRbV6fnkOjmxAJz0/v7cR8JP0jBGlqKKzh3ROh5/vo1/5VUZbCTLpc9dSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.835.0.tgz", + "integrity": "sha512-nqF6rYRAnJedmvDfrfKygzyeADcduDvtvn7GlbQQbXKeR2l7KnCdhuxHa0FALLvspkHiBx7NtInmvnd5IMuWsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/credential-provider-env": "3.835.0", + "@aws-sdk/credential-provider-http": "3.835.0", + "@aws-sdk/credential-provider-process": "3.835.0", + "@aws-sdk/credential-provider-sso": "3.835.0", + "@aws-sdk/credential-provider-web-identity": "3.835.0", + "@aws-sdk/nested-clients": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.835.0.tgz", + "integrity": "sha512-77B8elyZlaEd7vDYyCnYtVLuagIBwuJ0AQ98/36JMGrYX7TT8UVAhiDAfVe0NdUOMORvDNFfzL06VBm7wittYw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.835.0", + "@aws-sdk/credential-provider-http": "3.835.0", + "@aws-sdk/credential-provider-ini": "3.835.0", + "@aws-sdk/credential-provider-process": "3.835.0", + "@aws-sdk/credential-provider-sso": "3.835.0", + "@aws-sdk/credential-provider-web-identity": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.835.0.tgz", + "integrity": "sha512-qXkTt5pAhSi2Mp9GdgceZZFo/cFYrA735efqi/Re/nf0lpqBp8mRM8xv+iAaPHV4Q10q0DlkbEidT1DhxdT/+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.835.0.tgz", + "integrity": "sha512-jAiEMryaPFXayYGszrc7NcgZA/zrrE3QvvvUBh/Udasg+9Qp5ZELdJCm/p98twNyY9n5i6Ex6VgvdxZ7+iEheQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.835.0", + "@aws-sdk/core": "3.835.0", + "@aws-sdk/token-providers": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.835.0.tgz", + "integrity": "sha512-zfleEFXDLlcJ7cyfS4xSyCRpd8SVlYZfH3rp0pg2vPYKbnmXVE0r+gPIYXl4L+Yz4A2tizYl63nKCNdtbxadog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/nested-clients": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.830.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.830.0.tgz", + "integrity": "sha512-ElVeCReZSH5Ds+/pkL5ebneJjuo8f49e9JXV1cYizuH0OAOQfYaBU9+M+7+rn61pTttOFE8W//qKzrXBBJhfMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.821.0.tgz", + "integrity": "sha512-zAOoSZKe1njOrtynvK6ZORU57YGv5I7KP4+rwOvUN3ZhJbQ7QPf8gKtFUCYAPRMegaXCKF/ADPtDZBAmM+zZ9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.835.0.tgz", + "integrity": "sha512-9ezorQYlr5cQY28zWAReFhNKUTaXsi3TMvXIagMRrSeWtQ7R6TCYnt91xzHRCmFR2kp3zLI+dfoeH+wF3iCKUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.2", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.821.0.tgz", + "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.821.0.tgz", + "integrity": "sha512-sKrm80k0t3R0on8aA/WhWFoMaAl4yvdk+riotmMElLUpcMcRXAd1+600uFVrxJqZdbrKQ0mjX0PjT68DlkYXLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.821.0.tgz", + "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.821.0.tgz", + "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.835.0.tgz", + "integrity": "sha512-oPebxpVf9smInHhevHh3APFZagGU+4RPwXEWv9YtYapFvsMq+8QXFvOfxfVZ/mwpe0JVG7EiJzL9/9Kobmts8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/core": "^3.5.3", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.2", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.821.0.tgz", + "integrity": "sha512-YYi1Hhr2AYiU/24cQc8HIB+SWbQo6FBkMYojVuz/zgrtkFmALxENGF/21OPg7f/QWd+eadZJRxCjmRwh5F2Cxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.835.0.tgz", + "integrity": "sha512-2gmAYygeE/gzhyF2XlkcbMLYFTbNfV61n+iCFa/ZofJHXYE+RxSyl5g4kujLEs7bVZHmjQZJXhprVSkGccq3/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@smithy/core": "^3.5.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.835.0.tgz", + "integrity": "sha512-UtmOO0U5QkicjCEv+B32qqRAnS7o2ZkZhC+i3ccH1h3fsfaBshpuuNBwOYAzRCRBeKW5fw3ANFrV/+2FTp4jWg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.835.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.835.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.835.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.12", + "@smithy/middleware-retry": "^4.1.13", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.20", + "@smithy/util-defaults-mode-node": "^4.0.20", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.821.0.tgz", + "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.835.0.tgz", + "integrity": "sha512-rEtJH4dIwJYlXXe5rIH+uTCQmd2VIjuaoHlDY3Dr4nxF6po6U7vKsLfybIU2tgflGVqoqYQnXsfW/kj/Rh+/ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.835.0.tgz", + "integrity": "sha512-zN1P3BE+Rv7w7q/CDA8VCQox6SE9QTn0vDtQ47AHA3eXZQQgYzBqgoLgJxR9rKKBIRGZqInJa/VRskLL95VliQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.835.0", + "@aws-sdk/nested-clients": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.821.0.tgz", + "integrity": "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", + "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.828.0.tgz", + "integrity": "sha512-RvKch111SblqdkPzg3oCIdlGxlQs+k+P7Etory9FmxPHyPDvsP1j1c74PmgYqtzzMWmoXTjd+c9naUHh9xG8xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.821.0.tgz", + "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.835.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.835.0.tgz", + "integrity": "sha512-gY63QZ4W5w9JYHYuqvUxiVGpn7IbCt1ODPQB0ZZwGGr3WRmK+yyZxCtFjbYhEQDQLgTWpf8YgVxgQLv2ps0PJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.835.0", + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -895,15 +1670,66 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -912,6 +1738,33 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -922,6 +1775,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -942,14 +1827,38 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -974,18 +1883,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -993,9 +1902,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1016,12 +1925,12 @@ } }, "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.5.tgz", + "integrity": "sha512-yKBJUnt9U2uuSZ0i1+Uh4ifeQBqqVgPC2jux99ixYW8n63f5d3O/HvsHiJm++idfKvRYsdbQHQ4tfkR3fTHHow==", "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.4", "enabled": "2.0.x", "kuler": "^2.0.0" } @@ -1073,20 +1982,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "license": "MIT", "optional": true, "dependencies": { @@ -1094,9 +2003,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "license": "MIT", "optional": true, "dependencies": { @@ -2138,9 +3047,9 @@ } }, "node_modules/@floating-ui/dom": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", - "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.3", @@ -2148,12 +3057,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", - "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.3" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -2236,6 +3145,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" }, @@ -2253,31 +3163,18 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2304,13 +3201,24 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2323,16 +3231,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" + "@img/sharp-libvips-darwin-arm64": "1.1.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2345,16 +3254,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" + "@img/sharp-libvips-darwin-x64": "1.1.0" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2365,12 +3275,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2381,12 +3292,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2397,12 +3309,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2413,12 +3326,13 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2429,12 +3343,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2445,12 +3360,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2461,12 +3377,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2477,12 +3394,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2493,12 +3411,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2511,16 +3430,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" + "@img/sharp-libvips-linux-arm": "1.1.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2533,13 +3453,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" + "@img/sharp-libvips-linux-arm64": "1.1.0" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", "cpu": [ "ppc64" ], @@ -2555,16 +3475,33 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2577,16 +3514,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" + "@img/sharp-libvips-linux-s390x": "1.1.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2599,16 +3537,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" + "@img/sharp-libvips-linux-x64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2621,16 +3560,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2643,20 +3583,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.4" + "@emnapi/runtime": "^1.4.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -2666,9 +3607,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", "cpu": [ "arm64" ], @@ -2685,12 +3626,13 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2704,12 +3646,13 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2722,6 +3665,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -2764,7 +3713,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -2805,6 +3753,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2813,9 +3772,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "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": { @@ -2829,6 +3788,26 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.3.tgz", + "integrity": "sha512-V4FfdYlqzjBUX7f0KV6vfQOOI0Cp+3XeG/ZqSDFSEVg5P7fpROpDv5/I9aTM8sOCESK1SWT96Fem+QVUnBV1wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.42.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.42.0.tgz", + "integrity": "sha512-Zr2LCaOAoPCsdAQgeLyCSiQ1+xrAJtRCyuEYDj0qR5heUwpc+Pxbb88JyTVumcXFfKOBMOMmrlsTScLz2mrvQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2998,9 +3977,9 @@ } }, "node_modules/@noble/curves": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.6.tgz", - "integrity": "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "dev": true, "license": "MIT", "dependencies": { @@ -3652,12 +4631,12 @@ "license": "MIT" }, "node_modules/@peculiar/asn1-android": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.4.0.tgz", - "integrity": "sha512-YFueREq97CLslZZBI8dKzis7jMfEHSLxM+nr0Zdx1POiXFLjqqwoY5s0F1UimdBiEw/iKlHey2m56MRDv7Jtyg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz", + "integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.4.0", + "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } @@ -3807,10 +4786,28 @@ "tsyringe": "^4.10.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@posthog/core": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.2.tgz", - "integrity": "sha512-hWk3rUtJl2crQK0WNmwg13n82hnTwB99BT99/XI5gZSvIlYZ1TPmMZE8H2dhJJ98J/rm9vYJ/UXNzw3RV5HTpQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz", + "integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==", + "license": "MIT" + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "dev": true, "license": "MIT" }, "node_modules/@radix-ui/number": { @@ -4663,6 +5660,221 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.6.tgz", + "integrity": "sha512-3SeJxKeO3TO1zVw1Nl++Cp0krYk6zHDHMCUXXVkosIzl6Nxcvb07EerQpyD2wXQSJ5RZajrYAmPaydU8Hk1IyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.6.tgz", + "integrity": "sha512-XOBq9VqC+mIn5hzjGdJLhQbvQeiOpV5ExNE6qMQQPvFsCT44QUcxFzYytTWVoyWg9XKfgrleKmTeEyu6aoTPhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-roving-focus": "1.1.6", + "@radix-ui/react-toggle": "1.1.6", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.6.tgz", + "integrity": "sha512-D2ReXCuIueKf5L2f1ks/wTj3bWck1SvK1pjLmEHPbwksS1nOHBsvgY0b9Hypt81FczqBqSyLHQxn/vbsQ0gDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -4974,6 +6186,24 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/components/node_modules/@react-email/render": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.2.3.tgz", + "integrity": "sha512-qu3XYNkHGao3teJexVD5CrcgFkNLrzbZvpZN17a7EyQYUN3kHkTkE9saqY4VbvGx6QoNU3p8rsk/Xm++D/+pTw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@react-email/container": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", @@ -5094,10 +6324,826 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, - "node_modules/@react-email/render": { + "node_modules/@react-email/preview-server": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-email/preview-server/-/preview-server-4.1.0.tgz", + "integrity": "sha512-wz4dQyQtIjAavJ0bVIu+fkZMUUmfYkKGYAwFFD6YvNdNwhU+HdhxfzdCJ1zwOjkc4ETCGtA3rJIKQ41D3La3jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.26.10", + "@babel/parser": "^7.27.0", + "@babel/traverse": "^7.27.0", + "@lottiefiles/dotlottie-react": "0.13.3", + "@radix-ui/colors": "3.0.0", + "@radix-ui/react-collapsible": "1.1.7", + "@radix-ui/react-dropdown-menu": "2.1.10", + "@radix-ui/react-popover": "1.1.10", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-tabs": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.6", + "@radix-ui/react-tooltip": "1.2.3", + "@types/node": "22.14.1", + "@types/normalize-path": "3.0.2", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@types/webpack": "5.28.5", + "autoprefixer": "10.4.21", + "chalk": "^4.1.2", + "clsx": "2.1.1", + "esbuild": "^0.25.0", + "framer-motion": "12.7.5", + "json5": "2.2.3", + "log-symbols": "^4.1.0", + "module-punycode": "npm:punycode@2.3.1", + "next": "^15.3.2", + "node-html-parser": "7.0.1", + "ora": "^5.4.1", + "pretty-bytes": "6.1.1", + "prism-react-renderer": "2.4.1", + "react": "19.0.0", + "react-dom": "19.0.0", + "sharp": "0.34.1", + "socket.io-client": "4.8.1", + "sonner": "2.0.3", + "source-map-js": "1.2.1", + "spamc": "0.0.5", + "stacktrace-parser": "0.1.11", + "tailwind-merge": "3.2.0", + "tailwindcss": "3.4.0", + "use-debounce": "10.0.4", + "zod": "3.24.3" + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-arrow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", + "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-collapsible": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.7.tgz", + "integrity": "sha512-zGFsPcFJNdQa/UNd6MOgF40BS054FIGj32oOWBllixz42f+AkQg3QJ1YT9pw7vs+Ai+EgWkh839h69GEK8oH2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz", + "integrity": "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.10.tgz", + "integrity": "sha512-8qnILty92BmXbxKugWX3jgEeFeMoxtdggeCCxb/aB7l34QFAKB23IhJfnwyVMbRnAUJiT5LOay4kUS22+AWuRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.10", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz", + "integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-menu": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.10.tgz", + "integrity": "sha512-OupA+1PrVf2H0K4jIwkDyA+rsJ7vF1y/VxLEO43dmZ68GtCjvx9K1/B/QscPZM3jIeFNK/wPd0HmiLjT36hVcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-roving-focus": "1.1.6", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-popover": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.10.tgz", + "integrity": "sha512-IZN7b3sXqajiPsOzKuNJBSP9obF4MX5/5UhTgWNofw4r1H+eATWb0SyMlaxPD/kzA4vadFgy1s7Z1AEJ6WMyHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-popper": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", + "integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-portal": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", + "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-presence": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", + "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.6.tgz", + "integrity": "sha512-D2ReXCuIueKf5L2f1ks/wTj3bWck1SvK1pjLmEHPbwksS1nOHBsvgY0b9Hypt81FczqBqSyLHQxn/vbsQ0gDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-tabs": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.7.tgz", + "integrity": "sha512-sawt4HkD+6haVGjYOC3BMIiCumBpqTK6o407n6zN/6yReed2EN7bXyykNrpqg+xCfudpBUZg7Y2cJBd/x/iybA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-roving-focus": "1.1.6", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-tooltip": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.2.3.tgz", - "integrity": "sha512-qu3XYNkHGao3teJexVD5CrcgFkNLrzbZvpZN17a7EyQYUN3kHkTkE9saqY4VbvGx6QoNU3p8rsk/Xm++D/+pTw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.3.tgz", + "integrity": "sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.4", + "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.0.tgz", + "integrity": "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@react-email/preview-server/node_modules/@types/react-dom": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/@react-email/preview-server/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@react-email/preview-server/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/@react-email/preview-server/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@react-email/preview-server/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/@react-email/preview-server/node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-email/preview-server/node_modules/tailwind-merge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", + "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/@react-email/preview-server/node_modules/tailwindcss": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@react-email/preview-server/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/@react-email/preview-server/node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@react-email/render": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.3.1.tgz", + "integrity": "sha512-BOc/kanieEVyiuldFFvceriiBGBBVhe4JWWXCXE2ehLIqz+gSWD4rgCoXAGbJRnnVyyp4joPqK62bSfa88yonA==", "license": "MIT", "dependencies": { "html-to-text": "^9.0.5", @@ -5227,7 +7273,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5237,14 +7282,38 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.1.tgz", - "integrity": "sha512-FXil8q4QN7mgKwU2hCLm0ltab8NyY/1RiqEf25Jnf6WLS3wmb11zGAoLETqg1nur2Aoibun4w4MjeN9CMJ4G6A==", - "dev": true, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.1.0.tgz", + "integrity": "sha512-a36AtR7Q7XOhRPt6F/7HENmTWcB8kN7mDJcOFM/+FuKO6x88w8MQJfYCufMWh4fGyVkPjUh3Rrz/dnqFQdo6OQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.1.0.tgz", + "integrity": "sha512-Bnv0B3nSlfB2mPO0WgM49I/prl7+kamF042rrf3ezJ3Z4C7csPYvyYgZfXTGXwXfj1mAwDWjE/ybIf49PzFzvA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.2.tgz", + "integrity": "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", "@smithy/util-config-provider": "^4.1.0", "@smithy/util-middleware": "^4.1.1", @@ -5255,10 +7324,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.11.0.tgz", - "integrity": "sha512-Abs5rdP1o8/OINtE49wwNeWuynCu0kme1r4RI3VXVrHr4odVDG7h7mTnw1WXXfN5Il+c25QOnrdL2y56USfxkA==", - "dev": true, + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.13.0.tgz", + "integrity": "sha512-BI6ALLPOKnPOU1Cjkc+1TPhOlP3JXSR/UH14JmnaLq41t3ma+IjuXrKfhycVjr5IQ0XxRh2NnQo3olp+eCVrGg==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.1.1", @@ -5267,38 +7335,22 @@ "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.1", + "@smithy/util-stream": "^4.3.2", "@smithy/util-utf8": "^4.1.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.1.tgz", - "integrity": "sha512-1WdBfM9DwA59pnpIizxnUvBf/de18p4GP+6zP2AqrlFzoW3ERpZaT4QueBR0nS9deDMaQRkBlngpVlnkuuTisQ==", - "dev": true, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.2.tgz", + "integrity": "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/property-provider": "^4.1.1", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", @@ -5308,11 +7360,80 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.1.1.tgz", + "integrity": "sha512-PwkQw1hZwHTQB6X5hSUWz2OSeuj5Z6enWuAqke7DgWoP3t6vg3ktPpqPz3Erkn6w+tmsl8Oss6nrgyezoea2Iw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.5.0", + "@smithy/util-hex-encoding": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.1.1.tgz", + "integrity": "sha512-Q9QWdAzRaIuVkefupRPRFAasaG/droBqn1feiMnmLa+LLEUG45pqX1+FurHFmlqiCfobB3nUlgoJfeXZsr7MPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.2.1.tgz", + "integrity": "sha512-oSUkF9zDN9zcOUBMtxp8RewJlh71E9NoHWU8jE3hU9JMYCsmW4assVTpgic/iS3/dM317j6hO5x18cc3XrfvEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.1.1.tgz", + "integrity": "sha512-tn6vulwf/ScY0vjhzptSJuDJJqlhNtUjkxJ4wiv9E3SPoEqTEKbaq6bfqRO7nvhTG29ALICRcvfFheOUPl8KNA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.1.1.tgz", + "integrity": "sha512-uLOAiM/Dmgh2CbEXQx+6/ssK7fbzFhd+LjdyFxXid5ZBCbLHTFHLdD/QbXw5aEDsLxQhgzDxLLsZhsftAYwHJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.2.1", @@ -5325,11 +7446,25 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.1.1.tgz", + "integrity": "sha512-avAtk++s1e/1VODf+rg7c9R2pB5G9y8yaJaGY4lPZI2+UIqVyuSDMikWjeWfBVmFZ3O7NpDxBbUCyGhThVUKWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.1.0", + "@smithy/chunked-blob-reader-native": "^4.1.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/hash-node": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.1.1.tgz", "integrity": "sha512-H9DIU9WBLhYrvPs9v4sYvnZ1PiAI0oc8CgNQUJ1rpN3pP7QADbTOUjchI2FB764Ub0DstH5xbTqcMJu1pnVqxA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5341,11 +7476,24 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.1.1.tgz", + "integrity": "sha512-3ztT4pV0Moazs3JAYFdfKk11kYFDo4b/3R3+xVjIm6wY9YpJf+xfz+ocEnNKcWAdcmSMqi168i2EMaKmJHbJMA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/invalid-dependency": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.1.1.tgz", "integrity": "sha512-1AqLyFlfrrDkyES8uhINRlJXmHA2FkG+3DY8X+rmLSqmFwk3DJnvhyGzyByPyewh2jbmV+TYQBEfngQax8IFGg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5359,7 +7507,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5368,11 +7515,24 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/md5-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.1.1.tgz", + "integrity": "sha512-MvWXKK743BuHjr/hnWuT6uStdKEaoqxHAQUvbKJPPZM5ZojTNFI5D+47BoQfBE5RgGlRRty05EbWA+NXDv+hIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.1.1.tgz", "integrity": "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.2.1", @@ -5384,16 +7544,15 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.1.tgz", - "integrity": "sha512-fUTMmQvQQZakXOuKizfu7fBLDpwvWZjfH6zUK2OLsoNZRZGbNUdNSdLJHpwk1vS208jtDjpUIskh+JoA8zMzZg==", - "dev": true, + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.5.tgz", + "integrity": "sha512-DdOIpssQ5LFev7hV6GX9TMBW5ChTsQBxqgNW1ZGtJNSAi5ksd5klwPwwMY0ejejfEzwXXGqxgVO3cpaod4veiA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.11.0", + "@smithy/core": "^3.13.0", "@smithy/middleware-serde": "^4.1.1", - "@smithy/node-config-provider": "^4.2.1", - "@smithy/shared-ini-file-loader": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-middleware": "^4.1.1", @@ -5404,46 +7563,29 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.2.1.tgz", - "integrity": "sha512-JzfvjwSJXWRl7LkLgIRTUTd2Wj639yr3sQGpViGNEOjtb0AkAuYqRAHs+jSOI/LPC0ZTjmFVVtfrCICMuebexw==", - "dev": true, + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.3.1.tgz", + "integrity": "sha512-aH2bD1bzb6FB04XBhXA5mgedEZPKx3tD/qBuYCAKt5iieWvWO1Y2j++J9uLqOndXb9Pf/83Xka/YjSnMbcPchA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/protocol-http": "^5.2.1", - "@smithy/service-error-classification": "^4.1.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/service-error-classification": "^4.1.2", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "@smithy/util-middleware": "^4.1.1", - "@smithy/util-retry": "^4.1.1", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@smithy/util-retry": "^4.1.2", + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/middleware-serde": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.2.1", @@ -5458,7 +7600,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5469,14 +7610,13 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.1.tgz", - "integrity": "sha512-AIA0BJZq2h295J5NeCTKhg1WwtdTA/GqBCaVjk30bDgMHwniUETyh5cP9IiE9VrId7Kt8hS7zvREVMTv1VfA6g==", - "dev": true, + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", + "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -5488,7 +7628,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.1.1", @@ -5505,7 +7644,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5519,7 +7657,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5533,7 +7670,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5548,7 +7684,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5559,10 +7694,9 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.1.tgz", - "integrity": "sha512-Iam75b/JNXyDE41UvrlM6n8DNOa/r1ylFyvgruTUx7h2Uk7vDNV9AAwP1vfL1fOL8ls0xArwEGVcGZVd7IO/Cw==", - "dev": true, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.2.tgz", + "integrity": "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0" @@ -5572,10 +7706,9 @@ } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.1.1.tgz", - "integrity": "sha512-YkpikhIqGc4sfXeIbzSj10t2bJI/sSoP5qxLue6zG+tEE3ngOBSm8sO3+djacYvS/R5DfpxN/L9CyZsvwjWOAQ==", - "dev": true, + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", + "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5589,7 +7722,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.1.0", @@ -5606,18 +7738,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.1.tgz", - "integrity": "sha512-WolVLDb9UTPMEPPOncrCt6JmAMCSC/V2y5gst2STWJ5r7+8iNac+EFYQnmvDCYMfOLcilOSEpm5yXZXwbLak1Q==", - "dev": true, + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.5.tgz", + "integrity": "sha512-6J2hhuWu7EjnvLBIGltPCqzNswL1cW/AkaZx6i56qLsQ0ix17IAhmDD9aMmL+6CN9nCJODOXpBTCQS6iKAA7/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.11.0", - "@smithy/middleware-endpoint": "^4.2.1", + "@smithy/core": "^3.13.0", + "@smithy/middleware-endpoint": "^4.2.5", "@smithy/middleware-stack": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.1", + "@smithy/util-stream": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -5628,7 +7759,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5641,7 +7771,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.1.1", @@ -5656,7 +7785,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.1.0", @@ -5671,7 +7799,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5684,7 +7811,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5697,7 +7823,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.1.0", @@ -5711,7 +7836,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5721,14 +7845,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.1.tgz", - "integrity": "sha512-hA1AKIHFUMa9Tl6q6y8p0pJ9aWHCCG8s57flmIyLE0W7HcJeYrYtnqXDcGnftvXEhdQnSexyegXnzzTGk8bKLA==", - "dev": true, + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.5.tgz", + "integrity": "sha512-FGBhlmFZVSRto816l6IwrmDcQ9pUYX6ikdR1mmAhdtSS1m77FgADukbQg7F7gurXfAvloxE/pgsrb7SGja6FQA==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -5738,17 +7861,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.1.tgz", - "integrity": "sha512-RGSpmoBrA+5D2WjwtK7tto6Pc2wO9KSXKLpLONhFZ8VyuCbqlLdiDAfuDTNY9AJe4JoE+Cx806cpTQQoQ71zPQ==", - "dev": true, + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.5.tgz", + "integrity": "sha512-Gwj8KLgJ/+MHYjVubJF0EELEh9/Ir7z7DFqyYlwgmp4J37KE+5vz6b3pWUnSt53tIe5FjDfVjDmHGYKjwIvW0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.2.1", - "@smithy/credential-provider-imds": "^4.1.1", - "@smithy/node-config-provider": "^4.2.1", + "@smithy/config-resolver": "^4.2.2", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/node-config-provider": "^4.2.2", "@smithy/property-provider": "^4.1.1", - "@smithy/smithy-client": "^4.6.1", + "@smithy/smithy-client": "^4.6.5", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -5757,13 +7879,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.1.tgz", - "integrity": "sha512-qB4R9kO0SetA11Rzu6MVGFIaGYX3p6SGGGfWwsKnC6nXIf0n/0AKVwRTsYsz9ToN8CeNNtNgQRwKFBndGJZdyw==", - "dev": true, + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.2.tgz", + "integrity": "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.2.1", + "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -5775,7 +7896,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5788,7 +7908,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.5.0", @@ -5799,13 +7918,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.1.tgz", - "integrity": "sha512-jGeybqEZ/LIordPLMh5bnmnoIgsqnp4IEimmUp5c5voZ8yx+5kAlN5+juyr7p+f7AtZTgvhmInQk4Q0UVbrZ0Q==", - "dev": true, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.2.tgz", + "integrity": "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.1.1", + "@smithy/service-error-classification": "^4.1.2", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" }, @@ -5814,10 +7932,9 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.1.tgz", - "integrity": "sha512-khKkW/Jqkgh6caxMWbMuox9+YfGlsk9OnHOYCGVEdYQb/XVzcORXHLYUubHmmda0pubEDncofUrPNniS9d+uAA==", - "dev": true, + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", + "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.2.1", @@ -5837,7 +7954,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5850,7 +7966,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.1.0", @@ -5860,6 +7975,88 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/util-waiter": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.1.1.tgz", + "integrity": "sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.0.0.tgz", + "integrity": "sha512-OlA/yZHh0ekYFnbUkmYBDQPE6fGfdrvgz39ktp8Xf+FA6BfxLejPTMDOG0Nfk5/rDySAz1dRbFf24zaAFYVXlQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.5.tgz", + "integrity": "sha512-m2yI81kZ+2J2psTYtmBs3lfl/LM4WgLMCzkweMfQdbbyndcyOmFa6pblHjvjfblphPaGDzTDK+lmC/dUMSnv0A==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", + "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.0.1", + "color-string": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-convert": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", + "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-string": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", + "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -6122,66 +8319,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.4.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.4", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.4.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.0", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", @@ -6264,9 +8401,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "license": "MIT", "optional": true, "dependencies": { @@ -6331,6 +8468,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6451,6 +8610,13 @@ "@types/node": "*" } }, + "node_modules/@types/normalize-path": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/normalize-path/-/normalize-path-3.0.2.tgz", + "integrity": "sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", @@ -6463,6 +8629,13 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -6548,9 +8721,20 @@ "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, "license": "MIT" }, + "node_modules/@types/webpack": { + "version": "5.28.5", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", + "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -6579,16 +8763,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", - "integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/type-utils": "8.44.1", - "@typescript-eslint/utils": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -6602,7 +8786,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.44.1", + "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -6617,15 +8801,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", - "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "engines": { @@ -6641,13 +8825,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", - "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.44.1", - "@typescript-eslint/types": "^8.44.1", + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "engines": { @@ -6662,13 +8846,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", - "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6679,9 +8863,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", - "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6695,14 +8879,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", - "integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/utils": "8.44.1", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -6719,9 +8903,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", - "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6732,15 +8916,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", - "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.44.1", - "@typescript-eslint/tsconfig-utils": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -6812,15 +8996,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", - "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1" + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6835,12 +9019,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", - "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -7101,6 +9285,181 @@ "win32" ] }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -7126,6 +9485,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -7151,10 +9523,52 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -7178,6 +9592,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -7216,6 +9637,13 @@ "@oslojs/jwt": "0.2.0" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7452,12 +9880,59 @@ "node": ">= 0.4" } }, + "node_modules/async-generator-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-generator-function/-/async-generator-function-1.0.0.tgz", + "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7538,6 +10013,16 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", + "integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/better-sqlite3": { "version": "11.7.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.7.0.tgz", @@ -7602,11 +10087,17 @@ "node": ">=18" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/bowser": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", - "dev": true, "license": "MIT" }, "node_modules/brace-expansion": { @@ -7631,6 +10122,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "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.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -7733,10 +10258,20 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001734", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", - "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", + "version": "1.0.30001745", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", + "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", "funding": [ { "type": "opencollective", @@ -7799,12 +10334,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -7828,19 +10372,16 @@ } }, "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^5.0.0" + "restore-cursor": "^3.1.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/cli-spinners": { @@ -7877,9 +10418,9 @@ } }, "node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -7889,9 +10430,9 @@ } }, "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { @@ -7912,9 +10453,9 @@ } }, "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -7946,6 +10487,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -7966,8 +10516,8 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -7998,47 +10548,13 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "license": "MIT", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" - } - }, - "node_modules/colorspace/node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" - } - }, - "node_modules/colorspace/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/colorspace/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8105,6 +10621,13 @@ "node": ">= 0.6" } }, + "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/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", @@ -8209,6 +10732,49 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -8296,9 +10862,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8357,6 +10923,29 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8400,6 +10989,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8410,9 +11008,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -8424,6 +11022,13 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -8437,6 +11042,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -8505,9 +11117,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8711,6 +11323,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.227", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", + "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -8762,6 +11381,60 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -9008,6 +11681,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9575,6 +12255,16 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -9739,20 +12429,40 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" } ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" @@ -9768,10 +12478,13 @@ } }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -10028,6 +12741,48 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.7.5", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.7.5.tgz", + "integrity": "sha512-iD+vBOLn8E8bwBAFUQ1DYXjivm+cGGPgQUQ4Doleq7YP/zHdozUVwAMBJwOOfCTbtM8uOooMi77noD261Kxiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-dom": "^12.7.5", + "motion-utils": "^12.7.5", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -10103,6 +12858,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.0.tgz", + "integrity": "sha512-xPypGGincdfyl/AiSGa7GjXLkvld9V7GjZlowup9SHIJnQnHLFiLODCd/DqKOp0PBagbHJ68r1KJI9Mut7m4sA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10113,9 +12887,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "license": "MIT", "engines": { "node": ">=18" @@ -10125,16 +12899,19 @@ } }, "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.1.tgz", + "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", "license": "MIT", "dependencies": { + "async-function": "^1.0.0", + "async-generator-function": "^1.0.0", "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "generator-function": "^2.0.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", @@ -10253,6 +13030,13 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/minimatch": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", @@ -10333,7 +13117,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10429,6 +13212,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -10630,6 +13423,30 @@ "tslib": "^2.8.0" } }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -10666,9 +13483,10 @@ } }, "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -10867,16 +13685,13 @@ } }, "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-map": { @@ -11040,13 +13855,13 @@ } }, "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11105,7 +13920,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -11143,10 +13957,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", "devOptional": true, "license": "MIT", "bin": { @@ -11199,6 +14044,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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", @@ -11212,15 +14063,16 @@ "license": "MIT" }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "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", - "dependencies": { - "minimist": "^1.2.0" - }, "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/jsonwebtoken": { @@ -11597,6 +14449,33 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11612,12 +14491,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -11661,17 +14552,17 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11707,12 +14598,13 @@ } }, "node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "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", - "engines": { - "node": "20 || >=22" + "dependencies": { + "yallist": "^3.0.2" } }, "node_modules/lucide-react": { @@ -11755,6 +14647,20 @@ "node": ">= 0.4" } }, + "node_modules/maxmind": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.0.tgz", + "integrity": "sha512-ndhnbeQWKuiBU17BJ6cybUnvcyvNXaK+1VM5n9/I7+TIqAYFLDvX1DSoVfE1hgvZfudvAU9Ts1CW5sxYq/M8dA==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.1", + "tiny-lru": "11.3.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/md-to-react-email": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/md-to-react-email/-/md-to-react-email-5.0.5.tgz", @@ -11948,10 +14854,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -11960,28 +14865,33 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mmdb-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.1.tgz", + "integrity": "sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/module-punycode": { + "name": "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/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -11991,6 +14901,23 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12011,6 +14938,18 @@ "url": "https://github.com/sponsors/raouldeheer" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -12065,6 +15004,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/next": { "version": "15.5.3", "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", @@ -12154,6 +15100,383 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/next/node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next/node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -12182,10 +15505,53 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/next/node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, "node_modules/node-abi": { - "version": "3.75.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", - "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -12244,6 +15610,24 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-html-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, "node_modules/nodemailer": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", @@ -12263,10 +15647,20 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.0.tgz", - "integrity": "sha512-d/P7DbvYgYNde9Ehfeq99+13/E7E82PfZPw8uYZASr9sQ3ZhBBCA9cXSJRA1COfJ6jDLJ0K36UJnXQWhCvLXuQ==", + "version": "11.6.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.1.tgz", + "integrity": "sha512-7iDSHDoup6uMQJ37yWrhfqcbMhF0UEfGRap6Nv+aKQcrIJXlCi2cKbj75WBmiHlcwsQCy/U0zEwDZdAx6H/Vaw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -12345,57 +15739,57 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.1.4", - "@npmcli/config": "^10.4.0", + "@npmcli/arborist": "^9.1.5", + "@npmcli/config": "^10.4.1", "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^4.0.2", - "@npmcli/package-json": "^6.2.0", - "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.1", + "@npmcli/promise-spawn": "^8.0.3", "@npmcli/redact": "^3.2.2", - "@npmcli/run-script": "^9.1.0", - "@sigstore/tuf": "^3.1.1", + "@npmcli/run-script": "^10.0.0", + "@sigstore/tuf": "^4.0.0", "abbrev": "^3.0.1", "archy": "~1.0.0", - "cacache": "^19.0.1", - "chalk": "^5.4.1", + "cacache": "^20.0.1", + "chalk": "^5.6.2", "ci-info": "^4.3.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^10.4.5", + "glob": "^11.0.3", "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.1.0", + "hosted-git-info": "^9.0.0", "ini": "^5.0.0", - "init-package-json": "^8.2.1", - "is-cidr": "^5.1.1", + "init-package-json": "^8.2.2", + "is-cidr": "^6.0.0", "json-parse-even-better-errors": "^4.0.0", - "libnpmaccess": "^10.0.1", - "libnpmdiff": "^8.0.7", - "libnpmexec": "^10.1.6", - "libnpmfund": "^7.0.7", - "libnpmorg": "^8.0.0", - "libnpmpack": "^9.0.7", - "libnpmpublish": "^11.1.0", - "libnpmsearch": "^9.0.0", - "libnpmteam": "^8.0.1", - "libnpmversion": "^8.0.1", - "make-fetch-happen": "^14.0.3", - "minimatch": "^9.0.5", + "libnpmaccess": "^10.0.2", + "libnpmdiff": "^8.0.8", + "libnpmexec": "^10.1.7", + "libnpmfund": "^7.0.8", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.0.8", + "libnpmpublish": "^11.1.1", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.2", + "make-fetch-happen": "^15.0.2", + "minimatch": "^10.0.3", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^11.2.0", + "node-gyp": "^11.4.2", "nopt": "^8.1.0", - "normalize-package-data": "^7.0.1", + "normalize-package-data": "^8.0.0", "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.1", - "npm-package-arg": "^12.0.2", - "npm-pick-manifest": "^10.0.0", - "npm-profile": "^11.0.1", - "npm-registry-fetch": "^18.0.2", + "npm-install-checks": "^7.1.2", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-profile": "^12.0.0", + "npm-registry-fetch": "^19.0.0", "npm-user-validate": "^3.0.0", "p-map": "^7.0.3", - "pacote": "^21.0.0", + "pacote": "^21.0.3", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", @@ -12403,10 +15797,10 @@ "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", - "supports-color": "^10.0.0", - "tar": "^6.2.1", + "supports-color": "^10.2.2", + "tar": "^7.5.1", "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", + "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.2", "which": "^5.0.0" @@ -12432,6 +15826,25 @@ "node": ">=8" } }, + "node_modules/npm/node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/npm/node_modules/@isaacs/cliui": { "version": "8.0.2", "inBundle": true, @@ -12449,7 +15862,7 @@ } }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", + "version": "6.2.2", "inBundle": true, "license": "MIT", "engines": { @@ -12481,7 +15894,7 @@ } }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", + "version": "7.1.2", "inBundle": true, "license": "MIT", "dependencies": { @@ -12511,55 +15924,54 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", + "version": "4.0.0", "inBundle": true, "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", + "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.4", + "version": "9.1.5", "inBundle": true, "license": "ISC", "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/metavuln-calculator": "^9.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.1", + "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", + "@npmcli/run-script": "^10.0.0", "bin-links": "^5.0.0", - "cacache": "^19.0.1", + "cacache": "^20.0.1", "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^8.0.0", + "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.1", - "pacote": "^21.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", @@ -12573,12 +15985,12 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.0", + "version": "10.4.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^5.0.0", "nopt": "^8.1.0", @@ -12602,21 +16014,21 @@ } }, "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.3", + "version": "7.0.0", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/promise-spawn": "^8.0.0", "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/installed-package-contents": { @@ -12635,25 +16047,25 @@ } }, "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "4.0.2", + "version": "5.0.0", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0" + "@npmcli/package-json": "^7.0.0", + "glob": "^11.0.3", + "minimatch": "^10.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.1", + "version": "9.0.2", "inBundle": true, "license": "ISC", "dependencies": { - "cacache": "^19.0.0", + "cacache": "^20.0.0", "json-parse-even-better-errors": "^4.0.0", "pacote": "^21.0.0", "proc-log": "^5.0.0", @@ -12680,24 +16092,24 @@ } }, "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.2.0", + "version": "7.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", + "@npmcli/git": "^7.0.0", + "glob": "^11.0.3", + "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^4.0.0", "proc-log": "^5.0.0", "semver": "^7.5.3", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", + "version": "8.0.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -12727,19 +16139,19 @@ } }, "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.1.0", + "version": "10.0.0", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", + "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^8.0.0", "node-gyp": "^11.0.0", "proc-log": "^5.0.0", "which": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@pkgjs/parseargs": { @@ -12752,26 +16164,26 @@ } }, "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "3.1.0", + "version": "4.0.0", "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "2.0.0", + "version": "3.0.0", "inBundle": true, "license": "Apache-2.0", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.4.3", + "version": "0.5.0", "inBundle": true, "license": "Apache-2.0", "engines": { @@ -12779,44 +16191,44 @@ } }, "node_modules/npm/node_modules/@sigstore/sign": { - "version": "3.1.0", + "version": "4.0.0", "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "3.1.1", + "version": "4.0.0", "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "2.1.1", + "version": "3.0.0", "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.0.0", + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@tufjs/canonical-json": { @@ -12828,7 +16240,7 @@ } }, "node_modules/npm/node_modules/@tufjs/models": { - "version": "3.0.1", + "version": "4.0.0", "inBundle": true, "license": "MIT", "dependencies": { @@ -12836,7 +16248,21 @@ "minimatch": "^9.0.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/npm/node_modules/abbrev": { @@ -12864,7 +16290,7 @@ } }, "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", + "version": "6.2.3", "inBundle": true, "license": "MIT", "engines": { @@ -12924,86 +16350,28 @@ } }, "node_modules/npm/node_modules/cacache": { - "version": "19.0.1", + "version": "20.0.1", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", + "glob": "^11.0.3", + "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", - "tar": "^7.4.3", "unique-filename": "^4.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/minizlib": { - "version": "3.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/chalk": { - "version": "5.4.1", + "version": "5.6.2", "inBundle": true, "license": "MIT", "engines": { @@ -13014,11 +16382,11 @@ } }, "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", + "version": "3.0.0", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/npm/node_modules/ci-info": { @@ -13036,14 +16404,14 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.3", + "version": "5.0.0", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { "ip-regex": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/npm/node_modules/cli-columns": { @@ -13100,6 +16468,11 @@ "node": ">= 8" } }, + "node_modules/npm/node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, "node_modules/npm/node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "inBundle": true, @@ -13126,7 +16499,7 @@ } }, "node_modules/npm/node_modules/debug": { - "version": "4.4.1", + "version": "4.4.3", "inBundle": true, "license": "MIT", "dependencies": { @@ -13142,7 +16515,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "7.0.0", + "version": "8.0.2", "inBundle": true, "license": "BSD-3-Clause", "engines": { @@ -13221,20 +16594,23 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.4.5", + "version": "11.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -13245,14 +16621,14 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.1.0", + "version": "9.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/http-cache-semantics": { @@ -13297,14 +16673,14 @@ } }, "node_modules/npm/node_modules/ignore-walk": { - "version": "7.0.0", + "version": "8.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "minimatch": "^9.0.0" + "minimatch": "^10.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/imurmurhash": { @@ -13324,30 +16700,26 @@ } }, "node_modules/npm/node_modules/init-package-json": { - "version": "8.2.1", + "version": "8.2.2", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/package-json": "^6.1.0", - "npm-package-arg": "^12.0.0", + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", "promzard": "^2.0.0", "read": "^4.0.0", - "semver": "^7.3.5", + "semver": "^7.7.2", "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^6.0.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", + "version": "10.0.1", "inBundle": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -13364,14 +16736,14 @@ } }, "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.1", + "version": "6.0.0", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { - "cidr-regex": "^4.1.1" + "cidr-regex": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/npm/node_modules/is-fullwidth-code-point": { @@ -13383,29 +16755,27 @@ } }, "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", + "version": "3.1.1", "inBundle": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": ">=16" + } }, "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.3", + "version": "4.1.1", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "4.0.0", "inBundle": true, @@ -13441,50 +16811,51 @@ "license": "MIT" }, "node_modules/npm/node_modules/libnpmaccess": { - "version": "10.0.1", + "version": "10.0.2", "inBundle": true, "license": "ISC", "dependencies": { - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.7", + "version": "8.0.8", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.4", + "@npmcli/arborist": "^9.1.5", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^3.0.0", - "diff": "^7.0.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0", - "tar": "^6.2.1" + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.6", + "version": "10.1.7", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.4", - "@npmcli/package-json": "^6.1.1", - "@npmcli/run-script": "^9.0.1", + "@npmcli/arborist": "^9.1.5", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", "read": "^4.0.0", - "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", + "signal-exit": "^4.1.0", "walk-up-path": "^4.0.0" }, "engines": { @@ -13492,54 +16863,54 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.7", + "version": "7.0.8", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.4" + "@npmcli/arborist": "^9.1.5" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmorg": { - "version": "8.0.0", + "version": "8.0.1", "inBundle": true, "license": "ISC", "dependencies": { "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.7", + "version": "9.0.8", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.4", - "@npmcli/run-script": "^9.0.1", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0" + "@npmcli/arborist": "^9.1.5", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.0", + "version": "11.1.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/package-json": "^6.2.0", + "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", "proc-log": "^5.0.0", "semver": "^7.3.7", - "sigstore": "^3.0.0", + "sigstore": "^4.0.0", "ssri": "^12.0.0" }, "engines": { @@ -13547,35 +16918,35 @@ } }, "node_modules/npm/node_modules/libnpmsearch": { - "version": "9.0.0", + "version": "9.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmteam": { - "version": "8.0.1", + "version": "8.0.2", "inBundle": true, "license": "ISC", "dependencies": { "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.1", + "version": "8.0.2", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.1", - "@npmcli/run-script": "^9.0.1", + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", "json-parse-even-better-errors": "^4.0.0", "proc-log": "^5.0.0", "semver": "^7.3.7" @@ -13585,17 +16956,20 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "10.4.3", + "version": "11.2.1", "inBundle": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "14.0.3", + "version": "15.0.2", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", @@ -13607,26 +16981,18 @@ "ssri": "^12.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/minimatch": { - "version": "9.0.5", + "version": "10.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -13667,17 +17033,6 @@ "encoding": "^0.1.13" } }, - "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { - "version": "3.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/npm/node_modules/minipass-flush": { "version": "1.0.5", "inBundle": true, @@ -13745,37 +17100,14 @@ } }, "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", + "version": "3.1.0", "inBundle": true, "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" + "node": ">= 18" } }, "node_modules/npm/node_modules/ms": { @@ -13791,8 +17123,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/npm/node_modules/node-gyp": { - "version": "11.2.0", + "version": "11.4.2", "inBundle": true, "license": "MIT", "dependencies": { @@ -13814,61 +17154,129 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/agent": { "version": "3.0.0", "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { - "version": "3.0.2", - "inBundle": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "minipass": "^7.1.2" + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": ">= 18" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", + "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { + "version": "19.0.1", "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" }, "engines": { - "node": ">=10" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/glob": { + "version": "10.4.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", + "node_modules/npm/node_modules/node-gyp/node_modules/jackspeak": { + "version": "3.4.3", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/lru-cache": { + "version": "10.4.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { + "version": "14.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" }, "engines": { - "node": ">=18" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", + "node_modules/npm/node_modules/node-gyp/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/path-scurry": { + "version": "1.11.1", "inBundle": true, "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/npm/node_modules/nopt": { @@ -13886,16 +17294,16 @@ } }, "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.1", + "version": "8.0.0", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^8.0.0", + "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-audit-report": { @@ -13918,7 +17326,7 @@ } }, "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.1", + "version": "7.1.2", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -13937,83 +17345,72 @@ } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.2", + "version": "13.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "hosted-git-info": "^8.0.0", + "hosted-git-info": "^9.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.0", + "version": "10.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "ignore-walk": "^7.0.0" + "ignore-walk": "^8.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "10.0.0", + "version": "11.0.1", "inBundle": true, "license": "ISC", "dependencies": { "npm-install-checks": "^7.1.0", "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", + "npm-package-arg": "^13.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-profile": { - "version": "11.0.1", + "version": "12.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "npm-registry-fetch": "^18.0.0", + "npm-registry-fetch": "^19.0.0", "proc-log": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "18.0.2", + "version": "19.0.0", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/redact": "^3.0.0", "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", + "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", + "npm-package-arg": "^13.0.0", "proc-log": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { - "version": "3.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-user-validate": { @@ -14041,27 +17438,27 @@ "license": "BlueOak-1.0.0" }, "node_modules/npm/node_modules/pacote": { - "version": "21.0.0", + "version": "21.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", + "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", + "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^10.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", + "sigstore": "^4.0.0", "ssri": "^12.0.0", - "tar": "^6.1.11" + "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" @@ -14092,15 +17489,15 @@ } }, "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", + "version": "2.0.0", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14199,18 +17596,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/npm/node_modules/retry": { "version": "0.12.0", "inBundle": true, @@ -14267,19 +17652,19 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "3.1.0", + "version": "4.0.0", "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.0.0", + "@sigstore/tuf": "^4.0.0", + "@sigstore/verify": "^3.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/smart-buffer": { @@ -14292,11 +17677,11 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.6", + "version": "2.8.7", "inBundle": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -14350,15 +17735,10 @@ } }, "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.21", + "version": "3.0.22", "inBundle": true, "license": "CC0-1.0" }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "inBundle": true, - "license": "BSD-3-Clause" - }, "node_modules/npm/node_modules/ssri": { "version": "12.0.0", "inBundle": true, @@ -14421,7 +17801,7 @@ } }, "node_modules/npm/node_modules/supports-color": { - "version": "10.0.0", + "version": "10.2.2", "inBundle": true, "license": "MIT", "engines": { @@ -14432,49 +17812,26 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "6.2.1", + "version": "7.5.1", "inBundle": true, "license": "ISC", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { + "node_modules/npm/node_modules/tar/node_modules/yallist": { "version": "5.0.0", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/npm/node_modules/text-table": { @@ -14483,17 +17840,17 @@ "license": "MIT" }, "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", + "version": "2.0.2", "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.14", + "version": "0.2.15", "inBundle": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -14503,9 +17860,12 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", + "version": "6.5.0", "inBundle": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -14535,16 +17895,16 @@ } }, "node_modules/npm/node_modules/tuf-js": { - "version": "3.1.0", + "version": "4.0.0", "inBundle": true, "license": "MIT", "dependencies": { - "@tufjs/models": "3.0.1", + "@tufjs/models": "4.0.0", "debug": "^4.4.1", - "make-fetch-happen": "^14.0.3" + "make-fetch-happen": "^15.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/unique-filename": { @@ -14622,14 +17982,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, "node_modules/npm/node_modules/wrap-ansi": { "version": "8.1.0", "inBundle": true, @@ -14678,7 +18030,7 @@ } }, "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", + "version": "6.2.2", "inBundle": true, "license": "MIT", "engines": { @@ -14710,7 +18062,7 @@ } }, "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", + "version": "7.1.2", "inBundle": true, "license": "MIT", "dependencies": { @@ -14740,6 +18092,19 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nypm": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz", @@ -14976,95 +18341,50 @@ } }, "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "node_modules/ora/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": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ora/node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "node_modules/ora/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": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/oslo": { @@ -15487,13 +18807,23 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", "engines": { - "node": ">=16" + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/path-type": { @@ -15629,10 +18959,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", - "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "dev": true, "license": "MIT", "dependencies": { @@ -15692,6 +19042,97 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -15732,12 +19173,12 @@ } }, "node_modules/posthog-node": { - "version": "5.8.4", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.8.4.tgz", - "integrity": "sha512-O0lObQqeIiggNCjc5BQx5PaHqPzXxwKnCJdb+DuNkbDO6Vc442SQ5FDv0WjPd5Ejfwme1uGZmM5/xhHWKegFfQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.9.2.tgz", + "integrity": "sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA==", "license": "MIT", "dependencies": { - "@posthog/core": "1.0.2" + "@posthog/core": "1.2.2" }, "engines": { "node": ">=20" @@ -15793,6 +19234,33 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -15937,6 +19405,16 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -15947,18 +19425,34 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.6.3", + "iconv-lite": "0.7.0", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/rc": { @@ -16060,6 +19554,35 @@ "node": ">=18.0.0" } }, + "node_modules/react-email/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/react-email/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-email/node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -16070,6 +19593,39 @@ "node": ">=18" } }, + "node_modules/react-email/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-email/node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-email/node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -16080,17 +19636,139 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/react-email/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/react-email/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-email/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/react-email/node_modules/tsconfig-paths": { @@ -16223,6 +19901,16 @@ } } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -16265,6 +19953,27 @@ "node": ">=0.8.8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -16313,6 +20022,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reodotdev": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/reodotdev/-/reodotdev-1.0.0.tgz", + "integrity": "sha512-wXe1vJucZjrhQL0SxOL9EvmJrtbMCIEGMdZX5lj/57n2T3UhBHZsAcM5TQASJ0T6ZBbrETRnMhH33bsbJeRO6Q==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resend": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.1.1.tgz", + "integrity": "sha512-qHip8WF4uB2k83vG5EfLWQo27anlHpQagljWLFSIXgbkmNYzoIoAsXctl/RZJl7tf+7uCLM/MEwPzyW5zwCJTA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@react-email/render": "^1.1.0" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -16352,49 +20094,17 @@ } }, "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, "node_modules/reusify": { @@ -16539,6 +20249,63 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -16585,6 +20352,16 @@ "node": ">= 18" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -16653,16 +20430,16 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.4", - "semver": "^7.7.2" + "detect-libc": "^2.0.3", + "semver": "^7.7.1" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -16671,28 +20448,26 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" } }, "node_modules/shebang-command": { @@ -16841,9 +20616,10 @@ } }, "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" @@ -16936,6 +20712,40 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -17033,6 +20843,17 @@ "node": ">= 0.6" } }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17063,6 +20884,12 @@ "source-map": "^0.6.0" } }, + "node_modules/spamc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/spamc/-/spamc-0.0.5.tgz", + "integrity": "sha512-jYXItuZuiWZyG9fIdvgTUbp2MNRuyhuSwvvhhpPJd4JK/9oSZxkD7zAj53GJtowSlXwCJzLg6sCKAoE9wXsKgg==", + "dev": true + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -17087,6 +20914,25 @@ "node": "*" } }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -17298,9 +21144,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -17365,11 +21211,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.2.1.tgz", + "integrity": "sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "dev": true, + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", "funding": [ { "type": "github", @@ -17401,6 +21266,126 @@ } } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17426,9 +21411,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.27.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.1.tgz", - "integrity": "sha512-oGtpYO3lnoaqyGtlJalvryl7TwzgRuxpOVWqEHx8af0YXI+Kt+4jMpLdgMtMcmWmuQ0QTCHLKExwrBFMSxvAUA==", + "version": "5.29.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.1.tgz", + "integrity": "sha512-qyjpz0qgcomRr41a5Aye42o69TKwCeHM9F8htLGVeUMKekNS6qAqz9oS7CtSvgGJSppSNAYAIh7vrfrSdHj9zw==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -17480,17 +21465,15 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dev": true, + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -17531,12 +21514,114 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-lru": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.3.4.tgz", + "integrity": "sha512-UxWEfRKpFCabAf6fkTNdlfSw/RDUJ/4C6i1aLZaDnGF82PERHyYhz5CMCVYXtLt34LbqgfpJ2bjmgGKgxuF/6A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -17545,13 +21630,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -17602,6 +21687,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsc-alias": { "version": "1.8.16", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", @@ -17710,6 +21802,18 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -17776,9 +21880,9 @@ } }, "node_modules/tw-animate-css": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", - "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" @@ -17796,6 +21900,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -17898,16 +22012,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.1.tgz", - "integrity": "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", + "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.44.1", - "@typescript-eslint/parser": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/utils": "8.44.1" + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17989,6 +22103,37 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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", @@ -18019,6 +22164,19 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", + "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-intl": { "version": "4.3.9", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.9.tgz", @@ -18105,6 +22263,30 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -18114,11 +22296,116 @@ "node": ">= 8" } }, + "node_modules/webpack": { + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz", + "integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.2.3", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -18364,9 +22651,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -18402,6 +22689,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -18421,14 +22717,11 @@ } }, "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "license": "ISC" }, "node_modules/yaml": { "version": "2.8.1", @@ -18469,9 +22762,9 @@ } }, "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { @@ -18504,9 +22797,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 003974c8..3b82ab35 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,21 @@ "db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts", "db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts", "db:clear-migrations": "rm -rf server/migrations", + "set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts", + "set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts", + "set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts", + "set:sqlite": "echo 'export * from \"./sqlite\";' > server/db/index.ts", + "set:pg": "echo 'export * from \"./pg\";' > server/db/index.ts", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", - "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" + "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", + "db:sqlite:seed-exit-node": "sqlite3 config/db/db.sqlite \"INSERT INTO exitNodes (exitNodeId, name, address, endpoint, publicKey, listenPort, reachableAt, maxConnections, online, lastPing, type, region) VALUES (null, 'test', '10.0.0.1/24', 'localhost', 'MJ44MpnWGxMZURgxW/fWXDFsejhabnEFYDo60LQwK3A=', 1234, 'http://localhost:3003', 123, 1, null, 'gerbil', null);\"" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", + "@aws-sdk/client-s3": "3.837.0", "@hookform/resolvers": "5.2.2", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", @@ -78,10 +85,12 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", + "ioredis": "5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.544.0", + "maxmind": "5.0.0", "moment": "2.30.1", "next": "15.5.3", "next-intl": "^4.3.9", @@ -100,7 +109,10 @@ "react-hook-form": "7.62.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", + "reodotdev": "^1.0.0", + "resend": "^6.1.1", "semver": "^7.7.2", + "stripe": "18.2.1", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.8", @@ -116,6 +128,7 @@ "devDependencies": { "@dotenvx/dotenvx": "1.51.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@react-email/preview-server": "4.1.0", "@tailwindcss/postcss": "^4.1.13", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.9", diff --git a/server/apiServer.ts b/server/apiServer.ts index a400555b..7c64c14f 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -7,16 +7,20 @@ import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; +import { corsWithLoginPageSupport } from "@server/middlewares/private/corsWithLoginPage"; import { authenticated, unauthenticated } from "@server/routers/external"; import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws"; import { logIncomingMiddleware } from "./middlewares/logIncoming"; import { csrfProtectionMiddleware } from "./middlewares/csrfProtection"; import helmet from "helmet"; +import { stripeWebhookHandler } from "@server/routers/private/billing/webhooks"; +import { build } from "./build"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import HttpCode from "./types/HttpCode"; import requestTimeoutMiddleware from "./middlewares/requestTimeout"; -import { createStore } from "./lib/rateLimitStore"; +import { createStore } from "@server/lib/private/rateLimitStore"; +import hybridRouter from "@server/routers/private/hybrid"; const dev = config.isDev; const externalPort = config.getRawConfig().server.external_port; @@ -30,26 +34,39 @@ export function createApiServer() { apiServer.set("trust proxy", trustProxy); } + if (build == "saas") { + apiServer.post( + `${prefix}/billing/webhooks`, + express.raw({ type: "application/json" }), + stripeWebhookHandler + ); + } + const corsConfig = config.getRawConfig().server.cors; - const options = { - ...(corsConfig?.origins - ? { origin: corsConfig.origins } - : { - origin: (origin: any, callback: any) => { - callback(null, true); - } - }), - ...(corsConfig?.methods && { methods: corsConfig.methods }), - ...(corsConfig?.allowed_headers && { - allowedHeaders: corsConfig.allowed_headers - }), - credentials: !(corsConfig?.credentials === false) - }; + if (build == "oss") { + const options = { + ...(corsConfig?.origins + ? { origin: corsConfig.origins } + : { + origin: (origin: any, callback: any) => { + callback(null, true); + } + }), + ...(corsConfig?.methods && { methods: corsConfig.methods }), + ...(corsConfig?.allowed_headers && { + allowedHeaders: corsConfig.allowed_headers + }), + credentials: !(corsConfig?.credentials === false) + }; - logger.debug("Using CORS options", options); + logger.debug("Using CORS options", options); - apiServer.use(cors(options)); + apiServer.use(cors(options)); + } else { + // Use the custom CORS middleware with loginPage support + apiServer.use(corsWithLoginPageSupport(corsConfig)); + } if (!dev) { apiServer.use(helmet()); @@ -70,7 +87,8 @@ export function createApiServer() { 60 * 1000, max: config.getRawConfig().rate_limits.global.max_requests, - keyGenerator: (req) => `apiServerGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, + keyGenerator: (req) => + `apiServerGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, handler: (req, res, next) => { const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.global.max_requests} requests every ${config.getRawConfig().rate_limits.global.window_minutes} minute(s).`; return next( @@ -85,6 +103,9 @@ export function createApiServer() { // API routes apiServer.use(logIncomingMiddleware); apiServer.use(prefix, unauthenticated); + if (build !== "oss") { + apiServer.use(`${prefix}/hybrid`, hybridRouter); + } apiServer.use(prefix, authenticated); // WebSocket routes diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 6b6c9bf4..45d53eaa 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -4,6 +4,7 @@ import { userActions, roleActions, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { sendUsageNotification } from "@server/routers/org"; export enum ActionsEnum { createOrgUser = "createOrgUser", @@ -98,10 +99,23 @@ export enum ActionsEnum { listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", getApiKey = "getApiKey", + getCertificate = "getCertificate", + restartCertificate = "restartCertificate", + billing = "billing", createOrgDomain = "createOrgDomain", deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", + sendUsageNotification = "sendUsageNotification", + createRemoteExitNode = "createRemoteExitNode", + updateRemoteExitNode = "updateRemoteExitNode", + getRemoteExitNode = "getRemoteExitNode", + listRemoteExitNode = "listRemoteExitNode", + deleteRemoteExitNode = "deleteRemoteExitNode", updateOrgUser = "updateOrgUser", + createLoginPage = "createLoginPage", + updateLoginPage = "updateLoginPage", + getLoginPage = "getLoginPage", + deleteLoginPage = "deleteLoginPage", applyBlueprint = "applyBlueprint" } diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 514bee00..e846396d 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -3,13 +3,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -import { - resourceSessions, - Session, - sessions, - User, - users -} from "@server/db"; +import { resourceSessions, Session, sessions, User, users } from "@server/db"; import { db } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; @@ -24,8 +18,9 @@ export const SESSION_COOKIE_EXPIRES = 60 * 60 * config.getRawConfig().server.dashboard_session_length_hours; -export const COOKIE_DOMAIN = config.getRawConfig().app.dashboard_url ? - "." + new URL(config.getRawConfig().app.dashboard_url!).hostname : undefined; +export const COOKIE_DOMAIN = config.getRawConfig().app.dashboard_url + ? new URL(config.getRawConfig().app.dashboard_url!).hostname + : undefined; export function generateSessionToken(): string { const bytes = new Uint8Array(20); @@ -98,8 +93,8 @@ export async function invalidateSession(sessionId: string): Promise { try { await db.transaction(async (trx) => { await trx - .delete(resourceSessions) - .where(eq(resourceSessions.userSessionId, sessionId)); + .delete(resourceSessions) + .where(eq(resourceSessions.userSessionId, sessionId)); await trx.delete(sessions).where(eq(sessions.sessionId, sessionId)); }); } catch (e) { @@ -111,9 +106,9 @@ export async function invalidateAllSessions(userId: string): Promise { try { await db.transaction(async (trx) => { const userSessions = await trx - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); await trx.delete(resourceSessions).where( inArray( resourceSessions.userSessionId, diff --git a/server/auth/sessions/privateRemoteExitNode.ts b/server/auth/sessions/privateRemoteExitNode.ts new file mode 100644 index 00000000..fbb2ae1f --- /dev/null +++ b/server/auth/sessions/privateRemoteExitNode.ts @@ -0,0 +1,85 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { RemoteExitNode, remoteExitNodes, remoteExitNodeSessions, RemoteExitNodeSession } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; + +export const EXPIRES = 1000 * 60 * 60 * 24 * 30; + +export async function createRemoteExitNodeSession( + token: string, + remoteExitNodeId: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const session: RemoteExitNodeSession = { + sessionId: sessionId, + remoteExitNodeId, + expiresAt: new Date(Date.now() + EXPIRES).getTime(), + }; + await db.insert(remoteExitNodeSessions).values(session); + return session; +} + +export async function validateRemoteExitNodeSessionToken( + token: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const result = await db + .select({ remoteExitNode: remoteExitNodes, session: remoteExitNodeSessions }) + .from(remoteExitNodeSessions) + .innerJoin(remoteExitNodes, eq(remoteExitNodeSessions.remoteExitNodeId, remoteExitNodes.remoteExitNodeId)) + .where(eq(remoteExitNodeSessions.sessionId, sessionId)); + if (result.length < 1) { + return { session: null, remoteExitNode: null }; + } + const { remoteExitNode, session } = result[0]; + if (Date.now() >= session.expiresAt) { + await db + .delete(remoteExitNodeSessions) + .where(eq(remoteExitNodeSessions.sessionId, session.sessionId)); + return { session: null, remoteExitNode: null }; + } + if (Date.now() >= session.expiresAt - (EXPIRES / 2)) { + session.expiresAt = new Date( + Date.now() + EXPIRES, + ).getTime(); + await db + .update(remoteExitNodeSessions) + .set({ + expiresAt: session.expiresAt, + }) + .where(eq(remoteExitNodeSessions.sessionId, session.sessionId)); + } + return { session, remoteExitNode }; +} + +export async function invalidateRemoteExitNodeSession(sessionId: string): Promise { + await db.delete(remoteExitNodeSessions).where(eq(remoteExitNodeSessions.sessionId, sessionId)); +} + +export async function invalidateAllRemoteExitNodeSessions(remoteExitNodeId: string): Promise { + await db.delete(remoteExitNodeSessions).where(eq(remoteExitNodeSessions.remoteExitNodeId, remoteExitNodeId)); +} + +export type SessionValidationResult = + | { session: RemoteExitNodeSession; remoteExitNode: RemoteExitNode } + | { session: null; remoteExitNode: null }; diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 511dadda..a378202e 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -199,14 +199,14 @@ export function serializeResourceSessionCookie( const now = new Date().getTime(); if (!isHttp) { if (expiresAt === undefined) { - return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`; + return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${domain}`; } - return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`; + return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`; } else { if (expiresAt === undefined) { - return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`; + return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=$domain}`; } - return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`; + return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`; } } @@ -216,9 +216,9 @@ export function createBlankResourceSessionTokenCookie( isHttp: boolean = false ): string { if (!isHttp) { - return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`; + return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${domain}`; } else { - return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`; + return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${domain}`; } } diff --git a/server/build.ts b/server/build.ts deleted file mode 100644 index babe5e8b..00000000 --- a/server/build.ts +++ /dev/null @@ -1 +0,0 @@ -export const build = "oss" as any; diff --git a/server/db/countries.ts b/server/db/countries.ts new file mode 100644 index 00000000..52eb2b4b --- /dev/null +++ b/server/db/countries.ts @@ -0,0 +1,1014 @@ +export const COUNTRIES = [ + { + "name": "ALL COUNTRIES", + "code": "ALL" // THIS IS AN INVALID CC SO IT WILL NEVER MATCH + }, + { + "name": "Afghanistan", + "code": "AF" + }, + { + "name": "Albania", + "code": "AL" + }, + { + "name": "Algeria", + "code": "DZ" + }, + { + "name": "American Samoa", + "code": "AS" + }, + { + "name": "Andorra", + "code": "AD" + }, + { + "name": "Angola", + "code": "AO" + }, + { + "name": "Anguilla", + "code": "AI" + }, + { + "name": "Antarctica", + "code": "AQ" + }, + { + "name": "Antigua and Barbuda", + "code": "AG" + }, + { + "name": "Argentina", + "code": "AR" + }, + { + "name": "Armenia", + "code": "AM" + }, + { + "name": "Aruba", + "code": "AW" + }, + { + "name": "Asia/Pacific Region", + "code": "AP" + }, + { + "name": "Australia", + "code": "AU" + }, + { + "name": "Austria", + "code": "AT" + }, + { + "name": "Azerbaijan", + "code": "AZ" + }, + { + "name": "Bahamas", + "code": "BS" + }, + { + "name": "Bahrain", + "code": "BH" + }, + { + "name": "Bangladesh", + "code": "BD" + }, + { + "name": "Barbados", + "code": "BB" + }, + { + "name": "Belarus", + "code": "BY" + }, + { + "name": "Belgium", + "code": "BE" + }, + { + "name": "Belize", + "code": "BZ" + }, + { + "name": "Benin", + "code": "BJ" + }, + { + "name": "Bermuda", + "code": "BM" + }, + { + "name": "Bhutan", + "code": "BT" + }, + { + "name": "Bolivia", + "code": "BO" + }, + { + "name": "Bonaire, Sint Eustatius and Saba", + "code": "BQ" + }, + { + "name": "Bosnia and Herzegovina", + "code": "BA" + }, + { + "name": "Botswana", + "code": "BW" + }, + { + "name": "Bouvet Island", + "code": "BV" + }, + { + "name": "Brazil", + "code": "BR" + }, + { + "name": "British Indian Ocean Territory", + "code": "IO" + }, + { + "name": "Brunei Darussalam", + "code": "BN" + }, + { + "name": "Bulgaria", + "code": "BG" + }, + { + "name": "Burkina Faso", + "code": "BF" + }, + { + "name": "Burundi", + "code": "BI" + }, + { + "name": "Cambodia", + "code": "KH" + }, + { + "name": "Cameroon", + "code": "CM" + }, + { + "name": "Canada", + "code": "CA" + }, + { + "name": "Cape Verde", + "code": "CV" + }, + { + "name": "Cayman Islands", + "code": "KY" + }, + { + "name": "Central African Republic", + "code": "CF" + }, + { + "name": "Chad", + "code": "TD" + }, + { + "name": "Chile", + "code": "CL" + }, + { + "name": "China", + "code": "CN" + }, + { + "name": "Christmas Island", + "code": "CX" + }, + { + "name": "Cocos (Keeling) Islands", + "code": "CC" + }, + { + "name": "Colombia", + "code": "CO" + }, + { + "name": "Comoros", + "code": "KM" + }, + { + "name": "Congo", + "code": "CG" + }, + { + "name": "Congo, The Democratic Republic of the", + "code": "CD" + }, + { + "name": "Cook Islands", + "code": "CK" + }, + { + "name": "Costa Rica", + "code": "CR" + }, + { + "name": "Croatia", + "code": "HR" + }, + { + "name": "Cuba", + "code": "CU" + }, + { + "name": "Curaçao", + "code": "CW" + }, + { + "name": "Cyprus", + "code": "CY" + }, + { + "name": "Czech Republic", + "code": "CZ" + }, + { + "name": "Côte d'Ivoire", + "code": "CI" + }, + { + "name": "Denmark", + "code": "DK" + }, + { + "name": "Djibouti", + "code": "DJ" + }, + { + "name": "Dominica", + "code": "DM" + }, + { + "name": "Dominican Republic", + "code": "DO" + }, + { + "name": "Ecuador", + "code": "EC" + }, + { + "name": "Egypt", + "code": "EG" + }, + { + "name": "El Salvador", + "code": "SV" + }, + { + "name": "Equatorial Guinea", + "code": "GQ" + }, + { + "name": "Eritrea", + "code": "ER" + }, + { + "name": "Estonia", + "code": "EE" + }, + { + "name": "Ethiopia", + "code": "ET" + }, + { + "name": "Falkland Islands (Malvinas)", + "code": "FK" + }, + { + "name": "Faroe Islands", + "code": "FO" + }, + { + "name": "Fiji", + "code": "FJ" + }, + { + "name": "Finland", + "code": "FI" + }, + { + "name": "France", + "code": "FR" + }, + { + "name": "French Guiana", + "code": "GF" + }, + { + "name": "French Polynesia", + "code": "PF" + }, + { + "name": "French Southern Territories", + "code": "TF" + }, + { + "name": "Gabon", + "code": "GA" + }, + { + "name": "Gambia", + "code": "GM" + }, + { + "name": "Georgia", + "code": "GE" + }, + { + "name": "Germany", + "code": "DE" + }, + { + "name": "Ghana", + "code": "GH" + }, + { + "name": "Gibraltar", + "code": "GI" + }, + { + "name": "Greece", + "code": "GR" + }, + { + "name": "Greenland", + "code": "GL" + }, + { + "name": "Grenada", + "code": "GD" + }, + { + "name": "Guadeloupe", + "code": "GP" + }, + { + "name": "Guam", + "code": "GU" + }, + { + "name": "Guatemala", + "code": "GT" + }, + { + "name": "Guernsey", + "code": "GG" + }, + { + "name": "Guinea", + "code": "GN" + }, + { + "name": "Guinea-Bissau", + "code": "GW" + }, + { + "name": "Guyana", + "code": "GY" + }, + { + "name": "Haiti", + "code": "HT" + }, + { + "name": "Heard Island and Mcdonald Islands", + "code": "HM" + }, + { + "name": "Holy See (Vatican City State)", + "code": "VA" + }, + { + "name": "Honduras", + "code": "HN" + }, + { + "name": "Hong Kong", + "code": "HK" + }, + { + "name": "Hungary", + "code": "HU" + }, + { + "name": "Iceland", + "code": "IS" + }, + { + "name": "India", + "code": "IN" + }, + { + "name": "Indonesia", + "code": "ID" + }, + { + "name": "Iran, Islamic Republic Of", + "code": "IR" + }, + { + "name": "Iraq", + "code": "IQ" + }, + { + "name": "Ireland", + "code": "IE" + }, + { + "name": "Isle of Man", + "code": "IM" + }, + { + "name": "Israel", + "code": "IL" + }, + { + "name": "Italy", + "code": "IT" + }, + { + "name": "Jamaica", + "code": "JM" + }, + { + "name": "Japan", + "code": "JP" + }, + { + "name": "Jersey", + "code": "JE" + }, + { + "name": "Jordan", + "code": "JO" + }, + { + "name": "Kazakhstan", + "code": "KZ" + }, + { + "name": "Kenya", + "code": "KE" + }, + { + "name": "Kiribati", + "code": "KI" + }, + { + "name": "Korea, Republic of", + "code": "KR" + }, + { + "name": "Kuwait", + "code": "KW" + }, + { + "name": "Kyrgyzstan", + "code": "KG" + }, + { + "name": "Laos", + "code": "LA" + }, + { + "name": "Latvia", + "code": "LV" + }, + { + "name": "Lebanon", + "code": "LB" + }, + { + "name": "Lesotho", + "code": "LS" + }, + { + "name": "Liberia", + "code": "LR" + }, + { + "name": "Libyan Arab Jamahiriya", + "code": "LY" + }, + { + "name": "Liechtenstein", + "code": "LI" + }, + { + "name": "Lithuania", + "code": "LT" + }, + { + "name": "Luxembourg", + "code": "LU" + }, + { + "name": "Macao", + "code": "MO" + }, + { + "name": "Madagascar", + "code": "MG" + }, + { + "name": "Malawi", + "code": "MW" + }, + { + "name": "Malaysia", + "code": "MY" + }, + { + "name": "Maldives", + "code": "MV" + }, + { + "name": "Mali", + "code": "ML" + }, + { + "name": "Malta", + "code": "MT" + }, + { + "name": "Marshall Islands", + "code": "MH" + }, + { + "name": "Martinique", + "code": "MQ" + }, + { + "name": "Mauritania", + "code": "MR" + }, + { + "name": "Mauritius", + "code": "MU" + }, + { + "name": "Mayotte", + "code": "YT" + }, + { + "name": "Mexico", + "code": "MX" + }, + { + "name": "Micronesia, Federated States of", + "code": "FM" + }, + { + "name": "Moldova, Republic of", + "code": "MD" + }, + { + "name": "Monaco", + "code": "MC" + }, + { + "name": "Mongolia", + "code": "MN" + }, + { + "name": "Montenegro", + "code": "ME" + }, + { + "name": "Montserrat", + "code": "MS" + }, + { + "name": "Morocco", + "code": "MA" + }, + { + "name": "Mozambique", + "code": "MZ" + }, + { + "name": "Myanmar", + "code": "MM" + }, + { + "name": "Namibia", + "code": "NA" + }, + { + "name": "Nauru", + "code": "NR" + }, + { + "name": "Nepal", + "code": "NP" + }, + { + "name": "Netherlands", + "code": "NL" + }, + { + "name": "Netherlands Antilles", + "code": "AN" + }, + { + "name": "New Caledonia", + "code": "NC" + }, + { + "name": "New Zealand", + "code": "NZ" + }, + { + "name": "Nicaragua", + "code": "NI" + }, + { + "name": "Niger", + "code": "NE" + }, + { + "name": "Nigeria", + "code": "NG" + }, + { + "name": "Niue", + "code": "NU" + }, + { + "name": "Norfolk Island", + "code": "NF" + }, + { + "name": "North Korea", + "code": "KP" + }, + { + "name": "North Macedonia", + "code": "MK" + }, + { + "name": "Northern Mariana Islands", + "code": "MP" + }, + { + "name": "Norway", + "code": "NO" + }, + { + "name": "Oman", + "code": "OM" + }, + { + "name": "Pakistan", + "code": "PK" + }, + { + "name": "Palau", + "code": "PW" + }, + { + "name": "Palestinian Territory, Occupied", + "code": "PS" + }, + { + "name": "Panama", + "code": "PA" + }, + { + "name": "Papua New Guinea", + "code": "PG" + }, + { + "name": "Paraguay", + "code": "PY" + }, + { + "name": "Peru", + "code": "PE" + }, + { + "name": "Philippines", + "code": "PH" + }, + { + "name": "Pitcairn Islands", + "code": "PN" + }, + { + "name": "Poland", + "code": "PL" + }, + { + "name": "Portugal", + "code": "PT" + }, + { + "name": "Puerto Rico", + "code": "PR" + }, + { + "name": "Qatar", + "code": "QA" + }, + { + "name": "Reunion", + "code": "RE" + }, + { + "name": "Romania", + "code": "RO" + }, + { + "name": "Russian Federation", + "code": "RU" + }, + { + "name": "Rwanda", + "code": "RW" + }, + { + "name": "Saint Barthélemy", + "code": "BL" + }, + { + "name": "Saint Helena", + "code": "SH" + }, + { + "name": "Saint Kitts and Nevis", + "code": "KN" + }, + { + "name": "Saint Lucia", + "code": "LC" + }, + { + "name": "Saint Martin", + "code": "MF" + }, + { + "name": "Saint Pierre and Miquelon", + "code": "PM" + }, + { + "name": "Saint Vincent and the Grenadines", + "code": "VC" + }, + { + "name": "Samoa", + "code": "WS" + }, + { + "name": "San Marino", + "code": "SM" + }, + { + "name": "Sao Tome and Principe", + "code": "ST" + }, + { + "name": "Saudi Arabia", + "code": "SA" + }, + { + "name": "Senegal", + "code": "SN" + }, + { + "name": "Serbia", + "code": "RS" + }, + { + "name": "Serbia and Montenegro", + "code": "CS" + }, + { + "name": "Seychelles", + "code": "SC" + }, + { + "name": "Sierra Leone", + "code": "SL" + }, + { + "name": "Singapore", + "code": "SG" + }, + { + "name": "Sint Maarten", + "code": "SX" + }, + { + "name": "Slovakia", + "code": "SK" + }, + { + "name": "Slovenia", + "code": "SI" + }, + { + "name": "Solomon Islands", + "code": "SB" + }, + { + "name": "Somalia", + "code": "SO" + }, + { + "name": "South Africa", + "code": "ZA" + }, + { + "name": "South Georgia and the South Sandwich Islands", + "code": "GS" + }, + { + "name": "South Sudan", + "code": "SS" + }, + { + "name": "Spain", + "code": "ES" + }, + { + "name": "Sri Lanka", + "code": "LK" + }, + { + "name": "Sudan", + "code": "SD" + }, + { + "name": "Suriname", + "code": "SR" + }, + { + "name": "Svalbard and Jan Mayen", + "code": "SJ" + }, + { + "name": "Swaziland", + "code": "SZ" + }, + { + "name": "Sweden", + "code": "SE" + }, + { + "name": "Switzerland", + "code": "CH" + }, + { + "name": "Syrian Arab Republic", + "code": "SY" + }, + { + "name": "Taiwan", + "code": "TW" + }, + { + "name": "Tajikistan", + "code": "TJ" + }, + { + "name": "Tanzania, United Republic of", + "code": "TZ" + }, + { + "name": "Thailand", + "code": "TH" + }, + { + "name": "Timor-Leste", + "code": "TL" + }, + { + "name": "Togo", + "code": "TG" + }, + { + "name": "Tokelau", + "code": "TK" + }, + { + "name": "Tonga", + "code": "TO" + }, + { + "name": "Trinidad and Tobago", + "code": "TT" + }, + { + "name": "Tunisia", + "code": "TN" + }, + { + "name": "Turkey", + "code": "TR" + }, + { + "name": "Turkmenistan", + "code": "TM" + }, + { + "name": "Turks and Caicos Islands", + "code": "TC" + }, + { + "name": "Tuvalu", + "code": "TV" + }, + { + "name": "Uganda", + "code": "UG" + }, + { + "name": "Ukraine", + "code": "UA" + }, + { + "name": "United Arab Emirates", + "code": "AE" + }, + { + "name": "United Kingdom", + "code": "GB" + }, + { + "name": "United States", + "code": "US" + }, + { + "name": "United States Minor Outlying Islands", + "code": "UM" + }, + { + "name": "Uruguay", + "code": "UY" + }, + { + "name": "Uzbekistan", + "code": "UZ" + }, + { + "name": "Vanuatu", + "code": "VU" + }, + { + "name": "Venezuela", + "code": "VE" + }, + { + "name": "Vietnam", + "code": "VN" + }, + { + "name": "Virgin Islands, British", + "code": "VG" + }, + { + "name": "Virgin Islands, U.S.", + "code": "VI" + }, + { + "name": "Wallis and Futuna", + "code": "WF" + }, + { + "name": "Western Sahara", + "code": "EH" + }, + { + "name": "Yemen", + "code": "YE" + }, + { + "name": "Zambia", + "code": "ZM" + }, + { + "name": "Zimbabwe", + "code": "ZW" + }, + { + "name": "Åland Islands", + "code": "AX" + } +] \ No newline at end of file diff --git a/server/db/maxmind.ts b/server/db/maxmind.ts new file mode 100644 index 00000000..ca398df2 --- /dev/null +++ b/server/db/maxmind.ts @@ -0,0 +1,13 @@ +import maxmind, { CountryResponse, Reader } from "maxmind"; +import config from "@server/lib/config"; + +let maxmindLookup: Reader | null; +if (config.getRawConfig().server.maxmind_db_path) { + maxmindLookup = await maxmind.open( + config.getRawConfig().server.maxmind_db_path! + ); +} else { + maxmindLookup = null; +} + +export { maxmindLookup }; diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 34d16aa1..44b210b0 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -39,7 +39,7 @@ function createDb() { connectionString, max: 20, idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, + connectionTimeoutMillis: 5000, }); const replicas = []; @@ -52,7 +52,7 @@ function createDb() { connectionString: conn.connection_string, max: 10, idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, + connectionTimeoutMillis: 5000, }); replicas.push(DrizzlePostgres(replicaPool)); } diff --git a/server/db/pg/index.ts b/server/db/pg/index.ts index 4829c04c..5cc80e86 100644 --- a/server/db/pg/index.ts +++ b/server/db/pg/index.ts @@ -1,2 +1,3 @@ export * from "./driver"; -export * from "./schema"; \ No newline at end of file +export * from "./schema"; +export * from "./privateSchema"; diff --git a/server/db/pg/privateSchema.ts b/server/db/pg/privateSchema.ts new file mode 100644 index 00000000..8ea8f9de --- /dev/null +++ b/server/db/pg/privateSchema.ts @@ -0,0 +1,245 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + pgTable, + serial, + varchar, + boolean, + integer, + bigint, + real, + text +} from "drizzle-orm/pg-core"; +import { InferSelectModel } from "drizzle-orm"; +import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; + +export const certificates = pgTable("certificates", { + certId: serial("certId").primaryKey(), + domain: varchar("domain", { length: 255 }).notNull().unique(), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: boolean("wildcard").default(false), + status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: bigint("expiresAt", { mode: "number" }), + lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), + orderId: varchar("orderId", { length: 500 }), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + +export const dnsChallenge = pgTable("dnsChallenges", { + dnsChallengeId: serial("dnsChallengeId").primaryKey(), + domain: varchar("domain", { length: 255 }).notNull(), + token: varchar("token", { length: 255 }).notNull(), + keyAuthorization: varchar("keyAuthorization", { length: 1000 }).notNull(), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + completed: boolean("completed").default(false) +}); + +export const account = pgTable("account", { + accountId: serial("accountId").primaryKey(), + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }) +}); + +export const customers = pgTable("customers", { + customerId: varchar("customerId", { length: 255 }).primaryKey().notNull(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + // accountId: integer("accountId") + // .references(() => account.accountId, { onDelete: "cascade" }), // Optional, if using accounts + email: varchar("email", { length: 255 }), + name: varchar("name", { length: 255 }), + phone: varchar("phone", { length: 50 }), + address: text("address"), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() +}); + +export const subscriptions = pgTable("subscriptions", { + subscriptionId: varchar("subscriptionId", { length: 255 }) + .primaryKey() + .notNull(), + customerId: varchar("customerId", { length: 255 }) + .notNull() + .references(() => customers.customerId, { onDelete: "cascade" }), + status: varchar("status", { length: 50 }).notNull().default("active"), // active, past_due, canceled, unpaid + canceledAt: bigint("canceledAt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }), + billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }) +}); + +export const subscriptionItems = pgTable("subscriptionItems", { + subscriptionItemId: serial("subscriptionItemId").primaryKey(), + subscriptionId: varchar("subscriptionId", { length: 255 }) + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + planId: varchar("planId", { length: 255 }).notNull(), + priceId: varchar("priceId", { length: 255 }), + meterId: varchar("meterId", { length: 255 }), + unitAmount: real("unitAmount"), + tiers: text("tiers"), + interval: varchar("interval", { length: 50 }), + currentPeriodStart: bigint("currentPeriodStart", { mode: "number" }), + currentPeriodEnd: bigint("currentPeriodEnd", { mode: "number" }), + name: varchar("name", { length: 255 }) +}); + +export const accountDomains = pgTable("accountDomains", { + accountId: integer("accountId") + .notNull() + .references(() => account.accountId, { onDelete: "cascade" }), + domainId: varchar("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }) +}); + +export const usage = pgTable("usage", { + usageId: varchar("usageId", { length: 255 }).primaryKey(), + featureId: varchar("featureId", { length: 255 }).notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { onDelete: "cascade" }) + .notNull(), + meterId: varchar("meterId", { length: 255 }), + instantaneousValue: real("instantaneousValue"), + latestValue: real("latestValue").notNull(), + previousValue: real("previousValue"), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), + rolledOverAt: bigint("rolledOverAt", { mode: "number" }), + nextRolloverAt: bigint("nextRolloverAt", { mode: "number" }) +}); + +export const limits = pgTable("limits", { + limitId: varchar("limitId", { length: 255 }).primaryKey(), + featureId: varchar("featureId", { length: 255 }).notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + value: real("value"), + description: text("description") +}); + +export const usageNotifications = pgTable("usageNotifications", { + notificationId: serial("notificationId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + featureId: varchar("featureId", { length: 255 }).notNull(), + limitId: varchar("limitId", { length: 255 }).notNull(), + notificationType: varchar("notificationType", { length: 50 }).notNull(), + sentAt: bigint("sentAt", { mode: "number" }).notNull() +}); + +export const domainNamespaces = pgTable("domainNamespaces", { + domainNamespaceId: varchar("domainNamespaceId", { + length: 255 + }).primaryKey(), + domainId: varchar("domainId") + .references(() => domains.domainId, { + onDelete: "set null" + }) + .notNull() +}); + +export const exitNodeOrgs = pgTable("exitNodeOrgs", { + exitNodeId: integer("exitNodeId") + .notNull() + .references(() => exitNodes.exitNodeId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +export const remoteExitNodes = pgTable("remoteExitNode", { + remoteExitNodeId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), + exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { + onDelete: "cascade" + }) +}); + +export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { + sessionId: varchar("id").primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() +}); + +export const loginPage = pgTable("loginPage", { + loginPageId: serial("loginPageId").primaryKey(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }) +}); + +export const loginPageOrg = pgTable("loginPageOrg", { + loginPageId: integer("loginPageId") + .notNull() + .references(() => loginPage.loginPageId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +export const sessionTransferToken = pgTable("sessionTransferToken", { + token: varchar("token").primaryKey(), + sessionId: varchar("sessionId") + .notNull() + .references(() => sessions.sessionId, { + onDelete: "cascade" + }), + encryptedSession: text("encryptedSession").notNull(), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() +}); + +export type Limit = InferSelectModel; +export type Account = InferSelectModel; +export type Certificate = InferSelectModel; +export type DnsChallenge = InferSelectModel; +export type Customer = InferSelectModel; +export type Subscription = InferSelectModel; +export type SubscriptionItem = InferSelectModel; +export type Usage = InferSelectModel; +export type UsageLimit = InferSelectModel; +export type AccountDomain = InferSelectModel; +export type UsageNotification = InferSelectModel; +export type RemoteExitNode = InferSelectModel; +export type RemoteExitNodeSession = InferSelectModel< + typeof remoteExitNodeSessions +>; +export type ExitNodeOrg = InferSelectModel; +export type LoginPage = InferSelectModel; diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 18b29f35..29c14560 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -128,6 +128,27 @@ export const targets = pgTable("targets", { rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); +export const targetHealthCheck = pgTable("targetHealthCheck", { + targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), + targetId: integer("targetId") + .notNull() + .references(() => targets.targetId, { onDelete: "cascade" }), + hcEnabled: boolean("hcEnabled").notNull().default(false), + hcPath: varchar("hcPath"), + hcScheme: varchar("hcScheme"), + hcMode: varchar("hcMode").default("http"), + hcHostname: varchar("hcHostname"), + hcPort: integer("hcPort"), + hcInterval: integer("hcInterval").default(30), // in seconds + hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds + hcTimeout: integer("hcTimeout").default(5), // in seconds + hcHeaders: varchar("hcHeaders"), + hcFollowRedirects: boolean("hcFollowRedirects").default(true), + hcMethod: varchar("hcMethod").default("GET"), + hcStatus: integer("hcStatus"), // http code + hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy" +}); + export const exitNodes = pgTable("exitNodes", { exitNodeId: serial("exitNodeId").primaryKey(), name: varchar("name").notNull(), @@ -689,3 +710,4 @@ export type OrgDomains = InferSelectModel; export type SiteResource = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; +export type TargetHealthCheck = InferSelectModel; \ No newline at end of file diff --git a/server/db/private/rateLimit.test.ts b/server/db/private/rateLimit.test.ts new file mode 100644 index 00000000..59952c8c --- /dev/null +++ b/server/db/private/rateLimit.test.ts @@ -0,0 +1,202 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +// Simple test file for the rate limit service with Redis +// Run with: npx ts-node rateLimitService.test.ts + +import { RateLimitService } from './rateLimit'; + +function generateClientId() { + return 'client-' + Math.random().toString(36).substring(2, 15); +} + +async function runTests() { + console.log('Starting Rate Limit Service Tests...\n'); + + const rateLimitService = new RateLimitService(); + let testsPassed = 0; + let testsTotal = 0; + + // Helper function to run a test + async function test(name: string, testFn: () => Promise) { + testsTotal++; + try { + await testFn(); + console.log(`✅ ${name}`); + testsPassed++; + } catch (error) { + console.log(`❌ ${name}: ${error}`); + } + } + + // Helper function for assertions + function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } + } + + // Test 1: Basic rate limiting + await test('Should allow requests under the limit', async () => { + const clientId = generateClientId(); + const maxRequests = 5; + + for (let i = 0; i < maxRequests - 1; i++) { + const result = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(!result.isLimited, `Request ${i + 1} should be allowed`); + assert(result.totalHits === i + 1, `Expected ${i + 1} hits, got ${result.totalHits}`); + } + }); + + // Test 2: Rate limit blocking + await test('Should block requests over the limit', async () => { + const clientId = generateClientId(); + const maxRequests = 30; + + // Use up all allowed requests + for (let i = 0; i < maxRequests - 1; i++) { + const result = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(!result.isLimited, `Request ${i + 1} should be allowed`); + } + + // Next request should be blocked + const blockedResult = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(blockedResult.isLimited, 'Request should be blocked'); + assert(blockedResult.reason === 'global', 'Should be blocked for global reason'); + }); + + // Test 3: Message type limits + await test('Should handle message type limits', async () => { + const clientId = generateClientId(); + const globalMax = 10; + const messageTypeMax = 2; + + // Send messages of type 'ping' up to the limit + for (let i = 0; i < messageTypeMax - 1; i++) { + const result = await rateLimitService.checkRateLimit( + clientId, + 'ping', + globalMax, + messageTypeMax + ); + assert(!result.isLimited, `Ping message ${i + 1} should be allowed`); + } + + // Next 'ping' should be blocked + const blockedResult = await rateLimitService.checkRateLimit( + clientId, + 'ping', + globalMax, + messageTypeMax + ); + assert(blockedResult.isLimited, 'Ping message should be blocked'); + assert(blockedResult.reason === 'message_type:ping', 'Should be blocked for message type'); + + // Other message types should still work + const otherResult = await rateLimitService.checkRateLimit( + clientId, + 'pong', + globalMax, + messageTypeMax + ); + assert(!otherResult.isLimited, 'Pong message should be allowed'); + }); + + // Test 4: Reset functionality + await test('Should reset client correctly', async () => { + const clientId = generateClientId(); + const maxRequests = 3; + + // Use up some requests + await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + await rateLimitService.checkRateLimit(clientId, 'test', maxRequests); + + // Reset the client + await rateLimitService.resetKey(clientId); + + // Should be able to make fresh requests + const result = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(!result.isLimited, 'Request after reset should be allowed'); + assert(result.totalHits === 1, 'Should have 1 hit after reset'); + }); + + // Test 5: Different clients are independent + await test('Should handle different clients independently', async () => { + const client1 = generateClientId(); + const client2 = generateClientId(); + const maxRequests = 2; + + // Client 1 uses up their limit + await rateLimitService.checkRateLimit(client1, undefined, maxRequests); + await rateLimitService.checkRateLimit(client1, undefined, maxRequests); + const client1Blocked = await rateLimitService.checkRateLimit(client1, undefined, maxRequests); + assert(client1Blocked.isLimited, 'Client 1 should be blocked'); + + // Client 2 should still be able to make requests + const client2Result = await rateLimitService.checkRateLimit(client2, undefined, maxRequests); + assert(!client2Result.isLimited, 'Client 2 should not be blocked'); + assert(client2Result.totalHits === 1, 'Client 2 should have 1 hit'); + }); + + // Test 6: Decrement functionality + await test('Should decrement correctly', async () => { + const clientId = generateClientId(); + const maxRequests = 5; + + // Make some requests + await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + let result = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(result.totalHits === 3, 'Should have 3 hits before decrement'); + + // Decrement + await rateLimitService.decrementRateLimit(clientId); + + // Next request should reflect the decrement + result = await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); + assert(result.totalHits === 3, 'Should have 3 hits after decrement + increment'); + }); + + // Wait a moment for any pending Redis operations + console.log('\nWaiting for Redis sync...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Force sync to test Redis integration + await test('Should sync to Redis', async () => { + await rateLimitService.forceSyncAllPendingData(); + // If this doesn't throw, Redis sync is working + assert(true, 'Redis sync completed'); + }); + + // Cleanup + await rateLimitService.cleanup(); + + // Results + console.log(`\n--- Test Results ---`); + console.log(`✅ Passed: ${testsPassed}/${testsTotal}`); + console.log(`❌ Failed: ${testsTotal - testsPassed}/${testsTotal}`); + + if (testsPassed === testsTotal) { + console.log('\n🎉 All tests passed!'); + process.exit(0); + } else { + console.log('\n💥 Some tests failed!'); + process.exit(1); + } +} + +// Run the tests +runTests().catch(error => { + console.error('Test runner error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/server/db/private/rateLimit.ts b/server/db/private/rateLimit.ts new file mode 100644 index 00000000..ff8589bc --- /dev/null +++ b/server/db/private/rateLimit.ts @@ -0,0 +1,458 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import redisManager from "@server/db/private/redis"; +import { build } from "@server/build"; + +// Rate limiting configuration +export const RATE_LIMIT_WINDOW = 60; // 1 minute in seconds +export const RATE_LIMIT_MAX_REQUESTS = 100; +export const RATE_LIMIT_PER_MESSAGE_TYPE = 20; // Per message type limit within the window + +// Configuration for batched Redis sync +export const REDIS_SYNC_THRESHOLD = 15; // Sync to Redis every N messages +export const REDIS_SYNC_FORCE_INTERVAL = 30000; // Force sync every 30 seconds as backup + +interface RateLimitTracker { + count: number; + windowStart: number; + pendingCount: number; + lastSyncedCount: number; +} + +interface RateLimitResult { + isLimited: boolean; + reason?: string; + totalHits?: number; + resetTime?: Date; +} + +export class RateLimitService { + private localRateLimitTracker: Map = new Map(); + private localMessageTypeRateLimitTracker: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + private forceSyncInterval: NodeJS.Timeout | null = null; + + constructor() { + if (build == "oss") { + return; + } + + // Start cleanup and sync intervals + this.cleanupInterval = setInterval(() => { + this.cleanupLocalRateLimit().catch((error) => { + logger.error("Error during rate limit cleanup:", error); + }); + }, 60000); // Run cleanup every minute + + this.forceSyncInterval = setInterval(() => { + this.forceSyncAllPendingData().catch((error) => { + logger.error("Error during force sync:", error); + }); + }, REDIS_SYNC_FORCE_INTERVAL); + } + + // Redis keys + private getRateLimitKey(clientId: string): string { + return `ratelimit:${clientId}`; + } + + private getMessageTypeRateLimitKey(clientId: string, messageType: string): string { + return `ratelimit:${clientId}:${messageType}`; + } + + // Helper function to sync local rate limit data to Redis + private async syncRateLimitToRedis( + clientId: string, + tracker: RateLimitTracker + ): Promise { + if (!redisManager.isRedisEnabled() || tracker.pendingCount === 0) return; + + try { + const currentTime = Math.floor(Date.now() / 1000); + const globalKey = this.getRateLimitKey(clientId); + + // Get current value and add pending count + const currentValue = await redisManager.hget( + globalKey, + currentTime.toString() + ); + const newValue = ( + parseInt(currentValue || "0") + tracker.pendingCount + ).toString(); + await redisManager.hset(globalKey, currentTime.toString(), newValue); + + // Set TTL using the client directly + if (redisManager.getClient()) { + await redisManager + .getClient() + .expire(globalKey, RATE_LIMIT_WINDOW + 10); + } + + // Update tracking + tracker.lastSyncedCount = tracker.count; + tracker.pendingCount = 0; + + logger.debug(`Synced global rate limit to Redis for client ${clientId}`); + } catch (error) { + logger.error("Failed to sync global rate limit to Redis:", error); + } + } + + private async syncMessageTypeRateLimitToRedis( + clientId: string, + messageType: string, + tracker: RateLimitTracker + ): Promise { + if (!redisManager.isRedisEnabled() || tracker.pendingCount === 0) return; + + try { + const currentTime = Math.floor(Date.now() / 1000); + const messageTypeKey = this.getMessageTypeRateLimitKey(clientId, messageType); + + // Get current value and add pending count + const currentValue = await redisManager.hget( + messageTypeKey, + currentTime.toString() + ); + const newValue = ( + parseInt(currentValue || "0") + tracker.pendingCount + ).toString(); + await redisManager.hset( + messageTypeKey, + currentTime.toString(), + newValue + ); + + // Set TTL using the client directly + if (redisManager.getClient()) { + await redisManager + .getClient() + .expire(messageTypeKey, RATE_LIMIT_WINDOW + 10); + } + + // Update tracking + tracker.lastSyncedCount = tracker.count; + tracker.pendingCount = 0; + + logger.debug( + `Synced message type rate limit to Redis for client ${clientId}, type ${messageType}` + ); + } catch (error) { + logger.error("Failed to sync message type rate limit to Redis:", error); + } + } + + // Initialize local tracker from Redis data + private async initializeLocalTracker(clientId: string): Promise { + const currentTime = Math.floor(Date.now() / 1000); + const windowStart = currentTime - RATE_LIMIT_WINDOW; + + if (!redisManager.isRedisEnabled()) { + return { + count: 0, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: 0 + }; + } + + try { + const globalKey = this.getRateLimitKey(clientId); + const globalRateLimitData = await redisManager.hgetall(globalKey); + + let count = 0; + for (const [timestamp, countStr] of Object.entries(globalRateLimitData)) { + const time = parseInt(timestamp); + if (time >= windowStart) { + count += parseInt(countStr); + } + } + + return { + count, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: count + }; + } catch (error) { + logger.error("Failed to initialize global tracker from Redis:", error); + return { + count: 0, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: 0 + }; + } + } + + private async initializeMessageTypeTracker( + clientId: string, + messageType: string + ): Promise { + const currentTime = Math.floor(Date.now() / 1000); + const windowStart = currentTime - RATE_LIMIT_WINDOW; + + if (!redisManager.isRedisEnabled()) { + return { + count: 0, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: 0 + }; + } + + try { + const messageTypeKey = this.getMessageTypeRateLimitKey(clientId, messageType); + const messageTypeRateLimitData = await redisManager.hgetall(messageTypeKey); + + let count = 0; + for (const [timestamp, countStr] of Object.entries(messageTypeRateLimitData)) { + const time = parseInt(timestamp); + if (time >= windowStart) { + count += parseInt(countStr); + } + } + + return { + count, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: count + }; + } catch (error) { + logger.error("Failed to initialize message type tracker from Redis:", error); + return { + count: 0, + windowStart: currentTime, + pendingCount: 0, + lastSyncedCount: 0 + }; + } + } + + // Main rate limiting function + async checkRateLimit( + clientId: string, + messageType?: string, + maxRequests: number = RATE_LIMIT_MAX_REQUESTS, + messageTypeLimit: number = RATE_LIMIT_PER_MESSAGE_TYPE, + windowMs: number = RATE_LIMIT_WINDOW * 1000 + ): Promise { + const currentTime = Math.floor(Date.now() / 1000); + const windowStart = currentTime - Math.floor(windowMs / 1000); + + // Check global rate limit + let globalTracker = this.localRateLimitTracker.get(clientId); + + if (!globalTracker || globalTracker.windowStart < windowStart) { + // New window or first request - initialize from Redis if available + globalTracker = await this.initializeLocalTracker(clientId); + globalTracker.windowStart = currentTime; + this.localRateLimitTracker.set(clientId, globalTracker); + } + + // Increment global counters + globalTracker.count++; + globalTracker.pendingCount++; + this.localRateLimitTracker.set(clientId, globalTracker); + + // Check if global limit would be exceeded + if (globalTracker.count >= maxRequests) { + return { + isLimited: true, + reason: "global", + totalHits: globalTracker.count, + resetTime: new Date((globalTracker.windowStart + Math.floor(windowMs / 1000)) * 1000) + }; + } + + // Sync to Redis if threshold reached + if (globalTracker.pendingCount >= REDIS_SYNC_THRESHOLD) { + this.syncRateLimitToRedis(clientId, globalTracker); + } + + // Check message type specific rate limit if messageType is provided + if (messageType) { + const messageTypeKey = `${clientId}:${messageType}`; + let messageTypeTracker = this.localMessageTypeRateLimitTracker.get(messageTypeKey); + + if (!messageTypeTracker || messageTypeTracker.windowStart < windowStart) { + // New window or first request for this message type - initialize from Redis if available + messageTypeTracker = await this.initializeMessageTypeTracker(clientId, messageType); + messageTypeTracker.windowStart = currentTime; + this.localMessageTypeRateLimitTracker.set(messageTypeKey, messageTypeTracker); + } + + // Increment message type counters + messageTypeTracker.count++; + messageTypeTracker.pendingCount++; + this.localMessageTypeRateLimitTracker.set(messageTypeKey, messageTypeTracker); + + // Check if message type limit would be exceeded + if (messageTypeTracker.count >= messageTypeLimit) { + return { + isLimited: true, + reason: `message_type:${messageType}`, + totalHits: messageTypeTracker.count, + resetTime: new Date((messageTypeTracker.windowStart + Math.floor(windowMs / 1000)) * 1000) + }; + } + + // Sync to Redis if threshold reached + if (messageTypeTracker.pendingCount >= REDIS_SYNC_THRESHOLD) { + this.syncMessageTypeRateLimitToRedis(clientId, messageType, messageTypeTracker); + } + } + + return { + isLimited: false, + totalHits: globalTracker.count, + resetTime: new Date((globalTracker.windowStart + Math.floor(windowMs / 1000)) * 1000) + }; + } + + // Decrement function for skipSuccessfulRequests/skipFailedRequests functionality + async decrementRateLimit(clientId: string, messageType?: string): Promise { + // Decrement global counter + const globalTracker = this.localRateLimitTracker.get(clientId); + if (globalTracker && globalTracker.count > 0) { + globalTracker.count--; + // We need to account for this in pending count to sync correctly + globalTracker.pendingCount--; + } + + // Decrement message type counter if provided + if (messageType) { + const messageTypeKey = `${clientId}:${messageType}`; + const messageTypeTracker = this.localMessageTypeRateLimitTracker.get(messageTypeKey); + if (messageTypeTracker && messageTypeTracker.count > 0) { + messageTypeTracker.count--; + messageTypeTracker.pendingCount--; + } + } + } + + // Reset key function + async resetKey(clientId: string): Promise { + // Remove from local tracking + this.localRateLimitTracker.delete(clientId); + + // Remove all message type entries for this client + for (const [key] of this.localMessageTypeRateLimitTracker) { + if (key.startsWith(`${clientId}:`)) { + this.localMessageTypeRateLimitTracker.delete(key); + } + } + + // Remove from Redis if enabled + if (redisManager.isRedisEnabled()) { + const globalKey = this.getRateLimitKey(clientId); + await redisManager.del(globalKey); + + // Get all message type keys for this client and delete them + const client = redisManager.getClient(); + if (client) { + const messageTypeKeys = await client.keys(`ratelimit:${clientId}:*`); + if (messageTypeKeys.length > 0) { + await Promise.all(messageTypeKeys.map(key => redisManager.del(key))); + } + } + } + } + + // Cleanup old local rate limit entries and force sync pending data + private async cleanupLocalRateLimit(): Promise { + const currentTime = Math.floor(Date.now() / 1000); + const windowStart = currentTime - RATE_LIMIT_WINDOW; + + // Clean up global rate limit tracking and sync pending data + for (const [clientId, tracker] of this.localRateLimitTracker.entries()) { + if (tracker.windowStart < windowStart) { + // Sync any pending data before cleanup + if (tracker.pendingCount > 0) { + await this.syncRateLimitToRedis(clientId, tracker); + } + this.localRateLimitTracker.delete(clientId); + } + } + + // Clean up message type rate limit tracking and sync pending data + for (const [key, tracker] of this.localMessageTypeRateLimitTracker.entries()) { + if (tracker.windowStart < windowStart) { + // Sync any pending data before cleanup + if (tracker.pendingCount > 0) { + const [clientId, messageType] = key.split(":", 2); + await this.syncMessageTypeRateLimitToRedis(clientId, messageType, tracker); + } + this.localMessageTypeRateLimitTracker.delete(key); + } + } + } + + // Force sync all pending rate limit data to Redis + async forceSyncAllPendingData(): Promise { + if (!redisManager.isRedisEnabled()) return; + + logger.debug("Force syncing all pending rate limit data to Redis..."); + + // Sync all pending global rate limits + for (const [clientId, tracker] of this.localRateLimitTracker.entries()) { + if (tracker.pendingCount > 0) { + await this.syncRateLimitToRedis(clientId, tracker); + } + } + + // Sync all pending message type rate limits + for (const [key, tracker] of this.localMessageTypeRateLimitTracker.entries()) { + if (tracker.pendingCount > 0) { + const [clientId, messageType] = key.split(":", 2); + await this.syncMessageTypeRateLimitToRedis(clientId, messageType, tracker); + } + } + + logger.debug("Completed force sync of pending rate limit data"); + } + + // Cleanup function for graceful shutdown + async cleanup(): Promise { + if (build == "oss") { + return; + } + + // Clear intervals + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + if (this.forceSyncInterval) { + clearInterval(this.forceSyncInterval); + } + + // Force sync all pending data + await this.forceSyncAllPendingData(); + + // Clear local data + this.localRateLimitTracker.clear(); + this.localMessageTypeRateLimitTracker.clear(); + + logger.info("Rate limit service cleanup completed"); + } +} + +// Export singleton instance +export const rateLimitService = new RateLimitService(); + +// Handle process termination +process.on("SIGTERM", () => rateLimitService.cleanup()); +process.on("SIGINT", () => rateLimitService.cleanup()); \ No newline at end of file diff --git a/server/db/private/redis.ts b/server/db/private/redis.ts new file mode 100644 index 00000000..b1f01a49 --- /dev/null +++ b/server/db/private/redis.ts @@ -0,0 +1,782 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Redis, { RedisOptions } from "ioredis"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { build } from "@server/build"; + +class RedisManager { + public client: Redis | null = null; + private writeClient: Redis | null = null; // Master for writes + private readClient: Redis | null = null; // Replica for reads + private subscriber: Redis | null = null; + private publisher: Redis | null = null; + private isEnabled: boolean = false; + private isHealthy: boolean = true; + private isWriteHealthy: boolean = true; + private isReadHealthy: boolean = true; + private lastHealthCheck: number = 0; + private healthCheckInterval: number = 30000; // 30 seconds + private connectionTimeout: number = 15000; // 15 seconds + private commandTimeout: number = 15000; // 15 seconds + private hasReplicas: boolean = false; + private maxRetries: number = 3; + private baseRetryDelay: number = 100; // 100ms + private maxRetryDelay: number = 2000; // 2 seconds + private backoffMultiplier: number = 2; + private subscribers: Map< + string, + Set<(channel: string, message: string) => void> + > = new Map(); + private reconnectionCallbacks: Set<() => Promise> = new Set(); + + constructor() { + if (build == "oss") { + this.isEnabled = false; + return + } + this.isEnabled = config.getRawPrivateConfig().flags?.enable_redis || false; + if (this.isEnabled) { + this.initializeClients(); + } + } + + // Register callback to be called when Redis reconnects + public onReconnection(callback: () => Promise): void { + this.reconnectionCallbacks.add(callback); + } + + // Unregister reconnection callback + public offReconnection(callback: () => Promise): void { + this.reconnectionCallbacks.delete(callback); + } + + private async triggerReconnectionCallbacks(): Promise { + logger.info(`Triggering ${this.reconnectionCallbacks.size} reconnection callbacks`); + + const promises = Array.from(this.reconnectionCallbacks).map(async (callback) => { + try { + await callback(); + } catch (error) { + logger.error("Error in reconnection callback:", error); + } + }); + + await Promise.allSettled(promises); + } + + private async resubscribeToChannels(): Promise { + if (!this.subscriber || this.subscribers.size === 0) return; + + logger.info(`Re-subscribing to ${this.subscribers.size} channels after Redis reconnection`); + + try { + const channels = Array.from(this.subscribers.keys()); + if (channels.length > 0) { + await this.subscriber.subscribe(...channels); + logger.info(`Successfully re-subscribed to channels: ${channels.join(', ')}`); + } + } catch (error) { + logger.error("Failed to re-subscribe to channels:", error); + } + } + + private getRedisConfig(): RedisOptions { + const redisConfig = config.getRawPrivateConfig().redis!; + const opts: RedisOptions = { + host: redisConfig.host!, + port: redisConfig.port!, + password: redisConfig.password, + db: redisConfig.db, + // tls: { + // rejectUnauthorized: + // redisConfig.tls?.reject_unauthorized || false + // } + }; + return opts; + } + + private getReplicaRedisConfig(): RedisOptions | null { + const redisConfig = config.getRawPrivateConfig().redis!; + if (!redisConfig.replicas || redisConfig.replicas.length === 0) { + return null; + } + + // Use the first replica for simplicity + // In production, you might want to implement load balancing across replicas + const replica = redisConfig.replicas[0]; + const opts: RedisOptions = { + host: replica.host!, + port: replica.port!, + password: replica.password, + db: replica.db || redisConfig.db, + // tls: { + // rejectUnauthorized: + // replica.tls?.reject_unauthorized || false + // } + }; + return opts; + } + + // Add reconnection logic in initializeClients + private initializeClients(): void { + const masterConfig = this.getRedisConfig(); + const replicaConfig = this.getReplicaRedisConfig(); + + this.hasReplicas = replicaConfig !== null; + + try { + // Initialize master connection for writes + this.writeClient = new Redis({ + ...masterConfig, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: this.connectionTimeout, + commandTimeout: this.commandTimeout, + }); + + // Initialize replica connection for reads (if available) + if (this.hasReplicas) { + this.readClient = new Redis({ + ...replicaConfig!, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: this.connectionTimeout, + commandTimeout: this.commandTimeout, + }); + } else { + // Fallback to master for reads if no replicas + this.readClient = this.writeClient; + } + + // Backward compatibility - point to write client + this.client = this.writeClient; + + // Publisher uses master (writes) + this.publisher = new Redis({ + ...masterConfig, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: this.connectionTimeout, + commandTimeout: this.commandTimeout, + }); + + // Subscriber uses replica if available (reads) + this.subscriber = new Redis({ + ...(this.hasReplicas ? replicaConfig! : masterConfig), + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: this.connectionTimeout, + commandTimeout: this.commandTimeout, + }); + + // Add reconnection handlers for write client + this.writeClient.on("error", (err) => { + logger.error("Redis write client error:", err); + this.isWriteHealthy = false; + this.isHealthy = false; + }); + + this.writeClient.on("reconnecting", () => { + logger.info("Redis write client reconnecting..."); + this.isWriteHealthy = false; + this.isHealthy = false; + }); + + this.writeClient.on("ready", () => { + logger.info("Redis write client ready"); + this.isWriteHealthy = true; + this.updateOverallHealth(); + + // Trigger reconnection callbacks when Redis comes back online + if (this.isHealthy) { + this.triggerReconnectionCallbacks().catch(error => { + logger.error("Error triggering reconnection callbacks:", error); + }); + } + }); + + this.writeClient.on("connect", () => { + logger.info("Redis write client connected"); + }); + + // Add reconnection handlers for read client (if different from write) + if (this.hasReplicas && this.readClient !== this.writeClient) { + this.readClient.on("error", (err) => { + logger.error("Redis read client error:", err); + this.isReadHealthy = false; + this.updateOverallHealth(); + }); + + this.readClient.on("reconnecting", () => { + logger.info("Redis read client reconnecting..."); + this.isReadHealthy = false; + this.updateOverallHealth(); + }); + + this.readClient.on("ready", () => { + logger.info("Redis read client ready"); + this.isReadHealthy = true; + this.updateOverallHealth(); + + // Trigger reconnection callbacks when Redis comes back online + if (this.isHealthy) { + this.triggerReconnectionCallbacks().catch(error => { + logger.error("Error triggering reconnection callbacks:", error); + }); + } + }); + + this.readClient.on("connect", () => { + logger.info("Redis read client connected"); + }); + } else { + // If using same client for reads and writes + this.isReadHealthy = this.isWriteHealthy; + } + + this.publisher.on("error", (err) => { + logger.error("Redis publisher error:", err); + }); + + this.publisher.on("ready", () => { + logger.info("Redis publisher ready"); + }); + + this.publisher.on("connect", () => { + logger.info("Redis publisher connected"); + }); + + this.subscriber.on("error", (err) => { + logger.error("Redis subscriber error:", err); + }); + + this.subscriber.on("ready", () => { + logger.info("Redis subscriber ready"); + // Re-subscribe to all channels after reconnection + this.resubscribeToChannels().catch((error: any) => { + logger.error("Error re-subscribing to channels:", error); + }); + }); + + this.subscriber.on("connect", () => { + logger.info("Redis subscriber connected"); + }); + + // Set up message handler for subscriber + this.subscriber.on( + "message", + (channel: string, message: string) => { + const channelSubscribers = this.subscribers.get(channel); + if (channelSubscribers) { + channelSubscribers.forEach((callback) => { + try { + callback(channel, message); + } catch (error) { + logger.error( + `Error in subscriber callback for channel ${channel}:`, + error + ); + } + }); + } + } + ); + + const setupMessage = this.hasReplicas + ? "Redis clients initialized successfully with replica support" + : "Redis clients initialized successfully (single instance)"; + logger.info(setupMessage); + + // Start periodic health monitoring + this.startHealthMonitoring(); + } catch (error) { + logger.error("Failed to initialize Redis clients:", error); + this.isEnabled = false; + } + } + + private updateOverallHealth(): void { + // Overall health is true if write is healthy and (read is healthy OR we don't have replicas) + this.isHealthy = this.isWriteHealthy && (this.isReadHealthy || !this.hasReplicas); + } + + private async executeWithRetry( + operation: () => Promise, + operationName: string, + fallbackOperation?: () => Promise + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + // If this is the last attempt, try fallback if available + if (attempt === this.maxRetries && fallbackOperation) { + try { + logger.warn(`${operationName} primary operation failed, trying fallback`); + return await fallbackOperation(); + } catch (fallbackError) { + logger.error(`${operationName} fallback also failed:`, fallbackError); + throw lastError; + } + } + + // Don't retry on the last attempt + if (attempt === this.maxRetries) { + break; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + this.baseRetryDelay * Math.pow(this.backoffMultiplier, attempt), + this.maxRetryDelay + ); + + logger.warn(`${operationName} failed (attempt ${attempt + 1}/${this.maxRetries + 1}), retrying in ${delay}ms:`, error); + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + logger.error(`${operationName} failed after ${this.maxRetries + 1} attempts:`, lastError); + throw lastError; + } + + private startHealthMonitoring(): void { + if (!this.isEnabled) return; + + // Check health every 30 seconds + setInterval(async () => { + try { + await this.checkRedisHealth(); + } catch (error) { + logger.error("Error during Redis health monitoring:", error); + } + }, this.healthCheckInterval); + } + + public isRedisEnabled(): boolean { + return this.isEnabled && this.client !== null && this.isHealthy; + } + + private async checkRedisHealth(): Promise { + const now = Date.now(); + + // Only check health every 30 seconds + if (now - this.lastHealthCheck < this.healthCheckInterval) { + return this.isHealthy; + } + + this.lastHealthCheck = now; + + if (!this.writeClient) { + this.isHealthy = false; + this.isWriteHealthy = false; + this.isReadHealthy = false; + return false; + } + + try { + // Check write client (master) health + await Promise.race([ + this.writeClient.ping(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Write client health check timeout')), 2000) + ) + ]); + this.isWriteHealthy = true; + + // Check read client health if it's different from write client + if (this.hasReplicas && this.readClient && this.readClient !== this.writeClient) { + try { + await Promise.race([ + this.readClient.ping(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Read client health check timeout')), 2000) + ) + ]); + this.isReadHealthy = true; + } catch (error) { + logger.error("Redis read client health check failed:", error); + this.isReadHealthy = false; + } + } else { + this.isReadHealthy = this.isWriteHealthy; + } + + this.updateOverallHealth(); + return this.isHealthy; + } catch (error) { + logger.error("Redis write client health check failed:", error); + this.isWriteHealthy = false; + this.isReadHealthy = false; // If write fails, consider read as failed too for safety + this.isHealthy = false; + return false; + } + } + + public getClient(): Redis { + return this.client!; + } + + public getWriteClient(): Redis | null { + return this.writeClient; + } + + public getReadClient(): Redis | null { + return this.readClient; + } + + public hasReplicaSupport(): boolean { + return this.hasReplicas; + } + + public getHealthStatus(): { + isEnabled: boolean; + isHealthy: boolean; + isWriteHealthy: boolean; + isReadHealthy: boolean; + hasReplicas: boolean; + } { + return { + isEnabled: this.isEnabled, + isHealthy: this.isHealthy, + isWriteHealthy: this.isWriteHealthy, + isReadHealthy: this.isReadHealthy, + hasReplicas: this.hasReplicas + }; + } + + public async set( + key: string, + value: string, + ttl?: number + ): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + async () => { + if (ttl) { + await this.writeClient!.setex(key, ttl, value); + } else { + await this.writeClient!.set(key, value); + } + }, + "Redis SET" + ); + return true; + } catch (error) { + logger.error("Redis SET error:", error); + return false; + } + } + + public async get(key: string): Promise { + if (!this.isRedisEnabled() || !this.readClient) return null; + + try { + const fallbackOperation = (this.hasReplicas && this.writeClient && this.isWriteHealthy) + ? () => this.writeClient!.get(key) + : undefined; + + return await this.executeWithRetry( + () => this.readClient!.get(key), + "Redis GET", + fallbackOperation + ); + } catch (error) { + logger.error("Redis GET error:", error); + return null; + } + } + + public async del(key: string): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + () => this.writeClient!.del(key), + "Redis DEL" + ); + return true; + } catch (error) { + logger.error("Redis DEL error:", error); + return false; + } + } + + public async sadd(key: string, member: string): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + () => this.writeClient!.sadd(key, member), + "Redis SADD" + ); + return true; + } catch (error) { + logger.error("Redis SADD error:", error); + return false; + } + } + + public async srem(key: string, member: string): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + () => this.writeClient!.srem(key, member), + "Redis SREM" + ); + return true; + } catch (error) { + logger.error("Redis SREM error:", error); + return false; + } + } + + public async smembers(key: string): Promise { + if (!this.isRedisEnabled() || !this.readClient) return []; + + try { + const fallbackOperation = (this.hasReplicas && this.writeClient && this.isWriteHealthy) + ? () => this.writeClient!.smembers(key) + : undefined; + + return await this.executeWithRetry( + () => this.readClient!.smembers(key), + "Redis SMEMBERS", + fallbackOperation + ); + } catch (error) { + logger.error("Redis SMEMBERS error:", error); + return []; + } + } + + public async hset( + key: string, + field: string, + value: string + ): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + () => this.writeClient!.hset(key, field, value), + "Redis HSET" + ); + return true; + } catch (error) { + logger.error("Redis HSET error:", error); + return false; + } + } + + public async hget(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.readClient) return null; + + try { + const fallbackOperation = (this.hasReplicas && this.writeClient && this.isWriteHealthy) + ? () => this.writeClient!.hget(key, field) + : undefined; + + return await this.executeWithRetry( + () => this.readClient!.hget(key, field), + "Redis HGET", + fallbackOperation + ); + } catch (error) { + logger.error("Redis HGET error:", error); + return null; + } + } + + public async hdel(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + + try { + await this.executeWithRetry( + () => this.writeClient!.hdel(key, field), + "Redis HDEL" + ); + return true; + } catch (error) { + logger.error("Redis HDEL error:", error); + return false; + } + } + + public async hgetall(key: string): Promise> { + if (!this.isRedisEnabled() || !this.readClient) return {}; + + try { + const fallbackOperation = (this.hasReplicas && this.writeClient && this.isWriteHealthy) + ? () => this.writeClient!.hgetall(key) + : undefined; + + return await this.executeWithRetry( + () => this.readClient!.hgetall(key), + "Redis HGETALL", + fallbackOperation + ); + } catch (error) { + logger.error("Redis HGETALL error:", error); + return {}; + } + } + + public async publish(channel: string, message: string): Promise { + if (!this.isRedisEnabled() || !this.publisher) return false; + + // Quick health check before attempting to publish + const isHealthy = await this.checkRedisHealth(); + if (!isHealthy) { + logger.warn("Skipping Redis publish due to unhealthy connection"); + return false; + } + + try { + await this.executeWithRetry( + async () => { + // Add timeout to prevent hanging + return Promise.race([ + this.publisher!.publish(channel, message), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Redis publish timeout')), 3000) + ) + ]); + }, + "Redis PUBLISH" + ); + return true; + } catch (error) { + logger.error("Redis PUBLISH error:", error); + this.isHealthy = false; // Mark as unhealthy on error + return false; + } + } + + public async subscribe( + channel: string, + callback: (channel: string, message: string) => void + ): Promise { + if (!this.isRedisEnabled() || !this.subscriber) return false; + + try { + // Add callback to subscribers map + if (!this.subscribers.has(channel)) { + this.subscribers.set(channel, new Set()); + // Only subscribe to the channel if it's the first subscriber + await this.executeWithRetry( + async () => { + return Promise.race([ + this.subscriber!.subscribe(channel), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Redis subscribe timeout')), 5000) + ) + ]); + }, + "Redis SUBSCRIBE" + ); + } + + this.subscribers.get(channel)!.add(callback); + return true; + } catch (error) { + logger.error("Redis SUBSCRIBE error:", error); + this.isHealthy = false; + return false; + } + } + + public async unsubscribe( + channel: string, + callback?: (channel: string, message: string) => void + ): Promise { + if (!this.isRedisEnabled() || !this.subscriber) return false; + + try { + const channelSubscribers = this.subscribers.get(channel); + if (!channelSubscribers) return true; + + if (callback) { + // Remove specific callback + channelSubscribers.delete(callback); + if (channelSubscribers.size === 0) { + this.subscribers.delete(channel); + await this.executeWithRetry( + () => this.subscriber!.unsubscribe(channel), + "Redis UNSUBSCRIBE" + ); + } + } else { + // Remove all callbacks for this channel + this.subscribers.delete(channel); + await this.executeWithRetry( + () => this.subscriber!.unsubscribe(channel), + "Redis UNSUBSCRIBE" + ); + } + + return true; + } catch (error) { + logger.error("Redis UNSUBSCRIBE error:", error); + return false; + } + } + + public async disconnect(): Promise { + try { + if (this.client) { + await this.client.quit(); + this.client = null; + } + if (this.writeClient) { + await this.writeClient.quit(); + this.writeClient = null; + } + if (this.readClient && this.readClient !== this.writeClient) { + await this.readClient.quit(); + this.readClient = null; + } + if (this.publisher) { + await this.publisher.quit(); + this.publisher = null; + } + if (this.subscriber) { + await this.subscriber.quit(); + this.subscriber = null; + } + this.subscribers.clear(); + logger.info("Redis clients disconnected"); + } catch (error) { + logger.error("Error disconnecting Redis clients:", error); + } + } +} + +export const redisManager = new RedisManager(); +export const redis = redisManager.getClient(); +export default redisManager; diff --git a/server/db/private/redisStore.ts b/server/db/private/redisStore.ts new file mode 100644 index 00000000..235f8f8f --- /dev/null +++ b/server/db/private/redisStore.ts @@ -0,0 +1,223 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Store, Options, IncrementResponse } from 'express-rate-limit'; +import { rateLimitService } from './rateLimit'; +import logger from '@server/logger'; + +/** + * A Redis-backed rate limiting store for express-rate-limit that optimizes + * for local read performance and batched writes to Redis. + * + * This store uses the same optimized rate limiting logic as the WebSocket + * implementation, providing: + * - Local caching for fast reads + * - Batched writes to Redis to reduce load + * - Automatic cleanup of expired entries + * - Graceful fallback when Redis is unavailable + */ +export default class RedisStore implements Store { + /** + * The duration of time before which all hit counts are reset (in milliseconds). + */ + windowMs!: number; + + /** + * Maximum number of requests allowed within the window. + */ + max!: number; + + /** + * Optional prefix for Redis keys to avoid collisions. + */ + prefix: string; + + /** + * Whether to skip incrementing on failed requests. + */ + skipFailedRequests: boolean; + + /** + * Whether to skip incrementing on successful requests. + */ + skipSuccessfulRequests: boolean; + + /** + * @constructor for RedisStore. + * + * @param options - Configuration options for the store. + */ + constructor(options: { + prefix?: string; + skipFailedRequests?: boolean; + skipSuccessfulRequests?: boolean; + } = {}) { + this.prefix = options.prefix || 'express-rate-limit'; + this.skipFailedRequests = options.skipFailedRequests || false; + this.skipSuccessfulRequests = options.skipSuccessfulRequests || false; + } + + /** + * Method that actually initializes the store. Must be synchronous. + * + * @param options - The options used to setup express-rate-limit. + */ + init(options: Options): void { + this.windowMs = options.windowMs; + this.max = options.max as number; + + // logger.debug(`RedisStore initialized with windowMs: ${this.windowMs}, max: ${this.max}, prefix: ${this.prefix}`); + } + + /** + * Method to increment a client's hit counter. + * + * @param key - The identifier for a client (usually IP address). + * @returns Promise resolving to the number of hits and reset time for that client. + */ + async increment(key: string): Promise { + try { + const clientId = `${this.prefix}:${key}`; + + const result = await rateLimitService.checkRateLimit( + clientId, + undefined, // No message type for HTTP requests + this.max, + undefined, // No message type limit + this.windowMs + ); + + // logger.debug(`Incremented rate limit for key: ${key} with max: ${this.max}, totalHits: ${result.totalHits}`); + + return { + totalHits: result.totalHits || 1, + resetTime: result.resetTime || new Date(Date.now() + this.windowMs) + }; + } catch (error) { + logger.error(`RedisStore increment error for key ${key}:`, error); + + // Return safe defaults on error to prevent blocking requests + return { + totalHits: 1, + resetTime: new Date(Date.now() + this.windowMs) + }; + } + } + + /** + * Method to decrement a client's hit counter. + * Used when skipSuccessfulRequests or skipFailedRequests is enabled. + * + * @param key - The identifier for a client. + */ + async decrement(key: string): Promise { + try { + const clientId = `${this.prefix}:${key}`; + await rateLimitService.decrementRateLimit(clientId); + + // logger.debug(`Decremented rate limit for key: ${key}`); + } catch (error) { + logger.error(`RedisStore decrement error for key ${key}:`, error); + // Don't throw - decrement failures shouldn't block requests + } + } + + /** + * Method to reset a client's hit counter. + * + * @param key - The identifier for a client. + */ + async resetKey(key: string): Promise { + try { + const clientId = `${this.prefix}:${key}`; + await rateLimitService.resetKey(clientId); + + // logger.debug(`Reset rate limit for key: ${key}`); + } catch (error) { + logger.error(`RedisStore resetKey error for key ${key}:`, error); + // Don't throw - reset failures shouldn't block requests + } + } + + /** + * Method to reset everyone's hit counter. + * + * This method is optional and is never called by express-rate-limit. + * We implement it for completeness but it's not recommended for production use + * as it could be expensive with large datasets. + */ + async resetAll(): Promise { + try { + logger.warn('RedisStore resetAll called - this operation can be expensive'); + + // Force sync all pending data first + await rateLimitService.forceSyncAllPendingData(); + + // Note: We don't actually implement full reset as it would require + // scanning all Redis keys with our prefix, which could be expensive. + // In production, it's better to let entries expire naturally. + + logger.info('RedisStore resetAll completed (pending data synced)'); + } catch (error) { + logger.error('RedisStore resetAll error:', error); + // Don't throw - this is an optional method + } + } + + /** + * Get current hit count for a key without incrementing. + * This is a custom method not part of the Store interface. + * + * @param key - The identifier for a client. + * @returns Current hit count and reset time, or null if no data exists. + */ + async getHits(key: string): Promise<{ totalHits: number; resetTime: Date } | null> { + try { + const clientId = `${this.prefix}:${key}`; + + // Use checkRateLimit with max + 1 to avoid actually incrementing + // but still get the current count + const result = await rateLimitService.checkRateLimit( + clientId, + undefined, + this.max + 1000, // Set artificially high to avoid triggering limit + undefined, + this.windowMs + ); + + // Decrement since we don't actually want to count this check + await rateLimitService.decrementRateLimit(clientId); + + return { + totalHits: Math.max(0, (result.totalHits || 0) - 1), // Adjust for the decrement + resetTime: result.resetTime || new Date(Date.now() + this.windowMs) + }; + } catch (error) { + logger.error(`RedisStore getHits error for key ${key}:`, error); + return null; + } + } + + /** + * Cleanup method for graceful shutdown. + * This is not part of the Store interface but is useful for cleanup. + */ + async shutdown(): Promise { + try { + // The rateLimitService handles its own cleanup + logger.info('RedisStore shutdown completed'); + } catch (error) { + logger.error('RedisStore shutdown error:', error); + } + } +} diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 728880f2..f7719c50 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, loginPage, LoginPage, loginPageOrg } from "@server/db"; import { Resource, ResourcePassword, @@ -39,7 +39,10 @@ export async function getResourceByDomain( ): Promise { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/domain/${domain}`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/domain/${domain}`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -91,7 +94,10 @@ export async function getUserSessionWithUser( ): Promise { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/session/${userSessionId}`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/session/${userSessionId}`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -132,7 +138,10 @@ export async function getUserSessionWithUser( export async function getUserOrgRole(userId: string, orgId: string) { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/user/${userId}/org/${orgId}/role`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/user/${userId}/org/${orgId}/role`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -154,12 +163,7 @@ export async function getUserOrgRole(userId: string, orgId: string) { const userOrgRole = await db .select() .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, orgId) - ) - ) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); return userOrgRole.length > 0 ? userOrgRole[0] : null; @@ -168,10 +172,16 @@ export async function getUserOrgRole(userId: string, orgId: string) { /** * Check if role has access to resource */ -export async function getRoleResourceAccess(resourceId: number, roleId: number) { +export async function getRoleResourceAccess( + resourceId: number, + roleId: number +) { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/role/${roleId}/resource/${resourceId}/access`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/role/${roleId}/resource/${resourceId}/access`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -207,10 +217,16 @@ export async function getRoleResourceAccess(resourceId: number, roleId: number) /** * Check if user has direct access to resource */ -export async function getUserResourceAccess(userId: string, resourceId: number) { +export async function getUserResourceAccess( + userId: string, + resourceId: number +) { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/user/${userId}/resource/${resourceId}/access`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/user/${userId}/resource/${resourceId}/access`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -246,10 +262,15 @@ export async function getUserResourceAccess(userId: string, resourceId: number) /** * Get resource rules for a given resource */ -export async function getResourceRules(resourceId: number): Promise { +export async function getResourceRules( + resourceId: number +): Promise { if (config.isManagedMode()) { try { - const response = await axios.get(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/${resourceId}/rules`, await tokenManager.getAuthHeader()); + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/${resourceId}/rules`, + await tokenManager.getAuthHeader() + ); return response.data.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -275,3 +296,50 @@ export async function getResourceRules(resourceId: number): Promise { + if (config.isManagedMode()) { + try { + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/org/${orgId}/login-page`, + await tokenManager.getAuthHeader() + ); + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } + return null; + } + } + + const [result] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)) + .innerJoin( + loginPage, + eq(loginPageOrg.loginPageId, loginPage.loginPageId) + ) + .limit(1); + + if (!result) { + return null; + } + + return result?.loginPage; +} diff --git a/server/db/sqlite/index.ts b/server/db/sqlite/index.ts index 9ad4678c..8c7a15e5 100644 --- a/server/db/sqlite/index.ts +++ b/server/db/sqlite/index.ts @@ -1,2 +1,3 @@ export * from "./driver"; export * from "./schema"; +export * from "./privateSchema"; \ No newline at end of file diff --git a/server/db/sqlite/privateSchema.ts b/server/db/sqlite/privateSchema.ts new file mode 100644 index 00000000..fbe86e25 --- /dev/null +++ b/server/db/sqlite/privateSchema.ts @@ -0,0 +1,239 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + sqliteTable, + integer, + text, + real +} from "drizzle-orm/sqlite-core"; +import { InferSelectModel } from "drizzle-orm"; +import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; + +export const certificates = sqliteTable("certificates", { + certId: integer("certId").primaryKey({ autoIncrement: true }), + domain: text("domain").notNull().unique(), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: integer("wildcard", { mode: "boolean" }).default(false), + status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: integer("expiresAt"), + lastRenewalAttempt: integer("lastRenewalAttempt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull(), + orderId: text("orderId"), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + +export const dnsChallenge = sqliteTable("dnsChallenges", { + dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true }), + domain: text("domain").notNull(), + token: text("token").notNull(), + keyAuthorization: text("keyAuthorization").notNull(), + createdAt: integer("createdAt").notNull(), + expiresAt: integer("expiresAt").notNull(), + completed: integer("completed", { mode: "boolean" }).default(false) +}); + +export const account = sqliteTable("account", { + accountId: integer("accountId").primaryKey({ autoIncrement: true }), + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }) +}); + +export const customers = sqliteTable("customers", { + customerId: text("customerId").primaryKey().notNull(), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + // accountId: integer("accountId") + // .references(() => account.accountId, { onDelete: "cascade" }), // Optional, if using accounts + email: text("email"), + name: text("name"), + phone: text("phone"), + address: text("address"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() +}); + +export const subscriptions = sqliteTable("subscriptions", { + subscriptionId: text("subscriptionId") + .primaryKey() + .notNull(), + customerId: text("customerId") + .notNull() + .references(() => customers.customerId, { onDelete: "cascade" }), + status: text("status").notNull().default("active"), // active, past_due, canceled, unpaid + canceledAt: integer("canceledAt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt"), + billingCycleAnchor: integer("billingCycleAnchor") +}); + +export const subscriptionItems = sqliteTable("subscriptionItems", { + subscriptionItemId: integer("subscriptionItemId").primaryKey({ autoIncrement: true }), + subscriptionId: text("subscriptionId") + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + planId: text("planId").notNull(), + priceId: text("priceId"), + meterId: text("meterId"), + unitAmount: real("unitAmount"), + tiers: text("tiers"), + interval: text("interval"), + currentPeriodStart: integer("currentPeriodStart"), + currentPeriodEnd: integer("currentPeriodEnd"), + name: text("name") +}); + +export const accountDomains = sqliteTable("accountDomains", { + accountId: integer("accountId") + .notNull() + .references(() => account.accountId, { onDelete: "cascade" }), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }) +}); + +export const usage = sqliteTable("usage", { + usageId: text("usageId").primaryKey(), + featureId: text("featureId").notNull(), + orgId: text("orgId") + .references(() => orgs.orgId, { onDelete: "cascade" }) + .notNull(), + meterId: text("meterId"), + instantaneousValue: real("instantaneousValue"), + latestValue: real("latestValue").notNull(), + previousValue: real("previousValue"), + updatedAt: integer("updatedAt").notNull(), + rolledOverAt: integer("rolledOverAt"), + nextRolloverAt: integer("nextRolloverAt") +}); + +export const limits = sqliteTable("limits", { + limitId: text("limitId").primaryKey(), + featureId: text("featureId").notNull(), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + value: real("value"), + description: text("description") +}); + +export const usageNotifications = sqliteTable("usageNotifications", { + notificationId: integer("notificationId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + featureId: text("featureId").notNull(), + limitId: text("limitId").notNull(), + notificationType: text("notificationType").notNull(), + sentAt: integer("sentAt").notNull() +}); + +export const domainNamespaces = sqliteTable("domainNamespaces", { + domainNamespaceId: text("domainNamespaceId").primaryKey(), + domainId: text("domainId") + .references(() => domains.domainId, { + onDelete: "set null" + }) + .notNull() +}); + +export const exitNodeOrgs = sqliteTable("exitNodeOrgs", { + exitNodeId: integer("exitNodeId") + .notNull() + .references(() => exitNodes.exitNodeId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +export const remoteExitNodes = sqliteTable("remoteExitNode", { + remoteExitNodeId: text("id").primaryKey(), + secretHash: text("secretHash").notNull(), + dateCreated: text("dateCreated").notNull(), + version: text("version"), + exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { + onDelete: "cascade" + }) +}); + +export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { + sessionId: text("id").primaryKey(), + remoteExitNodeId: text("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + expiresAt: integer("expiresAt").notNull() +}); + +export const loginPage = sqliteTable("loginPage", { + loginPageId: integer("loginPageId").primaryKey({ autoIncrement: true }), + subdomain: text("subdomain"), + fullDomain: text("fullDomain"), + exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "set null" + }) +}); + +export const loginPageOrg = sqliteTable("loginPageOrg", { + loginPageId: integer("loginPageId") + .notNull() + .references(() => loginPage.loginPageId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +export const sessionTransferToken = sqliteTable("sessionTransferToken", { + token: text("token").primaryKey(), + sessionId: text("sessionId") + .notNull() + .references(() => sessions.sessionId, { + onDelete: "cascade" + }), + encryptedSession: text("encryptedSession").notNull(), + expiresAt: integer("expiresAt").notNull() +}); + +export type Limit = InferSelectModel; +export type Account = InferSelectModel; +export type Certificate = InferSelectModel; +export type DnsChallenge = InferSelectModel; +export type Customer = InferSelectModel; +export type Subscription = InferSelectModel; +export type SubscriptionItem = InferSelectModel; +export type Usage = InferSelectModel; +export type UsageLimit = InferSelectModel; +export type AccountDomain = InferSelectModel; +export type UsageNotification = InferSelectModel; +export type RemoteExitNode = InferSelectModel; +export type RemoteExitNodeSession = InferSelectModel< + typeof remoteExitNodeSessions +>; +export type ExitNodeOrg = InferSelectModel; +export type LoginPage = InferSelectModel; diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index e38d6b67..62fca8b4 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -140,6 +140,27 @@ export const targets = sqliteTable("targets", { rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); +export const targetHealthCheck = sqliteTable("targetHealthCheck", { + targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), + targetId: integer("targetId") + .notNull() + .references(() => targets.targetId, { onDelete: "cascade" }), + hcEnabled: integer("hcEnabled", { mode: "boolean" }).notNull().default(false), + hcPath: text("hcPath"), + hcScheme: text("hcScheme"), + hcMode: text("hcMode").default("http"), + hcHostname: text("hcHostname"), + hcPort: integer("hcPort"), + hcInterval: integer("hcInterval").default(30), // in seconds + hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds + hcTimeout: integer("hcTimeout").default(5), // in seconds + hcHeaders: text("hcHeaders"), + hcFollowRedirects: integer("hcFollowRedirects", { mode: "boolean" }).default(true), + hcMethod: text("hcMethod").default("GET"), + hcStatus: integer("hcStatus"), // http code + hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy" +}); + export const exitNodes = sqliteTable("exitNodes", { exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), @@ -458,18 +479,6 @@ export const userResources = sqliteTable("userResources", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); -export const limitsTable = sqliteTable("limits", { - limitId: integer("limitId").primaryKey({ autoIncrement: true }), - orgId: text("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - name: text("name").notNull(), - value: integer("value").notNull(), - description: text("description") -}); - export const userInvites = sqliteTable("userInvites", { inviteId: text("inviteId").primaryKey(), orgId: text("orgId") @@ -714,7 +723,6 @@ export type RoleSite = InferSelectModel; export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; -export type Limit = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; @@ -739,3 +747,4 @@ export type SiteResource = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; +export type TargetHealthCheck = InferSelectModel; \ No newline at end of file diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index 9b99d18e..24fb4fbd 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -11,7 +11,7 @@ export async function sendEmail( from: string | undefined; to: string | undefined; subject: string; - }, + } ) { if (!emailClient) { logger.warn("Email client not configured, skipping email send"); @@ -25,16 +25,16 @@ export async function sendEmail( const emailHtml = await render(template); - const appName = "Pangolin"; + const appName = config.getRawPrivateConfig().branding?.app_name || "Pangolin"; await emailClient.sendMail({ from: { name: opts.name || appName, - address: opts.from, + address: opts.from }, to: opts.to, subject: opts.subject, - html: emailHtml, + html: emailHtml }); } diff --git a/server/emails/templates/PrivateNotifyUsageLimitApproaching.tsx b/server/emails/templates/PrivateNotifyUsageLimitApproaching.tsx new file mode 100644 index 00000000..c66265e5 --- /dev/null +++ b/server/emails/templates/PrivateNotifyUsageLimitApproaching.tsx @@ -0,0 +1,82 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +interface Props { + email: string; + limitName: string; + currentUsage: number; + usageLimit: number; + billingLink: string; // Link to billing page +} + +export const NotifyUsageLimitApproaching = ({ email, limitName, currentUsage, usageLimit, billingLink }: Props) => { + const previewText = `Your usage for ${limitName} is approaching the limit.`; + const usagePercentage = Math.round((currentUsage / usageLimit) * 100); + + return ( + + + {previewText} + + + + + + Usage Limit Warning + + Hi there, + + + We wanted to let you know that your usage for {limitName} is approaching your plan limit. + + + + Current Usage: {currentUsage} of {usageLimit} ({usagePercentage}%) + + + + Once you reach your limit, some functionality may be restricted or your sites may disconnect until you upgrade your plan or your usage resets. + + + + To avoid any interruption to your service, we recommend upgrading your plan or monitoring your usage closely. You can upgrade your plan here. + + + + If you have any questions or need assistance, please don't hesitate to reach out to our support team. + + + + + + + + + + ); +}; + +export default NotifyUsageLimitApproaching; diff --git a/server/emails/templates/PrivateNotifyUsageLimitReached.tsx b/server/emails/templates/PrivateNotifyUsageLimitReached.tsx new file mode 100644 index 00000000..c4eac322 --- /dev/null +++ b/server/emails/templates/PrivateNotifyUsageLimitReached.tsx @@ -0,0 +1,84 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +interface Props { + email: string; + limitName: string; + currentUsage: number; + usageLimit: number; + billingLink: string; // Link to billing page +} + +export const NotifyUsageLimitReached = ({ email, limitName, currentUsage, usageLimit, billingLink }: Props) => { + const previewText = `You've reached your ${limitName} usage limit - Action required`; + const usagePercentage = Math.round((currentUsage / usageLimit) * 100); + + return ( + + + {previewText} + + + + + + Usage Limit Reached - Action Required + + Hi there, + + + You have reached your usage limit for {limitName}. + + + + Current Usage: {currentUsage} of {usageLimit} ({usagePercentage}%) + + + + Important: Your functionality may now be restricted and your sites may disconnect until you either upgrade your plan or your usage resets. To prevent any service interruption, immediate action is recommended. + + + + What you can do: +
Upgrade your plan immediately to restore full functionality +
• Monitor your usage to stay within limits in the future +
+ + + If you have any questions or need immediate assistance, please contact our support team right away. + + + + + +
+ +
+ + ); +}; + +export default NotifyUsageLimitReached; diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index ef5c37f8..d064c44f 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -17,7 +17,7 @@ export function EmailLetterHead() {
Fossorial Best regards,
- The Fossorial Team + The Pangolin Team

); diff --git a/server/hybridServer.ts b/server/hybridServer.ts index bb26489d..7e9ce095 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -3,7 +3,7 @@ import config from "@server/lib/config"; import { createWebSocketClient } from "./routers/ws/client"; import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; -import { TraefikConfigManager } from "./lib/traefikConfig"; +import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager"; import { tokenManager } from "./lib/tokenManager"; import { APP_VERSION } from "./lib/consts"; import axios from "axios"; diff --git a/server/index.ts b/server/index.ts index 746de7b9..2497e301 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,13 +5,13 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; -import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; +import { ApiKey, ApiKeyOrg, RemoteExitNode, Session, User, UserOrg } from "@server/db"; import { createIntegrationApiServer } from "./integrationApiServer"; import { createHybridClientServer } from "./hybridServer"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; import { initTelemetryClient } from "./lib/telemetry.js"; -import { TraefikConfigManager } from "./lib/traefikConfig.js"; +import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; async function startServers() { await setHostMeta(); @@ -63,6 +63,7 @@ declare global { userOrgRoleId?: number; userOrgId?: string; userOrgIds?: string[]; + remoteExitNode?: RemoteExitNode; } } } diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index eefaacd8..cbbea83f 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -13,6 +13,10 @@ import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; import { registry } from "./openApi"; +import fs from "fs"; +import path from "path"; +import { APP_PATH } from "./lib/consts"; +import yaml from "js-yaml"; const dev = process.env.ENVIRONMENT !== "prod"; const externalPort = config.getRawConfig().server.integration_port; @@ -92,7 +96,7 @@ function getOpenApiDocumentation() { const generator = new OpenApiGeneratorV3(registry.definitions); - return generator.generateDocument({ + const generated = generator.generateDocument({ openapi: "3.0.0", info: { version: "v1", @@ -100,4 +104,12 @@ function getOpenApiDocumentation() { }, servers: [{ url: "/v1" }] }); + + // convert to yaml and save to file + const outputPath = path.join(APP_PATH, "openapi.yaml"); + const yamlOutput = yaml.dump(generated); + fs.writeFileSync(outputPath, yamlOutput, "utf8"); + logger.info(`OpenAPI documentation saved to ${outputPath}`); + + return generated; } diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 47193420..6fac099a 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -69,9 +69,16 @@ export async function applyBlueprint( `Updating target ${target.targetId} on site ${site.sites.siteId}` ); + // see if you can find a matching target health check from the healthchecksToUpdate array + const matchingHealthcheck = + result.healthchecksToUpdate.find( + (hc) => hc.targetId === target.targetId + ); + await addProxyTargets( site.newt.newtId, [target], + matchingHealthcheck ? [matchingHealthcheck] : [], result.proxyResource.protocol, result.proxyResource.proxyPort ); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 9b349281..e6525191 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -8,6 +8,8 @@ import { roleResources, roles, Target, + TargetHealthCheck, + targetHealthCheck, Transaction, userOrgs, userResources, @@ -22,6 +24,7 @@ import { TargetData } from "./types"; import logger from "@server/logger"; +import { createCertificate } from "@server/routers/private/certificates/createCertificate"; import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; @@ -30,6 +33,7 @@ import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; export type ProxyResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; + healthchecksToUpdate: TargetHealthCheck[]; }[]; export async function updateProxyResources( @@ -43,7 +47,8 @@ export async function updateProxyResources( for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] )) { - const targetsToUpdate: Target[] = []; + let targetsToUpdate: Target[] = []; + let healthchecksToUpdate: TargetHealthCheck[] = []; let resource: Resource; async function createTarget( // reusable function to create a target @@ -114,6 +119,33 @@ export async function updateProxyResources( .returning(); targetsToUpdate.push(newTarget); + + const healthcheckData = targetData.healthcheck; + + const hcHeaders = healthcheckData?.headers ? JSON.stringify(healthcheckData.headers) : null; + + const [newHealthcheck] = await trx + .insert(targetHealthCheck) + .values({ + targetId: newTarget.targetId, + hcEnabled: healthcheckData?.enabled || false, + hcPath: healthcheckData?.path, + hcScheme: healthcheckData?.scheme, + hcMode: healthcheckData?.mode, + hcHostname: healthcheckData?.hostname, + hcPort: healthcheckData?.port, + hcInterval: healthcheckData?.interval, + hcUnhealthyInterval: healthcheckData?.unhealthyInterval, + hcTimeout: healthcheckData?.timeout, + hcHeaders: hcHeaders, + hcFollowRedirects: healthcheckData?.followRedirects, + hcMethod: healthcheckData?.method, + hcStatus: healthcheckData?.status, + hcHealth: "unknown" + }) + .returning(); + + healthchecksToUpdate.push(newHealthcheck); } // Find existing resource by niceId and orgId @@ -360,6 +392,64 @@ export async function updateProxyResources( targetsToUpdate.push(finalUpdatedTarget); } + + const healthcheckData = targetData.healthcheck; + + const [oldHealthcheck] = await trx + .select() + .from(targetHealthCheck) + .where( + eq( + targetHealthCheck.targetId, + existingTarget.targetId + ) + ) + .limit(1); + + const hcHeaders = healthcheckData?.headers ? JSON.stringify(healthcheckData.headers) : null; + + const [newHealthcheck] = await trx + .update(targetHealthCheck) + .set({ + hcEnabled: healthcheckData?.enabled || false, + hcPath: healthcheckData?.path, + hcScheme: healthcheckData?.scheme, + hcMode: healthcheckData?.mode, + hcHostname: healthcheckData?.hostname, + hcPort: healthcheckData?.port, + hcInterval: healthcheckData?.interval, + hcUnhealthyInterval: + healthcheckData?.unhealthyInterval, + hcTimeout: healthcheckData?.timeout, + hcHeaders: hcHeaders, + hcFollowRedirects: healthcheckData?.followRedirects, + hcMethod: healthcheckData?.method, + hcStatus: healthcheckData?.status + }) + .where( + eq( + targetHealthCheck.targetId, + existingTarget.targetId + ) + ) + .returning(); + + if ( + checkIfHealthcheckChanged( + oldHealthcheck, + newHealthcheck + ) + ) { + healthchecksToUpdate.push(newHealthcheck); + // if the target is not already in the targetsToUpdate array, add it + if ( + !targetsToUpdate.find( + (t) => t.targetId === updatedTarget.targetId + ) + ) { + targetsToUpdate.push(updatedTarget); + } + } } else { await createTarget(existingResource.resourceId, targetData); } @@ -573,7 +663,8 @@ export async function updateProxyResources( results.push({ proxyResource: resource, - targetsToUpdate + targetsToUpdate, + healthchecksToUpdate }); } @@ -783,6 +874,36 @@ async function syncWhitelistUsers( } } +function checkIfHealthcheckChanged( + existing: TargetHealthCheck | undefined, + incoming: TargetHealthCheck | undefined +) { + if (!existing && incoming) return true; + if (existing && !incoming) return true; + if (!existing || !incoming) return false; + + if (existing.hcEnabled !== incoming.hcEnabled) return true; + if (existing.hcPath !== incoming.hcPath) return true; + if (existing.hcScheme !== incoming.hcScheme) return true; + if (existing.hcMode !== incoming.hcMode) return true; + if (existing.hcHostname !== incoming.hcHostname) return true; + if (existing.hcPort !== incoming.hcPort) return true; + if (existing.hcInterval !== incoming.hcInterval) return true; + if (existing.hcUnhealthyInterval !== incoming.hcUnhealthyInterval) + return true; + if (existing.hcTimeout !== incoming.hcTimeout) return true; + if (existing.hcFollowRedirects !== incoming.hcFollowRedirects) return true; + if (existing.hcMethod !== incoming.hcMethod) return true; + if (existing.hcStatus !== incoming.hcStatus) return true; + if ( + JSON.stringify(existing.hcHeaders) !== + JSON.stringify(incoming.hcHeaders) + ) + return true; + + return false; +} + function checkIfTargetChanged( existing: Target | undefined, incoming: Target | undefined @@ -832,6 +953,8 @@ async function getDomain( ); } + await createCertificate(domain.domainId, fullDomain, trx); + return domain; } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index dda672a6..54105dde 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -5,6 +5,22 @@ export const SiteSchema = z.object({ "docker-socket-enabled": z.boolean().optional().default(true) }); +export const TargetHealthCheckSchema = z.object({ + hostname: z.string(), + port: z.number().int().min(1).max(65535), + enabled: z.boolean().optional().default(true), + path: z.string().optional(), + scheme: z.string().optional(), + mode: z.string().default("http"), + interval: z.number().int().default(30), + unhealthyInterval: z.number().int().default(30), + timeout: z.number().int().default(5), + headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional().default(null), + followRedirects: z.boolean().default(true), + method: z.string().default("GET"), + status: z.number().int().optional() +}); + // Schema for individual target within a resource export const TargetSchema = z.object({ site: z.string().optional(), @@ -15,6 +31,7 @@ export const TargetSchema = z.object({ "internal-port": z.number().int().min(1).max(65535).optional(), path: z.string().optional(), "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), + healthcheck: TargetHealthCheckSchema.optional(), rewritePath: z.string().optional(), "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }); diff --git a/server/lib/colorsSchema.ts b/server/lib/colorsSchema.ts new file mode 100644 index 00000000..0aeb65c3 --- /dev/null +++ b/server/lib/colorsSchema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const colorsSchema = z.object({ + background: z.string().optional(), + foreground: z.string().optional(), + card: z.string().optional(), + "card-foreground": z.string().optional(), + popover: z.string().optional(), + "popover-foreground": z.string().optional(), + primary: z.string().optional(), + "primary-foreground": z.string().optional(), + secondary: z.string().optional(), + "secondary-foreground": z.string().optional(), + muted: z.string().optional(), + "muted-foreground": z.string().optional(), + accent: z.string().optional(), + "accent-foreground": z.string().optional(), + destructive: z.string().optional(), + "destructive-foreground": z.string().optional(), + border: z.string().optional(), + input: z.string().optional(), + ring: z.string().optional(), + radius: z.string().optional(), + "chart-1": z.string().optional(), + "chart-2": z.string().optional(), + "chart-3": z.string().optional(), + "chart-4": z.string().optional(), + "chart-5": z.string().optional() +}); diff --git a/server/lib/config.ts b/server/lib/config.ts index 667df744..8b084e62 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -6,9 +6,16 @@ import { eq } from "drizzle-orm"; import { license } from "@server/license/license"; import { configSchema, readConfigFile } from "./readConfigFile"; import { fromError } from "zod-validation-error"; +import { + privateConfigSchema, + readPrivateConfigFile +} from "@server/lib/private/readConfigFile"; +import logger from "@server/logger"; +import { build } from "@server/build"; export class Config { private rawConfig!: z.infer; + private rawPrivateConfig!: z.infer; supporterData: SupporterKey | null = null; @@ -30,6 +37,19 @@ export class Config { throw new Error(`Invalid configuration file: ${errors}`); } + const privateEnvironment = readPrivateConfigFile(); + + const { + data: parsedPrivateConfig, + success: privateSuccess, + error: privateError + } = privateConfigSchema.safeParse(privateEnvironment); + + if (!privateSuccess) { + const errors = fromError(privateError); + throw new Error(`Invalid private configuration file: ${errors}`); + } + if ( // @ts-ignore parsedConfig.users || @@ -89,7 +109,110 @@ export class Config { ? "true" : "false"; + if (parsedPrivateConfig.branding?.colors) { + process.env.BRANDING_COLORS = JSON.stringify( + parsedPrivateConfig.branding?.colors + ); + } + + if (parsedPrivateConfig.branding?.logo?.light_path) { + process.env.BRANDING_LOGO_LIGHT_PATH = + parsedPrivateConfig.branding?.logo?.light_path; + } + if (parsedPrivateConfig.branding?.logo?.dark_path) { + process.env.BRANDING_LOGO_DARK_PATH = + parsedPrivateConfig.branding?.logo?.dark_path || undefined; + } + + process.env.HIDE_SUPPORTER_KEY = parsedPrivateConfig.flags + ?.hide_supporter_key + ? "true" + : "false"; + + if (build != "oss") { + if (parsedPrivateConfig.branding?.logo?.light_path) { + process.env.BRANDING_LOGO_LIGHT_PATH = + parsedPrivateConfig.branding?.logo?.light_path; + } + if (parsedPrivateConfig.branding?.logo?.dark_path) { + process.env.BRANDING_LOGO_DARK_PATH = + parsedPrivateConfig.branding?.logo?.dark_path || undefined; + } + + process.env.BRANDING_LOGO_AUTH_WIDTH = parsedPrivateConfig.branding + ?.logo?.auth_page?.width + ? parsedPrivateConfig.branding?.logo?.auth_page?.width.toString() + : undefined; + process.env.BRANDING_LOGO_AUTH_HEIGHT = parsedPrivateConfig.branding + ?.logo?.auth_page?.height + ? parsedPrivateConfig.branding?.logo?.auth_page?.height.toString() + : undefined; + + process.env.BRANDING_LOGO_NAVBAR_WIDTH = parsedPrivateConfig + .branding?.logo?.navbar?.width + ? parsedPrivateConfig.branding?.logo?.navbar?.width.toString() + : undefined; + process.env.BRANDING_LOGO_NAVBAR_HEIGHT = parsedPrivateConfig + .branding?.logo?.navbar?.height + ? parsedPrivateConfig.branding?.logo?.navbar?.height.toString() + : undefined; + + process.env.BRANDING_FAVICON_PATH = + parsedPrivateConfig.branding?.favicon_path; + + process.env.BRANDING_APP_NAME = + parsedPrivateConfig.branding?.app_name || "Pangolin"; + + if (parsedPrivateConfig.branding?.footer) { + process.env.BRANDING_FOOTER = JSON.stringify( + parsedPrivateConfig.branding?.footer + ); + } + + process.env.LOGIN_PAGE_TITLE_TEXT = + parsedPrivateConfig.branding?.login_page?.title_text || ""; + process.env.LOGIN_PAGE_SUBTITLE_TEXT = + parsedPrivateConfig.branding?.login_page?.subtitle_text || ""; + + process.env.SIGNUP_PAGE_TITLE_TEXT = + parsedPrivateConfig.branding?.signup_page?.title_text || ""; + process.env.SIGNUP_PAGE_SUBTITLE_TEXT = + parsedPrivateConfig.branding?.signup_page?.subtitle_text || ""; + + process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY = + parsedPrivateConfig.branding?.resource_auth_page + ?.hide_powered_by === true + ? "true" + : "false"; + process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO = + parsedPrivateConfig.branding?.resource_auth_page?.show_logo === + true + ? "true" + : "false"; + process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT = + parsedPrivateConfig.branding?.resource_auth_page?.title_text || + ""; + process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT = + parsedPrivateConfig.branding?.resource_auth_page + ?.subtitle_text || ""; + + if (parsedPrivateConfig.branding?.background_image_path) { + process.env.BACKGROUND_IMAGE_PATH = + parsedPrivateConfig.branding?.background_image_path; + } + + if (parsedPrivateConfig.server.reo_client_id) { + process.env.REO_CLIENT_ID = + parsedPrivateConfig.server.reo_client_id; + } + } + + if (parsedConfig.server.maxmind_db_path) { + process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path; + } + this.rawConfig = parsedConfig; + this.rawPrivateConfig = parsedPrivateConfig; } public async initServer() { @@ -107,7 +230,11 @@ export class Config { private async checkKeyStatus() { const licenseStatus = await license.check(); - if (!licenseStatus.isHostLicensed) { + if ( + !this.rawPrivateConfig.flags?.hide_supporter_key && + build != "oss" && + !licenseStatus.isHostLicensed + ) { this.checkSupporterKey(); } } @@ -116,6 +243,10 @@ export class Config { return this.rawConfig; } + public getRawPrivateConfig() { + return this.rawPrivateConfig; + } + public getNoReplyEmail(): string | undefined { return ( this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 5df517b8..544123a9 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -11,3 +11,5 @@ export const APP_PATH = path.join("config"); export const configFilePath1 = path.join(APP_PATH, "config.yml"); export const configFilePath2 = path.join(APP_PATH, "config.yaml"); + +export const privateConfigFilePath1 = path.join(APP_PATH, "privateConfig.yml"); diff --git a/server/lib/encryption.ts b/server/lib/encryption.ts new file mode 100644 index 00000000..7959fa4b --- /dev/null +++ b/server/lib/encryption.ts @@ -0,0 +1,39 @@ +import crypto from 'crypto'; + +export function encryptData(data: string, key: Buffer): string { + const algorithm = 'aes-256-gcm'; + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, key, iv); + + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Combine IV, auth tag, and encrypted data + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; +} + +// Helper function to decrypt data (you'll need this to read certificates) +export function decryptData(encryptedData: string, key: Buffer): string { + const algorithm = 'aes-256-gcm'; + const parts = encryptedData.split(':'); + + if (parts.length !== 3) { + throw new Error('Invalid encrypted data format'); + } + + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = parts[2]; + + const decipher = crypto.createDecipheriv(algorithm, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +// openssl rand -hex 32 > config/encryption.key \ No newline at end of file diff --git a/server/lib/exitNodeComms.ts b/server/lib/exitNodes/exitNodeComms.ts similarity index 100% rename from server/lib/exitNodeComms.ts rename to server/lib/exitNodes/exitNodeComms.ts diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index 7b571682..8372d675 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -16,7 +16,7 @@ export async function verifyExitNodeOrgAccess( return { hasAccess: true, exitNode }; } -export async function listExitNodes(orgId: string, filterOnline = false) { +export async function listExitNodes(orgId: string, filterOnline = false, noCloud = false) { // TODO: pick which nodes to send and ping better than just all of them that are not remote const allExitNodes = await db .select({ @@ -57,4 +57,9 @@ export function selectBestExitNode( export async function checkExitNodeOrg(exitNodeId: number, orgId: string) { return false; -} \ No newline at end of file +} + +export async function resolveExitNodes(hostname: string, publicKey: string) { + // OSS version: simple implementation that returns empty array + return []; +} diff --git a/server/lib/exitNodes/getCurrentExitNodeId.ts b/server/lib/exitNodes/getCurrentExitNodeId.ts new file mode 100644 index 00000000..d895ce42 --- /dev/null +++ b/server/lib/exitNodes/getCurrentExitNodeId.ts @@ -0,0 +1,33 @@ +import { eq } from "drizzle-orm"; +import { db, exitNodes } from "@server/db"; +import config from "@server/lib/config"; + +let currentExitNodeId: number; // we really only need to look this up once per instance +export async function getCurrentExitNodeId(): Promise { + if (!currentExitNodeId) { + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = config.getRawConfig().gerbil.exit_node_name!; + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)); + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } else { + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .limit(1); + + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } + } + return currentExitNodeId; +} \ No newline at end of file diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index 8889bc35..dda94368 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -1,2 +1,33 @@ -export * from "./exitNodes"; -export * from "./shared"; \ No newline at end of file +import { build } from "@server/build"; + +// Import both modules +import * as exitNodesModule from "./exitNodes"; +import * as privateExitNodesModule from "./privateExitNodes"; + +// Conditionally export exit nodes implementation based on build type +const exitNodesImplementation = build === "oss" ? exitNodesModule : privateExitNodesModule; + +// Re-export all items from the selected implementation +export const { + verifyExitNodeOrgAccess, + listExitNodes, + selectBestExitNode, + checkExitNodeOrg, + resolveExitNodes +} = exitNodesImplementation; + +// Import communications modules +import * as exitNodeCommsModule from "./exitNodeComms"; +import * as privateExitNodeCommsModule from "./privateExitNodeComms"; + +// Conditionally export communications implementation based on build type +const exitNodeCommsImplementation = build === "oss" ? exitNodeCommsModule : privateExitNodeCommsModule; + +// Re-export communications functions from the selected implementation +export const { + sendToExitNode +} = exitNodeCommsImplementation; + +// Re-export shared modules +export * from "./subnet"; +export * from "./getCurrentExitNodeId"; \ No newline at end of file diff --git a/server/lib/exitNodes/privateExitNodeComms.ts b/server/lib/exitNodes/privateExitNodeComms.ts new file mode 100644 index 00000000..163a962f --- /dev/null +++ b/server/lib/exitNodes/privateExitNodeComms.ts @@ -0,0 +1,145 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import axios from "axios"; +import logger from "@server/logger"; +import { db, ExitNode, remoteExitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "../../routers/ws"; +import { config } from "../config"; + +interface ExitNodeRequest { + remoteType?: string; + localPath: string; + method?: "POST" | "DELETE" | "GET" | "PUT"; + data?: any; + queryParams?: Record; +} + +/** + * Sends a request to an exit node, handling both remote and local exit nodes + * @param exitNode The exit node to send the request to + * @param request The request configuration + * @returns Promise Response data for local nodes, undefined for remote nodes + */ +export async function sendToExitNode( + exitNode: ExitNode, + request: ExitNodeRequest +): Promise { + if (exitNode.type === "remoteExitNode" && request.remoteType) { + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId)) + .limit(1); + + if (!remoteExitNode) { + throw new Error( + `Remote exit node with ID ${exitNode.exitNodeId} not found` + ); + } + + return sendToClient(remoteExitNode.remoteExitNodeId, { + type: request.remoteType, + data: request.data + }); + } else { + let hostname = exitNode.reachableAt; + + logger.debug(`Exit node details:`, { + type: exitNode.type, + name: exitNode.name, + reachableAt: exitNode.reachableAt, + }); + + logger.debug(`Configured local exit node name: ${config.getRawConfig().gerbil.exit_node_name}`); + + if (exitNode.name == config.getRawConfig().gerbil.exit_node_name) { + hostname = config.getRawPrivateConfig().gerbil.local_exit_node_reachable_at; + } + + if (!hostname) { + throw new Error( + `Exit node with ID ${exitNode.exitNodeId} is not reachable` + ); + } + + logger.debug(`Sending request to exit node at ${hostname}`, { + type: request.remoteType, + data: request.data + }); + + // Handle local exit node with HTTP API + const method = request.method || "POST"; + let url = `${hostname}${request.localPath}`; + + // Add query parameters if provided + if (request.queryParams) { + const params = new URLSearchParams(request.queryParams); + url += `?${params.toString()}`; + } + + try { + let response; + + switch (method) { + case "POST": + response = await axios.post(url, request.data, { + headers: { + "Content-Type": "application/json" + }, + timeout: 8000 + }); + break; + case "DELETE": + response = await axios.delete(url, { + timeout: 8000 + }); + break; + case "GET": + response = await axios.get(url, { + timeout: 8000 + }); + break; + case "PUT": + response = await axios.put(url, request.data, { + headers: { + "Content-Type": "application/json" + }, + timeout: 8000 + }); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + + logger.debug(`Exit node request successful:`, { + method, + url, + status: response.data.status + }); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}` + ); + } else { + logger.error( + `Error making ${method} request for exit node at ${hostname}: ${error}` + ); + } + } + } +} diff --git a/server/lib/exitNodes/privateExitNodes.ts b/server/lib/exitNodes/privateExitNodes.ts new file mode 100644 index 00000000..ea83fe9d --- /dev/null +++ b/server/lib/exitNodes/privateExitNodes.ts @@ -0,0 +1,379 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + exitNodes, + exitNodeOrgs, + resources, + targets, + sites, + targetHealthCheck +} from "@server/db"; +import logger from "@server/logger"; +import { ExitNodePingResult } from "@server/routers/newt"; +import { eq, and, or, ne, isNull } from "drizzle-orm"; +import axios from "axios"; +import config from "../config"; + +/** + * Checks if an exit node is actually online by making HTTP requests to its endpoint/ping + * Makes up to 3 attempts in parallel with small delays, returns as soon as one succeeds + */ +async function checkExitNodeOnlineStatus( + endpoint: string | undefined +): Promise { + if (!endpoint || endpoint == "") { + // the endpoint can start out as a empty string + return false; + } + + const maxAttempts = 3; + const timeoutMs = 5000; // 5 second timeout per request + const delayBetweenAttempts = 100; // 100ms delay between starting each attempt + + // Create promises for all attempts with staggered delays + const attemptPromises = Array.from({ length: maxAttempts }, async (_, index) => { + const attemptNumber = index + 1; + + // Add delay before each attempt (except the first) + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, delayBetweenAttempts * index)); + } + + try { + const response = await axios.get(`http://${endpoint}/ping`, { + timeout: timeoutMs, + validateStatus: (status) => status === 200 + }); + + if (response.status === 200) { + logger.debug( + `Exit node ${endpoint} is online (attempt ${attemptNumber}/${maxAttempts})` + ); + return { success: true, attemptNumber }; + } + return { success: false, attemptNumber, error: 'Non-200 status' }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.debug( + `Exit node ${endpoint} ping failed (attempt ${attemptNumber}/${maxAttempts}): ${errorMessage}` + ); + return { success: false, attemptNumber, error: errorMessage }; + } + }); + + try { + // Wait for the first successful response or all to fail + const results = await Promise.allSettled(attemptPromises); + + // Check if any attempt succeeded + for (const result of results) { + if (result.status === 'fulfilled' && result.value.success) { + return true; + } + } + + // All attempts failed + logger.warn( + `Exit node ${endpoint} is offline after ${maxAttempts} parallel attempts` + ); + return false; + } catch (error) { + logger.warn( + `Unexpected error checking exit node ${endpoint}: ${error instanceof Error ? error.message : "Unknown error"}` + ); + return false; + } +} + +export async function verifyExitNodeOrgAccess( + exitNodeId: number, + orgId: string +) { + const [result] = await db + .select({ + exitNode: exitNodes, + exitNodeOrgId: exitNodeOrgs.exitNodeId + }) + .from(exitNodes) + .leftJoin( + exitNodeOrgs, + and( + eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId), + eq(exitNodeOrgs.orgId, orgId) + ) + ) + .where(eq(exitNodes.exitNodeId, exitNodeId)); + + if (!result) { + return { hasAccess: false, exitNode: null }; + } + + const { exitNode } = result; + + // If the exit node is type "gerbil", access is allowed + if (exitNode.type === "gerbil") { + return { hasAccess: true, exitNode }; + } + + // If the exit node is type "remoteExitNode", check if it has org access + if (exitNode.type === "remoteExitNode") { + return { hasAccess: !!result.exitNodeOrgId, exitNode }; + } + + // For any other type, deny access + return { hasAccess: false, exitNode }; +} + +export async function listExitNodes(orgId: string, filterOnline = false, noCloud = false) { + const allExitNodes = await db + .select({ + exitNodeId: exitNodes.exitNodeId, + name: exitNodes.name, + address: exitNodes.address, + endpoint: exitNodes.endpoint, + publicKey: exitNodes.publicKey, + listenPort: exitNodes.listenPort, + reachableAt: exitNodes.reachableAt, + maxConnections: exitNodes.maxConnections, + online: exitNodes.online, + lastPing: exitNodes.lastPing, + type: exitNodes.type, + orgId: exitNodeOrgs.orgId, + region: exitNodes.region + }) + .from(exitNodes) + .leftJoin( + exitNodeOrgs, + eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId) + ) + .where( + or( + // Include all exit nodes that are NOT of type remoteExitNode + and( + eq(exitNodes.type, "gerbil"), + or( + // only choose nodes that are in the same region + eq(exitNodes.region, config.getRawPrivateConfig().app.region), + isNull(exitNodes.region) // or for enterprise where region is not set + ) + ), + // Include remoteExitNode types where the orgId matches the newt's organization + and( + eq(exitNodes.type, "remoteExitNode"), + eq(exitNodeOrgs.orgId, orgId) + ) + ) + ); + + // Filter the nodes. If there are NO remoteExitNodes then do nothing. If there are then remove all of the non-remoteExitNodes + if (allExitNodes.length === 0) { + logger.warn("No exit nodes found for ping request!"); + return []; + } + + // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails + const nodesWithRealOnlineStatus = await Promise.all( + allExitNodes.map(async (node) => { + // If database says it's online, verify with HTTP ping + let online: boolean; + if (filterOnline && node.type == "remoteExitNode") { + try { + const isActuallyOnline = await checkExitNodeOnlineStatus( + node.endpoint + ); + + // set the item in the database if it is offline + if (isActuallyOnline != node.online) { + await db + .update(exitNodes) + .set({ online: isActuallyOnline }) + .where(eq(exitNodes.exitNodeId, node.exitNodeId)); + } + online = isActuallyOnline; + } catch (error) { + logger.warn( + `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}` + ); + online = false; + } + } else { + online = node.online; + } + + return { + ...node, + online + }; + }) + ); + + const remoteExitNodes = nodesWithRealOnlineStatus.filter( + (node) => + node.type === "remoteExitNode" && (!filterOnline || node.online) + ); + const gerbilExitNodes = nodesWithRealOnlineStatus.filter( + (node) => node.type === "gerbil" && (!filterOnline || node.online) && !noCloud + ); + + // THIS PROVIDES THE FALL + const exitNodesList = + remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; + + return exitNodesList; +} + +/** + * Selects the most suitable exit node from a list of ping results. + * + * The selection algorithm follows these steps: + * + * 1. **Filter Invalid Nodes**: Excludes nodes with errors or zero weight. + * + * 2. **Sort by Latency**: Sorts valid nodes in ascending order of latency. + * + * 3. **Preferred Selection**: + * - If the lowest-latency node has sufficient capacity (≥10% weight), + * check if a previously connected node is also acceptable. + * - The previously connected node is preferred if its latency is within + * 30ms or 15% of the best node’s latency. + * + * 4. **Fallback to Next Best**: + * - If the lowest-latency node is under capacity, find the next node + * with acceptable capacity. + * + * 5. **Final Fallback**: + * - If no nodes meet the capacity threshold, fall back to the node + * with the highest weight (i.e., most available capacity). + * + */ +export function selectBestExitNode( + pingResults: ExitNodePingResult[] +): ExitNodePingResult | null { + const MIN_CAPACITY_THRESHOLD = 0.1; + const LATENCY_TOLERANCE_MS = 30; + const LATENCY_TOLERANCE_PERCENT = 0.15; + + // Filter out invalid nodes + const validNodes = pingResults.filter((n) => !n.error && n.weight > 0); + + if (validNodes.length === 0) { + logger.error("No valid exit nodes available"); + return null; + } + + // Sort by latency (ascending) + const sortedNodes = validNodes + .slice() + .sort((a, b) => a.latencyMs - b.latencyMs); + const lowestLatencyNode = sortedNodes[0]; + + logger.debug( + `Lowest latency node: ${lowestLatencyNode.exitNodeName} (${lowestLatencyNode.latencyMs} ms, weight=${lowestLatencyNode.weight.toFixed(2)})` + ); + + // If lowest latency node has enough capacity, check if previously connected node is acceptable + if (lowestLatencyNode.weight >= MIN_CAPACITY_THRESHOLD) { + const previouslyConnectedNode = sortedNodes.find( + (n) => + n.wasPreviouslyConnected && n.weight >= MIN_CAPACITY_THRESHOLD + ); + + if (previouslyConnectedNode) { + const latencyDiff = + previouslyConnectedNode.latencyMs - lowestLatencyNode.latencyMs; + const percentDiff = latencyDiff / lowestLatencyNode.latencyMs; + + if ( + latencyDiff <= LATENCY_TOLERANCE_MS || + percentDiff <= LATENCY_TOLERANCE_PERCENT + ) { + logger.info( + `Sticking with previously connected node: ${previouslyConnectedNode.exitNodeName} ` + + `(${previouslyConnectedNode.latencyMs} ms), latency diff = ${latencyDiff.toFixed(1)}ms ` + + `/ ${(percentDiff * 100).toFixed(1)}%.` + ); + return previouslyConnectedNode; + } + } + + return lowestLatencyNode; + } + + // Otherwise, find the next node (after the lowest) that has enough capacity + for (let i = 1; i < sortedNodes.length; i++) { + const node = sortedNodes[i]; + if (node.weight >= MIN_CAPACITY_THRESHOLD) { + logger.info( + `Lowest latency node under capacity. Using next best: ${node.exitNodeName} ` + + `(${node.latencyMs} ms, weight=${node.weight.toFixed(2)})` + ); + return node; + } + } + + // Fallback: pick the highest weight node + const fallbackNode = validNodes.reduce((a, b) => + a.weight > b.weight ? a : b + ); + logger.warn( + `No nodes with ≥10% weight. Falling back to highest capacity node: ${fallbackNode.exitNodeName}` + ); + return fallbackNode; +} + +export async function checkExitNodeOrg(exitNodeId: number, orgId: string) { + const [exitNodeOrg] = await db + .select() + .from(exitNodeOrgs) + .where( + and( + eq(exitNodeOrgs.exitNodeId, exitNodeId), + eq(exitNodeOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (!exitNodeOrg) { + return true; + } + + return false; +} + +export async function resolveExitNodes(hostname: string, publicKey: string) { + const resourceExitNodes = await db + .select({ + endpoint: exitNodes.endpoint, + publicKey: exitNodes.publicKey, + orgId: resources.orgId + }) + .from(resources) + .innerJoin(targets, eq(resources.resourceId, targets.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where( + and( + eq(resources.fullDomain, hostname), + ne(exitNodes.publicKey, publicKey), + ne(targetHealthCheck.hcHealth, "unhealthy") + ) + ); + + return resourceExitNodes; +} diff --git a/server/lib/exitNodes/shared.ts b/server/lib/exitNodes/subnet.ts similarity index 100% rename from server/lib/exitNodes/shared.ts rename to server/lib/exitNodes/subnet.ts diff --git a/server/lib/geoip.ts b/server/lib/geoip.ts index 042e53c9..d6252360 100644 --- a/server/lib/geoip.ts +++ b/server/lib/geoip.ts @@ -1,10 +1,42 @@ +import logger from "@server/logger"; +import { maxmindLookup } from "@server/db/maxmind"; import axios from "axios"; import config from "./config"; import { tokenManager } from "./tokenManager"; -import logger from "@server/logger"; export async function getCountryCodeForIp( ip: string +): Promise { + try { + if (!maxmindLookup) { + logger.warn( + "MaxMind DB path not configured, cannot perform GeoIP lookup" + ); + return; + } + + const result = maxmindLookup.get(ip); + + if (!result || !result.country) { + return; + } + + const { country } = result; + + logger.debug( + `GeoIP lookup successful for IP ${ip}: ${country.iso_code}` + ); + + return country.iso_code; + } catch (error) { + logger.error("Error fetching config in verify session:", error); + } + + return; +} + +export async function remoteGetCountryCodeForIp( + ip: string ): Promise { try { const response = await axios.get( diff --git a/server/lib/idp/generateRedirectUrl.ts b/server/lib/idp/generateRedirectUrl.ts index 4eea973e..077ac6f6 100644 --- a/server/lib/idp/generateRedirectUrl.ts +++ b/server/lib/idp/generateRedirectUrl.ts @@ -1,8 +1,48 @@ +import { db, loginPage, loginPageOrg } from "@server/db"; import config from "@server/lib/config"; +import { eq } from "drizzle-orm"; + +export async function generateOidcRedirectUrl( + idpId: number, + orgId?: string, + loginPageId?: number +): Promise { + let baseUrl: string | undefined; + + const secure = config.getRawConfig().app.dashboard_url?.startsWith("https"); + const method = secure ? "https" : "http"; + + if (loginPageId) { + const [res] = await db + .select() + .from(loginPage) + .where(eq(loginPage.loginPageId, loginPageId)) + .limit(1); + + if (res && res.fullDomain) { + baseUrl = `${method}://${res.fullDomain}`; + } + } else if (orgId) { + const [res] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)) + .innerJoin( + loginPage, + eq(loginPage.loginPageId, loginPageOrg.loginPageId) + ) + .limit(1); + + if (res?.loginPage && res.loginPage.domainId && res.loginPage.fullDomain) { + baseUrl = `${method}://${res.loginPage.fullDomain}`; + } + } + + if (!baseUrl) { + baseUrl = config.getRawConfig().app.dashboard_url!; + } -export function generateOidcRedirectUrl(idpId: number) { - const dashboardUrl = config.getRawConfig().app.dashboard_url; const redirectPath = `/auth/idp/${idpId}/oidc/callback`; - const redirectUrl = new URL(redirectPath, dashboardUrl).toString(); + const redirectUrl = new URL(redirectPath, baseUrl!).toString(); return redirectUrl; } diff --git a/server/lib/index.ts b/server/lib/index.ts deleted file mode 100644 index db1a73da..00000000 --- a/server/lib/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./response"; -export { tokenManager, TokenManager } from "./tokenManager"; -export * from "./geoip"; diff --git a/server/lib/private/billing/features.ts b/server/lib/private/billing/features.ts new file mode 100644 index 00000000..316d8e86 --- /dev/null +++ b/server/lib/private/billing/features.ts @@ -0,0 +1,85 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; + +export enum FeatureId { + SITE_UPTIME = "siteUptime", + USERS = "users", + EGRESS_DATA_MB = "egressDataMb", + DOMAINS = "domains", + REMOTE_EXIT_NODES = "remoteExitNodes" +} + +export const FeatureMeterIds: Record = { + [FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU", + [FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro", + [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW", + [FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU", + [FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE" +}; + +export const FeatureMeterIdsSandbox: Record = { + [FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu", + [FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au", + [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ", + [FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts", + [FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K" +}; + +export function getFeatureMeterId(featureId: FeatureId): string { + if (process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true") { + return FeatureMeterIds[featureId]; + } else { + return FeatureMeterIdsSandbox[featureId]; + } +} + +export function getFeatureIdByMetricId(metricId: string): FeatureId | undefined { + return (Object.entries(FeatureMeterIds) as [FeatureId, string][]) + .find(([_, v]) => v === metricId)?.[0]; +} + +export type FeaturePriceSet = { + [key in FeatureId]: string; +}; + +export const standardFeaturePriceSet: FeaturePriceSet = { // Free tier matches the freeLimitSet + [FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF", + [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea", + [FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk", + [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC", + [FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h" +}; + +export const standardFeaturePriceSetSandbox: FeaturePriceSet = { // Free tier matches the freeLimitSet + [FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU", + [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF", + [FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0", + [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b", + [FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL" +}; + +export function getStandardFeaturePriceSet(): FeaturePriceSet { + if (process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true") { + return standardFeaturePriceSet; + } else { + return standardFeaturePriceSetSandbox; + } +} + +export function getLineItems(featurePriceSet: FeaturePriceSet): Stripe.Checkout.SessionCreateParams.LineItem[] { + return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({ + price: priceId, + })); +} \ No newline at end of file diff --git a/server/lib/private/billing/index.ts b/server/lib/private/billing/index.ts new file mode 100644 index 00000000..0212ee1c --- /dev/null +++ b/server/lib/private/billing/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./limitSet"; +export * from "./features"; +export * from "./limitsService"; diff --git a/server/lib/private/billing/limitSet.ts b/server/lib/private/billing/limitSet.ts new file mode 100644 index 00000000..0cddb37c --- /dev/null +++ b/server/lib/private/billing/limitSet.ts @@ -0,0 +1,63 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { FeatureId } from "./features"; + +export type LimitSet = { + [key in FeatureId]: { + value: number | null; // null indicates no limit + description?: string; + }; +}; + +export const sandboxLimitSet: LimitSet = { + [FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days + [FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, + [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB + [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }, +}; + +export const freeLimitSet: LimitSet = { + [FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days + [FeatureId.USERS]: { value: 3, description: "Free tier limit" }, + [FeatureId.EGRESS_DATA_MB]: { + value: 25000, + description: "Free tier limit" + }, // 25 GB + [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } +}; + +export const subscribedLimitSet: LimitSet = { + [FeatureId.SITE_UPTIME]: { + value: 2232000, + description: "Contact us to increase soft limit.", + }, // 50 sites up for 31 days + [FeatureId.USERS]: { + value: 150, + description: "Contact us to increase soft limit." + }, + [FeatureId.EGRESS_DATA_MB]: { + value: 12000000, + description: "Contact us to increase soft limit." + }, // 12000 GB + [FeatureId.DOMAINS]: { + value: 20, + description: "Contact us to increase soft limit." + }, + [FeatureId.REMOTE_EXIT_NODES]: { + value: 5, + description: "Contact us to increase soft limit." + } +}; diff --git a/server/lib/private/billing/limitsService.ts b/server/lib/private/billing/limitsService.ts new file mode 100644 index 00000000..168f5580 --- /dev/null +++ b/server/lib/private/billing/limitsService.ts @@ -0,0 +1,51 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, limits } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { LimitSet } from "./limitSet"; +import { FeatureId } from "./features"; + +class LimitService { + async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise { + const limitEntries = Object.entries(limitSet); + + // delete existing limits for the org + await db.transaction(async (trx) => { + await trx.delete(limits).where(eq(limits.orgId, orgId)); + for (const [featureId, entry] of limitEntries) { + const limitId = `${orgId}-${featureId}`; + const { value, description } = entry; + await trx + .insert(limits) + .values({ limitId, orgId, featureId, value, description }); + } + }); + } + + async getOrgLimit( + orgId: string, + featureId: FeatureId + ): Promise { + const limitId = `${orgId}-${featureId}`; + const [limit] = await db + .select() + .from(limits) + .where(and(eq(limits.limitId, limitId))) + .limit(1); + + return limit ? limit.value : null; + } +} + +export const limitsService = new LimitService(); diff --git a/server/lib/private/billing/tiers.ts b/server/lib/private/billing/tiers.ts new file mode 100644 index 00000000..e6322c9f --- /dev/null +++ b/server/lib/private/billing/tiers.ts @@ -0,0 +1,37 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export enum TierId { + STANDARD = "standard", +} + +export type TierPriceSet = { + [key in TierId]: string; +}; + +export const tierPriceSet: TierPriceSet = { // Free tier matches the freeLimitSet + [TierId.STANDARD]: "price_1RrQ9cD3Ee2Ir7Wmqdy3KBa0", +}; + +export const tierPriceSetSandbox: TierPriceSet = { // Free tier matches the freeLimitSet + // when matching tier the keys closer to 0 index are matched first so list the tiers in descending order of value + [TierId.STANDARD]: "price_1RrAYJDCpkOb237By2s1P32m", +}; + +export function getTierPriceSet(environment?: string, sandbox_mode?: boolean): TierPriceSet { + if ((process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true") || (environment === "prod" && sandbox_mode !== true)) { // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE + return tierPriceSet; + } else { + return tierPriceSetSandbox; + } +} diff --git a/server/lib/private/billing/usageService.ts b/server/lib/private/billing/usageService.ts new file mode 100644 index 00000000..76099956 --- /dev/null +++ b/server/lib/private/billing/usageService.ts @@ -0,0 +1,889 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { eq, sql, and } from "drizzle-orm"; +import NodeCache from "node-cache"; +import { v4 as uuidv4 } from "uuid"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { s3Client } from "../s3"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { + db, + usage, + customers, + sites, + newts, + limits, + Usage, + Limit, + Transaction +} from "@server/db"; +import { FeatureId, getFeatureMeterId } from "./features"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { sendToClient } from "@server/routers/ws"; +import { build } from "@server/build"; + +interface StripeEvent { + identifier?: string; + timestamp: number; + event_name: string; + payload: { + value: number; + stripe_customer_id: string; + }; +} + +export class UsageService { + private cache: NodeCache; + private bucketName: string | undefined; + private currentEventFile: string | null = null; + private currentFileStartTime: number = 0; + private eventsDir: string | undefined; + private uploadingFiles: Set = new Set(); + + constructor() { + this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL + if (build !== "saas") { + return; + } + this.bucketName = config.getRawPrivateConfig().stripe?.s3Bucket; + this.eventsDir = config.getRawPrivateConfig().stripe?.localFilePath; + + // Ensure events directory exists + this.initializeEventsDirectory().then(() => { + this.uploadPendingEventFilesOnStartup(); + }); + + // Periodically check for old event files to upload + setInterval(() => { + this.uploadOldEventFiles().catch((err) => { + logger.error("Error in periodic event file upload:", err); + }); + }, 30000); // every 30 seconds + } + + /** + * Truncate a number to 11 decimal places to prevent precision issues + */ + private truncateValue(value: number): number { + return Math.round(value * 100000000000) / 100000000000; // 11 decimal places + } + + private async initializeEventsDirectory(): Promise { + if (!this.eventsDir) { + logger.warn("Stripe local file path is not configured, skipping events directory initialization."); + return; + } + try { + await fs.mkdir(this.eventsDir, { recursive: true }); + } catch (error) { + logger.error("Failed to create events directory:", error); + } + } + + private async uploadPendingEventFilesOnStartup(): Promise { + if (!this.eventsDir || !this.bucketName) { + logger.warn("Stripe local file path or bucket name is not configured, skipping leftover event file upload."); + return; + } + try { + const files = await fs.readdir(this.eventsDir); + for (const file of files) { + if (file.endsWith(".json")) { + const filePath = path.join(this.eventsDir, file); + try { + const fileContent = await fs.readFile( + filePath, + "utf-8" + ); + const events = JSON.parse(fileContent); + if (Array.isArray(events) && events.length > 0) { + // Upload to S3 + const uploadCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: file, + Body: fileContent, + ContentType: "application/json" + }); + await s3Client.send(uploadCommand); + + // Check if file still exists before unlinking + try { + await fs.access(filePath); + await fs.unlink(filePath); + } catch (unlinkError) { + logger.debug(`Startup file ${file} was already deleted`); + } + + logger.info( + `Uploaded leftover event file ${file} to S3 with ${events.length} events` + ); + } else { + // Remove empty file + try { + await fs.access(filePath); + await fs.unlink(filePath); + } catch (unlinkError) { + logger.debug(`Empty startup file ${file} was already deleted`); + } + } + } catch (err) { + logger.error( + `Error processing leftover event file ${file}:`, + err + ); + } + } + } + } catch (err) { + logger.error("Failed to scan for leftover event files:", err); + } + } + + public async add( + orgId: string, + featureId: FeatureId, + value: number, + transaction: any = null + ): Promise { + if (build !== "saas") { + return null; + } + + // Truncate value to 11 decimal places + value = this.truncateValue(value); + + // Implement retry logic for deadlock handling + const maxRetries = 3; + let attempt = 0; + + while (attempt <= maxRetries) { + try { + // Get subscription data for this org (with caching) + const customerId = await this.getCustomerId(orgId, featureId); + + if (!customerId) { + logger.warn( + `No subscription data found for org ${orgId} and feature ${featureId}` + ); + return null; + } + + let usage; + if (transaction) { + usage = await this.internalAddUsage( + orgId, + featureId, + value, + transaction + ); + } else { + await db.transaction(async (trx) => { + usage = await this.internalAddUsage(orgId, featureId, value, trx); + }); + } + + // Log event for Stripe + await this.logStripeEvent(featureId, value, customerId); + + return usage || null; + } catch (error: any) { + // Check if this is a deadlock error + const isDeadlock = error?.code === '40P01' || + error?.cause?.code === '40P01' || + (error?.message && error.message.includes('deadlock')); + + if (isDeadlock && attempt < maxRetries) { + attempt++; + // Exponential backoff with jitter: 50-150ms, 100-300ms, 200-600ms + const baseDelay = Math.pow(2, attempt - 1) * 50; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + + logger.warn( + `Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` + ); + + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + logger.error( + `Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`, + error + ); + break; + } + } + + return null; + } + + private async internalAddUsage( + orgId: string, + featureId: FeatureId, + value: number, + trx: Transaction + ): Promise { + // Truncate value to 11 decimal places + value = this.truncateValue(value); + + const usageId = `${orgId}-${featureId}`; + const meterId = getFeatureMeterId(featureId); + + // Use upsert: insert if not exists, otherwise increment + const [returnUsage] = await trx + .insert(usage) + .values({ + usageId, + featureId, + orgId, + meterId, + latestValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .onConflictDoUpdate({ + target: usage.usageId, + set: { + latestValue: sql`${usage.latestValue} + ${value}` + } + }).returning(); + + return returnUsage; + } + + // Helper function to get today's date as string (YYYY-MM-DD) + getTodayDateString(): string { + return new Date().toISOString().split("T")[0]; + } + + // Helper function to get date string from Date object + getDateString(date: number): string { + return new Date(date * 1000).toISOString().split("T")[0]; + } + + async updateDaily( + orgId: string, + featureId: FeatureId, + value?: number, + customerId?: string + ): Promise { + if (build !== "saas") { + return; + } + try { + if (!customerId) { + customerId = + (await this.getCustomerId(orgId, featureId)) || undefined; + if (!customerId) { + logger.warn( + `No subscription data found for org ${orgId} and feature ${featureId}` + ); + return; + } + } + + // Truncate value to 11 decimal places if provided + if (value !== undefined && value !== null) { + value = this.truncateValue(value); + } + + const today = this.getTodayDateString(); + + let currentUsage: Usage | null = null; + + await db.transaction(async (trx) => { + // Get existing meter record + const usageId = `${orgId}-${featureId}`; + // Get current usage record + [currentUsage] = await trx + .select() + .from(usage) + .where(eq(usage.usageId, usageId)) + .limit(1); + + if (currentUsage) { + const lastUpdateDate = this.getDateString( + currentUsage.updatedAt + ); + const currentRunningTotal = currentUsage.latestValue; + const lastDailyValue = currentUsage.instantaneousValue || 0; + + if (value == undefined || value === null) { + value = currentUsage.instantaneousValue || 0; + } + + if (lastUpdateDate === today) { + // Same day update: replace the daily value + // Remove old daily value from running total, add new value + const newRunningTotal = this.truncateValue( + currentRunningTotal - lastDailyValue + value + ); + + await trx + .update(usage) + .set({ + latestValue: newRunningTotal, + instantaneousValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .where(eq(usage.usageId, usageId)); + } else { + // New day: add to running total + const newRunningTotal = this.truncateValue( + currentRunningTotal + value + ); + + await trx + .update(usage) + .set({ + latestValue: newRunningTotal, + instantaneousValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .where(eq(usage.usageId, usageId)); + } + } else { + // First record for this meter + const meterId = getFeatureMeterId(featureId); + const truncatedValue = this.truncateValue(value || 0); + await trx.insert(usage).values({ + usageId, + featureId, + orgId, + meterId, + instantaneousValue: truncatedValue, + latestValue: truncatedValue, + updatedAt: Math.floor(Date.now() / 1000) + }); + } + }); + + await this.logStripeEvent(featureId, value || 0, customerId); + } catch (error) { + logger.error( + `Failed to update daily usage for ${orgId}/${featureId}:`, + error + ); + } + } + + private async getCustomerId( + orgId: string, + featureId: FeatureId + ): Promise { + const cacheKey = `customer_${orgId}_${featureId}`; + const cached = this.cache.get(cacheKey); + + if (cached) { + return cached; + } + + try { + // Query subscription data + const [customer] = await db + .select({ + customerId: customers.customerId + }) + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + if (!customer) { + return null; + } + + const customerId = customer.customerId; + + // Cache the result + this.cache.set(cacheKey, customerId); + + return customerId; + } catch (error) { + logger.error( + `Failed to get subscription data for ${orgId}/${featureId}:`, + error + ); + return null; + } + } + + private async logStripeEvent( + featureId: FeatureId, + value: number, + customerId: string + ): Promise { + // Truncate value to 11 decimal places before sending to Stripe + const truncatedValue = this.truncateValue(value); + + const event: StripeEvent = { + identifier: uuidv4(), + timestamp: Math.floor(new Date().getTime() / 1000), + event_name: featureId, + payload: { + value: truncatedValue, + stripe_customer_id: customerId + } + }; + + await this.writeEventToFile(event); + await this.checkAndUploadFile(); + } + + private async writeEventToFile(event: StripeEvent): Promise { + if (!this.eventsDir || !this.bucketName) { + logger.warn("Stripe local file path or bucket name is not configured, skipping event file write."); + return; + } + if (!this.currentEventFile) { + this.currentEventFile = this.generateEventFileName(); + this.currentFileStartTime = Date.now(); + } + + const filePath = path.join(this.eventsDir, this.currentEventFile); + + try { + let events: StripeEvent[] = []; + + // Try to read existing file + try { + const fileContent = await fs.readFile(filePath, "utf-8"); + events = JSON.parse(fileContent); + } catch (error) { + // File doesn't exist or is empty, start with empty array + events = []; + } + + // Add new event + events.push(event); + + // Write back to file + await fs.writeFile(filePath, JSON.stringify(events, null, 2)); + } catch (error) { + logger.error("Failed to write event to file:", error); + } + } + + private async checkAndUploadFile(): Promise { + if (!this.currentEventFile) { + return; + } + + const now = Date.now(); + const fileAge = now - this.currentFileStartTime; + + // Check if file is at least 1 minute old + if (fileAge >= 60000) { + // 60 seconds + await this.uploadFileToS3(); + } + } + + private async uploadFileToS3(): Promise { + if (!this.bucketName || !this.eventsDir) { + logger.warn("Stripe local file path or bucket name is not configured, skipping S3 upload."); + return; + } + if (!this.currentEventFile) { + return; + } + + const fileName = this.currentEventFile; + const filePath = path.join(this.eventsDir, fileName); + + // Check if this file is already being uploaded + if (this.uploadingFiles.has(fileName)) { + logger.debug(`File ${fileName} is already being uploaded, skipping`); + return; + } + + // Mark file as being uploaded + this.uploadingFiles.add(fileName); + + try { + // Check if file exists before trying to read it + try { + await fs.access(filePath); + } catch (error) { + logger.debug(`File ${fileName} does not exist, may have been already processed`); + this.uploadingFiles.delete(fileName); + // Reset current file if it was this file + if (this.currentEventFile === fileName) { + this.currentEventFile = null; + this.currentFileStartTime = 0; + } + return; + } + + // Check if file exists and has content + const fileContent = await fs.readFile(filePath, "utf-8"); + const events = JSON.parse(fileContent); + + if (events.length === 0) { + // No events to upload, just clean up + try { + await fs.unlink(filePath); + } catch (unlinkError) { + // File may have been already deleted + logger.debug(`File ${fileName} was already deleted during cleanup`); + } + this.currentEventFile = null; + this.uploadingFiles.delete(fileName); + return; + } + + // Upload to S3 + const uploadCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: fileName, + Body: fileContent, + ContentType: "application/json" + }); + + await s3Client.send(uploadCommand); + + // Clean up local file - check if it still exists before unlinking + try { + await fs.access(filePath); + await fs.unlink(filePath); + } catch (unlinkError) { + // File may have been already deleted by another process + logger.debug(`File ${fileName} was already deleted during upload`); + } + + logger.info( + `Uploaded ${fileName} to S3 with ${events.length} events` + ); + + // Reset for next file + this.currentEventFile = null; + this.currentFileStartTime = 0; + } catch (error) { + logger.error( + `Failed to upload ${fileName} to S3:`, + error + ); + } finally { + // Always remove from uploading set + this.uploadingFiles.delete(fileName); + } + } + + private generateEventFileName(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const uuid = uuidv4().substring(0, 8); + return `events-${timestamp}-${uuid}.json`; + } + + public async getUsage( + orgId: string, + featureId: FeatureId + ): Promise { + if (build !== "saas") { + return null; + } + + const usageId = `${orgId}-${featureId}`; + + try { + const [result] = await db + .select() + .from(usage) + .where(eq(usage.usageId, usageId)) + .limit(1); + + if (!result) { + // Lets create one if it doesn't exist using upsert to handle race conditions + logger.info( + `Creating new usage record for ${orgId}/${featureId}` + ); + const meterId = getFeatureMeterId(featureId); + + try { + const [newUsage] = await db + .insert(usage) + .values({ + usageId, + featureId, + orgId, + meterId, + latestValue: 0, + updatedAt: Math.floor(Date.now() / 1000) + }) + .onConflictDoNothing() + .returning(); + + if (newUsage) { + return newUsage; + } else { + // Record was created by another process, fetch it + const [existingUsage] = await db + .select() + .from(usage) + .where(eq(usage.usageId, usageId)) + .limit(1); + return existingUsage || null; + } + } catch (insertError) { + // Fallback: try to fetch existing record in case of any insert issues + logger.warn( + `Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`, + insertError + ); + const [existingUsage] = await db + .select() + .from(usage) + .where(eq(usage.usageId, usageId)) + .limit(1); + return existingUsage || null; + } + } + + return result + } catch (error) { + logger.error( + `Failed to get usage for ${orgId}/${featureId}:`, + error + ); + throw error; + } + } + + public async getUsageDaily( + orgId: string, + featureId: FeatureId + ): Promise { + if (build !== "saas") { + return null; + } + await this.updateDaily(orgId, featureId); // Ensure daily usage is updated + return this.getUsage(orgId, featureId); + } + + public async forceUpload(): Promise { + await this.uploadFileToS3(); + } + + public clearCache(): void { + this.cache.flushAll(); + } + + /** + * Scan the events directory for files older than 1 minute and upload them if not empty. + */ + private async uploadOldEventFiles(): Promise { + if (!this.eventsDir || !this.bucketName) { + logger.warn("Stripe local file path or bucket name is not configured, skipping old event file upload."); + return; + } + try { + const files = await fs.readdir(this.eventsDir); + const now = Date.now(); + for (const file of files) { + if (!file.endsWith(".json")) continue; + + // Skip files that are already being uploaded + if (this.uploadingFiles.has(file)) { + logger.debug(`Skipping file ${file} as it's already being uploaded`); + continue; + } + + const filePath = path.join(this.eventsDir, file); + + try { + // Check if file still exists before processing + try { + await fs.access(filePath); + } catch (accessError) { + logger.debug(`File ${file} does not exist, skipping`); + continue; + } + + const stat = await fs.stat(filePath); + const age = now - stat.mtimeMs; + if (age >= 90000) { + // 1.5 minutes - Mark as being uploaded + this.uploadingFiles.add(file); + + try { + const fileContent = await fs.readFile( + filePath, + "utf-8" + ); + const events = JSON.parse(fileContent); + if (Array.isArray(events) && events.length > 0) { + // Upload to S3 + const uploadCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: file, + Body: fileContent, + ContentType: "application/json" + }); + await s3Client.send(uploadCommand); + + // Check if file still exists before unlinking + try { + await fs.access(filePath); + await fs.unlink(filePath); + } catch (unlinkError) { + logger.debug(`File ${file} was already deleted during interval upload`); + } + + logger.info( + `Interval: Uploaded event file ${file} to S3 with ${events.length} events` + ); + // If this was the current event file, reset it + if (this.currentEventFile === file) { + this.currentEventFile = null; + this.currentFileStartTime = 0; + } + } else { + // Remove empty file + try { + await fs.access(filePath); + await fs.unlink(filePath); + } catch (unlinkError) { + logger.debug(`Empty file ${file} was already deleted`); + } + } + } finally { + // Always remove from uploading set + this.uploadingFiles.delete(file); + } + } + } catch (err) { + logger.error( + `Interval: Error processing event file ${file}:`, + err + ); + // Remove from uploading set on error + this.uploadingFiles.delete(file); + } + } + } catch (err) { + logger.error("Interval: Failed to scan for event files:", err); + } + } + + public async checkLimitSet(orgId: string, kickSites = false, featureId?: FeatureId, usage?: Usage): Promise { + if (build !== "saas") { + return false; + } + // This method should check the current usage against the limits set for the organization + // and kick out all of the sites on the org + let hasExceededLimits = false; + + try { + let orgLimits: Limit[] = []; + if (featureId) { + // Get all limits set for this organization + orgLimits = await db + .select() + .from(limits) + .where( + and( + eq(limits.orgId, orgId), + eq(limits.featureId, featureId) + ) + ); + } else { + // Get all limits set for this organization + orgLimits = await db + .select() + .from(limits) + .where(eq(limits.orgId, orgId)); + } + + if (orgLimits.length === 0) { + logger.debug(`No limits set for org ${orgId}`); + return false; + } + + // Check each limit against current usage + for (const limit of orgLimits) { + let currentUsage: Usage | null; + if (usage) { + currentUsage = usage; + } else { + currentUsage = await this.getUsage(orgId, limit.featureId as FeatureId); + } + + const usageValue = currentUsage?.instantaneousValue || currentUsage?.latestValue || 0; + logger.debug(`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`); + logger.debug(`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`); + if (currentUsage && limit.value !== null && usageValue > limit.value) { + logger.debug( + `Org ${orgId} has exceeded limit for ${limit.featureId}: ` + + `${usageValue} > ${limit.value}` + ); + hasExceededLimits = true; + break; // Exit early if any limit is exceeded + } + } + + // If any limits are exceeded, disconnect all sites for this organization + if (hasExceededLimits && kickSites) { + logger.warn(`Disconnecting all sites for org ${orgId} due to exceeded limits`); + + // Get all sites for this organization + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, orgId)); + + // Mark all sites as offline and send termination messages + const siteUpdates = orgSites.map(site => site.siteId); + + if (siteUpdates.length > 0) { + // Send termination messages to newt sites + for (const site of orgSites) { + if (site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + const payload = { + type: `newt/wg/terminate`, + data: { + reason: "Usage limits exceeded" + } + }; + + // Don't await to prevent blocking + sendToClient(newt.newtId, payload).catch((error: any) => { + logger.error( + `Failed to send termination message to newt ${newt.newtId}:`, + error + ); + }); + } + } + } + + logger.info(`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`); + } + } + } catch (error) { + logger.error(`Error checking limits for org ${orgId}:`, error); + } + + return hasExceededLimits; + } +} + +export const usageService = new UsageService(); diff --git a/server/lib/private/createUserAccountOrg.ts b/server/lib/private/createUserAccountOrg.ts new file mode 100644 index 00000000..abde5ca7 --- /dev/null +++ b/server/lib/private/createUserAccountOrg.ts @@ -0,0 +1,206 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { isValidCIDR } from "@server/lib/validators"; +import { getNextAvailableOrgSubnet } from "@server/lib/ip"; +import { + actions, + apiKeyOrg, + apiKeys, + db, + domains, + Org, + orgDomains, + orgs, + roleActions, + roles, + userOrgs +} from "@server/db"; +import { eq } from "drizzle-orm"; +import { defaultRoleAllowedActions } from "@server/routers/role"; +import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/private/billing"; +import { createCustomer } from "@server/routers/private/billing/createCustomer"; +import { usageService } from "@server/lib/private/billing/usageService"; + +export async function createUserAccountOrg( + userId: string, + userEmail: string +): Promise<{ + success: boolean; + org?: { + orgId: string; + name: string; + subnet: string; + }; + error?: string; +}> { + // const subnet = await getNextAvailableOrgSubnet(); + const orgId = "org_" + userId; + const name = `${userEmail}'s Organization`; + + // if (!isValidCIDR(subnet)) { + // return { + // success: false, + // error: "Invalid subnet format. Please provide a valid CIDR notation." + // }; + // } + + // // make sure the subnet is unique + // const subnetExists = await db + // .select() + // .from(orgs) + // .where(eq(orgs.subnet, subnet)) + // .limit(1); + + // if (subnetExists.length > 0) { + // return { success: false, error: `Subnet ${subnet} already exists` }; + // } + + // make sure the orgId is unique + const orgExists = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (orgExists.length > 0) { + return { + success: false, + error: `Organization with ID ${orgId} already exists` + }; + } + + let error = ""; + let org: Org | null = null; + + await db.transaction(async (trx) => { + const allDomains = await trx + .select() + .from(domains) + .where(eq(domains.configManaged, true)); + + const newOrg = await trx + .insert(orgs) + .values({ + orgId, + name, + // subnet + subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs? + createdAt: new Date().toISOString() + }) + .returning(); + + if (newOrg.length === 0) { + error = "Failed to create organization"; + trx.rollback(); + return; + } + + org = newOrg[0]; + + // Create admin role within the same transaction + const [insertedRole] = await trx + .insert(roles) + .values({ + orgId: newOrg[0].orgId, + isAdmin: true, + name: "Admin", + description: "Admin role with the most permissions" + }) + .returning({ roleId: roles.roleId }); + + if (!insertedRole || !insertedRole.roleId) { + error = "Failed to create Admin role"; + trx.rollback(); + return; + } + + const roleId = insertedRole.roleId; + + // Get all actions and create role actions + const actionIds = await trx.select().from(actions).execute(); + + if (actionIds.length > 0) { + await trx.insert(roleActions).values( + actionIds.map((action) => ({ + roleId, + actionId: action.actionId, + orgId: newOrg[0].orgId + })) + ); + } + + if (allDomains.length) { + await trx.insert(orgDomains).values( + allDomains.map((domain) => ({ + orgId: newOrg[0].orgId, + domainId: domain.domainId + })) + ); + } + + await trx.insert(userOrgs).values({ + userId, + orgId: newOrg[0].orgId, + roleId: roleId, + isOwner: true + }); + + const memberRole = await trx + .insert(roles) + .values({ + name: "Member", + description: "Members can only view resources", + orgId + }) + .returning(); + + await trx.insert(roleActions).values( + defaultRoleAllowedActions.map((action) => ({ + roleId: memberRole[0].roleId, + actionId: action, + orgId + })) + ); + }); + + await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet); + + if (!org) { + return { success: false, error: "Failed to create org" }; + } + + if (error) { + return { + success: false, + error: `Failed to create org: ${error}` + }; + } + + // make sure we have the stripe customer + const customerId = await createCustomer(orgId, userEmail); + + if (customerId) { + await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org + } + + return { + org: { + orgId, + name, + // subnet + subnet: "100.90.128.0/24" + }, + success: true + }; +} diff --git a/server/lib/private/rateLimitStore.ts b/server/lib/private/rateLimitStore.ts new file mode 100644 index 00000000..4700ba09 --- /dev/null +++ b/server/lib/private/rateLimitStore.ts @@ -0,0 +1,25 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import RedisStore from "@server/db/private/redisStore"; +import { MemoryStore, Store } from "express-rate-limit"; + +export function createStore(): Store { + const rateLimitStore: Store = new RedisStore({ + prefix: 'api-rate-limit', // Optional: customize Redis key prefix + skipFailedRequests: true, // Don't count failed requests + skipSuccessfulRequests: false, // Count successful requests + }); + + return rateLimitStore; +} diff --git a/server/lib/private/readConfigFile.ts b/server/lib/private/readConfigFile.ts new file mode 100644 index 00000000..ce2abcf4 --- /dev/null +++ b/server/lib/private/readConfigFile.ts @@ -0,0 +1,192 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import fs from "fs"; +import yaml from "js-yaml"; +import { privateConfigFilePath1 } from "@server/lib/consts"; +import { z } from "zod"; +import { colorsSchema } from "@server/lib/colorsSchema"; +import { build } from "@server/build"; + +const portSchema = z.number().positive().gt(0).lte(65535); + +export const privateConfigSchema = z + .object({ + app: z.object({ + region: z.string().optional().default("default"), + base_domain: z.string().optional() + }).optional().default({ + region: "default" + }), + server: z.object({ + encryption_key_path: z + .string() + .optional() + .default("./config/encryption.pem") + .pipe(z.string().min(8)), + resend_api_key: z.string().optional(), + reo_client_id: z.string().optional(), + }).optional().default({ + encryption_key_path: "./config/encryption.pem" + }), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.number().int().nonnegative().optional().default(0), + replicas: z + .array( + z.object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.number().int().nonnegative().optional().default(0) + }) + ) + .optional() + // tls: z + // .object({ + // reject_unauthorized: z + // .boolean() + // .optional() + // .default(true) + // }) + // .optional() + }) + .optional(), + gerbil: z + .object({ + local_exit_node_reachable_at: z.string().optional().default("http://gerbil:3003") + }) + .optional() + .default({}), + flags: z + .object({ + enable_redis: z.boolean().optional(), + hide_supporter_key: z.boolean().optional() + }) + .optional(), + branding: z + .object({ + app_name: z.string().optional(), + background_image_path: z.string().optional(), + colors: z + .object({ + light: colorsSchema.optional(), + dark: colorsSchema.optional() + }) + .optional(), + logo: z + .object({ + light_path: z.string().optional(), + dark_path: z.string().optional(), + auth_page: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional(), + navbar: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .optional(), + favicon_path: z.string().optional(), + footer: z + .array( + z.object({ + text: z.string(), + href: z.string().optional() + }) + ) + .optional(), + login_page: z + .object({ + subtitle_text: z.string().optional(), + title_text: z.string().optional() + }) + .optional(), + signup_page: z + .object({ + subtitle_text: z.string().optional(), + title_text: z.string().optional() + }) + .optional(), + resource_auth_page: z + .object({ + show_logo: z.boolean().optional(), + hide_powered_by: z.boolean().optional(), + title_text: z.string().optional(), + subtitle_text: z.string().optional() + }) + .optional(), + emails: z + .object({ + signature: z.string().optional(), + colors: z + .object({ + primary: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional(), + stripe: z + .object({ + secret_key: z.string(), + webhook_secret: z.string(), + s3Bucket: z.string(), + s3Region: z.string().default("us-east-1"), + localFilePath: z.string() + }) + .optional(), + }) + +export function readPrivateConfigFile() { + if (build == "oss") { + return {}; + } + + const loadConfig = (configPath: string) => { + try { + const yamlContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(yamlContent); + return config; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Error loading configuration file: ${error.message}` + ); + } + throw error; + } + }; + + let environment: any; + if (fs.existsSync(privateConfigFilePath1)) { + environment = loadConfig(privateConfigFilePath1); + } + + if (!environment) { + throw new Error( + "No private configuration file found." + ); + } + + return environment; +} diff --git a/server/lib/private/resend.ts b/server/lib/private/resend.ts new file mode 100644 index 00000000..a502e98c --- /dev/null +++ b/server/lib/private/resend.ts @@ -0,0 +1,124 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Resend } from "resend"; +import config from "../config"; +import logger from "@server/logger"; + +export enum AudienceIds { + General = "5cfbf99b-c592-40a9-9b8a-577a4681c158", + Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20", + Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549" +} + +const resend = new Resend( + config.getRawPrivateConfig().server.resend_api_key || "missing" +); + +export default resend; + +export async function moveEmailToAudience( + email: string, + audienceId: AudienceIds +) { + if (process.env.ENVIRONMENT !== "prod") { + logger.debug(`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`); + return; + } + const { error, data } = await retryWithBackoff(async () => { + const { data, error } = await resend.contacts.create({ + email, + unsubscribed: false, + audienceId + }); + if (error) { + throw new Error( + `Error adding email ${email} to audience ${audienceId}: ${error}` + ); + } + return { error, data }; + }); + + if (error) { + logger.error( + `Error adding email ${email} to audience ${audienceId}: ${error}` + ); + return; + } + + if (data) { + logger.debug( + `Added email ${email} to audience ${audienceId} with contact ID ${data.id}` + ) + } + + const otherAudiences = Object.values(AudienceIds).filter( + (id) => id !== audienceId + ); + + for (const otherAudienceId of otherAudiences) { + const { error, data } = await retryWithBackoff(async () => { + const { data, error } = await resend.contacts.remove({ + email, + audienceId: otherAudienceId + }); + if (error) { + throw new Error( + `Error removing email ${email} from audience ${otherAudienceId}: ${error}` + ); + } + return { error, data }; + }); + + if (error) { + logger.error( + `Error removing email ${email} from audience ${otherAudienceId}: ${error}` + ); + } + + if (data) { + logger.info( + `Removed email ${email} from audience ${otherAudienceId}` + ); + } + } +} + +type RetryOptions = { + retries?: number; + initialDelayMs?: number; + factor?: number; +}; + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const { retries = 5, initialDelayMs = 500, factor = 2 } = options; + + let attempt = 0; + let delay = initialDelayMs; + + while (true) { + try { + return await fn(); + } catch (err) { + attempt++; + + if (attempt > retries) throw err; + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= factor; + } + } +} diff --git a/server/lib/private/s3.ts b/server/lib/private/s3.ts new file mode 100644 index 00000000..26b1d49b --- /dev/null +++ b/server/lib/private/s3.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { S3Client } from "@aws-sdk/client-s3"; +import config from "@server/lib/config"; + +export const s3Client = new S3Client({ + region: config.getRawPrivateConfig().stripe?.s3Region || "us-east-1", +}); diff --git a/server/lib/private/stripe.ts b/server/lib/private/stripe.ts new file mode 100644 index 00000000..1170202d --- /dev/null +++ b/server/lib/private/stripe.ts @@ -0,0 +1,28 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { build } from "@server/build"; + +let stripe: Stripe | undefined = undefined; +if (build == "saas") { + const stripeApiKey = config.getRawPrivateConfig().stripe?.secret_key; + if (!stripeApiKey) { + logger.error("Stripe secret key is not configured"); + } + stripe = new Stripe(stripeApiKey!); +} + +export default stripe; diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 4ae80ac2..b70818ec 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -30,7 +30,7 @@ export const configSchema = z anonymous_usage: z.boolean().optional().default(true) }) .optional() - .default({}) + .default({}), }).optional().default({ log_level: "info", save_logs: false, @@ -130,7 +130,8 @@ export const configSchema = z secret: z .string() .pipe(z.string().min(8)) - .optional() + .optional(), + maxmind_db_path: z.string().optional() }).optional().default({ integration_port: 3003, external_port: 3000, diff --git a/server/lib/remoteCertificates/certificates.ts b/server/lib/remoteCertificates/certificates.ts index 9a4ce001..6404ee75 100644 --- a/server/lib/remoteCertificates/certificates.ts +++ b/server/lib/remoteCertificates/certificates.ts @@ -13,8 +13,8 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set) wildcard: boolean | null; certFile: string | null; keyFile: string | null; - expiresAt: Date | null; - updatedAt?: Date | null; + expiresAt: number | null; + updatedAt?: number | null; }> > { if (domains.size === 0) { @@ -72,8 +72,8 @@ export async function getValidCertificatesForDomains(domains: Set): Prom wildcard: boolean | null; certFile: string | null; keyFile: string | null; - expiresAt: Date | null; - updatedAt?: Date | null; + expiresAt: number | null; + updatedAt?: number | null; }> > { return []; // stub diff --git a/server/lib/remoteCertificates/index.ts b/server/lib/remoteCertificates/index.ts index 53051b6c..fcd43d30 100644 --- a/server/lib/remoteCertificates/index.ts +++ b/server/lib/remoteCertificates/index.ts @@ -1 +1,14 @@ -export * from "./certificates"; \ No newline at end of file +import { build } from "@server/build"; + +// Import both modules +import * as certificateModule from "./certificates"; +import * as privateCertificateModule from "./privateCertificates"; + +// Conditionally export Remote Certificates implementation based on build type +const remoteCertificatesImplementation = build === "oss" ? certificateModule : privateCertificateModule; + +// Re-export all items from the selected implementation +export const { + getValidCertificatesForDomains, + getValidCertificatesForDomainsHybrid + } = remoteCertificatesImplementation; \ No newline at end of file diff --git a/server/lib/remoteCertificates/privateCertificates.ts b/server/lib/remoteCertificates/privateCertificates.ts new file mode 100644 index 00000000..fabc9ea5 --- /dev/null +++ b/server/lib/remoteCertificates/privateCertificates.ts @@ -0,0 +1,116 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import config from "../config"; +import { certificates, db } from "@server/db"; +import { and, eq, isNotNull } from "drizzle-orm"; +import { decryptData } from "../encryption"; +import * as fs from "fs"; + +export async function getValidCertificatesForDomains( + domains: Set +): Promise< + Array<{ + id: number; + domain: string; + wildcard: boolean | null; + certFile: string | null; + keyFile: string | null; + expiresAt: number | null; + updatedAt?: number | null; + }> +> { + if (domains.size === 0) { + return []; + } + + const domainArray = Array.from(domains); + + // TODO: add more foreign keys to make this query more efficient - we dont need to keep getting every certificate + const validCerts = await db + .select({ + id: certificates.certId, + domain: certificates.domain, + certFile: certificates.certFile, + keyFile: certificates.keyFile, + expiresAt: certificates.expiresAt, + updatedAt: certificates.updatedAt, + wildcard: certificates.wildcard + }) + .from(certificates) + .where( + and( + eq(certificates.status, "valid"), + isNotNull(certificates.certFile), + isNotNull(certificates.keyFile) + ) + ); + + // Filter certificates for the specified domains and if it is a wildcard then you can match on everything up to the first dot + const validCertsFiltered = validCerts.filter((cert) => { + return ( + domainArray.includes(cert.domain) || + (cert.wildcard && + domainArray.some((domain) => + domain.endsWith(`.${cert.domain}`) + )) + ); + }); + + const encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path; + + if (!fs.existsSync(encryptionKeyPath)) { + throw new Error( + "Encryption key file not found. Please generate one first." + ); + } + + const encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim(); + const encryptionKey = Buffer.from(encryptionKeyHex, "hex"); + + const validCertsDecrypted = validCertsFiltered.map((cert) => { + // Decrypt and save certificate file + const decryptedCert = decryptData( + cert.certFile!, // is not null from query + encryptionKey + ); + + // Decrypt and save key file + const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + + // Return only the certificate data without org information + return { + ...cert, + certFile: decryptedCert, + keyFile: decryptedKey + }; + }); + + return validCertsDecrypted; +} + +export async function getValidCertificatesForDomainsHybrid( + domains: Set +): Promise< + Array<{ + id: number; + domain: string; + wildcard: boolean | null; + certFile: string | null; + keyFile: string | null; + expiresAt: number | null; + updatedAt?: number | null; + }> +> { + return []; // stub +} diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 0888ff31..5e2bd400 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -17,3 +17,12 @@ export const tlsNameSchema = z ) .transform((val) => val.toLowerCase()); +export const privateNamespaceSubdomainSchema = z + .string() + .regex( + /^[a-zA-Z0-9-]+$/, + "Namespace subdomain can only contain letters, numbers, and hyphens" + ) + .min(1, "Namespace subdomain must be at least 1 character long") + .max(32, "Namespace subdomain must be at most 32 characters long") + .transform((val) => val.toLowerCase()); diff --git a/server/lib/traefikConfig.ts b/server/lib/traefik/TraefikConfigManager.ts similarity index 93% rename from server/lib/traefikConfig.ts rename to server/lib/traefik/TraefikConfigManager.ts index 8b133419..51466ecf 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -6,16 +6,15 @@ import * as yaml from "js-yaml"; import axios from "axios"; import { db, exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; -import { tokenManager } from "./tokenManager"; -import { - getCurrentExitNodeId, - getTraefikConfig -} from "@server/routers/traefik"; +import { tokenManager } from "../tokenManager"; +import { getCurrentExitNodeId } from "@server/lib/exitNodes"; +import { getTraefikConfig } from "./"; import { getValidCertificatesForDomains, getValidCertificatesForDomainsHybrid -} from "./remoteCertificates"; -import { sendToExitNode } from "./exitNodeComms"; +} from "../remoteCertificates"; +import { sendToExitNode } from "../exitNodes"; +import { build } from "@server/build"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -28,8 +27,8 @@ export class TraefikConfigManager { string, { exists: boolean; - lastModified: Date | null; - expiresAt: Date | null; + lastModified: number | null; + expiresAt: number | null; wildcard: boolean | null; } >(); @@ -115,8 +114,8 @@ export class TraefikConfigManager { string, { exists: boolean; - lastModified: Date | null; - expiresAt: Date | null; + lastModified: number | null; + expiresAt: number | null; wildcard: boolean; } > @@ -217,7 +216,12 @@ export class TraefikConfigManager { // Filter out domains covered by wildcard certificates const domainsNeedingCerts = new Set(); for (const domain of currentDomains) { - if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + if ( + !isDomainCoveredByWildcard( + domain, + this.lastLocalCertificateState + ) + ) { domainsNeedingCerts.add(domain); } } @@ -225,7 +229,12 @@ export class TraefikConfigManager { // Fetch if domains needing certificates have changed const lastDomainsNeedingCerts = new Set(); for (const domain of this.lastKnownDomains) { - if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + if ( + !isDomainCoveredByWildcard( + domain, + this.lastLocalCertificateState + ) + ) { lastDomainsNeedingCerts.add(domain); } } @@ -255,7 +264,7 @@ export class TraefikConfigManager { // Check if certificate is expiring soon (within 30 days) if (localState.expiresAt) { const daysUntilExpiry = - (localState.expiresAt.getTime() - Date.now()) / + (localState.expiresAt - Math.floor(Date.now() / 1000)) / (1000 * 60 * 60 * 24); if (daysUntilExpiry < 30) { logger.info( @@ -276,7 +285,7 @@ export class TraefikConfigManager { public async HandleTraefikConfig(): Promise { try { // Get all active domains for this exit node via HTTP call - const getTraefikConfig = await this.getTraefikConfig(); + const getTraefikConfig = await this.internalGetTraefikConfig(); if (!getTraefikConfig) { logger.error( @@ -315,15 +324,20 @@ export class TraefikConfigManager { wildcard: boolean | null; certFile: string | null; keyFile: string | null; - expiresAt: Date | null; - updatedAt?: Date | null; + expiresAt: number | null; + updatedAt?: number | null; }> = []; if (this.shouldFetchCertificates(domains)) { // Filter out domains that are already covered by wildcard certificates const domainsToFetch = new Set(); for (const domain of domains) { - if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + if ( + !isDomainCoveredByWildcard( + domain, + this.lastLocalCertificateState + ) + ) { domainsToFetch.add(domain); } else { logger.debug( @@ -339,7 +353,7 @@ export class TraefikConfigManager { await getValidCertificatesForDomainsHybrid( domainsToFetch ); - } else { + } else { validCertificates = await getValidCertificatesForDomains( domainsToFetch @@ -428,7 +442,7 @@ export class TraefikConfigManager { /** * Get all domains currently in use from traefik config API */ - private async getTraefikConfig(): Promise<{ + private async internalGetTraefikConfig(): Promise<{ domains: Set; traefikConfig: any; } | null> { @@ -451,9 +465,13 @@ export class TraefikConfigManager { traefikConfig = resp.data.data; } else { const currentExitNode = await getCurrentExitNodeId(); + // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); traefikConfig = await getTraefikConfig( + // this is called by the local exit node to get its own config currentExitNode, - config.getRawConfig().traefik.site_types + config.getRawConfig().traefik.site_types, + build == "oss", // filter out the namespace domains in open source + build != "oss" // generate the login pages on the cloud and hybrid ); } @@ -621,7 +639,8 @@ export class TraefikConfigManager { } // If no exact match, check for wildcard certificates that cover this domain - for (const [certDomain, certState] of this.lastLocalCertificateState) { + for (const [certDomain, certState] of this + .lastLocalCertificateState) { if (certState.exists && certState.wildcard) { // Check if this wildcard certificate covers the domain if (domain.endsWith("." + certDomain)) { @@ -671,8 +690,8 @@ export class TraefikConfigManager { wildcard: boolean | null; certFile: string | null; keyFile: string | null; - expiresAt: Date | null; - updatedAt?: Date | null; + expiresAt: number | null; + updatedAt?: number | null; }> ): Promise { const dynamicConfigPath = @@ -758,7 +777,7 @@ export class TraefikConfigManager { // Update local state tracking this.lastLocalCertificateState.set(cert.domain, { exists: true, - lastModified: new Date(), + lastModified: Math.floor(Date.now() / 1000), expiresAt: cert.expiresAt, wildcard: cert.wildcard }); @@ -800,8 +819,8 @@ export class TraefikConfigManager { cert: { id: number; domain: string; - expiresAt: Date | null; - updatedAt?: Date | null; + expiresAt: number | null; + updatedAt?: number | null; }, certPath: string, keyPath: string, @@ -818,12 +837,12 @@ export class TraefikConfigManager { } // Read last update time from .last_update file - let lastUpdateTime: Date | null = null; + let lastUpdateTime: number | null = null; try { const lastUpdateStr = fs .readFileSync(lastUpdatePath, "utf8") .trim(); - lastUpdateTime = new Date(lastUpdateStr); + lastUpdateTime = Math.floor(new Date(lastUpdateStr).getTime() / 1000); } catch { lastUpdateTime = null; } @@ -1004,7 +1023,12 @@ export class TraefikConfigManager { // Find domains covered by wildcards for (const domain of this.activeDomains) { - if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + if ( + isDomainCoveredByWildcard( + domain, + this.lastLocalCertificateState + ) + ) { domainsCoveredByWildcards.push(domain); } } @@ -1025,7 +1049,13 @@ export class TraefikConfigManager { /** * Check if a domain is covered by existing wildcard certificates */ -export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map): boolean { +export function isDomainCoveredByWildcard( + domain: string, + lastLocalCertificateState: Map< + string, + { exists: boolean; wildcard: boolean | null } + > +): boolean { for (const [certDomain, state] of lastLocalCertificateState) { if (state.exists && state.wildcard) { // If stored as example.com but is wildcard, check subdomains diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts similarity index 76% rename from server/routers/traefik/getTraefikConfig.ts rename to server/lib/traefik/getTraefikConfig.ts index fa722ed5..598ce984 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,95 +1,14 @@ -import { Request, Response } from "express"; -import { db, exitNodes } from "@server/db"; +import { db, exitNodes, targetHealthCheck } from "@server/db"; import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm"; import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; import { build } from "@server/build"; +import createPathRewriteMiddleware from "./middleware"; -let currentExitNodeId: number; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; -export async function getCurrentExitNodeId(): Promise { - if (!currentExitNodeId) { - if (config.getRawConfig().gerbil.exit_node_name) { - const exitNodeName = config.getRawConfig().gerbil.exit_node_name!; - const [exitNode] = await db - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .where(eq(exitNodes.name, exitNodeName)); - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } else { - const [exitNode] = await db - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .limit(1); - - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } - } - return currentExitNodeId; -} - -export async function traefikConfigProvider( - _: Request, - res: Response -): Promise { - try { - // First query to get resources with site and org info - // Get the current exit node name from config - await getCurrentExitNodeId(); - - const traefikConfig = await getTraefikConfig( - currentExitNodeId, - config.getRawConfig().traefik.site_types - ); - - if (traefikConfig?.http?.middlewares) { - // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING - traefikConfig.http.middlewares[badgerMiddlewareName] = { - plugin: { - [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, - userSessionCookieName: - config.getRawConfig().server.session_cookie_name, - - // deprecated - accessTokenQueryParam: - config.getRawConfig().server - .resource_access_token_param, - - resourceSessionRequestParam: - config.getRawConfig().server - .resource_session_request_param - } - } - }; - } - - return res.status(HttpCode.OK).json(traefikConfig); - } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); - return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ - error: "Failed to build Traefik config" - }); - } -} - function validatePathRewriteConfig( path: string | null, @@ -157,140 +76,11 @@ function validatePathRewriteConfig( return { isValid: true }; } - -function createPathRewriteMiddleware( - middlewareName: string, - path: string, - pathMatchType: string, - rewritePath: string, - rewritePathType: string -): { middlewares: { [key: string]: any }; chain?: string[] } { - const middlewares: { [key: string]: any } = {}; - - if (pathMatchType !== "regex" && !path.startsWith("/")) { - path = `/${path}`; - } - - if (rewritePathType !== "regex" && rewritePath !== "" && !rewritePath.startsWith("/")) { - rewritePath = `/${rewritePath}`; - } - - switch (rewritePathType) { - case "exact": - // Replace the path with the exact rewrite path - let exactPattern = `^${escapeRegex(path)}$`; - middlewares[middlewareName] = { - replacePathRegex: { - regex: exactPattern, - replacement: rewritePath - } - }; - break; - - case "prefix": - // Replace matched prefix with new prefix, preserve the rest - switch (pathMatchType) { - case "prefix": - middlewares[middlewareName] = { - replacePathRegex: { - regex: `^${escapeRegex(path)}(.*)`, - replacement: `${rewritePath}$1` - } - }; - break; - case "exact": - middlewares[middlewareName] = { - replacePathRegex: { - regex: `^${escapeRegex(path)}$`, - replacement: rewritePath - } - }; - break; - case "regex": - // For regex path matching with prefix rewrite, we assume the regex has capture groups - middlewares[middlewareName] = { - replacePathRegex: { - regex: path, - replacement: rewritePath - } - }; - break; - } - break; - - case "regex": - // Use advanced regex replacement - works with any match type - let regexPattern: string; - if (pathMatchType === "regex") { - regexPattern = path; - } else if (pathMatchType === "prefix") { - regexPattern = `^${escapeRegex(path)}(.*)`; - } else { // exact - regexPattern = `^${escapeRegex(path)}$`; - } - - middlewares[middlewareName] = { - replacePathRegex: { - regex: regexPattern, - replacement: rewritePath - } - }; - break; - - case "stripPrefix": - // Strip the matched prefix and optionally add new path - if (pathMatchType === "prefix") { - middlewares[middlewareName] = { - stripPrefix: { - prefixes: [path] - } - }; - - // If rewritePath is provided and not empty, add it as a prefix after stripping - if (rewritePath && rewritePath !== "" && rewritePath !== "/") { - const addPrefixMiddlewareName = `addprefix-${middlewareName.replace('rewrite-', '')}`; - middlewares[addPrefixMiddlewareName] = { - addPrefix: { - prefix: rewritePath - } - }; - return { - middlewares, - chain: [middlewareName, addPrefixMiddlewareName] - }; - } - } else { - // For exact and regex matches, use replacePathRegex to strip - let regexPattern: string; - if (pathMatchType === "exact") { - regexPattern = `^${escapeRegex(path)}$`; - } else if (pathMatchType === "regex") { - regexPattern = path; - } else { - regexPattern = `^${escapeRegex(path)}`; - } - - const replacement = rewritePath || "/"; - middlewares[middlewareName] = { - replacePathRegex: { - regex: regexPattern, - replacement: replacement - } - }; - } - break; - - default: - logger.error(`Unknown rewritePathType: ${rewritePathType}`); - throw new Error(`Unknown rewritePathType: ${rewritePathType}`); - } - - return { middlewares }; -} - export async function getTraefikConfig( exitNodeId: number, - siteTypes: string[] + siteTypes: string[], + filterOutNamespaceDomains = false, + generateLoginPageRouters = false ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -329,6 +119,7 @@ export async function getTraefikConfig( method: targets.method, port: targets.port, internalPort: targets.internalPort, + hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, @@ -343,11 +134,19 @@ export async function getTraefikConfig( .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) .where( and( eq(targets.enabled, true), eq(resources.enabled, true), or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)), + or( + ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets + isNull(targetHealthCheck.hcHealth) // Include targets with no health check record + ), inArray(sites.type, siteTypes), config.getRawConfig().traefik.allow_raw_resources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true @@ -863,8 +662,4 @@ function sanitizeForMiddlewareName(str: string): string { // Replace any characters that aren't alphanumeric or dash with dash // and remove consecutive dashes return str.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); -} - -function escapeRegex(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } \ No newline at end of file diff --git a/server/lib/traefik/index.ts b/server/lib/traefik/index.ts new file mode 100644 index 00000000..1d654510 --- /dev/null +++ b/server/lib/traefik/index.ts @@ -0,0 +1,11 @@ +import { build } from "@server/build"; + +// Import both modules +import * as traefikModule from "./getTraefikConfig"; +import * as privateTraefikModule from "./privateGetTraefikConfig"; + +// Conditionally export Traefik configuration implementation based on build type +const traefikImplementation = build === "oss" ? traefikModule : privateTraefikModule; + +// Re-export all items from the selected implementation +export const { getTraefikConfig } = traefikImplementation; \ No newline at end of file diff --git a/server/lib/traefik/middleware.ts b/server/lib/traefik/middleware.ts new file mode 100644 index 00000000..e6660776 --- /dev/null +++ b/server/lib/traefik/middleware.ts @@ -0,0 +1,140 @@ +import logger from "@server/logger"; + +export default function createPathRewriteMiddleware( + middlewareName: string, + path: string, + pathMatchType: string, + rewritePath: string, + rewritePathType: string +): { middlewares: { [key: string]: any }; chain?: string[] } { + const middlewares: { [key: string]: any } = {}; + + if (pathMatchType !== "regex" && !path.startsWith("/")) { + path = `/${path}`; + } + + if ( + rewritePathType !== "regex" && + rewritePath !== "" && + !rewritePath.startsWith("/") + ) { + rewritePath = `/${rewritePath}`; + } + + switch (rewritePathType) { + case "exact": + // Replace the path with the exact rewrite path + let exactPattern = `^${escapeRegex(path)}$`; + middlewares[middlewareName] = { + replacePathRegex: { + regex: exactPattern, + replacement: rewritePath + } + }; + break; + + case "prefix": + // Replace matched prefix with new prefix, preserve the rest + switch (pathMatchType) { + case "prefix": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}(.*)`, + replacement: `${rewritePath}$1` + } + }; + break; + case "exact": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}$`, + replacement: rewritePath + } + }; + break; + case "regex": + // For regex path matching with prefix rewrite, we assume the regex has capture groups + middlewares[middlewareName] = { + replacePathRegex: { + regex: path, + replacement: rewritePath + } + }; + break; + } + break; + + case "regex": + // Use advanced regex replacement - works with any match type + let regexPattern: string; + if (pathMatchType === "regex") { + regexPattern = path; + } else if (pathMatchType === "prefix") { + regexPattern = `^${escapeRegex(path)}(.*)`; + } else { + // exact + regexPattern = `^${escapeRegex(path)}$`; + } + + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: rewritePath + } + }; + break; + + case "stripPrefix": + // Strip the matched prefix and optionally add new path + if (pathMatchType === "prefix") { + middlewares[middlewareName] = { + stripPrefix: { + prefixes: [path] + } + }; + + // If rewritePath is provided and not empty, add it as a prefix after stripping + if (rewritePath && rewritePath !== "" && rewritePath !== "/") { + const addPrefixMiddlewareName = `addprefix-${middlewareName.replace("rewrite-", "")}`; + middlewares[addPrefixMiddlewareName] = { + addPrefix: { + prefix: rewritePath + } + }; + return { + middlewares, + chain: [middlewareName, addPrefixMiddlewareName] + }; + } + } else { + // For exact and regex matches, use replacePathRegex to strip + let regexPattern: string; + if (pathMatchType === "exact") { + regexPattern = `^${escapeRegex(path)}$`; + } else if (pathMatchType === "regex") { + regexPattern = path; + } else { + regexPattern = `^${escapeRegex(path)}`; + } + + const replacement = rewritePath || "/"; + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: replacement + } + }; + } + break; + + default: + logger.error(`Unknown rewritePathType: ${rewritePathType}`); + throw new Error(`Unknown rewritePathType: ${rewritePathType}`); + } + + return { middlewares }; +} + +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/server/lib/traefik/privateGetTraefikConfig.ts b/server/lib/traefik/privateGetTraefikConfig.ts new file mode 100644 index 00000000..7f1ff614 --- /dev/null +++ b/server/lib/traefik/privateGetTraefikConfig.ts @@ -0,0 +1,692 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response } from "express"; +import { + certificates, + db, + domainNamespaces, + exitNodes, + loginPage, + targetHealthCheck +} from "@server/db"; +import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import config from "@server/lib/config"; +import { orgs, resources, sites, Target, targets } from "@server/db"; +import { build } from "@server/build"; + +const redirectHttpsMiddlewareName = "redirect-to-https"; +const redirectToRootMiddlewareName = "redirect-to-root"; +const badgerMiddlewareName = "badger"; + +export async function getTraefikConfig( + exitNodeId: number, + siteTypes: string[], + filterOutNamespaceDomains = false, + generateLoginPageRouters = false +): Promise { + // Define extended target type with site information + type TargetWithSite = Target & { + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + online: boolean; + }; + }; + + // Get resources with their targets and sites in a single optimized query + // Start from sites on this exit node, then join to targets and resources + const resourcesWithTargetsAndSites = await db + .select({ + // Resource fields + resourceId: resources.resourceId, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + http: resources.http, + proxyPort: resources.proxyPort, + protocol: resources.protocol, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + stickySession: resources.stickySession, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader, + enableProxy: resources.enableProxy, + headers: resources.headers, + // Target fields + targetId: targets.targetId, + targetEnabled: targets.enabled, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + hcHealth: targetHealthCheck.hcHealth, + path: targets.path, + pathMatchType: targets.pathMatchType, + + // Site fields + siteId: sites.siteId, + siteType: sites.type, + siteOnline: sites.online, + subnet: sites.subnet, + exitNodeId: sites.exitNodeId, + // Namespace + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Certificate + certificateStatus: certificates.status + }) + .from(sites) + .innerJoin(targets, eq(targets.siteId, sites.siteId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .leftJoin( + domainNamespaces, + eq(domainNamespaces.domainId, resources.domainId) + ) // THIS IS CLOUD ONLY TO FILTER OUT THE DOMAIN NAMESPACES IF REQUIRED + .where( + and( + eq(targets.enabled, true), + eq(resources.enabled, true), + // or( + eq(sites.exitNodeId, exitNodeId), + // isNull(sites.exitNodeId) + // ), + or( + ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets + isNull(targetHealthCheck.hcHealth) // Include targets with no health check record + ), + inArray(sites.type, siteTypes), + config.getRawConfig().traefik.allow_raw_resources + ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true + : eq(resources.http, true) + ) + ); + + // Group by resource and include targets with their unique site data + const resourcesMap = new Map(); + + resourcesWithTargetsAndSites.forEach((row) => { + const resourceId = row.resourceId; + const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths + const pathMatchType = row.pathMatchType || ""; + + if (filterOutNamespaceDomains && row.domainNamespaceId) { + return; + } + + // Create a unique key combining resourceId and path+pathMatchType + const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-"); + const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); + + if (!resourcesMap.has(mapKey)) { + resourcesMap.set(mapKey, { + resourceId: row.resourceId, + fullDomain: row.fullDomain, + ssl: row.ssl, + http: row.http, + proxyPort: row.proxyPort, + protocol: row.protocol, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + stickySession: row.stickySession, + tlsServerName: row.tlsServerName, + setHostHeader: row.setHostHeader, + enableProxy: row.enableProxy, + certificateStatus: row.certificateStatus, + targets: [], + headers: row.headers, + path: row.path, // the targets will all have the same path + pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType + }); + } + + // Add target with its associated site data + resourcesMap.get(mapKey).targets.push({ + resourceId: row.resourceId, + targetId: row.targetId, + ip: row.ip, + method: row.method, + port: row.port, + internalPort: row.internalPort, + enabled: row.targetEnabled, + site: { + siteId: row.siteId, + type: row.siteType, + subnet: row.subnet, + exitNodeId: row.exitNodeId, + online: row.siteOnline + } + }); + }); + + // make sure we have at least one resource + if (resourcesMap.size === 0) { + return {}; + } + + const config_output: any = { + http: { + middlewares: { + [redirectHttpsMiddlewareName]: { + redirectScheme: { + scheme: "https" + } + }, + [redirectToRootMiddlewareName]: { + redirectRegex: { + regex: "^(https?)://([^/]+)(/.*)?", + replacement: "${1}://${2}/auth/org", + permanent: false + } + } + } + } + }; + + // get the key and the resource + for (const [key, resource] of resourcesMap.entries()) { + const targets = resource.targets; + + const routerName = `${key}-router`; + const serviceName = `${key}-service`; + const fullDomain = `${resource.fullDomain}`; + const transportName = `${key}-transport`; + const headersMiddlewareName = `${key}-headers-middleware`; + + if (!resource.enabled) { + continue; + } + + if (resource.http) { + if (!resource.domainId) { + continue; + } + + if (!resource.fullDomain) { + continue; + } + + if (resource.certificateStatus !== "valid") { + logger.debug( + `Resource ${resource.resourceId} has certificate stats ${resource.certificateStats}` + ); + continue; + } + + // add routers and services empty objects if they don't exist + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + + if (!config_output.http.services) { + config_output.http.services = {}; + } + + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + + let certResolver: string, preferWildcardCert: boolean; + if (!configDomain) { + certResolver = config.getRawConfig().traefik.cert_resolver; + preferWildcardCert = + config.getRawConfig().traefik.prefer_wildcard_cert; + } else { + certResolver = configDomain.cert_resolver; + preferWildcardCert = configDomain.prefer_wildcard_cert; + } + + let tls = {}; + if (build == "oss") { + tls = { + certResolver: certResolver, + ...(preferWildcardCert + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + } + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + if (resource.headers || resource.setHostHeader) { + // if there are headers, parse them into an object + const headersObj: { [key: string]: string } = {}; + if (resource.headers) { + let headersArr: { name: string; value: string }[] = []; + try { + headersArr = JSON.parse(resource.headers) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse headers for resource ${resource.resourceId}: ${e}` + ); + } + + headersArr.forEach((header) => { + headersObj[header.name] = header.value; + }); + } + + if (resource.setHostHeader) { + headersObj["Host"] = resource.setHostHeader; + } + + // check if the object is not empty + if (Object.keys(headersObj).length > 0) { + // Add the headers middleware + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[headersMiddlewareName] = { + headers: { + customRequestHeaders: headersObj + } + }; + + routerMiddlewares.push(headersMiddlewareName); + } + } + + let rule = `Host(\`${fullDomain}\`)`; + let priority = 100; + if (resource.path && resource.pathMatchType) { + priority += 1; + // add path to rule based on match type + let path = resource.path; + // if the path doesn't start with a /, add it + if (!path.startsWith("/")) { + path = `/${path}`; + } + if (resource.pathMatchType === "exact") { + rule += ` && Path(\`${path}\`)`; + } else if (resource.pathMatchType === "prefix") { + rule += ` && PathPrefix(\`${path}\`)`; + } else if (resource.pathMatchType === "regex") { + rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex + } + } + + config_output.http.routers![routerName] = { + entryPoints: [ + resource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: serviceName, + rule: rule, + priority: priority, + ...(resource.ssl ? { tls } : {}) + }; + + if (resource.ssl) { + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: rule, + priority: priority + }; + } + + config_output.http.services![serviceName] = { + loadBalancer: { + servers: (() => { + // Check if any sites are online + // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK + // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE + // RECEIVE BANDWIDTH ENDPOINT. + + // TODO: HOW TO HANDLE ^^^^^^ BETTER + const anySitesOnline = ( + targets as TargetWithSite[] + ).some((target: TargetWithSite) => target.site.online); + + return ( + (targets as TargetWithSite[]) + .filter((target: TargetWithSite) => { + if (!target.enabled) { + return false; + } + + // If any sites are online, exclude offline sites + if (anySitesOnline && !target.site.online) { + return false; + } + + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + if ( + !target.ip || + !target.port || + !target.method + ) { + return false; + } + } else if (target.site.type === "newt") { + if ( + !target.internalPort || + !target.method || + !target.site.subnet + ) { + return false; + } + } + return true; + }) + .map((target: TargetWithSite) => { + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + return { + url: `${target.method}://${target.ip}:${target.port}` + }; + } else if (target.site.type === "newt") { + const ip = + target.site.subnet!.split("/")[0]; + return { + url: `${target.method}://${ip}:${target.internalPort}` + }; + } + }) + // filter out duplicates + .filter( + (v, i, a) => + a.findIndex( + (t) => t && v && t.url === v.url + ) === i + ) + ); + })(), + ...(resource.stickySession + ? { + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } + : {}) + } + }; + + // Add the serversTransport if TLS server name is provided + if (resource.tlsServerName) { + if (!config_output.http.serversTransports) { + config_output.http.serversTransports = {}; + } + config_output.http.serversTransports![transportName] = { + serverName: resource.tlsServerName, + //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings + // if defined in the static config and here. if not set, self-signed certs won't work + insecureSkipVerify: true + }; + config_output.http.services![ + serviceName + ].loadBalancer.serversTransport = transportName; + } + } else { + // Non-HTTP (TCP/UDP) configuration + if (!resource.enableProxy) { + continue; + } + + const protocol = resource.protocol.toLowerCase(); + const port = resource.proxyPort; + + if (!port) { + continue; + } + + if (!config_output[protocol]) { + config_output[protocol] = { + routers: {}, + services: {} + }; + } + + config_output[protocol].routers[routerName] = { + entryPoints: [`${protocol}-${port}`], + service: serviceName, + ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) + }; + + config_output[protocol].services[serviceName] = { + loadBalancer: { + servers: (() => { + // Check if any sites are online + const anySitesOnline = ( + targets as TargetWithSite[] + ).some((target: TargetWithSite) => target.site.online); + + return (targets as TargetWithSite[]) + .filter((target: TargetWithSite) => { + if (!target.enabled) { + return false; + } + + // If any sites are online, exclude offline sites + if (anySitesOnline && !target.site.online) { + return false; + } + + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + if (!target.ip || !target.port) { + return false; + } + } else if (target.site.type === "newt") { + if ( + !target.internalPort || + !target.site.subnet + ) { + return false; + } + } + return true; + }) + .map((target: TargetWithSite) => { + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + return { + address: `${target.ip}:${target.port}` + }; + } else if (target.site.type === "newt") { + const ip = + target.site.subnet!.split("/")[0]; + return { + address: `${ip}:${target.internalPort}` + }; + } + }); + })(), + ...(resource.stickySession + ? { + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } + : {}) + } + }; + } + } + + if (generateLoginPageRouters) { + const exitNodeLoginPages = await db + .select({ + loginPageId: loginPage.loginPageId, + fullDomain: loginPage.fullDomain, + exitNodeId: exitNodes.exitNodeId, + domainId: loginPage.domainId, + certificateStatus: certificates.status + }) + .from(loginPage) + .innerJoin( + exitNodes, + eq(exitNodes.exitNodeId, loginPage.exitNodeId) + ) + .leftJoin( + certificates, + eq(certificates.domainId, loginPage.domainId) + ) + .where(eq(exitNodes.exitNodeId, exitNodeId)); + + if (exitNodeLoginPages.length > 0) { + if (!config_output.http.services) { + config_output.http.services = {}; + } + + if (!config_output.http.services["landing-service"]) { + config_output.http.services["landing-service"] = { + loadBalancer: { + servers: [ + { + url: `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.next_port}` + } + ] + } + }; + } + + for (const lp of exitNodeLoginPages) { + if (!lp.domainId) { + continue; + } + + if (!lp.fullDomain) { + continue; + } + + if (lp.certificateStatus !== "valid") { + continue; + } + + // auth-allowed: + // rule: "Host(`auth.pangolin.internal`) && (PathRegexp(`^/auth/resource/[0-9]+$`) || PathPrefix(`/_next`))" + // service: next-service + // entryPoints: + // - websecure + + const routerName = `loginpage-${lp.loginPageId}`; + const fullDomain = `${lp.fullDomain}`; + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + + config_output.http.routers![routerName + "-router"] = { + entryPoints: [ + config.getRawConfig().traefik.https_entrypoint + ], + service: "landing-service", + rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`, + priority: 203, + tls: {} + }; + + // auth-catchall: + // rule: "Host(`auth.example.com`)" + // middlewares: + // - redirect-to-root + // service: next-service + // entryPoints: + // - web + + config_output.http.routers![routerName + "-catchall"] = { + entryPoints: [ + config.getRawConfig().traefik.https_entrypoint + ], + middlewares: [redirectToRootMiddlewareName], + service: "landing-service", + rule: `Host(\`${fullDomain}\`)`, + priority: 202, + tls: {} + }; + + // we need to add a redirect from http to https too + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: "landing-service", + rule: `Host(\`${fullDomain}\`)`, + priority: 201 + }; + } + } + } + + return config_output; +} + +function sanitizePath(path: string | null | undefined): string | undefined { + if (!path) return undefined; + // clean any non alphanumeric characters from the path and replace with dashes + // the path cant be too long either, so limit to 50 characters + if (path.length > 50) { + path = path.substring(0, 50); + } + return path.replace(/[^a-zA-Z0-9]/g, ""); +} diff --git a/server/lib/traefikConfig.test.ts b/server/lib/traefik/traefikConfig.test.ts similarity index 99% rename from server/lib/traefikConfig.test.ts rename to server/lib/traefik/traefikConfig.test.ts index 55d19647..88e5da49 100644 --- a/server/lib/traefikConfig.test.ts +++ b/server/lib/traefik/traefikConfig.test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "@test/assert"; -import { isDomainCoveredByWildcard } from "./traefikConfig"; +import { isDomainCoveredByWildcard } from "./TraefikConfigManager"; function runTests() { console.log('Running wildcard domain coverage tests...'); diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 522e5018..59776105 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -163,6 +163,26 @@ export function validateHeaders(headers: string): boolean { }); } +export function isSecondLevelDomain(domain: string): boolean { + if (!domain || typeof domain !== 'string') { + return false; + } + + const trimmedDomain = domain.trim().toLowerCase(); + + // Split into parts + const parts = trimmedDomain.split('.'); + + // Should have exactly 2 parts for a second-level domain (e.g., "example.com") + if (parts.length !== 2) { + return false; + } + + // Check if the TLD part is valid + const tld = parts[1].toUpperCase(); + return validTlds.includes(tld); +} + const validTlds = [ "AAA", "AARP", diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 28a73afd..f211fa9e 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,4 +27,4 @@ export * from "./verifyApiKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; -export * from "./verifySiteResourceAccess"; +export * from "./verifySiteResourceAccess"; \ No newline at end of file diff --git a/server/middlewares/private/corsWithLoginPage.ts b/server/middlewares/private/corsWithLoginPage.ts new file mode 100644 index 00000000..03725c50 --- /dev/null +++ b/server/middlewares/private/corsWithLoginPage.ts @@ -0,0 +1,98 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import cors, { CorsOptions } from "cors"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { db, loginPage } from "@server/db"; +import { eq } from "drizzle-orm"; + +async function isValidLoginPageDomain(host: string): Promise { + try { + const [result] = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, host)) + .limit(1); + + const isValid = !!result; + + return isValid; + } catch (error) { + logger.error("Error checking loginPage domain:", error); + return false; + } +} + +export function corsWithLoginPageSupport(corsConfig: any) { + const options = { + ...(corsConfig?.origins + ? { origin: corsConfig.origins } + : { + origin: (origin: any, callback: any) => { + callback(null, true); + } + }), + ...(corsConfig?.methods && { methods: corsConfig.methods }), + ...(corsConfig?.allowed_headers && { + allowedHeaders: corsConfig.allowed_headers + }), + credentials: !(corsConfig?.credentials === false) + }; + + return async (req: Request, res: Response, next: NextFunction) => { + const originValidatedCorsConfig = { + origin: async ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void + ) => { + // If no origin (e.g., same-origin request), allow it + + if (!origin) { + return callback(null, true); + } + + const dashboardUrl = config.getRawConfig().app.dashboard_url; + + // If no dashboard_url is configured, allow all origins + if (!dashboardUrl) { + return callback(null, true); + } + + // Check if origin matches dashboard URL + const dashboardHost = new URL(dashboardUrl).host; + const originHost = new URL(origin).host; + + if (originHost === dashboardHost) { + return callback(null, true); + } + + // If origin doesn't match dashboard URL, check if it's a valid loginPage domain + const isValidDomain = await isValidLoginPageDomain(originHost); + + if (isValidDomain) { + return callback(null, true); + } + + // Origin is not valid + return callback(null, false); + }, + methods: corsConfig?.methods, + allowedHeaders: corsConfig?.allowed_headers, + credentials: corsConfig?.credentials !== false + } as CorsOptions; + + return cors(originValidatedCorsConfig)(req, res, next); + }; +} diff --git a/server/middlewares/private/index.ts b/server/middlewares/private/index.ts new file mode 100644 index 00000000..f034001d --- /dev/null +++ b/server/middlewares/private/index.ts @@ -0,0 +1,18 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./verifyCertificateAccess"; +export * from "./verifyRemoteExitNodeAccess"; +export * from "./verifyIdpAccess"; +export * from "./verifyLoginPageAccess"; +export * from "./corsWithLoginPage"; \ No newline at end of file diff --git a/server/middlewares/private/verifyCertificateAccess.ts b/server/middlewares/private/verifyCertificateAccess.ts new file mode 100644 index 00000000..1708215e --- /dev/null +++ b/server/middlewares/private/verifyCertificateAccess.ts @@ -0,0 +1,126 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { db, domainNamespaces } from "@server/db"; +import { certificates } from "@server/db"; +import { domains, orgDomains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyCertificateAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + // Assume user/org access is already verified + const orgId = req.params.orgId; + const certId = req.params.certId || req.body?.certId || req.query?.certId; + let domainId = + req.params.domainId || req.body?.domainId || req.query?.domainId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!domainId) { + + if (!certId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Must provide either certId or domainId") + ); + } + + // Get the certificate and its domainId + const [cert] = await db + .select() + .from(certificates) + .where(eq(certificates.certId, Number(certId))) + .limit(1); + + if (!cert) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Certificate with ID ${certId} not found` + ) + ); + } + + domainId = cert.domainId; + if (!domainId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Certificate with ID ${certId} does not have a domain` + ) + ); + } + } + + if (!domainId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Must provide either certId or domainId") + ); + } + + // Check if the domain is a namespace domain + const [namespaceDomain] = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (namespaceDomain) { + // If it's a namespace domain, we can skip the org check + return next(); + } + + // Check if the domain is associated with the org + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ) + .limit(1); + + if (!orgDomain) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have access to this certificate" + ) + ); + } + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying certificate access" + ) + ); + } +} +export default verifyCertificateAccess; diff --git a/server/middlewares/private/verifyIdpAccess.ts b/server/middlewares/private/verifyIdpAccess.ts new file mode 100644 index 00000000..87397a3d --- /dev/null +++ b/server/middlewares/private/verifyIdpAccess.ts @@ -0,0 +1,102 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { userOrgs, db, idp, idpOrg } from "@server/db"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyIdpAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const idpId = + req.params.idpId || req.body.idpId || req.query.idpId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!idpId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [idpRes] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) + .where( + and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)) + ) + .limit(1); + + if (!idpRes || !idpRes.idp || !idpRes.idpOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} not found for organization ${orgId}` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, idpRes.idpOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying idp access" + ) + ); + } +} diff --git a/server/middlewares/private/verifyLoginPageAccess.ts b/server/middlewares/private/verifyLoginPageAccess.ts new file mode 100644 index 00000000..bc9e8713 --- /dev/null +++ b/server/middlewares/private/verifyLoginPageAccess.ts @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { userOrgs, db, loginPageOrg } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyLoginPageAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const loginPageId = + req.params.loginPageId || + req.body.loginPageId || + req.query.loginPageId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!loginPageId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid login page ID") + ); + } + + const loginPageOrgs = await db + .select({ + orgId: loginPageOrg.orgId + }) + .from(loginPageOrg) + .where(eq(loginPageOrg.loginPageId, loginPageId)); + + const orgIds = loginPageOrgs.map((lpo) => lpo.orgId); + + const existingUserOrgs = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + inArray(userOrgs.orgId, orgIds) + ) + ); + + if (existingUserOrgs.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Login page with ID ${loginPageId} not found for user's organizations` + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying login page access" + ) + ); + } +} diff --git a/server/middlewares/private/verifyRemoteExitNode.ts b/server/middlewares/private/verifyRemoteExitNode.ts new file mode 100644 index 00000000..45c244e2 --- /dev/null +++ b/server/middlewares/private/verifyRemoteExitNode.ts @@ -0,0 +1,56 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Response } from "express"; +import ErrorResponse from "@server/types/ErrorResponse"; +import config from "@server/lib/config"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; +import logger from "@server/logger"; +import { validateRemoteExitNodeSessionToken } from "@server/auth/sessions/privateRemoteExitNode"; + +export const verifySessionRemoteExitNodeMiddleware = async ( + req: any, + res: Response, + next: NextFunction +) => { + // get the token from the auth header + const token = req.headers["authorization"]?.split(" ")[1] || ""; + + const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token); + + if (!session || !remoteExitNode) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info(`Remote exit node session not found. IP: ${req.ip}.`); + } + return next(unauthorized()); + } + + // const existingUser = await db + // .select() + // .from(users) + // .where(eq(users.userId, user.userId)); + + // if (!existingUser || !existingUser[0]) { + // if (config.getRawConfig().app.log_failed_attempts) { + // logger.info(`User session not found. IP: ${req.ip}.`); + // } + // return next( + // createHttpError(HttpCode.BAD_REQUEST, "User does not exist") + // ); + // } + + req.session = session; + req.remoteExitNode = remoteExitNode; + + next(); +}; diff --git a/server/middlewares/private/verifyRemoteExitNodeAccess.ts b/server/middlewares/private/verifyRemoteExitNodeAccess.ts new file mode 100644 index 00000000..a2cd2bac --- /dev/null +++ b/server/middlewares/private/verifyRemoteExitNodeAccess.ts @@ -0,0 +1,118 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; +import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyRemoteExitNodeAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; // Assuming you have user information in the request + const orgId = req.params.orgId; + const remoteExitNodeId = + req.params.remoteExitNodeId || + req.body.remoteExitNodeId || + req.query.remoteExitNodeId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + try { + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(and(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + if (!remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Remote exit node with ID ${remoteExitNodeId} does not have an exit node ID` + ) + ); + } + + const [exitNodeOrg] = await db + .select() + .from(exitNodeOrgs) + .where( + and( + eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId), + eq(exitNodeOrgs.orgId, orgId) + ) + ); + + if (!exitNodeOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found in organization ${orgId}` + ) + ); + } + + if (!req.userOrg) { + // Get user's role ID in the organization + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, exitNodeOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying remote exit node access" + ) + ); + } +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index a2cc44f2..521f1002 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -37,7 +37,7 @@ export async function verifyOrgAccess( } if (!req.userOrg) { - next( + return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 3a9120e3..64efb696 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { eq } from "drizzle-orm"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { hashPassword, verifyPassword diff --git a/server/routers/auth/checkResourceSession.ts b/server/routers/auth/checkResourceSession.ts index ca7d80cc..9840d564 100644 --- a/server/routers/auth/checkResourceSession.ts +++ b/server/routers/auth/checkResourceSession.ts @@ -3,7 +3,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import logger from "@server/logger"; diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 7fbea2e5..da19c0d7 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { eq } from "drizzle-orm"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 505d12c2..9db5931a 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -10,7 +10,10 @@ export * from "./resetPassword"; export * from "./requestPasswordReset"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; +export * from "./privateQuickStart"; export * from "./validateSetupToken"; export * from "./changePassword"; export * from "./checkResourceSession"; export * from "./securityKey"; +export * from "./privateGetSessionTransferToken"; +export * from "./privateTransferSession"; diff --git a/server/routers/auth/initialSetupComplete.ts b/server/routers/auth/initialSetupComplete.ts index 8da9acd7..2b616c97 100644 --- a/server/routers/auth/initialSetupComplete.ts +++ b/server/routers/auth/initialSetupComplete.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db, users } from "@server/db"; import { eq } from "drizzle-orm"; diff --git a/server/routers/auth/privateGetSessionTransferToken.ts b/server/routers/auth/privateGetSessionTransferToken.ts new file mode 100644 index 00000000..ba295923 --- /dev/null +++ b/server/routers/auth/privateGetSessionTransferToken.ts @@ -0,0 +1,97 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, sessionTransferToken } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { + generateSessionToken, + SESSION_COOKIE_NAME +} from "@server/auth/sessions/app"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { response } from "@server/lib/response"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; + +const paramsSchema = z.object({}).strict(); + +export type GetSessionTransferTokenRenponse = { + token: string; +}; + +export async function getSessionTransferToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { user, session } = req; + + if (!user || !session) { + return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); + } + + const tokenRaw = generateSessionToken(); + const token = encodeHexLowerCase( + sha256(new TextEncoder().encode(tokenRaw)) + ); + + const rawSessionId = req.cookies[SESSION_COOKIE_NAME]; + + if (!rawSessionId) { + return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); + } + + const encryptedSession = encrypt( + rawSessionId, + config.getRawConfig().server.secret! + ); + + await db.insert(sessionTransferToken).values({ + encryptedSession, + token, + sessionId: session.sessionId, + expiresAt: Date.now() + 30 * 1000 // Token valid for 30 seconds + }); + + return response(res, { + data: { + token: tokenRaw + }, + success: true, + error: false, + message: "Transfer token created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auth/privateQuickStart.ts b/server/routers/auth/privateQuickStart.ts new file mode 100644 index 00000000..bdb95e5b --- /dev/null +++ b/server/routers/auth/privateQuickStart.ts @@ -0,0 +1,581 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { + account, + db, + domainNamespaces, + domains, + exitNodes, + newts, + newtSessions, + orgs, + passwordResetTokens, + Resource, + resourcePassword, + resourcePincode, + resources, + resourceWhitelist, + roleResources, + roles, + roleSites, + sites, + targetHealthCheck, + targets, + userResources, + userSites +} from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { users } from "@server/db"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { SqliteError } from "better-sqlite3"; +import { eq, and, sql } from "drizzle-orm"; +import moment from "moment"; +import { generateId } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { UserType } from "@server/types/UserTypes"; +import { createUserAccountOrg } from "@server/lib/private/createUserAccountOrg"; +import { sendEmail } from "@server/emails"; +import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate, TimeSpan } from "oslo"; +import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names"; +import { pickPort } from "../target/helpers"; +import { addTargets } from "../newt/targets"; +import { isTargetValid } from "@server/lib/validators"; +import { listExitNodes } from "@server/lib/exitNodes"; + +const bodySchema = z.object({ + email: z.string().toLowerCase().email(), + ip: z.string().refine(isTargetValid), + method: z.enum(["http", "https"]), + port: z.number().int().min(1).max(65535), + pincode: z + .string() + .regex(/^\d{6}$/) + .optional(), + password: z.string().min(4).max(100).optional(), + enableWhitelist: z.boolean().optional().default(true), + animalId: z.string() // This is actually the secret key for the backend +}); + +export type QuickStartBody = z.infer; + +export type QuickStartResponse = { + newtId: string; + newtSecret: string; + resourceUrl: string; + completeSignUpLink: string; +}; + +const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22"; + +export async function quickStart( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { + email, + ip, + method, + port, + pincode, + password, + enableWhitelist, + animalId + } = parsedBody.data; + + try { + const tokenValidation = validateTokenOnApi(animalId); + + if (!tokenValidation.isValid) { + logger.warn( + `Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid or expired token" + ) + ); + } + + if (animalId === DEMO_UBO_KEY) { + if (email !== "mehrdad@getubo.com") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid email for demo Ubo key" + ) + ); + } + + const [existing] = await db + .select() + .from(users) + .where( + and( + eq(users.email, email), + eq(users.type, UserType.Internal) + ) + ); + + if (existing) { + // delete the user if it already exists + await db.delete(users).where(eq(users.userId, existing.userId)); + const orgId = `org_${existing.userId}`; + await db.delete(orgs).where(eq(orgs.orgId, orgId)); + } + } + + const tempPassword = generateId(15); + const passwordHash = await hashPassword(tempPassword); + const userId = generateId(15); + + // TODO: see if that user already exists? + + // Create the sandbox user + const existing = await db + .select() + .from(users) + .where( + and(eq(users.email, email), eq(users.type, UserType.Internal)) + ); + + if (existing && existing.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A user with that email address already exists" + ) + ); + } + + let newtId: string; + let secret: string; + let fullDomain: string; + let resource: Resource; + let orgId: string; + let completeSignUpLink: string; + + await db.transaction(async (trx) => { + await trx.insert(users).values({ + userId: userId, + type: UserType.Internal, + username: email, + email: email, + passwordHash, + dateCreated: moment().toISOString() + }); + + // create user"s account + await trx.insert(account).values({ + userId + }); + }); + + const { success, error, org } = await createUserAccountOrg( + userId, + email + ); + if (!success) { + if (error) { + throw new Error(error); + } + throw new Error("Failed to create user account and organization"); + } + if (!org) { + throw new Error("Failed to create user account and organization"); + } + + orgId = org.orgId; + + await db.transaction(async (trx) => { + const token = generateRandomString( + 8, + alphabet("0-9", "A-Z", "a-z") + ); + + await trx + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, userId)); + + const tokenHash = await hashPassword(token); + + await trx.insert(passwordResetTokens).values({ + userId: userId, + email: email, + tokenHash, + expiresAt: createDate(new TimeSpan(7, "d")).getTime() + }); + + // // Create the sandbox newt + // const newClientAddress = await getNextAvailableClientSubnet(orgId); + // if (!newClientAddress) { + // throw new Error("No available subnet found"); + // } + + // const clientAddress = newClientAddress.split("/")[0]; + + newtId = generateId(15); + secret = generateId(48); + + // Create the sandbox site + const siteNiceId = await getUniqueSiteName(orgId); + const siteName = `First Site`; + let siteId: number | undefined; + + // pick a random exit node + const exitNodesList = await listExitNodes(orgId); + + // select a random exit node + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + + if (!randomExitNode) { + throw new Error("No exit nodes available"); + } + + const [newSite] = await trx + .insert(sites) + .values({ + orgId, + exitNodeId: randomExitNode.exitNodeId, + name: siteName, + niceId: siteNiceId, + // address: clientAddress, + type: "newt", + dockerSocketEnabled: true + }) + .returning(); + + siteId = newSite.siteId; + + const adminRole = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + throw new Error("Admin role not found"); + } + + await trx.insert(roleSites).values({ + roleId: adminRole[0].roleId, + siteId: newSite.siteId + }); + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the site + await trx.insert(userSites).values({ + userId: req.user?.userId!, + siteId: newSite.siteId + }); + } + + // add the peer to the exit node + const secretHash = await hashPassword(secret!); + + await trx.insert(newts).values({ + newtId: newtId!, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + + const [randomNamespace] = await trx + .select() + .from(domainNamespaces) + .orderBy(sql`RANDOM()`) + .limit(1); + + if (!randomNamespace) { + throw new Error("No domain namespace available"); + } + + const [randomNamespaceDomain] = await trx + .select() + .from(domains) + .where(eq(domains.domainId, randomNamespace.domainId)) + .limit(1); + + if (!randomNamespaceDomain) { + throw new Error("No domain found for the namespace"); + } + + const resourceNiceId = await getUniqueResourceName(orgId); + + // Create sandbox resource + const subdomain = `${resourceNiceId}-${generateId(5)}`; + fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`; + + const resourceName = `First Resource`; + + const newResource = await trx + .insert(resources) + .values({ + niceId: resourceNiceId, + fullDomain, + domainId: randomNamespaceDomain.domainId, + orgId, + name: resourceName, + subdomain, + http: true, + protocol: "tcp", + ssl: true, + sso: false, + emailWhitelistEnabled: enableWhitelist + }) + .returning(); + + await trx.insert(roleResources).values({ + roleId: adminRole[0].roleId, + resourceId: newResource[0].resourceId + }); + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the resource + await trx.insert(userResources).values({ + userId: req.user?.userId!, + resourceId: newResource[0].resourceId + }); + } + + resource = newResource[0]; + + // Create the sandbox target + const { internalPort, targetIps } = await pickPort(siteId!, trx); + + if (!internalPort) { + throw new Error("No available internal port"); + } + + const newTarget = await trx + .insert(targets) + .values({ + resourceId: resource.resourceId, + siteId: siteId!, + internalPort, + ip, + method, + port, + enabled: true + }) + .returning(); + + const newHealthcheck = await trx + .insert(targetHealthCheck) + .values({ + targetId: newTarget[0].targetId, + hcEnabled: false + }).returning(); + + // add the new target to the targetIps array + targetIps.push(`${ip}/32`); + + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteId!)) + .limit(1); + + await addTargets(newt.newtId, newTarget, newHealthcheck, resource.protocol); + + // Set resource pincode if provided + if (pincode) { + await trx + .delete(resourcePincode) + .where( + eq(resourcePincode.resourceId, resource!.resourceId) + ); + + const pincodeHash = await hashPassword(pincode); + + await trx.insert(resourcePincode).values({ + resourceId: resource!.resourceId, + pincodeHash, + digitLength: 6 + }); + } + + // Set resource password if provided + if (password) { + await trx + .delete(resourcePassword) + .where( + eq(resourcePassword.resourceId, resource!.resourceId) + ); + + const passwordHash = await hashPassword(password); + + await trx.insert(resourcePassword).values({ + resourceId: resource!.resourceId, + passwordHash + }); + } + + // Set resource OTP if whitelist is enabled + if (enableWhitelist) { + await trx.insert(resourceWhitelist).values({ + email, + resourceId: resource!.resourceId + }); + } + + completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`; + + // Store token for email outside transaction + await sendEmail( + WelcomeQuickStart({ + username: email, + link: completeSignUpLink, + fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`, + resourceMethod: method, + resourceHostname: ip, + resourcePort: port, + resourceUrl: `https://${fullDomain}`, + cliCommand: `newt --id ${newtId} --secret ${secret}` + }), + { + to: email, + from: config.getNoReplyEmail(), + subject: `Access your Pangolin dashboard and resources` + } + ); + }); + + return response(res, { + data: { + newtId: newtId!, + newtSecret: secret!, + resourceUrl: `https://${fullDomain!}`, + completeSignUpLink: completeSignUpLink! + }, + success: true, + error: false, + message: "Quick start completed successfully", + status: HttpCode.OK + }); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A user with that email address already exists" + ) + ); + } else { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to do quick start" + ) + ); + } + } +} + +const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501"; + +/** + * Validates a token received from the frontend. + * @param {string} token The validation token from the request. + * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. + */ +const validateTokenOnApi = ( + token: string +): { isValid: boolean; message: string } => { + if (token === DEMO_UBO_KEY) { + // Special case for demo UBO key + return { isValid: true, message: "Demo UBO key is valid." }; + } + + if (!token) { + return { isValid: false, message: "Error: No token provided." }; + } + + try { + // 1. Decode the base64 string + const decodedB64 = atob(token); + + // 2. Reverse the character code manipulation + const deobfuscated = decodedB64 + .split("") + .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift + .join(""); + + // 3. Split the data to get the original secret and timestamp + const parts = deobfuscated.split("|"); + if (parts.length !== 2) { + throw new Error("Invalid token format."); + } + const receivedKey = parts[0]; + const tokenTimestamp = parseInt(parts[1], 10); + + // 4. Check if the secret key matches + if (receivedKey !== BACKEND_SECRET_KEY) { + return { isValid: false, message: "Invalid token: Key mismatch." }; + } + + // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks + const now = Date.now(); + const timeDifference = now - tokenTimestamp; + + if (timeDifference > 30000) { + // 30 seconds + return { isValid: false, message: "Invalid token: Expired." }; + } + + if (timeDifference < 0) { + // Timestamp is in the future + return { + isValid: false, + message: "Invalid token: Timestamp is in the future." + }; + } + + // If all checks pass, the token is valid + return { isValid: true, message: "Token is valid!" }; + } catch (error) { + // This will catch errors from atob (if not valid base64) or other issues. + return { + isValid: false, + message: `Error: ${(error as Error).message}` + }; + } +}; diff --git a/server/routers/auth/privateTransferSession.ts b/server/routers/auth/privateTransferSession.ts new file mode 100644 index 00000000..e75f77dd --- /dev/null +++ b/server/routers/auth/privateTransferSession.ts @@ -0,0 +1,128 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { sessions, sessionTransferToken } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import { response } from "@server/lib/response"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { serializeSessionCookie } from "@server/auth/sessions/app"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; + +const bodySchema = z.object({ + token: z.string() +}); + +export type TransferSessionBodySchema = z.infer; + +export type TransferSessionResponse = { + valid: boolean; + cookie?: string; +}; + +export async function transferSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { token } = parsedBody.data; + + const tokenRaw = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)) + ); + + const [existing] = await db + .select() + .from(sessionTransferToken) + .where(eq(sessionTransferToken.token, tokenRaw)) + .innerJoin( + sessions, + eq(sessions.sessionId, sessionTransferToken.sessionId) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token") + ); + } + + const transferToken = existing.sessionTransferToken; + const session = existing.session; + + if (!transferToken) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token") + ); + } + + await db + .delete(sessionTransferToken) + .where(eq(sessionTransferToken.token, tokenRaw)); + + if (Date.now() > transferToken.expiresAt) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Transfer token expired") + ); + } + + const rawSession = decrypt( + transferToken.encryptedSession, + config.getRawConfig().server.secret! + ); + + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + rawSession, + isSecure, + new Date(session.expiresAt) + ); + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: { valid: true, cookie }, + success: true, + error: false, + message: "Session exchanged successfully", + status: HttpCode.OK + }); + } catch (e) { + console.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to exchange session" + ) + ); + } +} diff --git a/server/routers/auth/requestEmailVerificationCode.ts b/server/routers/auth/requestEmailVerificationCode.ts index eeabedf2..7358e6ed 100644 --- a/server/routers/auth/requestEmailVerificationCode.ts +++ b/server/routers/auth/requestEmailVerificationCode.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { User } from "@server/db"; import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode"; import config from "@server/lib/config"; diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 62951ab1..52dce2e3 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -3,7 +3,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 753867b6..e6ae4fe4 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { encodeHex } from "oslo/encoding"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -113,7 +113,7 @@ export async function requestTotpSecret( const hex = crypto.getRandomValues(new Uint8Array(20)); const secret = encodeHex(hex); const uri = createTOTPKeyURI( - "Pangolin", + config.getRawPrivateConfig().branding?.app_name || "Pangolin", user.email!, hex ); diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 8ae62eb0..05293727 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -4,7 +4,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index 7e131dfd..1e75764b 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { db } from "@server/db"; import { User, securityKeys, users, webauthnChallenge } from "@server/db"; import { eq, and, lt } from "drizzle-orm"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import logger from "@server/logger"; import { generateRegistrationOptions, diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index ebb95359..716feca4 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -7,7 +7,7 @@ import { generateId } from "@server/auth/sessions/app"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { passwordSchema } from "@server/auth/passwordSchema"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db, users, setupTokens } from "@server/db"; import { eq, and } from "drizzle-orm"; import { UserType } from "@server/types/UserTypes"; diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 09c8db07..0d4f6865 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -21,7 +21,12 @@ import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; +import { createUserAccountOrg } from "@server/lib/private/createUserAccountOrg"; import { build } from "@server/build"; +import resend, { + AudienceIds, + moveEmailToAudience +} from "@server/lib/private/resend"; export const signupBodySchema = z.object({ email: z.string().toLowerCase().email(), @@ -188,6 +193,26 @@ export async function signup( // orgId: null, // }); + if (build == "saas") { + const { success, error, org } = await createUserAccountOrg( + userId, + email + ); + if (!success) { + if (error) { + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error) + ); + } + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create user account and organization" + ) + ); + } + } + const token = generateSessionToken(); const sess = await createSession(token, userId); const isSecure = req.protocol === "https"; @@ -198,6 +223,10 @@ export async function signup( ); res.appendHeader("Set-Cookie", cookie); + if (build == "saas") { + moveEmailToAudience(email, AudienceIds.General); + } + if (config.getRawConfig().flags?.require_email_verification) { sendEmailVerificationCode(email, userId); diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index 97ab540b..010ddf28 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -3,13 +3,15 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db, userOrgs } from "@server/db"; import { User, emailVerificationCodes, users } from "@server/db"; import { eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; import config from "@server/lib/config"; import logger from "@server/logger"; +import { freeLimitSet, limitsService } from "@server/lib/private/billing"; +import { build } from "@server/build"; export const verifyEmailBody = z .object({ @@ -88,6 +90,19 @@ export async function verifyEmail( ); } + if (build == "saas") { + const orgs = await db + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, user.userId)); + const orgIds = orgs.map((org) => org.orgId); + await Promise.all( + orgIds.map(async (orgId) => { + await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); + }) + ); + } + return response(res, { success: true, error: false, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 6b45a93e..c44c0c53 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -3,7 +3,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import { db } from "@server/db"; import { twoFactorBackupCodes, User, users } from "@server/db"; import { eq, and } from "drizzle-orm"; diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index d6f2c7c7..b4b2deea 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -15,7 +15,7 @@ import { import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index c482a564..7a0139bb 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -11,9 +11,11 @@ import { getUserOrgRole, getRoleResourceAccess, getUserResourceAccess, - getResourceRules + getResourceRules, + getOrgLoginPage } from "@server/db/queries/verifySessionQueries"; import { + LoginPage, Resource, ResourceAccessToken, ResourcePassword, @@ -32,7 +34,9 @@ import createHttpError from "http-errors"; import NodeCache from "node-cache"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { getCountryCodeForIp } from "@server/lib"; +import { getCountryCodeForIp, remoteGetCountryCodeForIp } from "@server/lib/geoip"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -196,16 +200,7 @@ export async function verifyResourceSession( return allowed(res); } - let endpoint: string; - if (config.isManagedMode()) { - endpoint = - config.getRawConfig().managed?.redirect_endpoint || - config.getRawConfig().managed?.endpoint || - ""; - } else { - endpoint = config.getRawConfig().app.dashboard_url!; - } - const redirectUrl = `${endpoint}/auth/resource/${encodeURIComponent( + const redirectPath = `/auth/resource/${encodeURIComponent( resource.resourceGuid )}?redirect=${encodeURIComponent(originalRequestURL)}`; @@ -408,7 +403,10 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } - return notAllowed(res, redirectUrl); + + logger.debug(`Redirecting to login at ${redirectPath}`); + + return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e); return next( @@ -463,7 +461,34 @@ function extractResourceSessionToken( return latest.token; } -function notAllowed(res: Response, redirectUrl?: string) { +async function notAllowed(res: Response, redirectPath?: string, orgId?: string) { + let loginPage: LoginPage | null = null; + if (orgId) { + const { tier } = await getOrgTierData(orgId); // returns null in oss + if (tier === TierId.STANDARD) { + loginPage = await getOrgLoginPage(orgId); + } + } + + let redirectUrl: string | undefined = undefined; + if (redirectPath) { + let endpoint: string; + + if (loginPage && loginPage.domainId && loginPage.fullDomain) { + const secure = config.getRawConfig().app.dashboard_url?.startsWith("https"); + const method = secure ? "https" : "http"; + endpoint = `${method}://${loginPage.fullDomain}`; + } else if (config.isManagedMode()) { + endpoint = + config.getRawConfig().managed?.redirect_endpoint || + config.getRawConfig().managed?.endpoint || + ""; + } else { + endpoint = config.getRawConfig().app.dashboard_url!; + } + redirectUrl = `${endpoint}${redirectPath}`; + } + const data = { data: { valid: false, redirectUrl }, success: true, @@ -762,7 +787,11 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise { let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); if (!cachedCountryCode) { - cachedCountryCode = await getCountryCodeForIp(ip); + if (config.isManagedMode()) { + cachedCountryCode = await remoteGetCountryCodeForIp(ip); + } else { + cachedCountryCode = await getCountryCodeForIp(ip); // do it locally + } // Cache for longer since IP geolocation doesn't change frequently cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 5adbfa01..f60f14a0 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -17,7 +17,7 @@ import { addPeer as olmAddPeer, deletePeer as olmDeletePeer } from "../olm/peers"; -import { sendToExitNode } from "../../lib/exitNodeComms"; +import { sendToExitNode } from "@server/lib/exitNodes"; const updateClientParamsSchema = z .object({ diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 08718d44..3744f044 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -9,7 +9,9 @@ import { fromError } from "zod-validation-error"; import { subdomainSchema } from "@server/lib/schemas"; import { generateId } from "@server/auth/sessions/app"; import { eq, and } from "drizzle-orm"; -import { isValidDomain } from "@server/lib/validators"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators"; import { build } from "@server/build"; import config from "@server/lib/config"; @@ -98,6 +100,45 @@ export async function createOrgDomain( ); } + if (isSecondLevelDomain(baseDomain) && type == "cname") { + // many providers dont allow cname for this. Lets prevent it for the user for now + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "You cannot create a CNAME record on a root domain. RFC 1912 § 2.4 prohibits CNAME records at the zone apex. Please use a subdomain." + ) + ); + } + + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectDomains = await usageService.checkLimitSet( + orgId, + false, + FeatureId.DOMAINS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectDomains) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Domain limit exceeded. Please upgrade your plan." + ) + ); + } + } + let numOrgDomains: OrgDomains[] | undefined; let aRecords: CreateDomainResponse["aRecords"]; let cnameRecords: CreateDomainResponse["cnameRecords"]; @@ -260,6 +301,14 @@ export async function createOrgDomain( .where(eq(orgDomains.orgId, orgId)); }); + if (numOrgDomains) { + await usageService.updateDaily( + orgId, + FeatureId.DOMAINS, + numOrgDomains.length + ); + } + if (!returned) { return next( createHttpError( diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index 345dafe7..8932733c 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -7,6 +7,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq } from "drizzle-orm"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; const paramsSchema = z .object({ @@ -88,6 +90,14 @@ export async function deleteAccountDomain( .where(eq(orgDomains.orgId, orgId)); }); + if (numOrgDomains) { + await usageService.updateDaily( + orgId, + FeatureId.DOMAINS, + numOrgDomains.length + ); + } + return response(res, { data: { success: true }, success: true, diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index c0cafafe..e833e532 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1,4 +1,6 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; +export * from "./privateListDomainNamespaces"; +export * from "./privateCheckDomainNamespaceAvailability"; export * from "./restartOrgDomain"; \ No newline at end of file diff --git a/server/routers/domain/privateCheckDomainNamespaceAvailability.ts b/server/routers/domain/privateCheckDomainNamespaceAvailability.ts new file mode 100644 index 00000000..f1a4e103 --- /dev/null +++ b/server/routers/domain/privateCheckDomainNamespaceAvailability.ts @@ -0,0 +1,127 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { db, domainNamespaces, resources } from "@server/db"; +import { inArray } from "drizzle-orm"; + +const paramsSchema = z.object({}).strict(); + +const querySchema = z + .object({ + subdomain: z.string() + }) + .strict(); + +export type CheckDomainAvailabilityResponse = { + available: boolean; + options: { + domainNamespaceId: string; + domainId: string; + fullDomain: string; + }[]; +}; + +registry.registerPath({ + method: "get", + path: "/domain/check-namespace-availability", + description: "Check if a domain namespace is available based on subdomain", + tags: [OpenAPITags.Domain], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function checkDomainNamespaceAvailability( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { subdomain } = parsedQuery.data; + + const namespaces = await db.select().from(domainNamespaces); + let possibleDomains = namespaces.map((ns) => { + const desired = `${subdomain}.${ns.domainNamespaceId}`; + return { + fullDomain: desired, + domainId: ns.domainId, + domainNamespaceId: ns.domainNamespaceId + }; + }); + + if (!possibleDomains.length) { + return response(res, { + data: { + available: false, + options: [] + }, + success: true, + error: false, + message: "No domain namespaces available", + status: HttpCode.OK + }); + } + + const existingResources = await db + .select() + .from(resources) + .where( + inArray( + resources.fullDomain, + possibleDomains.map((d) => d.fullDomain) + ) + ); + + possibleDomains = possibleDomains.filter( + (domain) => + !existingResources.some( + (resource) => resource.fullDomain === domain.fullDomain + ) + ); + + return response(res, { + data: { + available: possibleDomains.length > 0, + options: possibleDomains + }, + success: true, + error: false, + message: "Domain namespaces checked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/privateListDomainNamespaces.ts b/server/routers/domain/privateListDomainNamespaces.ts new file mode 100644 index 00000000..10bcc91b --- /dev/null +++ b/server/routers/domain/privateListDomainNamespaces.ts @@ -0,0 +1,130 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domainNamespaces } from "@server/db"; +import { domains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({}).strict(); + +const querySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function query(limit: number, offset: number) { + const res = await db + .select({ + domainNamespaceId: domainNamespaces.domainNamespaceId, + domainId: domainNamespaces.domainId + }) + .from(domainNamespaces) + .innerJoin( + domains, + eq(domains.domainId, domainNamespaces.domainNamespaceId) + ) + .limit(limit) + .offset(offset); + return res; +} + +export type ListDomainNamespacesResponse = { + domainNamespaces: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/domains/namepaces", + description: "List all domain namespaces in the system", + tags: [OpenAPITags.Domain], + request: { + query: querySchema + }, + responses: {} +}); + +export async function listDomainNamespaces( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const domainNamespacesList = await query(limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(domainNamespaces); + + return response(res, { + data: { + domainNamespaces: domainNamespacesList, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Namespaces retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 08b3c119..d6fa4a16 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -38,13 +38,25 @@ import { verifyUserIsOrgOwner, verifySiteResourceAccess } from "@server/middlewares"; -import { createStore } from "@server/lib/rateLimitStore"; +import { + verifyCertificateAccess, + verifyRemoteExitNodeAccess, + verifyIdpAccess, + verifyLoginPageAccess +} from "@server/middlewares/private"; +import { createStore } from "@server/lib/private/rateLimitStore"; import { ActionsEnum } from "@server/auth/actions"; import { createNewt, getNewtToken } from "./newt"; import { getOlmToken } from "./olm"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; +import * as certificates from "./private/certificates"; +import * as billing from "@server/routers/private/billing"; +import { quickStart } from "./auth/privateQuickStart"; import { build } from "@server/build"; +import * as remoteExitNode from "@server/routers/private/remoteExitNode"; +import * as loginPage from "@server/routers/private/loginPage"; +import * as orgIdp from "@server/routers/private/orgIdp"; // Root routes export const unauthenticated = Router(); @@ -53,6 +65,45 @@ unauthenticated.get("/", (_, res) => { res.status(HttpCode.OK).json({ message: "Healthy" }); }); +if (build === "saas") { + unauthenticated.post( + "/quick-start", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + keyGenerator: (req) => req.path, + handler: (req, res, next) => { + const message = `We're too busy right now. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + quickStart + ); +} + +if (build !== "oss") { + unauthenticated.post( + "/remote-exit-node/quick-start", + rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + keyGenerator: (req) => + `${req.path}:${ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only create 5 remote exit nodes every hour. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + remoteExitNode.quickStartRemoteExitNode + ); +} + // Authenticated Root routes export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); @@ -540,7 +591,10 @@ authenticated.post( ); authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey); -unauthenticated.get("/resource/:resourceGuid/auth", resource.getResourceAuthInfo); +unauthenticated.get( + "/resource/:resourceGuid/auth", + resource.getResourceAuthInfo +); // authenticated.get( // "/role/:roleId/resources", @@ -666,6 +720,46 @@ authenticated.post( idp.updateOidcIdp ); +if (build !== "oss") { + authenticated.put( + "/org/:orgId/idp/oidc", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp + ); + + authenticated.post( + "/org/:orgId/idp/:idpId/oidc", + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.updateIdp), + orgIdp.updateOrgOidcIdp + ); + + authenticated.delete( + "/org/:orgId/idp/:idpId", + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.deleteIdp), + idp.deleteIdp + ); + + authenticated.get( + "/org/:orgId/idp/:idpId", + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.getIdp), + orgIdp.getOrgIdp + ); + + authenticated.get( + "/org/:orgId/idp", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listIdps), + orgIdp.listOrgIdps + ); +} + authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); @@ -694,6 +788,9 @@ authenticated.get( idp.listIdpOrgPolicies ); +if (build !== "oss") { + authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids +} authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); @@ -826,6 +923,126 @@ authenticated.delete( domain.deleteAccountDomain ); +if (build !== "oss") { + authenticated.get( + "/org/:orgId/certificate/:domainId/:domain", + verifyOrgAccess, + verifyCertificateAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getCertificate + ); + + authenticated.post( + "/org/:orgId/certificate/:certId/restart", + verifyOrgAccess, + verifyCertificateAccess, + verifyUserHasAction(ActionsEnum.restartCertificate), + certificates.restartCertificate + ); + + authenticated.post( + "/org/:orgId/billing/create-checkout-session", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.createCheckoutSession + ); + + authenticated.post( + "/org/:orgId/billing/create-portal-session", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.createPortalSession + ); + + authenticated.get( + "/org/:orgId/billing/subscription", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.getOrgSubscription + ); + + authenticated.get( + "/org/:orgId/billing/usage", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + billing.getOrgUsage + ); + + authenticated.get("/domain/namespaces", domain.listDomainNamespaces); + + authenticated.get( + "/domain/check-namespace-availability", + domain.checkDomainNamespaceAvailability + ); + + authenticated.put( + "/org/:orgId/remote-exit-node", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createRemoteExitNode), + remoteExitNode.createRemoteExitNode + ); + + authenticated.get( + "/org/:orgId/remote-exit-nodes", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listRemoteExitNode), + remoteExitNode.listRemoteExitNodes + ); + + authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId", + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.getRemoteExitNode + ); + + authenticated.get( + "/org/:orgId/pick-remote-exit-node-defaults", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createRemoteExitNode), + remoteExitNode.pickRemoteExitNodeDefaults + ); + + authenticated.delete( + "/org/:orgId/remote-exit-node/:remoteExitNodeId", + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), + remoteExitNode.deleteRemoteExitNode + ); + + authenticated.put( + "/org/:orgId/login-page", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createLoginPage), + loginPage.createLoginPage + ); + + authenticated.post( + "/org/:orgId/login-page/:loginPageId", + verifyOrgAccess, + verifyLoginPageAccess, + verifyUserHasAction(ActionsEnum.updateLoginPage), + loginPage.updateLoginPage + ); + + authenticated.delete( + "/org/:orgId/login-page/:loginPageId", + verifyOrgAccess, + verifyLoginPageAccess, + verifyUserHasAction(ActionsEnum.deleteLoginPage), + loginPage.deleteLoginPage + ); + + authenticated.get( + "/org/:orgId/login-page", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getLoginPage), + loginPage.getLoginPage + ); +} + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); @@ -833,7 +1050,8 @@ authRouter.use( rateLimit({ windowMs: config.getRawConfig().rate_limits.auth.window_minutes, max: config.getRawConfig().rate_limits.auth.max_requests, - keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, + keyGenerator: (req) => + `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, handler: (req, res, next) => { const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.auth.max_requests} requests every ${config.getRawConfig().rate_limits.auth.window_minutes} minute(s).`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -847,7 +1065,8 @@ authRouter.put( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`, + keyGenerator: (req) => + `signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`, handler: (req, res, next) => { const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -861,7 +1080,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `login:${req.body.email || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `login:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -876,7 +1096,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 900, - keyGenerator: (req) => `newtGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `newtGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a Newt token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -890,7 +1111,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 900, - keyGenerator: (req) => `olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -900,6 +1122,26 @@ authRouter.post( getOlmToken ); +if (build !== "oss") { + authRouter.post( + "/remoteExitNode/get-token", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 900, + keyGenerator: (req) => + `remoteExitNodeGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only request an remoteExitNodeToken token ${900} times every ${15} minutes. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + remoteExitNode.getRemoteExitNodeToken + ); +} + authRouter.post( "/2fa/enable", rateLimit({ @@ -938,7 +1180,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `signup:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `signup:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only disable 2FA ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -952,7 +1195,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `signup:${req.body.email || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `signup:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -1007,7 +1251,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -1064,6 +1309,26 @@ authRouter.post( resource.authWithWhitelist ); +if (build !== "oss") { + authRouter.post( + "/transfer-session-token", + rateLimit({ + windowMs: 1 * 60 * 1000, + max: 60, + keyGenerator: (req) => + `transferSessionToken:${ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only transfer a session token ${5} times every ${1} minute. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + auth.transferSession + ); +} + authRouter.post( "/resource/:resourceId/access-token", resource.authWithAccessToken @@ -1129,7 +1394,8 @@ authRouter.delete( rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, // Allow 10 authentication attempts per 15 minutes per IP - keyGenerator: (req) => `securityKeyAuth:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + keyGenerator: (req) => + `securityKeyAuth:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only delete a security key ${10} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 71d1a45e..afae4009 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -13,7 +13,7 @@ import { fromError } from "zod-validation-error"; import { getAllowedIps } from "../target/helpers"; import { proxyToRemote } from "@server/lib/remoteProxy"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; -import { createExitNode } from "./createExitNode"; +import { createExitNode } from "./privateCreateExitNode"; // Define Zod schema for request validation const getConfigSchema = z.object({ diff --git a/server/routers/gerbil/getResolvedHostname.ts b/server/routers/gerbil/getResolvedHostname.ts index da2ab39a..17067c55 100644 --- a/server/routers/gerbil/getResolvedHostname.ts +++ b/server/routers/gerbil/getResolvedHostname.ts @@ -4,6 +4,9 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { resolveExitNodes } from "@server/lib/exitNodes"; +import config from "@server/lib/config"; +import { build } from "@server/build"; // Define Zod schema for request validation const getResolvedHostnameSchema = z.object({ @@ -17,22 +20,42 @@ export async function getResolvedHostname( next: NextFunction ): Promise { try { - // Validate request parameters - const parsedParams = getResolvedHostnameSchema.safeParse( - req.body - ); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) + let endpoints: string[] = []; // always route locally + + if (build != "oss") { + // Validate request parameters + const parsedParams = getResolvedHostnameSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { hostname, publicKey } = parsedParams.data; + + const baseDomain = config.getRawPrivateConfig().app.base_domain; + + // if the hostname ends with the base domain then send back a empty array + if (baseDomain && hostname.endsWith(baseDomain)) { + return res.status(HttpCode.OK).send({ + endpoints: [] // this should force to route locally + }); + } + + const resourceExitNodes = await resolveExitNodes( + hostname, + publicKey ); + + endpoints = resourceExitNodes.map((node) => node.endpoint); } // return the endpoints return res.status(HttpCode.OK).send({ - endpoints: [] // ALWAYS ROUTE LOCALLY + endpoints }); } catch (error) { logger.error(error); diff --git a/server/routers/gerbil/peers.ts b/server/routers/gerbil/peers.ts index 51a338a7..1cdc9184 100644 --- a/server/routers/gerbil/peers.ts +++ b/server/routers/gerbil/peers.ts @@ -2,7 +2,7 @@ import logger from "@server/logger"; import { db } from "@server/db"; import { exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; -import { sendToExitNode } from "../../lib/exitNodeComms"; +import { sendToExitNode } from "@server/lib/exitNodes"; export async function addPeer( exitNodeId: number, diff --git a/server/routers/gerbil/privateCreateExitNode.ts b/server/routers/gerbil/privateCreateExitNode.ts new file mode 100644 index 00000000..9c2a104d --- /dev/null +++ b/server/routers/gerbil/privateCreateExitNode.ts @@ -0,0 +1,67 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, ExitNode, exitNodes } from "@server/db"; +import { getUniqueExitNodeEndpointName } from "@server/db/names"; +import config from "@server/lib/config"; +import { getNextAvailableSubnet } from "@server/lib/exitNodes"; +import logger from "@server/logger"; +import { eq } from "drizzle-orm"; + +export async function createExitNode( + publicKey: string, + reachableAt: string | undefined +) { + // Fetch exit node + const [exitNodeQuery] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.publicKey, publicKey)); + let exitNode: ExitNode; + if (!exitNodeQuery) { + const address = await getNextAvailableSubnet(); + // TODO: eventually we will want to get the next available port so that we can multiple exit nodes + // const listenPort = await getNextAvailablePort(); + const listenPort = config.getRawConfig().gerbil.start_port; + let subEndpoint = ""; + if (config.getRawConfig().gerbil.use_subdomain) { + subEndpoint = await getUniqueExitNodeEndpointName(); + } + + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name || + `Exit Node ${publicKey.slice(0, 8)}`; + + // create a new exit node + [exitNode] = await db + .insert(exitNodes) + .values({ + publicKey, + endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, + address, + listenPort, + reachableAt, + name: exitNodeName + }) + .returning() + .execute(); + + logger.info( + `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` + ); + } else { + exitNode = exitNodeQuery; + } + + return exitNode; +} diff --git a/server/routers/gerbil/privateReceiveBandwidth.ts b/server/routers/gerbil/privateReceiveBandwidth.ts new file mode 100644 index 00000000..de0b2d2b --- /dev/null +++ b/server/routers/gerbil/privateReceiveBandwidth.ts @@ -0,0 +1,13 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index fb7723ee..d10141b9 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -6,7 +6,10 @@ import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing/features"; import { checkExitNodeOrg } from "@server/lib/exitNodes"; +import { build } from "@server/build"; // Track sites that are already offline to avoid unnecessary queries const offlineSites = new Set(); @@ -29,7 +32,7 @@ export const receiveBandwidth = async ( throw new Error("Invalid bandwidth data"); } - await updateSiteBandwidth(bandwidthData); + await updateSiteBandwidth(bandwidthData, build == "saas"); // we are checking the usage on saas only return response(res, { data: {}, @@ -51,6 +54,7 @@ export const receiveBandwidth = async ( export async function updateSiteBandwidth( bandwidthData: PeerBandwidth[], + calcUsageAndLimits: boolean, exitNodeId?: number ) { const currentTime = new Date(); @@ -89,18 +93,23 @@ export async function updateSiteBandwidth( lastBandwidthUpdate: sites.lastBandwidthUpdate }); - if (exitNodeId) { - if (await checkExitNodeOrg(exitNodeId, updatedSite.orgId)) { - // not allowed - logger.warn( - `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` - ); - // THIS SHOULD TRIGGER THE TRANSACTION TO FAIL? - throw new Error("Exit node not allowed"); - } - } - if (updatedSite) { + if (exitNodeId) { + if ( + await checkExitNodeOrg( + exitNodeId, + updatedSite.orgId + ) + ) { + // not allowed + logger.warn( + `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` + ); + // THIS SHOULD TRIGGER THE TRANSACTION TO FAIL? + throw new Error("Exit node not allowed"); + } + } + updatedSites.push({ ...updatedSite, peer }); } } @@ -116,6 +125,74 @@ export async function updateSiteBandwidth( const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0; orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds } + + if (calcUsageAndLimits) { + // REMOTE EXIT NODES DO NOT COUNT TOWARDS USAGE + // Process all usage updates sequentially by organization to reduce deadlock risk + const allOrgIds = new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()]); + + for (const orgId of allOrgIds) { + try { + // Process bandwidth usage for this org + const totalBandwidth = orgUsageMap.get(orgId); + if (totalBandwidth) { + const bandwidthUsage = await usageService.add( + orgId, + FeatureId.EGRESS_DATA_MB, + totalBandwidth, + trx + ); + if (bandwidthUsage) { + usageService + .checkLimitSet( + orgId, + true, + FeatureId.EGRESS_DATA_MB, + bandwidthUsage + ) + .catch((error: any) => { + logger.error( + `Error checking bandwidth limits for org ${orgId}:`, + error + ); + }); + } + } + + // Process uptime usage for this org + const totalUptime = orgUptimeMap.get(orgId); + if (totalUptime) { + const uptimeUsage = await usageService.add( + orgId, + FeatureId.SITE_UPTIME, + totalUptime, + trx + ); + if (uptimeUsage) { + usageService + .checkLimitSet( + orgId, + true, + FeatureId.SITE_UPTIME, + uptimeUsage + ) + .catch((error: any) => { + logger.error( + `Error checking uptime limits for org ${orgId}:`, + error + ); + }); + } + } + } catch (error) { + logger.error( + `Error processing usage for org ${orgId}:`, + error + ); + // Don't break the loop, continue with other orgs + } + } + } } // Handle sites that reported zero bandwidth but need online status updated @@ -161,7 +238,7 @@ export async function updateSiteBandwidth( .where(eq(sites.siteId, site.siteId)) .returning(); - if (exitNodeId) { + if (updatedSite && exitNodeId) { if ( await checkExitNodeOrg( exitNodeId, diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 1662e420..65217178 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -105,7 +105,7 @@ export async function updateHolePunch( destinations: destinations }); } catch (error) { - logger.error(error); + // logger.error(error); // FIX THIS return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -122,7 +122,8 @@ export async function updateAndGenerateEndpointDestinations( port: number, timestamp: number, token: string, - exitNode: ExitNode + exitNode: ExitNode, + checkOrg = false ) { let currentSiteId: number | undefined; const destinations: PeerDestination[] = []; @@ -158,7 +159,7 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(clients.clientId, olm.clientId)) .returning(); - if (await checkExitNodeOrg(exitNode.exitNodeId, client.orgId)) { + if (await checkExitNodeOrg(exitNode.exitNodeId, client.orgId) && checkOrg) { // not allowed logger.warn( `Exit node ${exitNode.exitNodeId} is not allowed for org ${client.orgId}` @@ -253,7 +254,7 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(sites.siteId, newt.siteId)) .limit(1); - if (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId)) { + if (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId) && checkOrg) { // not allowed logger.warn( `Exit node ${exitNode.exitNodeId} is not allowed for org ${site.orgId}` diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 6078f5aa..223a08b8 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -112,7 +112,7 @@ export async function createOidcIdp( }); }); - const redirectUrl = generateOidcRedirectUrl(idpId as number); + const redirectUrl = await generateOidcRedirectUrl(idpId as number); return response(res, { data: { diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts index e862c81c..58b231b7 100644 --- a/server/routers/idp/deleteIdp.ts +++ b/server/routers/idp/deleteIdp.ts @@ -12,6 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ + orgId: z.string().optional(), // Optional; used with org idp in saas idpId: z.coerce.number() }) .strict(); diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index c507198a..90144816 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -10,10 +10,12 @@ import { idp, idpOidcConfig, idpOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; -import cookie from "cookie"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; const paramsSchema = z .object({ @@ -27,6 +29,10 @@ const bodySchema = z }) .strict(); +const querySchema = z.object({ + orgId: z.string().optional() // check what actuall calls it +}); + const ensureTrailingSlash = (url: string): string => { return url; }; @@ -65,6 +71,18 @@ export async function generateOidcUrl( const { redirectUrl: postAuthRedirectUrl } = parsedBody.data; + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedQuery.data; + const [existingIdp] = await db .select() .from(idp) @@ -80,6 +98,36 @@ export async function generateOidcUrl( ); } + if (orgId) { + const [idpOrgLink] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!idpOrgLink) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP not found for the organization" + ) + ); + } + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + } + const parsedScopes = existingIdp.idpOidcConfig.scopes .split(" ") .map((scope) => { @@ -100,7 +148,12 @@ export async function generateOidcUrl( key ); - const redirectUrl = generateOidcRedirectUrl(idpId); + const redirectUrl = await generateOidcRedirectUrl(idpId, orgId); + logger.debug("OIDC client info", { + decryptedClientId, + decryptedClientSecret, + redirectUrl + }); const client = new arctic.OAuth2Client( decryptedClientId, decryptedClientSecret, @@ -116,7 +169,6 @@ export async function generateOidcUrl( codeVerifier, parsedScopes ); - logger.debug("Generated OIDC URL", { url }); const stateJwt = jsonwebtoken.sign( { diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index f0dcf02e..81cec8d1 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -8,4 +8,4 @@ export * from "./getIdp"; export * from "./createIdpOrgPolicy"; export * from "./deleteIdpOrgPolicy"; export * from "./listIdpOrgPolicies"; -export * from "./updateIdpOrgPolicy"; +export * from "./updateIdpOrgPolicy"; \ No newline at end of file diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 46baa517..fec21e41 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -30,6 +30,8 @@ import { } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import { UserType } from "@server/types/UserTypes"; +import { FeatureId } from "@server/lib/private/billing"; +import { usageService } from "@server/lib/private/billing/usageService"; const ensureTrailingSlash = (url: string): string => { return url; @@ -47,6 +49,10 @@ const bodySchema = z.object({ storedState: z.string().nonempty() }); +const querySchema = z.object({ + loginPageId: z.coerce.number().optional() +}); + export type ValidateOidcUrlCallbackResponse = { redirectUrl: string; }; @@ -79,6 +85,18 @@ export async function validateOidcCallback( ); } + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { loginPageId } = parsedQuery.data; + const { storedState, code, state: expectedState } = parsedBody.data; const [existingIdp] = await db @@ -107,7 +125,11 @@ export async function validateOidcCallback( key ); - const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId); + const redirectUrl = await generateOidcRedirectUrl( + existingIdp.idp.idpId, + undefined, + loginPageId + ); const client = new arctic.OAuth2Client( decryptedClientId, decryptedClientSecret, @@ -380,12 +402,14 @@ export async function validateOidcCallback( } // Update roles for existing auto-provisioned orgs where the role has changed - const orgsToUpdate = autoProvisionedOrgs.filter((currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - }); + const orgsToUpdate = autoProvisionedOrgs.filter( + (currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + } + ); if (orgsToUpdate.length > 0) { for (const org of orgsToUpdate) { @@ -441,6 +465,14 @@ export async function validateOidcCallback( } }); + for (const orgCount of orgUserCounts) { + await usageService.updateDaily( + orgCount.orgId, + FeatureId.USERS, + orgCount.userCount + ); + } + const token = generateSessionToken(); const sess = await createSession(token, existingUserId!); const isSecure = req.protocol === "https"; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6a43aaa7..edad73b9 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -30,6 +30,7 @@ import { import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; import { ActionsEnum } from "@server/auth/actions"; +import { build } from "@server/build"; export const unauthenticated = Router(); @@ -597,6 +598,15 @@ authenticated.get( idp.listIdpOrgPolicies ); +if (build == "saas") { + authenticated.post( + `/org/:orgId/send-usage-notification`, + verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine + verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), + org.sendUsageNotification + ); +} + authenticated.get( "/org/:orgId/pick-client-defaults", verifyClientsEnabled, diff --git a/server/routers/internal.ts b/server/routers/internal.ts index b961ef6f..e4525118 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -7,6 +7,7 @@ import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as license from "@server/routers/license"; import * as idp from "@server/routers/idp"; +import * as loginPage from "@server/routers/private/loginPage"; import { proxyToRemote } from "@server/lib/remoteProxy"; import config from "@server/lib/config"; import HttpCode from "@server/types/HttpCode"; @@ -14,6 +15,9 @@ import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; +import { build } from "@server/build"; +import * as billing from "@server/routers/private/billing"; +import * as orgIdp from "@server/routers/private/orgIdp"; // Root routes const internalRouter = Router(); @@ -47,6 +51,12 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +if (build !== "oss") { + internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps); + + internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier); +} + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); @@ -98,4 +108,14 @@ if (config.isManagedMode()) { badgerRouter.post("/exchange-session", badger.exchangeSession); } +if (build !== "oss") { + internalRouter.get("/login-page", loginPage.loadLoginPage); + + internalRouter.post( + "/get-session-transfer-token", + verifySessionUserMiddleware, + auth.getSessionTransferToken + ); +} + export default internalRouter; diff --git a/server/routers/license/activateLicense.ts b/server/routers/license/activateLicense.ts index 3826277c..832bc19d 100644 --- a/server/routers/license/activateLicense.ts +++ b/server/routers/license/activateLicense.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import license, { LicenseStatus } from "@server/license/license"; import { z } from "zod"; import { fromError } from "zod-validation-error"; diff --git a/server/routers/license/deleteLicenseKey.ts b/server/routers/license/deleteLicenseKey.ts index 2663308e..37b74fee 100644 --- a/server/routers/license/deleteLicenseKey.ts +++ b/server/routers/license/deleteLicenseKey.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { db } from "@server/db"; diff --git a/server/routers/license/getLicenseStatus.ts b/server/routers/license/getLicenseStatus.ts index e46b4caa..e4f28882 100644 --- a/server/routers/license/getLicenseStatus.ts +++ b/server/routers/license/getLicenseStatus.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import license, { LicenseStatus } from "@server/license/license"; export type GetLicenseStatusResponse = LicenseStatus; diff --git a/server/routers/license/listLicenseKeys.ts b/server/routers/license/listLicenseKeys.ts index 915690df..d106abd7 100644 --- a/server/routers/license/listLicenseKeys.ts +++ b/server/routers/license/listLicenseKeys.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import license, { LicenseKeyCache } from "@server/license/license"; export type ListLicenseKeysResponse = LicenseKeyCache[]; diff --git a/server/routers/license/recheckStatus.ts b/server/routers/license/recheckStatus.ts index d2ab7939..cd4bf779 100644 --- a/server/routers/license/recheckStatus.ts +++ b/server/routers/license/recheckStatus.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import license, { LicenseStatus } from "@server/license/license"; export type RecheckStatusResponse = LicenseStatus; diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index b6206064..2b65fd06 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -14,7 +14,7 @@ import { import { clients, clientSites, Newt, sites } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; import { updatePeer } from "../olm/peers"; -import { sendToExitNode } from "../../lib/exitNodeComms"; +import { sendToExitNode } from "@server/lib/exitNodes"; const inputSchema = z.object({ publicKey: z.string(), @@ -66,7 +66,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // we need to wait for hole punch success if (!existingSite.endpoint) { - logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`); + logger.debug(`In newt get config: existing site ${existingSite.siteId} has no endpoint, skipping`); return; } @@ -74,12 +74,12 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly) } - if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 6) { - logger.warn( - `Site ${existingSite.siteId} last hole punch is too old, skipping` - ); - return; - } + // if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 6) { + // logger.warn( + // `Site ${existingSite.siteId} last hole punch is too old, skipping` + // ); + // return; + // } // update the endpoint and the public key const [site] = await db @@ -176,7 +176,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (!endpoint) { logger.warn( - `Site ${site.siteId} has no endpoint, skipping` + `In Newt get config: Peer site ${site.siteId} has no endpoint, skipping` ); return null; } diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index f93862f6..aeb7a155 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -29,7 +29,14 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { .where(eq(sites.siteId, newt.siteId)) .limit(1); - const exitNodesList = await listExitNodes(site.orgId, true); // filter for only the online ones + if (!site || !site.orgId) { + logger.warn("Site not found"); + return; + } + + const { noCloud } = message.data; + + const exitNodesList = await listExitNodes(site.orgId, true, noCloud || false); // filter for only the online ones let lastExitNodeId = null; if (newt.siteId) { diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index eef78765..51a1ab05 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,6 +1,7 @@ -import { db, newts } from "@server/db"; +import { db, exitNodeOrgs, newts } from "@server/db"; import { MessageHandler } from "../ws"; import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; +import { targetHealthCheck } from "@server/db"; import { eq, and, sql, inArray } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; @@ -9,7 +10,12 @@ import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; -import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { + selectBestExitNode, + verifyExitNodeOrgAccess +} from "@server/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; export type ExitNodePingResult = { @@ -22,6 +28,8 @@ export type ExitNodePingResult = { wasPreviouslyConnected: boolean; }; +let numTimesLimitExceededForId: Record = {}; + export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; @@ -86,13 +94,52 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } + const rejectSiteUptime = await usageService.checkLimitSet( + oldSite.orgId, + false, + FeatureId.SITE_UPTIME + ); + const rejectEgressDataMb = await usageService.checkLimitSet( + oldSite.orgId, + false, + FeatureId.EGRESS_DATA_MB + ); + + // Do we need to check the users and domains daily limits here? + // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); + // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); + + // if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) { + if (rejectEgressDataMb || rejectSiteUptime) { + logger.info( + `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` + ); + + // PREVENT FURTHER REGISTRATION ATTEMPTS SO WE DON'T SPAM + + // Increment the limit exceeded count for this site + numTimesLimitExceededForId[newt.newtId] = + (numTimesLimitExceededForId[newt.newtId] || 0) + 1; + + if (numTimesLimitExceededForId[newt.newtId] > 15) { + logger.debug( + `Newt ${newt.newtId} has exceeded usage limits 15 times. Terminating...` + ); + } + + return; + } + let siteSubnet = oldSite.subnet; let exitNodeIdToQuery = oldSite.exitNodeId; if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { // This effectively moves the exit node to the new one exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId - const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(exitNodeIdToQuery, oldSite.orgId); + const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( + exitNodeIdToQuery, + oldSite.orgId + ); if (!exitNode) { logger.warn("Exit node not found"); @@ -114,6 +161,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const blockSize = config.getRawConfig().gerbil.site_block_size; const subnets = sitesQuery .map((site) => site.subnet) + .filter( + (subnet) => + subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) + ) .filter((subnet) => subnet !== null); subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); const newSubnet = findNextAvailableCidr( @@ -122,7 +173,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { exitNode.address ); if (!newSubnet) { - logger.error("No available subnets found for the new exit node"); + logger.error( + `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` + ); return; } @@ -168,11 +221,25 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { return; } - // add the peer to the exit node - await addPeer(exitNodeIdToQuery, { - publicKey: publicKey, - allowedIps: [siteSubnet] - }); + try { + // add the peer to the exit node + await addPeer(exitNodeIdToQuery, { + publicKey: publicKey, + allowedIps: [siteSubnet] + }); + } catch (error) { + logger.error(`Failed to add peer to exit node: ${error}`); + } + + if (newtVersion && newtVersion !== newt.version) { + // update the newt version in the database + await db + .update(newts) + .set({ + version: newtVersion as string + }) + .where(eq(newts.newtId, newt.newtId)); + } if (newtVersion && newtVersion !== newt.version) { // update the newt version in the database @@ -194,10 +261,25 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - protocol: resources.protocol + protocol: resources.protocol, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcMethod: targetHealthCheck.hcMethod }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); const { tcpTargets, udpTargets } = allTargets.reduce( @@ -222,6 +304,59 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { { tcpTargets: [] as string[], udpTargets: [] as string[] } ); + const healthCheckTargets = allTargets.map((target) => { + // make sure the stuff is defined + if ( + !target.hcPath || + !target.hcHostname || + !target.hcPort || + !target.hcInterval || + !target.hcMethod + ) { + logger.debug( + `Skipping target ${target.targetId} due to missing health check fields` + ); + return null; // Skip targets with missing health check fields + } + + // parse headers + const hcHeadersParse = target.hcHeaders + ? JSON.parse(target.hcHeaders) + : null; + let hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + hcHeadersParse.forEach( + (header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + } + ); + } + + return { + id: target.targetId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath, + hcScheme: target.hcScheme, + hcMode: target.hcMode, + hcHostname: target.hcHostname, + hcPort: target.hcPort, + hcInterval: target.hcInterval, // in seconds + hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds + hcTimeout: target.hcTimeout, // in seconds + hcHeaders: hcHeadersSend, + hcMethod: target.hcMethod + }; + }); + + // Filter out any null values from health check targets + const validHealthCheckTargets = healthCheckTargets.filter( + (target) => target !== null + ); + + logger.debug( + `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` + ); + return { message: { type: "newt/wg/connect", @@ -233,10 +368,11 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { targets: { udp: udpTargets, tcp: tcpTargets - } + }, + healthCheckTargets: validHealthCheckTargets } }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; -}; \ No newline at end of file +}; diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 803c3e27..c05a64a1 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,10 +1,12 @@ -import { Target } from "@server/db"; +import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db"; import { sendToClient } from "../ws"; import logger from "@server/logger"; +import { eq, inArray } from "drizzle-orm"; export async function addTargets( newtId: string, targets: Target[], + healthCheckData: TargetHealthCheck[], protocol: string, port: number | null = null ) { @@ -21,6 +23,62 @@ export async function addTargets( targets: payloadTargets } }); + + // Create a map for quick lookup + const healthCheckMap = new Map(); + healthCheckData.forEach(hc => { + healthCheckMap.set(hc.targetId, hc); + }); + + const healthCheckTargets = targets.map((target) => { + const hc = healthCheckMap.get(target.targetId); + + // If no health check data found, skip this target + if (!hc) { + logger.warn(`No health check configuration found for target ${target.targetId}`); + return null; + } + + // Ensure all necessary fields are present + if (!hc.hcPath || !hc.hcHostname || !hc.hcPort || !hc.hcInterval || !hc.hcMethod) { + logger.debug(`Skipping target ${target.targetId} due to missing health check fields`); + return null; // Skip targets with missing health check fields + } + + const hcHeadersParse = hc.hcHeaders ? JSON.parse(hc.hcHeaders) : null; + const hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + // transform + hcHeadersParse.forEach((header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + }); + } + + return { + id: target.targetId, + hcEnabled: hc.hcEnabled, + hcPath: hc.hcPath, + hcScheme: hc.hcScheme, + hcMode: hc.hcMode, + hcHostname: hc.hcHostname, + hcPort: hc.hcPort, + hcInterval: hc.hcInterval, // in seconds + hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds + hcTimeout: hc.hcTimeout, // in seconds + hcHeaders: hcHeadersSend, + hcMethod: hc.hcMethod + }; + }); + + // Filter out any null values from health check targets + const validHealthCheckTargets = healthCheckTargets.filter((target) => target !== null); + + await sendToClient(newtId, { + type: `newt/healthcheck/add`, + data: { + targets: validHealthCheckTargets + } + }); } export async function removeTargets( @@ -42,4 +100,15 @@ export async function removeTargets( targets: payloadTargets } }); + + const healthCheckTargets = targets.map((target) => { + return target.targetId + }); + + await sendToClient(newtId, { + type: `newt/healthcheck/remove`, + data: { + ids: healthCheckTargets + } + }); } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 11ca8b5e..33d7f9cb 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -88,10 +88,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .where(eq(olms.olmId, olm.olmId)); } - if (now - (client.lastHolePunch || 0) > 6) { - logger.warn("Client last hole punch is too old, skipping all sites"); - return; - } + // if (now - (client.lastHolePunch || 0) > 6) { + // logger.warn("Client last hole punch is too old, skipping all sites"); + // return; + // } if (client.pubKey !== publicKey) { logger.info( @@ -145,7 +145,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // Validate endpoint and hole punch status if (!site.endpoint) { - logger.warn(`Site ${site.siteId} has no endpoint, skipping`); + logger.warn(`In olm register: site ${site.siteId} has no endpoint, skipping`); continue; } diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index d26774dd..a12520a4 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -24,6 +24,10 @@ import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; +import { createCustomer } from "@server/routers/private/billing/createCustomer"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { build } from "@server/build"; const createOrgSchema = z .object({ @@ -249,6 +253,19 @@ export async function createOrg( return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)); } + if (build == "saas") { + // make sure we have the stripe customer + const customerId = await createCustomer(orgId, req.user?.email); + if (customerId) { + await usageService.updateDaily( + orgId, + FeatureId.USERS, + 1, + customerId + ); // Only 1 because we are creating the org + } + } + return response(res, { data: org, success: true, diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 35c1a5f7..e467374f 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -17,7 +17,7 @@ const getOrgSchema = z .strict(); export type GetOrgResponse = { - org: Org; + org: Org & { settings: { } | null }; }; registry.registerPath({ @@ -64,9 +64,27 @@ export async function getOrg( ); } + // Parse settings from JSON string back to object + let parsedSettings = null; + if (org[0].settings) { + try { + parsedSettings = JSON.parse(org[0].settings); + } catch (error) { + // If parsing fails, keep as string for backward compatibility + parsedSettings = org[0].settings; + } + } + + logger.info( + `returning data: ${JSON.stringify({ ...org[0], settings: parsedSettings })}` + ); + return response(res, { data: { - org: org[0] + org: { + ...org[0], + settings: parsedSettings + } }, success: true, error: false, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 754def66..013f6c6d 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -7,4 +7,5 @@ export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; -export * from "./applyBlueprint"; \ No newline at end of file +export * from "./privateSendUsageNotifications"; +export * from "./applyBlueprint"; diff --git a/server/routers/org/privateSendUsageNotifications.ts b/server/routers/org/privateSendUsageNotifications.ts new file mode 100644 index 00000000..8b2a773d --- /dev/null +++ b/server/routers/org/privateSendUsageNotifications.ts @@ -0,0 +1,249 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, users, roles, orgs } from "@server/db"; +import { eq, and, or } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import NotifyUsageLimitApproaching from "@server/emails/templates/PrivateNotifyUsageLimitApproaching"; +import NotifyUsageLimitReached from "@server/emails/templates/PrivateNotifyUsageLimitReached"; +import config from "@server/lib/config"; +import { OpenAPITags, registry } from "@server/openApi"; + +const sendUsageNotificationParamsSchema = z.object({ + orgId: z.string() +}); + +const sendUsageNotificationBodySchema = z.object({ + notificationType: z.enum(["approaching_70", "approaching_90", "reached"]), + limitName: z.string(), + currentUsage: z.number(), + usageLimit: z.number(), +}); + +type SendUsageNotificationRequest = z.infer; + +export type SendUsageNotificationResponse = { + success: boolean; + emailsSent: number; + adminEmails: string[]; +}; + +// WE SHOULD NOT REGISTER THE PATH IN SAAS +// registry.registerPath({ +// method: "post", +// path: "/org/{orgId}/send-usage-notification", +// description: "Send usage limit notification emails to all organization admins.", +// tags: [OpenAPITags.Org], +// request: { +// params: sendUsageNotificationParamsSchema, +// body: { +// content: { +// "application/json": { +// schema: sendUsageNotificationBodySchema +// } +// } +// } +// }, +// responses: { +// 200: { +// description: "Usage notifications sent successfully", +// content: { +// "application/json": { +// schema: z.object({ +// success: z.boolean(), +// emailsSent: z.number(), +// adminEmails: z.array(z.string()) +// }) +// } +// } +// } +// } +// }); + +async function getOrgAdmins(orgId: string) { + // Get all users in the organization who are either: + // 1. Organization owners (isOwner = true) + // 2. Have admin roles (role.isAdmin = true) + const admins = await db + .select({ + userId: users.userId, + email: users.email, + name: users.name, + isOwner: userOrgs.isOwner, + roleName: roles.name, + isAdminRole: roles.isAdmin + }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .where( + and( + eq(userOrgs.orgId, orgId), + or( + eq(userOrgs.isOwner, true), + eq(roles.isAdmin, true) + ) + ) + ); + + // Filter to only include users with verified emails + const orgAdmins = admins.filter(admin => + admin.email && + admin.email.length > 0 + ); + + return orgAdmins; +} + +export async function sendUsageNotification( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = sendUsageNotificationParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = sendUsageNotificationBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + notificationType, + limitName, + currentUsage, + usageLimit, + } = parsedBody.data; + + // Verify organization exists + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // Get all admin users for this organization + const orgAdmins = await getOrgAdmins(orgId); + + if (orgAdmins.length === 0) { + logger.warn(`No admin users found for organization ${orgId}`); + return response(res, { + data: { + success: true, + emailsSent: 0, + adminEmails: [] + }, + success: true, + error: false, + message: "No admin users found to notify", + status: HttpCode.OK + }); + } + + // Default billing link if not provided + const defaultBillingLink = `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; + + let emailsSent = 0; + const adminEmails: string[] = []; + + // Send emails to all admin users + for (const admin of orgAdmins) { + if (!admin.email) continue; + + try { + let template; + let subject; + + if (notificationType === "approaching_70" || notificationType === "approaching_90") { + template = NotifyUsageLimitApproaching({ + email: admin.email, + limitName, + currentUsage, + usageLimit, + billingLink: defaultBillingLink + }); + subject = `Usage limit warning for ${limitName}`; + } else { + template = NotifyUsageLimitReached({ + email: admin.email, + limitName, + currentUsage, + usageLimit, + billingLink: defaultBillingLink + }); + subject = `URGENT: Usage limit reached for ${limitName}`; + } + + await sendEmail(template, { + to: admin.email, + from: config.getNoReplyEmail(), + subject + }); + + emailsSent++; + adminEmails.push(admin.email); + + logger.info(`Usage notification sent to admin ${admin.email} for org ${orgId}`); + } catch (emailError) { + logger.error(`Failed to send usage notification to ${admin.email}:`, emailError); + // Continue with other admins even if one fails + } + } + + return response(res, { + data: { + success: true, + emailsSent, + adminEmails + }, + success: true, + error: false, + message: `Usage notifications sent to ${emailsSent} administrators`, + status: HttpCode.OK + }); + + } catch (error) { + logger.error("Error sending usage notifications:", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to send usage notifications") + ); + } +} diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 6dcd1016..6f30e62c 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -12,13 +12,15 @@ import { OpenAPITags, registry } from "@server/openApi"; const updateOrgParamsSchema = z .object({ - orgId: z.string() + orgId: z.string(), }) .strict(); const updateOrgBodySchema = z .object({ - name: z.string().min(1).max(255).optional() + name: z.string().min(1).max(255).optional(), + settings: z.object({ + }).optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -70,11 +72,15 @@ export async function updateOrg( } const { orgId } = parsedParams.data; - const updateData = parsedBody.data; + + const settings = parsedBody.data.settings ? JSON.stringify(parsedBody.data.settings) : undefined; const updatedOrg = await db .update(orgs) - .set(updateData) + .set({ + name: parsedBody.data.name, + settings: settings + }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/server/routers/private/billing/createCheckoutSession.ts b/server/routers/private/billing/createCheckoutSession.ts new file mode 100644 index 00000000..67507b68 --- /dev/null +++ b/server/routers/private/billing/createCheckoutSession.ts @@ -0,0 +1,101 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { fromError } from "zod-validation-error"; +import stripe from "@server/lib/private/stripe"; +import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/private/billing"; +import { getTierPriceSet, TierId } from "@server/lib/private/billing/tiers"; + +const createCheckoutSessionSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export async function createCheckoutSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = createCheckoutSessionSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + // check if we already have a customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + // If we don't have a customer, create one + if (!customer) { + // error + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + const standardTierPrice = getTierPriceSet()[TierId.STANDARD]; + + const session = await stripe!.checkout.sessions.create({ + client_reference_id: orgId, // So we can look it up the org later on the webhook + billing_address_collection: "required", + line_items: [ + { + price: standardTierPrice, // Use the standard tier + quantity: 1 + }, + ...getLineItems(getStandardFeaturePriceSet()) + ], // Start with the standard feature set that matches the free limits + customer: customer.customerId, + mode: "subscription", + success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true` + }); + + return response(res, { + data: session.url, + success: true, + error: false, + message: "Organization created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/billing/createCustomer.ts b/server/routers/private/billing/createCustomer.ts new file mode 100644 index 00000000..d1c08a0e --- /dev/null +++ b/server/routers/private/billing/createCustomer.ts @@ -0,0 +1,48 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import stripe from "@server/lib/private/stripe"; +import { build } from "@server/build"; + +export async function createCustomer( + orgId: string, + email: string | null | undefined +): Promise { + if (build !== "saas") { + return; + } + + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + let customerId: string; + // If we don't have a customer, create one + if (!customer) { + const newCustomer = await stripe!.customers.create({ + metadata: { + orgId: orgId + }, + email: email || undefined + }); + customerId = newCustomer.id; + // It will get inserted into the database by the webhook + } else { + customerId = customer.customerId; + } + return customerId; +} diff --git a/server/routers/private/billing/createPortalSession.ts b/server/routers/private/billing/createPortalSession.ts new file mode 100644 index 00000000..aa672377 --- /dev/null +++ b/server/routers/private/billing/createPortalSession.ts @@ -0,0 +1,89 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { account, customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { fromError } from "zod-validation-error"; +import stripe from "@server/lib/private/stripe"; + +const createPortalSessionSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export async function createPortalSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = createPortalSessionSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + // check if we already have a customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + let customerId: string; + // If we don't have a customer, create one + if (!customer) { + // error + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } else { + // If we have a customer, use the existing customer ID + customerId = customer.customerId; + } + const portalSession = await stripe!.billingPortal.sessions.create({ + customer: customerId, + return_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing` + }); + + return response(res, { + data: portalSession.url, + success: true, + error: false, + message: "Organization created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/billing/getOrgSubscription.ts b/server/routers/private/billing/getOrgSubscription.ts new file mode 100644 index 00000000..3e4f575e --- /dev/null +++ b/server/routers/private/billing/getOrgSubscription.ts @@ -0,0 +1,157 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { Org, orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +// Import tables for billing +import { + customers, + subscriptions, + subscriptionItems, + Subscription, + SubscriptionItem +} from "@server/db"; + +const getOrgSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export type GetOrgSubscriptionResponse = { + subscription: Subscription | null; + items: SubscriptionItem[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/billing/subscription", + description: "Get an organization", + tags: [OpenAPITags.Org], + request: { + params: getOrgSchema + }, + responses: {} +}); + +export async function getOrgSubscription( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + let subscriptionData = null; + let itemsData: SubscriptionItem[] = []; + try { + const { subscription, items } = await getOrgSubscriptionData(orgId); + subscriptionData = subscription; + itemsData = items; + } catch (err) { + if ((err as Error).message === "Not found") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + throw err; + } + + return response(res, { + data: { + subscription: subscriptionData, + items: itemsData + }, + success: true, + error: false, + message: "Organization and subscription retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + +export async function getOrgSubscriptionData( + orgId: string +): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> { + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + throw new Error(`Not found`); + } + + // Get customer for org + const customer = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + let subscription = null; + let items: SubscriptionItem[] = []; + + if (customer.length > 0) { + // Get subscription for customer + const subs = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.customerId, customer[0].customerId)) + .limit(1); + + if (subs.length > 0) { + subscription = subs[0]; + // Get subscription items + items = await db + .select() + .from(subscriptionItems) + .where( + eq( + subscriptionItems.subscriptionId, + subscription.subscriptionId + ) + ); + } + } + + return { subscription, items }; +} diff --git a/server/routers/private/billing/getOrgUsage.ts b/server/routers/private/billing/getOrgUsage.ts new file mode 100644 index 00000000..b387fd52 --- /dev/null +++ b/server/routers/private/billing/getOrgUsage.ts @@ -0,0 +1,129 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { Limit, limits, Usage, usage } from "@server/db"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; + +const getOrgSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export type GetOrgUsageResponse = { + usage: Usage[]; + limits: Limit[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/billing/usage", + description: "Get an organization's billing usage", + tags: [OpenAPITags.Org], + request: { + params: getOrgSchema + }, + responses: {} +}); + +export async function getOrgUsage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + + // Get usage for org + let usageData = []; + + const siteUptime = await usageService.getUsage(orgId, FeatureId.SITE_UPTIME) + const users = await usageService.getUsageDaily(orgId, FeatureId.USERS) + const domains = await usageService.getUsageDaily(orgId, FeatureId.DOMAINS) + const remoteExitNodes = await usageService.getUsageDaily(orgId, FeatureId.REMOTE_EXIT_NODES) + const egressData = await usageService.getUsage(orgId, FeatureId.EGRESS_DATA_MB) + + if (siteUptime) { + usageData.push(siteUptime); + } + if (users) { + usageData.push(users); + } + if (egressData) { + usageData.push(egressData); + } + if (domains) { + usageData.push(domains); + } + if (remoteExitNodes) { + usageData.push(remoteExitNodes); + } + + const orgLimits = await db.select() + .from(limits) + .where(eq(limits.orgId, orgId)); + + return response(res, { + data: { + usage: usageData, + limits: orgLimits + }, + success: true, + error: false, + message: "Organization usage retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/billing/hooks/handleCustomerCreated.ts b/server/routers/private/billing/hooks/handleCustomerCreated.ts new file mode 100644 index 00000000..fdccc8dd --- /dev/null +++ b/server/routers/private/billing/hooks/handleCustomerCreated.ts @@ -0,0 +1,57 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export async function handleCustomerCreated( + customer: Stripe.Customer +): Promise { + try { + const [existingCustomer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, customer.id)) + .limit(1); + + if (existingCustomer) { + logger.info(`Customer with ID ${customer.id} already exists.`); + return; + } + + if (!customer.metadata.orgId) { + logger.error( + `Customer with ID ${customer.id} does not have an orgId in metadata.` + ); + return; + } + + await db.insert(customers).values({ + customerId: customer.id, + orgId: customer.metadata.orgId, + email: customer.email || null, + name: customer.name || null, + createdAt: customer.created, + updatedAt: customer.created + }); + logger.info(`Customer with ID ${customer.id} created successfully.`); + } catch (error) { + logger.error( + `Error handling customer created event for ID ${customer.id}:`, + error + ); + } + return; +} diff --git a/server/routers/private/billing/hooks/handleCustomerDeleted.ts b/server/routers/private/billing/hooks/handleCustomerDeleted.ts new file mode 100644 index 00000000..aa2e6964 --- /dev/null +++ b/server/routers/private/billing/hooks/handleCustomerDeleted.ts @@ -0,0 +1,44 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export async function handleCustomerDeleted( + customer: Stripe.Customer +): Promise { + try { + const [existingCustomer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, customer.id)) + .limit(1); + + if (!existingCustomer) { + logger.info(`Customer with ID ${customer.id} does not exist.`); + return; + } + + await db + .delete(customers) + .where(eq(customers.customerId, customer.id)); + } catch (error) { + logger.error( + `Error handling customer created event for ID ${customer.id}:`, + error + ); + } + return; +} diff --git a/server/routers/private/billing/hooks/handleCustomerUpdated.ts b/server/routers/private/billing/hooks/handleCustomerUpdated.ts new file mode 100644 index 00000000..3a0210a9 --- /dev/null +++ b/server/routers/private/billing/hooks/handleCustomerUpdated.ts @@ -0,0 +1,54 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export async function handleCustomerUpdated( + customer: Stripe.Customer +): Promise { + try { + const [existingCustomer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, customer.id)) + .limit(1); + + if (!existingCustomer) { + logger.info(`Customer with ID ${customer.id} does not exist.`); + return; + } + + const newCustomer = { + customerId: customer.id, + orgId: customer.metadata.orgId, + email: customer.email || null, + name: customer.name || null, + updatedAt: Math.floor(Date.now() / 1000) + }; + + // Update the existing customer record + await db + .update(customers) + .set(newCustomer) + .where(eq(customers.customerId, customer.id)); + } catch (error) { + logger.error( + `Error handling customer created event for ID ${customer.id}:`, + error + ); + } + return; +} diff --git a/server/routers/private/billing/hooks/handleSubscriptionCreated.ts b/server/routers/private/billing/hooks/handleSubscriptionCreated.ts new file mode 100644 index 00000000..ee6376c9 --- /dev/null +++ b/server/routers/private/billing/hooks/handleSubscriptionCreated.ts @@ -0,0 +1,153 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { + customers, + subscriptions, + db, + subscriptionItems, + userOrgs, + users +} from "@server/db"; +import { eq, and } from "drizzle-orm"; +import logger from "@server/logger"; +import stripe from "@server/lib/private/stripe"; +import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; +import { AudienceIds, moveEmailToAudience } from "@server/lib/private/resend"; + +export async function handleSubscriptionCreated( + subscription: Stripe.Subscription +): Promise { + try { + // Fetch the subscription from Stripe with expanded price.tiers + const fullSubscription = await stripe!.subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price.tiers"] + } + ); + + logger.info(JSON.stringify(fullSubscription, null, 2)); + // Check if subscription already exists + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.subscriptionId, subscription.id)) + .limit(1); + + if (existingSubscription) { + logger.info( + `Subscription with ID ${subscription.id} already exists.` + ); + return; + } + + const newSubscription = { + subscriptionId: subscription.id, + customerId: subscription.customer as string, + status: subscription.status, + canceledAt: subscription.canceled_at + ? subscription.canceled_at + : null, + createdAt: subscription.created + }; + + await db.insert(subscriptions).values(newSubscription); + logger.info( + `Subscription with ID ${subscription.id} created successfully.` + ); + + // Insert subscription items + if (Array.isArray(fullSubscription.items?.data)) { + const itemsToInsertPromises = fullSubscription.items.data.map( + async (item) => { + // try to get the product name from stripe and add it to the item + let name = null; + if (item.price.product) { + const product = await stripe!.products.retrieve( + item.price.product as string + ); + name = product.name || null; + } + + return { + subscriptionId: subscription.id, + planId: item.plan.id, + priceId: item.price.id, + meterId: item.plan.meter, + unitAmount: item.price.unit_amount || 0, + currentPeriodStart: item.current_period_start, + currentPeriodEnd: item.current_period_end, + tiers: item.price.tiers + ? JSON.stringify(item.price.tiers) + : null, + interval: item.plan.interval, + name + }; + } + ); + + // wait for all items to be processed + const itemsToInsert = await Promise.all(itemsToInsertPromises); + + if (itemsToInsert.length > 0) { + await db.insert(subscriptionItems).values(itemsToInsert); + logger.info( + `Inserted ${itemsToInsert.length} subscription items for subscription ${subscription.id}.` + ); + } + } + + // Lookup customer to get orgId + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, subscription.customer as string)) + .limit(1); + + if (!customer) { + logger.error( + `Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.` + ); + return; + } + + await handleSubscriptionLifesycle(customer.orgId, subscription.status); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) + ) + .innerJoin(users, eq(userOrgs.userId, users.userId)); + + if (orgUserRes) { + const email = orgUserRes.user.email; + + if (email) { + moveEmailToAudience(email, AudienceIds.Subscribed); + } + } + } catch (error) { + logger.error( + `Error handling subscription created event for ID ${subscription.id}:`, + error + ); + } + return; +} diff --git a/server/routers/private/billing/hooks/handleSubscriptionDeleted.ts b/server/routers/private/billing/hooks/handleSubscriptionDeleted.ts new file mode 100644 index 00000000..95123731 --- /dev/null +++ b/server/routers/private/billing/hooks/handleSubscriptionDeleted.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { subscriptions, db, subscriptionItems, customers, userOrgs, users } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import logger from "@server/logger"; +import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; +import { AudienceIds, moveEmailToAudience } from "@server/lib/private/resend"; + +export async function handleSubscriptionDeleted( + subscription: Stripe.Subscription +): Promise { + try { + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.subscriptionId, subscription.id)) + .limit(1); + + if (!existingSubscription) { + logger.info( + `Subscription with ID ${subscription.id} does not exist.` + ); + return; + } + + await db + .delete(subscriptions) + .where(eq(subscriptions.subscriptionId, subscription.id)); + + await db + .delete(subscriptionItems) + .where(eq(subscriptionItems.subscriptionId, subscription.id)); + + + // Lookup customer to get orgId + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, subscription.customer as string)) + .limit(1); + + if (!customer) { + logger.error( + `Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.` + ); + return; + } + + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status + ); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) + ) + .innerJoin(users, eq(userOrgs.userId, users.userId)); + + if (orgUserRes) { + const email = orgUserRes.user.email; + + if (email) { + moveEmailToAudience(email, AudienceIds.Churned); + } + } + } catch (error) { + logger.error( + `Error handling subscription updated event for ID ${subscription.id}:`, + error + ); + } + return; +} diff --git a/server/routers/private/billing/hooks/handleSubscriptionUpdated.ts b/server/routers/private/billing/hooks/handleSubscriptionUpdated.ts new file mode 100644 index 00000000..f1cbcafe --- /dev/null +++ b/server/routers/private/billing/hooks/handleSubscriptionUpdated.ts @@ -0,0 +1,296 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import Stripe from "stripe"; +import { + subscriptions, + db, + subscriptionItems, + usage, + sites, + customers, + orgs +} from "@server/db"; +import { eq, and } from "drizzle-orm"; +import logger from "@server/logger"; +import { getFeatureIdByMetricId } from "@server/lib/private/billing/features"; +import stripe from "@server/lib/private/stripe"; +import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; + +export async function handleSubscriptionUpdated( + subscription: Stripe.Subscription, + previousAttributes: Partial | undefined +): Promise { + try { + // Fetch the subscription from Stripe with expanded price.tiers + const fullSubscription = await stripe!.subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price.tiers"] + } + ); + + logger.info(JSON.stringify(fullSubscription, null, 2)); + + const [existingSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.subscriptionId, subscription.id)) + .limit(1); + + if (!existingSubscription) { + logger.info( + `Subscription with ID ${subscription.id} does not exist.` + ); + return; + } + + // get the customer + const [existingCustomer] = await db + .select() + .from(customers) + .where(eq(customers.customerId, subscription.customer as string)) + .limit(1); + + await db + .update(subscriptions) + .set({ + status: subscription.status, + canceledAt: subscription.canceled_at + ? subscription.canceled_at + : null, + updatedAt: Math.floor(Date.now() / 1000), + billingCycleAnchor: subscription.billing_cycle_anchor + }) + .where(eq(subscriptions.subscriptionId, subscription.id)); + + await handleSubscriptionLifesycle( + existingCustomer.orgId, + subscription.status + ); + + // Upsert subscription items + if (Array.isArray(fullSubscription.items?.data)) { + const itemsToUpsert = fullSubscription.items.data.map((item) => ({ + subscriptionId: subscription.id, + planId: item.plan.id, + priceId: item.price.id, + meterId: item.plan.meter, + unitAmount: item.price.unit_amount || 0, + currentPeriodStart: item.current_period_start, + currentPeriodEnd: item.current_period_end, + tiers: item.price.tiers + ? JSON.stringify(item.price.tiers) + : null, + interval: item.plan.interval + })); + if (itemsToUpsert.length > 0) { + await db.transaction(async (trx) => { + await trx + .delete(subscriptionItems) + .where( + eq( + subscriptionItems.subscriptionId, + subscription.id + ) + ); + + await trx.insert(subscriptionItems).values(itemsToUpsert); + }); + logger.info( + `Updated ${itemsToUpsert.length} subscription items for subscription ${subscription.id}.` + ); + } + + // --- Detect cycled items and update usage --- + if (previousAttributes) { + // Only proceed if latest_invoice changed (per Stripe docs) + if ("latest_invoice" in previousAttributes) { + // If items array present in previous_attributes, check each item + if (Array.isArray(previousAttributes.items?.data)) { + for (const item of subscription.items.data) { + const prevItem = previousAttributes.items.data.find( + (pi: any) => pi.id === item.id + ); + if ( + prevItem && + prevItem.current_period_end && + item.current_period_start && + prevItem.current_period_end === + item.current_period_start && + item.current_period_start > + prevItem.current_period_start + ) { + logger.info( + `Subscription item ${item.id} has cycled. Resetting usage.` + ); + } else { + continue; + } + + // This item has cycled + const meterId = item.plan.meter; + if (!meterId) { + logger.warn( + `No meterId found for subscription item ${item.id}. Skipping usage reset.` + ); + continue; + } + const featureId = getFeatureIdByMetricId(meterId); + if (!featureId) { + logger.warn( + `No featureId found for meterId ${meterId}. Skipping usage reset.` + ); + continue; + } + + const orgId = existingCustomer.orgId; + + if (!orgId) { + logger.warn( + `No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.` + ); + continue; + } + + await db.transaction(async (trx) => { + const [usageRow] = await trx + .select() + .from(usage) + .where( + eq( + usage.usageId, + `${orgId}-${featureId}` + ) + ) + .limit(1); + + if (usageRow) { + // get the next rollover date + + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + const lastRollover = usageRow.rolledOverAt + ? new Date(usageRow.rolledOverAt * 1000) + : new Date(); + const anchorDate = org.createdAt + ? new Date(org.createdAt) + : new Date(); + + const nextRollover = + calculateNextRollOverDate( + lastRollover, + anchorDate + ); + + await trx + .update(usage) + .set({ + previousValue: usageRow.latestValue, + latestValue: + usageRow.instantaneousValue || + 0, + updatedAt: Math.floor( + Date.now() / 1000 + ), + rolledOverAt: Math.floor( + Date.now() / 1000 + ), + nextRolloverAt: Math.floor( + nextRollover.getTime() / 1000 + ) + }) + .where( + eq(usage.usageId, usageRow.usageId) + ); + logger.info( + `Usage reset for org ${orgId}, meter ${featureId} on subscription item cycle.` + ); + } + + // Also reset the sites to 0 + await trx + .update(sites) + .set({ + megabytesIn: 0, + megabytesOut: 0 + }) + .where(eq(sites.orgId, orgId)); + }); + } + } + } + } + // --- end usage update --- + } + } catch (error) { + logger.error( + `Error handling subscription updated event for ID ${subscription.id}:`, + error + ); + } + return; +} + +/** + * Calculate the next billing date based on monthly billing cycle + * Handles end-of-month scenarios as described in the requirements + * Made public for testing + */ +function calculateNextRollOverDate(lastRollover: Date, anchorDate: Date): Date { + const rolloverDate = new Date(lastRollover); + const anchor = new Date(anchorDate); + + // Get components from rollover date + const rolloverYear = rolloverDate.getUTCFullYear(); + const rolloverMonth = rolloverDate.getUTCMonth(); + + // Calculate target month and year (next month) + let targetMonth = rolloverMonth + 1; + let targetYear = rolloverYear; + + if (targetMonth > 11) { + targetMonth = 0; + targetYear++; + } + + // Get anchor day for billing + const anchorDay = anchor.getUTCDate(); + + // Get the last day of the target month + const lastDayOfMonth = new Date( + Date.UTC(targetYear, targetMonth + 1, 0) + ).getUTCDate(); + + // Use the anchor day or the last day of the month, whichever is smaller + const targetDay = Math.min(anchorDay, lastDayOfMonth); + + // Create the next billing date using UTC + const nextBilling = new Date( + Date.UTC( + targetYear, + targetMonth, + targetDay, + anchor.getUTCHours(), + anchor.getUTCMinutes(), + anchor.getUTCSeconds(), + anchor.getUTCMilliseconds() + ) + ); + + return nextBilling; +} diff --git a/server/routers/private/billing/index.ts b/server/routers/private/billing/index.ts new file mode 100644 index 00000000..913ae865 --- /dev/null +++ b/server/routers/private/billing/index.ts @@ -0,0 +1,18 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createCheckoutSession"; +export * from "./createPortalSession"; +export * from "./getOrgSubscription"; +export * from "./getOrgUsage"; +export * from "./internalGetOrgTier"; \ No newline at end of file diff --git a/server/routers/private/billing/internalGetOrgTier.ts b/server/routers/private/billing/internalGetOrgTier.ts new file mode 100644 index 00000000..7f8cc642 --- /dev/null +++ b/server/routers/private/billing/internalGetOrgTier.ts @@ -0,0 +1,119 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { getTierPriceSet } from "@server/lib/private/billing/tiers"; +import { getOrgSubscriptionData } from "./getOrgSubscription"; +import { build } from "@server/build"; + +const getOrgSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export type GetOrgTierResponse = { + tier: string | null; + active: boolean; +}; + +export async function getOrgTier( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + let tierData = null; + let activeData = false; + + try { + const { tier, active } = await getOrgTierData(orgId); + tierData = tier; + activeData = active; + } catch (err) { + if ((err as Error).message === "Not found") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + throw err; + } + + return response(res, { + data: { + tier: tierData, + active: activeData + }, + success: true, + error: false, + message: "Organization and subscription retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + +export async function getOrgTierData( + orgId: string +): Promise<{ tier: string | null; active: boolean }> { + let tier = null; + let active = false; + + if (build !== "saas") { + return { tier, active }; + } + + const { subscription, items } = await getOrgSubscriptionData(orgId); + + if (items && items.length > 0) { + const tierPriceSet = getTierPriceSet(); + // Iterate through tiers in order (earlier keys are higher tiers) + for (const [tierId, priceId] of Object.entries(tierPriceSet)) { + // Check if any subscription item matches this tier's price ID + const matchingItem = items.find((item) => item.priceId === priceId); + if (matchingItem) { + tier = tierId; + break; + } + } + } + if (subscription && subscription.status === "active") { + active = true; + } + return { tier, active }; +} diff --git a/server/routers/private/billing/subscriptionLifecycle.ts b/server/routers/private/billing/subscriptionLifecycle.ts new file mode 100644 index 00000000..82dbfdbe --- /dev/null +++ b/server/routers/private/billing/subscriptionLifecycle.ts @@ -0,0 +1,45 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { freeLimitSet, limitsService, subscribedLimitSet } from "@server/lib/private/billing"; +import { usageService } from "@server/lib/private/billing/usageService"; +import logger from "@server/logger"; + +export async function handleSubscriptionLifesycle(orgId: string, status: string) { + switch (status) { + case "active": + await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet); + await usageService.checkLimitSet(orgId, true); + break; + case "canceled": + await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); + await usageService.checkLimitSet(orgId, true); + break; + case "past_due": + // Optionally handle past due status, e.g., notify customer + break; + case "unpaid": + await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); + await usageService.checkLimitSet(orgId, true); + break; + case "incomplete": + // Optionally handle incomplete status, e.g., notify customer + break; + case "incomplete_expired": + await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); + await usageService.checkLimitSet(orgId, true); + break; + default: + break; + } +} \ No newline at end of file diff --git a/server/routers/private/billing/webhooks.ts b/server/routers/private/billing/webhooks.ts new file mode 100644 index 00000000..2844943a --- /dev/null +++ b/server/routers/private/billing/webhooks.ts @@ -0,0 +1,136 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import stripe from "@server/lib/private/stripe"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import createHttpError from "http-errors"; +import { response } from "@server/lib/response"; +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import Stripe from "stripe"; +import { handleCustomerCreated } from "./hooks/handleCustomerCreated"; +import { handleSubscriptionCreated } from "./hooks/handleSubscriptionCreated"; +import { handleSubscriptionUpdated } from "./hooks/handleSubscriptionUpdated"; +import { handleCustomerUpdated } from "./hooks/handleCustomerUpdated"; +import { handleSubscriptionDeleted } from "./hooks/handleSubscriptionDeleted"; +import { handleCustomerDeleted } from "./hooks/handleCustomerDeleted"; + +export async function stripeWebhookHandler( + req: Request, + res: Response, + next: NextFunction +): Promise { + let event: Stripe.Event = req.body; + const endpointSecret = config.getRawPrivateConfig().stripe?.webhook_secret; + if (!endpointSecret) { + logger.warn("Stripe webhook secret is not configured. Webhook events will not be priocessed."); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "") + ); + } + + // Only verify the event if you have an endpoint secret defined. + // Otherwise use the basic event deserialized with JSON.parse + if (endpointSecret) { + // Get the signature sent by Stripe + const signature = req.headers["stripe-signature"]; + + if (!signature) { + logger.info("No stripe signature found in headers."); + return next( + createHttpError(HttpCode.BAD_REQUEST, "No stripe signature found in headers") + ); + } + + try { + event = stripe!.webhooks.constructEvent( + req.body, + signature, + endpointSecret + ); + } catch (err) { + logger.error(`Webhook signature verification failed.`, err); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Webhook signature verification failed") + ); + } + } + let subscription; + let previousAttributes; + // Handle the event + switch (event.type) { + case "customer.created": + const customer = event.data.object; + logger.info("Customer created: ", customer); + handleCustomerCreated(customer); + break; + case "customer.updated": + const customerUpdated = event.data.object; + logger.info("Customer updated: ", customerUpdated); + handleCustomerUpdated(customerUpdated); + break; + case "customer.deleted": + const customerDeleted = event.data.object; + logger.info("Customer deleted: ", customerDeleted); + handleCustomerDeleted(customerDeleted); + break; + case "customer.subscription.paused": + subscription = event.data.object; + previousAttributes = event.data.previous_attributes; + handleSubscriptionUpdated(subscription, previousAttributes); + break; + case "customer.subscription.resumed": + subscription = event.data.object; + previousAttributes = event.data.previous_attributes; + handleSubscriptionUpdated(subscription, previousAttributes); + break; + case "customer.subscription.deleted": + subscription = event.data.object; + handleSubscriptionDeleted(subscription); + break; + case "customer.subscription.created": + subscription = event.data.object; + handleSubscriptionCreated(subscription); + break; + case "customer.subscription.updated": + subscription = event.data.object; + previousAttributes = event.data.previous_attributes; + handleSubscriptionUpdated(subscription, previousAttributes); + break; + case "customer.subscription.trial_will_end": + subscription = event.data.object; + // Then define and call a method to handle the subscription trial ending. + // handleSubscriptionTrialEnding(subscription); + break; + case "entitlements.active_entitlement_summary.updated": + subscription = event.data.object; + logger.info( + `Active entitlement summary updated for ${subscription}.` + ); + // Then define and call a method to handle active entitlement summary updated + // handleEntitlementUpdated(subscription); + break; + default: + // Unexpected event type + logger.info(`Unhandled event type ${event.type}.`); + } + // Return a 200 response to acknowledge receipt of the event + return response(res, { + data: null, + success: true, + error: false, + message: "Webhook event processed successfully", + status: HttpCode.CREATED + }); +} diff --git a/server/routers/private/certificates/createCertificate.ts b/server/routers/private/certificates/createCertificate.ts new file mode 100644 index 00000000..210878ef --- /dev/null +++ b/server/routers/private/certificates/createCertificate.ts @@ -0,0 +1,85 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Certificate, certificates, db, domains } from "@server/db"; +import logger from "@server/logger"; +import { Transaction } from "@server/db"; +import { eq, or, and, like } from "drizzle-orm"; +import { build } from "@server/build"; + +/** + * Checks if a certificate exists for the given domain. + * If not, creates a new certificate in 'pending' state. + * Wildcard certs cover subdomains. + */ +export async function createCertificate(domainId: string, domain: string, trx: Transaction | typeof db) { + if (build !== "saas") { + return; + } + + const [domainRecord] = await trx + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + let existing: Certificate[] = []; + if (domainRecord.type == "ns") { + const domainLevelDown = domain.split('.').slice(1).join('.'); + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.wildcard, true), // only NS domains can have wildcard certs + or( + eq(certificates.domain, domain), + eq(certificates.domain, domainLevelDown), + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + if (existing.length > 0) { + logger.info( + `Certificate already exists for domain ${domain}` + ); + return; + } + + // No cert found, create a new one in pending state + await trx.insert(certificates).values({ + domain, + domainId, + wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains + status: "pending", + updatedAt: Math.floor(Date.now() / 1000), + createdAt: Math.floor(Date.now() / 1000) + }); +} diff --git a/server/routers/private/certificates/getCertificate.ts b/server/routers/private/certificates/getCertificate.ts new file mode 100644 index 00000000..a0bf74f6 --- /dev/null +++ b/server/routers/private/certificates/getCertificate.ts @@ -0,0 +1,167 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { certificates, db, domains } from "@server/db"; +import { eq, and, or, like } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { registry } from "@server/openApi"; + +const getCertificateSchema = z + .object({ + domainId: z.string(), + domain: z.string().min(1).max(255), + orgId: z.string() + }) + .strict(); + +async function query(domainId: string, domain: string) { + const [domainRecord] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + let existing: any[] = []; + if (domainRecord.type == "ns") { + const domainLevelDown = domain.split('.').slice(1).join('.'); + + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.wildcard, true), // only NS domains can have wildcard certs + or( + eq(certificates.domain, domain), + eq(certificates.domain, domainLevelDown), + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + return existing.length > 0 ? existing[0] : null; +} + +export type GetCertificateResponse = { + certId: number; + domain: string; + domainId: string; + wildcard: boolean; + status: string; // pending, requested, valid, expired, failed + expiresAt: string | null; + lastRenewalAttempt: Date | null; + createdAt: string; + updatedAt: string; + errorMessage?: string | null; + renewalCount: number; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/certificate/{domainId}/{domain}", + description: "Get a certificate by domain.", + tags: ["Certificate"], + request: { + params: z.object({ + domainId: z + .string(), + domain: z.string().min(1).max(255), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getCertificateSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domainId, domain } = parsedParams.data; + + const cert = await query(domainId, domain); + + if (!cert) { + logger.warn(`Certificate not found for domain: ${domainId}`); + return next(createHttpError(HttpCode.NOT_FOUND, "Certificate not found")); + } + + return response(res, { + data: cert, + success: true, + error: false, + message: "Certificate retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/certificates/index.ts b/server/routers/private/certificates/index.ts new file mode 100644 index 00000000..e1b81ae1 --- /dev/null +++ b/server/routers/private/certificates/index.ts @@ -0,0 +1,15 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./getCertificate"; +export * from "./restartCertificate"; \ No newline at end of file diff --git a/server/routers/private/certificates/restartCertificate.ts b/server/routers/private/certificates/restartCertificate.ts new file mode 100644 index 00000000..1ad3f6a7 --- /dev/null +++ b/server/routers/private/certificates/restartCertificate.ts @@ -0,0 +1,116 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { certificates, db } from "@server/db"; +import { sites } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const restartCertificateParamsSchema = z + .object({ + certId: z.string().transform(stoi).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/certificate/{certId}", + description: "Restart a certificate by ID.", + tags: ["Certificate"], + request: { + params: z.object({ + certId: z + .string() + .transform(stoi) + .pipe(z.number().int().positive()), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function restartCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = restartCertificateParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { certId } = parsedParams.data; + + // get the certificate by ID + const [cert] = await db + .select() + .from(certificates) + .where(eq(certificates.certId, certId)) + .limit(1); + + if (!cert) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Certificate not found") + ); + } + + if (cert.status != "failed" && cert.status != "expired") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Certificate is already valid, no need to restart" + ) + ); + } + + // update the certificate status to 'pending' + await db + .update(certificates) + .set({ + status: "pending", + errorMessage: null, + lastRenewalAttempt: Math.floor(Date.now() / 1000) + }) + .where(eq(certificates.certId, certId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Certificate restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/hybrid.ts b/server/routers/private/hybrid.ts new file mode 100644 index 00000000..c8b0960d --- /dev/null +++ b/server/routers/private/hybrid.ts @@ -0,0 +1,1485 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { verifySessionRemoteExitNodeMiddleware } from "@server/middlewares/private/verifyRemoteExitNode"; +import { Router } from "express"; +import { + db, + exitNodes, + Resource, + ResourcePassword, + ResourcePincode, + Session, + User, + certificates, + exitNodeOrgs, + RemoteExitNode, + olms, + newts, + clients, + sites, + domains, + orgDomains, + targets, + loginPage, + loginPageOrg, + LoginPage +} from "@server/db"; +import { + resources, + resourcePincode, + resourcePassword, + sessions, + users, + userOrgs, + roleResources, + userResources, + resourceRules +} from "@server/db"; +import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { getTraefikConfig } from "../../lib/traefik" +import { + generateGerbilConfig, + generateRelayMappings, + updateAndGenerateEndpointDestinations, + updateSiteBandwidth +} from "../gerbil"; +import * as gerbil from "@server/routers/gerbil"; +import logger from "@server/logger"; +import { decryptData } from "@server/lib/encryption"; +import { config } from "@server/lib/config"; +import * as fs from "fs"; +import { exchangeSession } from "../badger"; +import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { checkExitNodeOrg, resolveExitNodes } from "@server/lib/exitNodes"; +import { maxmindLookup } from "@server/db/maxmind"; + +// Zod schemas for request validation +const getResourceByDomainParamsSchema = z + .object({ + domain: z.string().min(1, "Domain is required") + }) + .strict(); + +const getUserSessionParamsSchema = z + .object({ + userSessionId: z.string().min(1, "User session ID is required") + }) + .strict(); + +const getUserOrgRoleParamsSchema = z + .object({ + userId: z.string().min(1, "User ID is required"), + orgId: z.string().min(1, "Organization ID is required") + }) + .strict(); + +const getRoleResourceAccessParamsSchema = z + .object({ + roleId: z + .string() + .transform(Number) + .pipe( + z.number().int().positive("Role ID must be a positive integer") + ), + resourceId: z + .string() + .transform(Number) + .pipe( + z + .number() + .int() + .positive("Resource ID must be a positive integer") + ) + }) + .strict(); + +const getUserResourceAccessParamsSchema = z + .object({ + userId: z.string().min(1, "User ID is required"), + resourceId: z + .string() + .transform(Number) + .pipe( + z + .number() + .int() + .positive("Resource ID must be a positive integer") + ) + }) + .strict(); + +const getResourceRulesParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe( + z + .number() + .int() + .positive("Resource ID must be a positive integer") + ) + }) + .strict(); + +const validateResourceSessionTokenParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe( + z + .number() + .int() + .positive("Resource ID must be a positive integer") + ) + }) + .strict(); + +const validateResourceSessionTokenBodySchema = z + .object({ + token: z.string().min(1, "Token is required") + }) + .strict(); + +// Certificates by domains query validation +const getCertificatesByDomainsQuerySchema = z + .object({ + // Accept domains as string or array (domains or domains[]) + domains: z + .union([z.array(z.string().min(1)), z.string().min(1)]) + .optional(), + // Handle array format from query parameters (domains[]) + "domains[]": z + .union([z.array(z.string().min(1)), z.string().min(1)]) + .optional() + }) + .strict(); + +// Type exports for request schemas +export type GetResourceByDomainParams = z.infer< + typeof getResourceByDomainParamsSchema +>; +export type GetUserSessionParams = z.infer; +export type GetUserOrgRoleParams = z.infer; +export type GetRoleResourceAccessParams = z.infer< + typeof getRoleResourceAccessParamsSchema +>; +export type GetUserResourceAccessParams = z.infer< + typeof getUserResourceAccessParamsSchema +>; +export type GetResourceRulesParams = z.infer< + typeof getResourceRulesParamsSchema +>; +export type ValidateResourceSessionTokenParams = z.infer< + typeof validateResourceSessionTokenParamsSchema +>; +export type ValidateResourceSessionTokenBody = z.infer< + typeof validateResourceSessionTokenBodySchema +>; + +// Type definitions for API responses +export type ResourceWithAuth = { + resource: Resource | null; + pincode: ResourcePincode | null; + password: ResourcePassword | null; +}; + +export type UserSessionWithUser = { + session: Session | null; + user: User | null; +}; + +// Root routes +const hybridRouter = Router(); +hybridRouter.use(verifySessionRemoteExitNodeMiddleware); + +hybridRouter.get( + "/traefik-config", + async (req: Request, res: Response, next: NextFunction) => { + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + try { + const traefikConfig = await getTraefikConfig( + remoteExitNode.exitNodeId, + ["newt", "local", "wireguard"], // Allow them to use all the site types + true // But don't allow domain namespace resources + ); + return response(res, { + data: traefikConfig, + success: true, + error: false, + message: "Traefik config retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get Traefik config" + ) + ); + } + } +); + +// Get valid certificates for given domains (supports wildcard certs) +hybridRouter.get( + "/certificates/domains", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsed = getCertificatesByDomainsQuerySchema.safeParse( + req.query + ); + if (!parsed.success) { + logger.info("Invalid query parameters:", parsed.error); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + logger.error("Remote exit node not found"); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + // Normalize domains into a unique array + const rawDomains = parsed.data.domains; + const rawDomainsArray = parsed.data["domains[]"]; + + // Combine both possible sources + const allRawDomains = [ + ...(Array.isArray(rawDomains) + ? rawDomains + : rawDomains + ? [rawDomains] + : []), + ...(Array.isArray(rawDomainsArray) + ? rawDomainsArray + : rawDomainsArray + ? [rawDomainsArray] + : []) + ]; + + const uniqueDomains = Array.from( + new Set( + allRawDomains + .map((d) => (typeof d === "string" ? d.trim() : "")) + .filter((d) => d.length > 0) + ) + ); + + if (uniqueDomains.length === 0) { + return response(res, { + data: [], + success: true, + error: false, + message: "No domains provided", + status: HttpCode.OK + }); + } + + // Build candidate domain list: exact + first-suffix for wildcard lookup + const suffixes = uniqueDomains + .map((domain) => { + const firstDot = domain.indexOf("."); + return firstDot > 0 ? domain.slice(firstDot + 1) : null; + }) + .filter((d): d is string => !!d); + + const candidateDomains = Array.from( + new Set([...uniqueDomains, ...suffixes]) + ); + + // Query certificates with domain and org information to check authorization + const certRows = await db + .select({ + id: certificates.certId, + domain: certificates.domain, + certFile: certificates.certFile, + keyFile: certificates.keyFile, + expiresAt: certificates.expiresAt, + updatedAt: certificates.updatedAt, + wildcard: certificates.wildcard, + domainId: certificates.domainId, + orgId: orgDomains.orgId + }) + .from(certificates) + .leftJoin(domains, eq(domains.domainId, certificates.domainId)) + .leftJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) + .where( + and( + eq(certificates.status, "valid"), + isNotNull(certificates.certFile), + isNotNull(certificates.keyFile), + inArray(certificates.domain, candidateDomains) + ) + ); + + // Filter certificates based on wildcard matching and exit node authorization + const filtered = []; + for (const cert of certRows) { + // Check if the domain matches our request + const domainMatches = + uniqueDomains.includes(cert.domain) || + (cert.wildcard === true && + uniqueDomains.some((d) => + d.endsWith(`.${cert.domain}`) + )); + + if (!domainMatches) { + continue; + } + + // Check if the exit node has access to the org that owns this domain + if (cert.orgId) { + const hasAccess = await checkExitNodeOrg( + remoteExitNode.exitNodeId, + cert.orgId + ); + if (hasAccess) { + // checkExitNodeOrg returns true when access is denied + continue; + } + } + + filtered.push(cert); + } + + const encryptionKeyPath = + config.getRawPrivateConfig().server.encryption_key_path; + + if (!fs.existsSync(encryptionKeyPath)) { + throw new Error( + "Encryption key file not found. Please generate one first." + ); + } + + const encryptionKeyHex = fs + .readFileSync(encryptionKeyPath, "utf8") + .trim(); + const encryptionKey = Buffer.from(encryptionKeyHex, "hex"); + + const result = filtered.map((cert) => { + // Decrypt and save certificate file + const decryptedCert = decryptData( + cert.certFile!, // is not null from query + encryptionKey + ); + + // Decrypt and save key file + const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + + // Return only the certificate data without org information + return { + id: cert.id, + domain: cert.domain, + certFile: decryptedCert, + keyFile: decryptedKey, + expiresAt: cert.expiresAt, + updatedAt: cert.updatedAt, + wildcard: cert.wildcard + }; + }); + + return response(res, { + data: result, + success: true, + error: false, + message: "Certificates retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get certificates for domains" + ) + ); + } + } +); + +// Get resource by domain with pincode and password information +hybridRouter.get( + "/resource/domain/:domain", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceByDomainParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domain } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [result] = await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.fullDomain, domain)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + result.resources.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + if (!result) { + return response(res, { + data: null, + success: true, + error: false, + message: "Resource not found", + status: HttpCode.OK + }); + } + + const resourceWithAuth: ResourceWithAuth = { + resource: result.resources, + pincode: result.resourcePincode, + password: result.resourcePassword + }; + + return response(res, { + data: resourceWithAuth, + success: true, + error: false, + message: "Resource retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get resource by domain" + ) + ); + } + } +); + +const getOrgLoginPageParamsSchema = z + .object({ + orgId: z.string().min(1) + }) + .strict(); + +hybridRouter.get( + "/org/:orgId/login-page", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [result] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)) + .innerJoin( + loginPage, + eq(loginPageOrg.loginPageId, loginPage.loginPageId) + ) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + result.loginPageOrg.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + if (!result) { + return response(res, { + data: null, + success: true, + error: false, + message: "Login page not found", + status: HttpCode.OK + }); + } + + return response(res, { + data: result.loginPage, + success: true, + error: false, + message: "Login page retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get org login page" + ) + ); + } + } +); + +// Get user session with user information +hybridRouter.get( + "/session/:userSessionId", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserSessionParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userSessionId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [res_data] = await db + .select() + .from(sessions) + .leftJoin(users, eq(users.userId, sessions.userId)) + .where(eq(sessions.sessionId, userSessionId)); + + if (!res_data) { + return response(res, { + data: null, + success: true, + error: false, + message: "Session not found", + status: HttpCode.OK + }); + } + + // TODO: THIS SEEMS TO BE TERRIBLY INEFFICIENT AND WE CAN FIX WITH SOME KIND OF BETTER SCHEMA!!!!!!!!!!!!!!! + // Check if the user belongs to any organization that the exit node has access to + if (res_data.user) { + const userOrgsResult = await db + .select({ + orgId: userOrgs.orgId + }) + .from(userOrgs) + .where(eq(userOrgs.userId, res_data.user.userId)); + + // Check if the exit node has access to any of the user's organizations + let hasAccess = false; + for (const userOrg of userOrgsResult) { + const accessDenied = await checkExitNodeOrg( + remoteExitNode.exitNodeId, + userOrg.orgId + ); + if (!accessDenied) { + // checkExitNodeOrg returns true when access is denied, false when allowed + hasAccess = true; + break; + } + } + + if (!hasAccess) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not authorized to access this user session" + ) + ); + } + } + + const userSessionWithUser: UserSessionWithUser = { + session: res_data.session, + user: res_data.user + }; + + return response(res, { + data: userSessionWithUser, + success: true, + error: false, + message: "Session retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user session" + ) + ); + } + } +); + +// Get user organization role +hybridRouter.get( + "/user/:userId/org/:orgId/role", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + const result = userOrgRole.length > 0 ? userOrgRole[0] : null; + + return response(res, { + data: result, + success: true, + error: false, + message: result + ? "User org role retrieved successfully" + : "User org role not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// Check if role has access to resource +hybridRouter.get( + "/role/:roleId/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getRoleResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { roleId, resourceId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ) + .limit(1); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + + return response(res, { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + +// Check if user has direct access to resource +hybridRouter.get( + "/user/:userId/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, resourceId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const userResourceAccess = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resourceId) + ) + ) + .limit(1); + + const result = + userResourceAccess.length > 0 ? userResourceAccess[0] : null; + + return response(res, { + data: result, + success: true, + error: false, + message: result + ? "User resource access retrieved successfully" + : "User resource access not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user resource access" + ) + ); + } + } +); + +// Get resource rules for a given resource +hybridRouter.get( + "/resource/:resourceId/rules", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceRulesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const rules = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + + return response<(typeof resourceRules.$inferSelect)[]>(res, { + data: rules, + success: true, + error: false, + message: "Resource rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get resource rules" + ) + ); + } + } +); + +// Validate resource session token +hybridRouter.post( + "/resource/:resourceId/session/validate", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = + validateResourceSessionTokenParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = validateResourceSessionTokenBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { token } = parsedBody.data; + + const result = await validateResourceSessionToken( + token, + resourceId + ); + + return response(res, { + data: result, + success: true, + error: false, + message: result.resourceSession + ? "Resource session token is valid" + : "Resource session token is invalid or expired", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to validate resource session token" + ) + ); + } + } +); + +const geoIpLookupParamsSchema = z.object({ + ip: z.string().ip() +}); +hybridRouter.get( + "/geoip/:ip", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = geoIpLookupParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { ip } = parsedParams.data; + + if (!maxmindLookup) { + return next( + createHttpError( + HttpCode.SERVICE_UNAVAILABLE, + "GeoIP service is not available" + ) + ); + } + + const result = maxmindLookup.get(ip); + + if (!result || !result.country) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "GeoIP information not found" + ) + ); + } + + const { country } = result; + + logger.debug( + `GeoIP lookup successful for IP ${ip}: ${country.iso_code}` + ); + + return response(res, { + data: { countryCode: country.iso_code }, + success: true, + error: false, + message: "GeoIP lookup successful", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to validate resource session token" + ) + ); + } + } +); + +// GERBIL ROUTERS +const getConfigSchema = z.object({ + publicKey: z.string(), + endpoint: z.string(), + listenPort: z.number() +}); +hybridRouter.post( + "/gerbil/get-config", + async (req: Request, res: Response, next: NextFunction) => { + try { + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + + if (!exitNode) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") + ); + } + + const parsedParams = getConfigSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { publicKey, endpoint, listenPort } = parsedParams.data; + + // update the public key + await db + .update(exitNodes) + .set({ + publicKey: publicKey, + endpoint: endpoint, + listenPort: listenPort + }) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + + const configResponse = await generateGerbilConfig(exitNode); + + return res.status(HttpCode.OK).send(configResponse); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get gerbil config" + ) + ); + } + } +); + +hybridRouter.post( + "/gerbil/receive-bandwidth", + async (req: Request, res: Response, next: NextFunction) => { + try { + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const bandwidthData: any[] = req.body; + + if (!Array.isArray(bandwidthData)) { + throw new Error("Invalid bandwidth data"); + } + + await updateSiteBandwidth( + bandwidthData, + false, + remoteExitNode.exitNodeId + ); // we dont want to check limits + + return res.status(HttpCode.OK).send({ success: true }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to receive bandwidth data" + ) + ); + } + } +); + +const updateHolePunchSchema = z.object({ + olmId: z.string().optional(), + newtId: z.string().optional(), + token: z.string(), + ip: z.string(), + port: z.number(), + timestamp: z.number(), + reachableAt: z.string().optional(), + publicKey: z.string().optional() +}); +hybridRouter.post( + "/gerbil/update-hole-punch", + async (req: Request, res: Response, next: NextFunction) => { + try { + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + + if (!exitNode) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") + ); + } + + // Validate request parameters + const parsedParams = updateHolePunchSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId, newtId, ip, port, timestamp, token, reachableAt } = + parsedParams.data; + + const destinations = await updateAndGenerateEndpointDestinations( + olmId, + newtId, + ip, + port, + timestamp, + token, + exitNode, + true + ); + + return res.status(HttpCode.OK).send({ + destinations: destinations + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +hybridRouter.post( + "/gerbil/get-all-relays", + async (req: Request, res: Response, next: NextFunction) => { + try { + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + + if (!exitNode) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") + ); + } + + const mappings = await generateRelayMappings(exitNode); + + logger.debug( + `Returning mappings for ${Object.keys(mappings).length} endpoints` + ); + return res.status(HttpCode.OK).send({ mappings }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +hybridRouter.post("/badger/exchange-session", exchangeSession); + +const getResolvedHostnameSchema = z.object({ + hostname: z.string(), + publicKey: z.string() +}); + +hybridRouter.post( + "/gerbil/get-resolved-hostname", + async (req: Request, res: Response, next: NextFunction) => { + try { + // Validate request parameters + const parsedParams = getResolvedHostnameSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { hostname, publicKey } = parsedParams.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + + if (!exitNode) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") + ); + } + + const resourceExitNodes = await resolveExitNodes( + hostname, + publicKey + ); + + if (resourceExitNodes.length === 0) { + return res.status(HttpCode.OK).send({ endpoints: [] }); + } + + // Filter endpoints based on exit node authorization + // WE DONT WANT SOMEONE TO SEND A REQUEST TO SOMEONE'S + // EXIT NODE AND TO FORWARD IT TO ANOTHER'S! + const authorizedEndpoints = []; + for (const node of resourceExitNodes) { + const accessDenied = await checkExitNodeOrg( + remoteExitNode.exitNodeId, + node.orgId + ); + if (!accessDenied) { + // checkExitNodeOrg returns true when access is denied, false when allowed + authorizedEndpoints.push(node.endpoint); + } + } + + if (authorizedEndpoints.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not authorized to access this resource" + ) + ); + } + + const endpoints = authorizedEndpoints; + + logger.debug( + `Returning ${Object.keys(endpoints).length} endpoints: ${JSON.stringify(endpoints)}` + ); + return res.status(HttpCode.OK).send({ endpoints }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +export default hybridRouter; diff --git a/server/routers/private/loginPage/createLoginPage.ts b/server/routers/private/loginPage/createLoginPage.ts new file mode 100644 index 00000000..fca29aae --- /dev/null +++ b/server/routers/private/loginPage/createLoginPage.ts @@ -0,0 +1,225 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + exitNodes, + loginPage, + LoginPage, + loginPageOrg, + resources, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { createCertificate } from "@server/routers/private/certificates/createCertificate"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; +import { build } from "@server/build"; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + subdomain: z.string().nullable().optional(), + domainId: z.string() + }) + .strict(); + +export type CreateLoginPageBody = z.infer; + +export type CreateLoginPageResponse = LoginPage; + +export async function createLoginPage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { domainId, subdomain } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + + const [existing] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)); + + if (existing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A login page already exists for this organization" + ) + ); + } + + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + const { fullDomain, subdomain: finalSubdomain } = domainResult; + + logger.debug(`Full domain: ${fullDomain}`); + + const existingResource = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + + const existingLoginPages = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)); + + if (existingLoginPages.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Login page with that domain already exists" + ) + ); + } + + let returned: LoginPage | undefined; + await db.transaction(async (trx) => { + + const orgSites = await trx + .select() + .from(sites) + .innerJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId)) + .where(and(eq(sites.orgId, orgId), eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true))) + .limit(10); + + let exitNodesList = orgSites.map((s) => s.exitNodes); + + if (exitNodesList.length === 0) { + exitNodesList = await trx + .select() + .from(exitNodes) + .where(and(eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true))) + .limit(10); + } + + // select a random exit node + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + + if (!randomExitNode) { + throw new Error("No exit nodes available"); + } + + const [returnedLoginPage] = await db + .insert(loginPage) + .values({ + subdomain: finalSubdomain, + fullDomain, + domainId, + exitNodeId: randomExitNode.exitNodeId + }) + .returning(); + + await trx.insert(loginPageOrg).values({ + orgId, + loginPageId: returnedLoginPage.loginPageId + }); + + returned = returnedLoginPage; + }); + + if (!returned) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create login page" + ) + ); + } + + await createCertificate(domainId, fullDomain, db); + + return response(res, { + data: returned, + success: true, + error: false, + message: "Login page created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/loginPage/deleteLoginPage.ts b/server/routers/private/loginPage/deleteLoginPage.ts new file mode 100644 index 00000000..7cc957a2 --- /dev/null +++ b/server/routers/private/loginPage/deleteLoginPage.ts @@ -0,0 +1,106 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, loginPage, LoginPage, loginPageOrg } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string(), + loginPageId: z.coerce.number() + }) + .strict(); + +export type DeleteLoginPageResponse = LoginPage; + +export async function deleteLoginPage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const [existingLoginPage] = await db + .select() + .from(loginPage) + .where(eq(loginPage.loginPageId, parsedParams.data.loginPageId)) + .innerJoin( + loginPageOrg, + eq(loginPageOrg.orgId, parsedParams.data.orgId) + ); + + if (!existingLoginPage) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Login page not found") + ); + } + + await db + .delete(loginPageOrg) + .where( + and( + eq(loginPageOrg.orgId, parsedParams.data.orgId), + eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId) + ) + ); + + // const leftoverLinks = await db + // .select() + // .from(loginPageOrg) + // .where(eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId)) + // .limit(1); + + // if (!leftoverLinks.length) { + await db + .delete(loginPage) + .where( + eq(loginPage.loginPageId, parsedParams.data.loginPageId) + ); + + await db + .delete(loginPageOrg) + .where( + eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId) + ); + // } + + return response(res, { + data: existingLoginPage.loginPage, + success: true, + error: false, + message: "Login page deleted successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/loginPage/getLoginPage.ts b/server/routers/private/loginPage/getLoginPage.ts new file mode 100644 index 00000000..9c9a18f5 --- /dev/null +++ b/server/routers/private/loginPage/getLoginPage.ts @@ -0,0 +1,86 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, loginPage, loginPageOrg } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +async function query(orgId: string) { + const [res] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)) + .innerJoin( + loginPage, + eq(loginPage.loginPageId, loginPageOrg.loginPageId) + ) + .limit(1); + return res?.loginPage; +} + +export type GetLoginPageResponse = NonNullable< + Awaited> +>; + +export async function getLoginPage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const loginPage = await query(orgId); + + if (!loginPage) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Login page not found") + ); + } + + return response(res, { + data: loginPage, + success: true, + error: false, + message: "Login page retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/loginPage/index.ts b/server/routers/private/loginPage/index.ts new file mode 100644 index 00000000..2372ddfa --- /dev/null +++ b/server/routers/private/loginPage/index.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createLoginPage"; +export * from "./updateLoginPage"; +export * from "./getLoginPage"; +export * from "./loadLoginPage"; +export * from "./updateLoginPage"; +export * from "./deleteLoginPage"; diff --git a/server/routers/private/loginPage/loadLoginPage.ts b/server/routers/private/loginPage/loadLoginPage.ts new file mode 100644 index 00000000..91cc002e --- /dev/null +++ b/server/routers/private/loginPage/loadLoginPage.ts @@ -0,0 +1,148 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg, loginPage, loginPageOrg, resources } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const querySchema = z.object({ + resourceId: z.coerce.number().int().positive().optional(), + idpId: z.coerce.number().int().positive().optional(), + orgId: z.coerce.number().int().positive().optional(), + fullDomain: z.string().min(1) +}); + +async function query(orgId: string | undefined, fullDomain: string) { + if (!orgId) { + const [res] = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)) + .innerJoin( + loginPageOrg, + eq(loginPage.loginPageId, loginPageOrg.loginPageId) + ) + .limit(1); + return { + ...res.loginPage, + orgId: res.loginPageOrg.orgId + }; + } + + const [orgLink] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)); + + if (!orgLink) { + return null; + } + + const [res] = await db + .select() + .from(loginPage) + .where( + and( + eq(loginPage.loginPageId, orgLink.loginPageId), + eq(loginPage.fullDomain, fullDomain) + ) + ) + .limit(1); + return { + ...res, + orgId: orgLink.orgId + }; +} + +export type LoadLoginPageResponse = NonNullable< + Awaited> +> & { orgId: string }; + +export async function loadLoginPage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { resourceId, idpId, fullDomain } = parsedQuery.data; + + let orgId; + if (resourceId) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + orgId = resource.orgId; + } else if (idpId) { + const [idpOrgLink] = await db + .select() + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + if (!idpOrgLink) { + return next( + createHttpError(HttpCode.NOT_FOUND, "IdP not found") + ); + } + + orgId = idpOrgLink.orgId; + } else if (parsedQuery.data.orgId) { + orgId = parsedQuery.data.orgId.toString(); + } + + const loginPage = await query(orgId, fullDomain); + + if (!loginPage) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Login page not found") + ); + } + + return response(res, { + data: loginPage, + success: true, + error: false, + message: "Login page retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/loginPage/updateLoginPage.ts b/server/routers/private/loginPage/updateLoginPage.ts new file mode 100644 index 00000000..9c19913d --- /dev/null +++ b/server/routers/private/loginPage/updateLoginPage.ts @@ -0,0 +1,227 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, loginPage, LoginPage, loginPageOrg, resources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { subdomainSchema } from "@server/lib/schemas"; +import { createCertificate } from "@server/routers/private/certificates/createCertificate"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; +import { build } from "@server/build"; + +const paramsSchema = z + .object({ + orgId: z.string(), + loginPageId: z.coerce.number() + }) + .strict(); + +const bodySchema = z + .object({ + subdomain: subdomainSchema.nullable().optional(), + domainId: z.string().optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }) + .refine( + (data) => { + if (data.subdomain) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { message: "Invalid subdomain" } + ); + +export type UpdateLoginPageBody = z.infer; + +export type UpdateLoginPageResponse = LoginPage; + +export async function updateLoginPage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const updateData = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { loginPageId, orgId } = parsedParams.data; + + if (build === "saas"){ + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + + const [existingLoginPage] = await db + .select() + .from(loginPage) + .where(eq(loginPage.loginPageId, loginPageId)); + + if (!existingLoginPage) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Login page not found") + ); + } + + const [orgLink] = await db + .select() + .from(loginPageOrg) + .where( + and( + eq(loginPageOrg.orgId, orgId), + eq(loginPageOrg.loginPageId, loginPageId) + ) + ); + + if (!orgLink) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Login page not found for this organization" + ) + ); + } + + if (updateData.domainId) { + const domainId = updateData.domainId; + + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + updateData.subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + const { fullDomain, subdomain: finalSubdomain } = domainResult; + + logger.debug(`Full domain: ${fullDomain}`); + + if (fullDomain) { + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingDomain) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + + const [existingLoginPage] = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)); + + if ( + existingLoginPage && + existingLoginPage.loginPageId !== loginPageId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Login page with that domain already exists" + ) + ); + } + + // update the full domain if it has changed + if (fullDomain && fullDomain !== existingLoginPage?.fullDomain) { + await db + .update(loginPage) + .set({ fullDomain }) + .where(eq(loginPage.loginPageId, loginPageId)); + } + + await createCertificate(domainId, fullDomain, db); + } + + updateData.subdomain = finalSubdomain; + } + + const updatedLoginPage = await db + .update(loginPage) + .set({ ...updateData }) + .where(eq(loginPage.loginPageId, loginPageId)) + .returning(); + + if (updatedLoginPage.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Login page with ID ${loginPageId} not found` + ) + ); + } + + return response(res, { + data: updatedLoginPage[0], + success: true, + error: false, + message: "Login page created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/orgIdp/createOrgOidcIdp.ts b/server/routers/private/orgIdp/createOrgOidcIdp.ts new file mode 100644 index 00000000..16697f98 --- /dev/null +++ b/server/routers/private/orgIdp/createOrgOidcIdp.ts @@ -0,0 +1,185 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; + +const paramsSchema = z.object({ orgId: z.string().nonempty() }).strict(); + +const bodySchema = z + .object({ + name: z.string().nonempty(), + clientId: z.string().nonempty(), + clientSecret: z.string().nonempty(), + authUrl: z.string().url(), + tokenUrl: z.string().url(), + identifierPath: z.string().nonempty(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().nonempty(), + autoProvision: z.boolean().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), + roleMapping: z.string().optional() + }) + .strict(); + +export type CreateOrgIdpResponse = { + idpId: number; + redirectUrl: string; +}; + +// registry.registerPath({ +// method: "put", +// path: "/idp/oidc", +// description: "Create an OIDC IdP.", +// tags: [OpenAPITags.Idp], +// request: { +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// } +// }, +// responses: {} +// }); + +export async function createOrgOidcIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + name, + autoProvision, + variant, + roleMapping + } = parsedBody.data; + + if (build === "saas") { + const { tier, active } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + + const key = config.getRawConfig().server.secret!; + + const encryptedSecret = encrypt(clientSecret, key); + const encryptedClientId = encrypt(clientId, key); + + let idpId: number | undefined; + await db.transaction(async (trx) => { + const [idpRes] = await trx + .insert(idp) + .values({ + name, + autoProvision, + type: "oidc" + }) + .returning(); + + idpId = idpRes.idpId; + + await trx.insert(idpOidcConfig).values({ + idpId: idpRes.idpId, + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + variant + }); + + await trx.insert(idpOrg).values({ + idpId: idpRes.idpId, + orgId: orgId, + roleMapping: roleMapping || null, + orgMapping: `'${orgId}'` + }); + }); + + const redirectUrl = await generateOidcRedirectUrl(idpId as number, orgId); + + return response(res, { + data: { + idpId: idpId as number, + redirectUrl + }, + success: true, + error: false, + message: "Org Idp created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/orgIdp/getOrgIdp.ts b/server/routers/private/orgIdp/getOrgIdp.ts new file mode 100644 index 00000000..73ccdcbb --- /dev/null +++ b/server/routers/private/orgIdp/getOrgIdp.ts @@ -0,0 +1,117 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg, loginPage, loginPageOrg } from "@server/db"; +import { idp, idpOidcConfig } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number() + }) + .strict(); + +async function query(idpId: number, orgId: string) { + const [res] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .leftJoin( + idpOrg, + and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, orgId)) + ) + .limit(1); + return res; +} + +export type GetOrgIdpResponse = NonNullable< + Awaited> +> & { redirectUrl: string }; + +// registry.registerPath({ +// method: "get", +// path: "/idp/{idpId}", +// description: "Get an IDP by its IDP ID.", +// tags: [OpenAPITags.Idp], +// request: { +// params: paramsSchema +// }, +// responses: {} +// }); + +export async function getOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + + const idpRes = await query(idpId, orgId); + + if (!idpRes) { + return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found")); + } + + const key = config.getRawConfig().server.secret!; + + if (idpRes.idp.type === "oidc") { + const clientSecret = idpRes.idpOidcConfig!.clientSecret; + const clientId = idpRes.idpOidcConfig!.clientId; + + idpRes.idpOidcConfig!.clientSecret = decrypt(clientSecret, key); + idpRes.idpOidcConfig!.clientId = decrypt(clientId, key); + } + + const redirectUrl = await generateOidcRedirectUrl(idpRes.idp.idpId, orgId); + + return response(res, { + data: { + ...idpRes, + redirectUrl + }, + success: true, + error: false, + message: "Org Idp retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/orgIdp/index.ts b/server/routers/private/orgIdp/index.ts new file mode 100644 index 00000000..99c30654 --- /dev/null +++ b/server/routers/private/orgIdp/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createOrgOidcIdp"; +export * from "./getOrgIdp"; +export * from "./listOrgIdps"; +export * from "./updateOrgOidcIdp"; \ No newline at end of file diff --git a/server/routers/private/orgIdp/listOrgIdps.ts b/server/routers/private/orgIdp/listOrgIdps.ts new file mode 100644 index 00000000..208732de --- /dev/null +++ b/server/routers/private/orgIdp/listOrgIdps.ts @@ -0,0 +1,142 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOidcConfig } from "@server/db"; +import { idp, idpOrg } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +const paramsSchema = z + .object({ + orgId: z.string().nonempty() + }) + .strict(); + +async function query(orgId: string, limit: number, offset: number) { + const res = await db + .select({ + idpId: idp.idpId, + orgId: idpOrg.orgId, + name: idp.name, + type: idp.type, + variant: idpOidcConfig.variant + }) + .from(idpOrg) + .where(eq(idpOrg.orgId, orgId)) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .orderBy(sql`idp.name DESC`) + .limit(limit) + .offset(offset); + return res; +} + +export type ListOrgIdpsResponse = { + idps: Awaited>; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +// registry.registerPath({ +// method: "get", +// path: "/idp", +// description: "List all IDP in the system.", +// tags: [OpenAPITags.Idp], +// request: { +// query: querySchema +// }, +// responses: {} +// }); + +export async function listOrgIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await query(orgId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idp); + + return response(res, { + data: { + idps: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/orgIdp/updateOrgOidcIdp.ts b/server/routers/private/orgIdp/updateOrgOidcIdp.ts new file mode 100644 index 00000000..a3be85c3 --- /dev/null +++ b/server/routers/private/orgIdp/updateOrgOidcIdp.ts @@ -0,0 +1,237 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOidcConfig } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import license from "@server/license/license"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z + .object({ + name: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + authUrl: z.string().optional(), + tokenUrl: z.string().optional(), + identifierPath: z.string().optional(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().optional(), + autoProvision: z.boolean().optional(), + roleMapping: z.string().optional() + }) + .strict(); + +export type UpdateOrgIdpResponse = { + idpId: number; +}; + +// registry.registerPath({ +// method: "post", +// path: "/idp/{idpId}/oidc", +// description: "Update an OIDC IdP.", +// tags: [OpenAPITags.Idp], +// request: { +// params: paramsSchema, +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// } +// }, +// responses: {} +// }); + +export async function updateOrgOidcIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + name, + autoProvision, + roleMapping + } = parsedBody.data; + + if (build === "saas") { + const { tier, active } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + + // Check if IDP exists and is of type OIDC + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + const [existingIdpOrg] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))); + + if (!existingIdpOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found for this organization" + ) + ); + } + + if (existingIdp.type !== "oidc") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP is not an OIDC provider" + ) + ); + } + + const key = config.getRawConfig().server.secret!; + const encryptedSecret = clientSecret + ? encrypt(clientSecret, key) + : undefined; + const encryptedClientId = clientId ? encrypt(clientId, key) : undefined; + + await db.transaction(async (trx) => { + const idpData = { + name, + autoProvision + }; + + // only update if at least one key is not undefined + let keysToUpdate = Object.keys(idpData).filter( + (key) => idpData[key as keyof typeof idpData] !== undefined + ); + + if (keysToUpdate.length > 0) { + await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId)); + } + + const configData = { + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath + }; + + keysToUpdate = Object.keys(configData).filter( + (key) => + configData[key as keyof typeof configData] !== undefined + ); + + if (keysToUpdate.length > 0) { + // Update OIDC config + await trx + .update(idpOidcConfig) + .set(configData) + .where(eq(idpOidcConfig.idpId, idpId)); + } + + if (roleMapping !== undefined) { + // Update IdP-org policy + await trx + .update(idpOrg) + .set({ + roleMapping + }) + .where( + and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) + ); + } + }); + + return response(res, { + data: { + idpId + }, + success: true, + error: false, + message: "Org IdP updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/remoteExitNode/createRemoteExitNode.ts b/server/routers/private/remoteExitNode/createRemoteExitNode.ts new file mode 100644 index 00000000..ac4fd231 --- /dev/null +++ b/server/routers/private/remoteExitNode/createRemoteExitNode.ts @@ -0,0 +1,278 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { remoteExitNodes } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { SqliteError } from "better-sqlite3"; +import moment from "moment"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { createRemoteExitNodeSession } from "@server/auth/sessions/privateRemoteExitNode"; +import { fromError } from "zod-validation-error"; +import { hashPassword, verifyPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import { and, eq } from "drizzle-orm"; +import { getNextAvailableSubnet } from "@server/lib/exitNodes"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; + +export const paramsSchema = z.object({ + orgId: z.string() +}); + +export type CreateRemoteExitNodeResponse = { + token: string; + remoteExitNodeId: string; + secret: string; +}; + +const bodySchema = z + .object({ + remoteExitNodeId: z.string().length(15), + secret: z.string().length(48) + }) + .strict(); + +export type CreateRemoteExitNodeBody = z.infer; + +export async function createRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { remoteExitNodeId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const usage = await usageService.getUsage( + orgId, + FeatureId.REMOTE_EXIT_NODES + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectRemoteExitNodes = await usageService.checkLimitSet( + orgId, + false, + FeatureId.REMOTE_EXIT_NODES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectRemoteExitNodes) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Remote exit node limit exceeded. Please upgrade your plan or contact us at support@fossorial.io" + ) + ); + } + + const secretHash = await hashPassword(secret); + // const address = await getNextAvailableSubnet(); + const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES + + const [existingRemoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + if (existingRemoteExitNode) { + // validate the secret + + const validSecret = await verifyPassword( + secret, + existingRemoteExitNode.secretHash + ); + if (!validSecret) { + logger.info( + `Failed secret validation for remote exit node: ${remoteExitNodeId}` + ); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid secret for remote exit node" + ) + ); + } + } + + let existingExitNode: ExitNode | null = null; + if (existingRemoteExitNode?.exitNodeId) { + const [res] = await db + .select() + .from(exitNodes) + .where( + eq(exitNodes.exitNodeId, existingRemoteExitNode.exitNodeId) + ); + existingExitNode = res; + } + + let existingExitNodeOrg: ExitNodeOrg | null = null; + if (existingRemoteExitNode?.exitNodeId) { + const [res] = await db + .select() + .from(exitNodeOrgs) + .where( + and( + eq( + exitNodeOrgs.exitNodeId, + existingRemoteExitNode.exitNodeId + ), + eq(exitNodeOrgs.orgId, orgId) + ) + ); + existingExitNodeOrg = res; + } + + if (existingExitNodeOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node already exists in this organization" + ) + ); + } + + let numExitNodeOrgs: ExitNodeOrg[] | undefined; + + await db.transaction(async (trx) => { + if (!existingExitNode) { + const [res] = await trx + .insert(exitNodes) + .values({ + name: remoteExitNodeId, + address, + endpoint: "", + publicKey: "", + listenPort: 0, + online: false, + type: "remoteExitNode" + }) + .returning(); + existingExitNode = res; + } + + if (!existingRemoteExitNode) { + await trx.insert(remoteExitNodes).values({ + remoteExitNodeId: remoteExitNodeId, + secretHash, + dateCreated: moment().toISOString(), + exitNodeId: existingExitNode.exitNodeId + }); + } else { + // update the existing remote exit node + await trx + .update(remoteExitNodes) + .set({ + exitNodeId: existingExitNode.exitNodeId + }) + .where( + eq( + remoteExitNodes.remoteExitNodeId, + existingRemoteExitNode.remoteExitNodeId + ) + ); + } + + if (!existingExitNodeOrg) { + await trx.insert(exitNodeOrgs).values({ + exitNodeId: existingExitNode.exitNodeId, + orgId: orgId + }); + } + + numExitNodeOrgs = await trx + .select() + .from(exitNodeOrgs) + .where(eq(exitNodeOrgs.orgId, orgId)); + }); + + if (numExitNodeOrgs) { + await usageService.updateDaily( + orgId, + FeatureId.REMOTE_EXIT_NODES, + numExitNodeOrgs.length + ); + } + + const token = generateSessionToken(); + await createRemoteExitNodeSession(token, remoteExitNodeId); + + return response(res, { + data: { + remoteExitNodeId, + secret, + token + }, + success: true, + error: false, + message: "RemoteExitNode created successfully", + status: HttpCode.OK + }); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A remote exit node with that ID already exists" + ) + ); + } else { + logger.error("Failed to create remoteExitNode", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create remoteExitNode" + ) + ); + } + } +} diff --git a/server/routers/private/remoteExitNode/deleteRemoteExitNode.ts b/server/routers/private/remoteExitNode/deleteRemoteExitNode.ts new file mode 100644 index 00000000..84ef0fab --- /dev/null +++ b/server/routers/private/remoteExitNode/deleteRemoteExitNode.ts @@ -0,0 +1,131 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db"; +import { remoteExitNodes } from "@server/db"; +import { and, count, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; + +const paramsSchema = z + .object({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) + }) + .strict(); + +export async function deleteRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + if (!remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Remote exit node with ID ${remoteExitNodeId} does not have an exit node ID` + ) + ); + } + + let numExitNodeOrgs: ExitNodeOrg[] | undefined; + await db.transaction(async (trx) => { + await trx + .delete(exitNodeOrgs) + .where( + and( + eq(exitNodeOrgs.orgId, orgId), + eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!) + ) + ); + + const [remainingExitNodeOrgs] = await trx + .select({ count: count() }) + .from(exitNodeOrgs) + .where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!)); + + if (remainingExitNodeOrgs.count === 0) { + await trx + .delete(remoteExitNodes) + .where( + eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId) + ); + await trx + .delete(exitNodes) + .where( + eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!) + ); + } + + numExitNodeOrgs = await trx + .select() + .from(exitNodeOrgs) + .where(eq(exitNodeOrgs.orgId, orgId)); + }); + + if (numExitNodeOrgs) { + await usageService.updateDaily( + orgId, + FeatureId.REMOTE_EXIT_NODES, + numExitNodeOrgs.length + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Remote exit node deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/remoteExitNode/getRemoteExitNode.ts b/server/routers/private/remoteExitNode/getRemoteExitNode.ts new file mode 100644 index 00000000..19c4f263 --- /dev/null +++ b/server/routers/private/remoteExitNode/getRemoteExitNode.ts @@ -0,0 +1,99 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, exitNodes } from "@server/db"; +import { remoteExitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const getRemoteExitNodeSchema = z + .object({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) + }) + .strict(); + +async function query(remoteExitNodeId: string) { + const [remoteExitNode] = await db + .select({ + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + dateCreated: remoteExitNodes.dateCreated, + version: remoteExitNodes.version, + exitNodeId: remoteExitNodes.exitNodeId, + name: exitNodes.name, + address: exitNodes.address, + endpoint: exitNodes.endpoint, + online: exitNodes.online, + type: exitNodes.type + }) + .from(remoteExitNodes) + .innerJoin( + exitNodes, + eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId) + ) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + return remoteExitNode; +} + +export type GetRemoteExitNodeResponse = Awaited>; + +export async function getRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getRemoteExitNodeSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const remoteExitNode = await query(remoteExitNodeId); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + return response(res, { + data: remoteExitNode, + success: true, + error: false, + message: "Remote exit node retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/remoteExitNode/getRemoteExitNodeToken.ts b/server/routers/private/remoteExitNode/getRemoteExitNodeToken.ts new file mode 100644 index 00000000..3905f1f7 --- /dev/null +++ b/server/routers/private/remoteExitNode/getRemoteExitNodeToken.ts @@ -0,0 +1,130 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { generateSessionToken } from "@server/auth/sessions/app"; +import { db } from "@server/db"; +import { remoteExitNodes } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { + createRemoteExitNodeSession, + validateRemoteExitNodeSessionToken +} from "@server/auth/sessions/privateRemoteExitNode"; +import { verifyPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import config from "@server/lib/config"; + +export const remoteExitNodeGetTokenBodySchema = z.object({ + remoteExitNodeId: z.string(), + secret: z.string(), + token: z.string().optional() +}); + +export type RemoteExitNodeGetTokenBody = z.infer; + +export async function getRemoteExitNodeToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = remoteExitNodeGetTokenBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { remoteExitNodeId, secret, token } = parsedBody.data; + + try { + if (token) { + const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token); + if (session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `RemoteExitNode session already valid. RemoteExitNode ID: ${remoteExitNodeId}. IP: ${req.ip}.` + ); + } + return response(res, { + data: null, + success: true, + error: false, + message: "Token session already valid", + status: HttpCode.OK + }); + } + } + + const existingRemoteExitNodeRes = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + if (!existingRemoteExitNodeRes || !existingRemoteExitNodeRes.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No remoteExitNode found with that remoteExitNodeId" + ) + ); + } + + const existingRemoteExitNode = existingRemoteExitNodeRes[0]; + + const validSecret = await verifyPassword( + secret, + existingRemoteExitNode.secretHash + ); + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `RemoteExitNode id or secret is incorrect. RemoteExitNode: ID ${remoteExitNodeId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") + ); + } + + const resToken = generateSessionToken(); + await createRemoteExitNodeSession(resToken, existingRemoteExitNode.remoteExitNodeId); + + // logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`); + + return response<{ token: string }>(res, { + data: { + token: resToken + }, + success: true, + error: false, + message: "Token created successfully", + status: HttpCode.OK + }); + } catch (e) { + console.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to authenticate remoteExitNode" + ) + ); + } +} diff --git a/server/routers/private/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/routers/private/remoteExitNode/handleRemoteExitNodePingMessage.ts new file mode 100644 index 00000000..3e1e130d --- /dev/null +++ b/server/routers/private/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -0,0 +1,140 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, RemoteExitNode } from "@server/db"; +import { eq, lt, isNull, and, or, inArray } from "drizzle-orm"; +import logger from "@server/logger"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor((Date.now() - OFFLINE_THRESHOLD_MS) / 1000); + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + const newlyOfflineNodes = await db + .update(exitNodes) + .set({ online: false }) + .where( + and( + eq(exitNodes.online, true), + eq(exitNodes.type, "remoteExitNode"), + or( + lt(exitNodes.lastPing, twoMinutesAgo), + isNull(exitNodes.lastPing) + ) + ) + ).returning(); + + + // Update the sites to offline if they have not pinged either + const exitNodeIds = newlyOfflineNodes.map(node => node.exitNodeId); + + const sitesOnNode = await db + .select() + .from(sites) + .where( + and( + eq(sites.online, true), + inArray(sites.exitNodeId, exitNodeIds) + ) + ); + + // loop through the sites and process their lastBandwidthUpdate as an iso string and if its more than 1 minute old then mark the site offline + for (const site of sitesOnNode) { + if (!site.lastBandwidthUpdate) { + continue; + } + const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate); + if (Date.now() - lastBandwidthUpdate.getTime() > 60 * 1000) { + await db + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, site.siteId)); + } + } + + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.info("Started offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline clients + */ +export const stopRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +}; + +/** + * Handles ping messages from clients and responds with pong + */ +export const handleRemoteExitNodePingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const remoteExitNode = c as RemoteExitNode; + + if (!remoteExitNode) { + logger.debug("RemoteExitNode not found"); + return; + } + + if (!remoteExitNode.exitNodeId) { + logger.debug("RemoteExitNode has no exit node ID!"); // this can happen if the exit node is created but not adopted yet + return; + } + + try { + // Update the exit node's last ping timestamp + await db + .update(exitNodes) + .set({ + lastPing: Math.floor(Date.now() / 1000), + online: true, + }) + .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); + } catch (error) { + logger.error("Error handling ping message", { error }); + } + + return { + message: { + type: "pong", + data: { + timestamp: new Date().toISOString(), + } + }, + broadcast: false, + excludeSender: false + }; +}; \ No newline at end of file diff --git a/server/routers/private/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts b/server/routers/private/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts new file mode 100644 index 00000000..9e50a841 --- /dev/null +++ b/server/routers/private/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts @@ -0,0 +1,49 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, RemoteExitNode, remoteExitNodes } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export const handleRemoteExitNodeRegisterMessage: MessageHandler = async ( + context +) => { + const { message, client, sendToClient } = context; + const remoteExitNode = client as RemoteExitNode; + + logger.debug("Handling register remoteExitNode message!"); + + if (!remoteExitNode) { + logger.warn("Remote exit node not found"); + return; + } + + const { remoteExitNodeVersion } = message.data; + + if (!remoteExitNodeVersion) { + logger.warn("Remote exit node version not found"); + return; + } + + // update the version + await db + .update(remoteExitNodes) + .set({ version: remoteExitNodeVersion }) + .where( + eq( + remoteExitNodes.remoteExitNodeId, + remoteExitNode.remoteExitNodeId + ) + ); +}; diff --git a/server/routers/private/remoteExitNode/index.ts b/server/routers/private/remoteExitNode/index.ts new file mode 100644 index 00000000..2a04f9d9 --- /dev/null +++ b/server/routers/private/remoteExitNode/index.ts @@ -0,0 +1,23 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createRemoteExitNode"; +export * from "./getRemoteExitNode"; +export * from "./listRemoteExitNodes"; +export * from "./getRemoteExitNodeToken"; +export * from "./handleRemoteExitNodeRegisterMessage"; +export * from "./handleRemoteExitNodePingMessage"; +export * from "./deleteRemoteExitNode"; +export * from "./listRemoteExitNodes"; +export * from "./pickRemoteExitNodeDefaults"; +export * from "./quickStartRemoteExitNode"; diff --git a/server/routers/private/remoteExitNode/listRemoteExitNodes.ts b/server/routers/private/remoteExitNode/listRemoteExitNodes.ts new file mode 100644 index 00000000..d6d2466e --- /dev/null +++ b/server/routers/private/remoteExitNode/listRemoteExitNodes.ts @@ -0,0 +1,147 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, exitNodeOrgs, exitNodes } from "@server/db"; +import { remoteExitNodes } from "@server/db"; +import { eq, and, count } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const listRemoteExitNodesParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listRemoteExitNodesSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryRemoteExitNodes(orgId: string) { + return db + .select({ + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + dateCreated: remoteExitNodes.dateCreated, + version: remoteExitNodes.version, + exitNodeId: remoteExitNodes.exitNodeId, + name: exitNodes.name, + address: exitNodes.address, + endpoint: exitNodes.endpoint, + online: exitNodes.online, + type: exitNodes.type + }) + .from(exitNodeOrgs) + .where(eq(exitNodeOrgs.orgId, orgId)) + .innerJoin(exitNodes, eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId)) + .innerJoin( + remoteExitNodes, + eq(remoteExitNodes.exitNodeId, exitNodeOrgs.exitNodeId) + ); +} + +export type ListRemoteExitNodesResponse = { + remoteExitNodes: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listRemoteExitNodes( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listRemoteExitNodesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listRemoteExitNodesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const baseQuery = queryRemoteExitNodes(orgId); + + const countQuery = db + .select({ count: count() }) + .from(remoteExitNodes) + .innerJoin( + exitNodes, + eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId) + ) + .where(eq(exitNodes.type, "remoteExitNode")); + + const remoteExitNodesList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + remoteExitNodes: remoteExitNodesList, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Remote exit nodes retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/remoteExitNode/pickRemoteExitNodeDefaults.ts b/server/routers/private/remoteExitNode/pickRemoteExitNodeDefaults.ts new file mode 100644 index 00000000..684e616c --- /dev/null +++ b/server/routers/private/remoteExitNode/pickRemoteExitNodeDefaults.ts @@ -0,0 +1,71 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; +import { fromError } from "zod-validation-error"; +import { z } from "zod"; + +export type PickRemoteExitNodeDefaultsResponse = { + remoteExitNodeId: string; + secret: string; +}; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export async function pickRemoteExitNodeDefaults( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const remoteExitNodeId = generateId(15); + const secret = generateId(48); + + return response(res, { + data: { + remoteExitNodeId, + secret + }, + success: true, + error: false, + message: "Organization retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/private/remoteExitNode/quickStartRemoteExitNode.ts b/server/routers/private/remoteExitNode/quickStartRemoteExitNode.ts new file mode 100644 index 00000000..689580b9 --- /dev/null +++ b/server/routers/private/remoteExitNode/quickStartRemoteExitNode.ts @@ -0,0 +1,170 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { db, exitNodes, exitNodeOrgs } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { remoteExitNodes } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { SqliteError } from "better-sqlite3"; +import moment from "moment"; +import { generateId } from "@server/auth/sessions/app"; +import { hashPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +export type QuickStartRemoteExitNodeResponse = { + remoteExitNodeId: string; + secret: string; +}; + +const INSTALLER_KEY = "af4e4785-7e09-11f0-b93a-74563c4e2a7e"; + +const quickStartRemoteExitNodeBodySchema = z.object({ + token: z.string() +}); + +export async function quickStartRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = quickStartRemoteExitNodeBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { token } = parsedBody.data; + + const tokenValidation = validateTokenOnApi(token); + if (!tokenValidation.isValid) { + logger.info(`Failed token validation: ${tokenValidation.message}`); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + fromError(tokenValidation.message).toString() + ) + ); + } + + const remoteExitNodeId = generateId(15); + const secret = generateId(48); + const secretHash = await hashPassword(secret); + + await db.insert(remoteExitNodes).values({ + remoteExitNodeId, + secretHash, + dateCreated: moment().toISOString() + }); + + return response(res, { + data: { + remoteExitNodeId, + secret + }, + success: true, + error: false, + message: "Remote exit node created successfully", + status: HttpCode.OK + }); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A remote exit node with that ID already exists" + ) + ); + } else { + logger.error("Failed to create remoteExitNode", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create remoteExitNode" + ) + ); + } + } +} + +/** + * Validates a token received from the frontend. + * @param {string} token The validation token from the request. + * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. + */ +const validateTokenOnApi = ( + token: string +): { isValid: boolean; message: string } => { + if (!token) { + return { isValid: false, message: "Error: No token provided." }; + } + + try { + // 1. Decode the base64 string + const decodedB64 = atob(token); + + // 2. Reverse the character code manipulation + const deobfuscated = decodedB64 + .split("") + .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift + .join(""); + + // 3. Split the data to get the original secret and timestamp + const parts = deobfuscated.split("|"); + if (parts.length !== 2) { + throw new Error("Invalid token format."); + } + const receivedKey = parts[0]; + const tokenTimestamp = parseInt(parts[1], 10); + + // 4. Check if the secret key matches + if (receivedKey !== INSTALLER_KEY) { + logger.info(`Token key mismatch. Received: ${receivedKey}`); + return { isValid: false, message: "Invalid token: Key mismatch." }; + } + + // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks + const now = Date.now(); + const timeDifference = now - tokenTimestamp; + + if (timeDifference > 30000) { + // 30 seconds + return { isValid: false, message: "Invalid token: Expired." }; + } + + if (timeDifference < 0) { + // Timestamp is in the future + return { + isValid: false, + message: "Invalid token: Timestamp is in the future." + }; + } + + // If all checks pass, the token is valid + return { isValid: true, message: "Token is valid!" }; + } catch (error) { + // This will catch errors from atob (if not valid base64) or other issues. + return { + isValid: false, + message: `Error: ${(error as Error).message}` + }; + } +}; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 60dfc0cb..53af0b72 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, loginPage } from "@server/db"; import { domains, orgDomains, @@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; +import { createCertificate } from "../private/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; @@ -54,7 +55,7 @@ const createRawResourceSchema = z name: z.string().min(1).max(255), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), - proxyPort: z.number().int().min(1).max(65535), + proxyPort: z.number().int().min(1).max(65535) // enableProxy: z.boolean().default(true) // always true now }) .strict() @@ -142,10 +143,7 @@ export async function createResource( const { http } = req.body; if (http) { - return await createHttpResource( - { req, res, next }, - { orgId } - ); + return await createHttpResource({ req, res, next }, { orgId }); } else { if ( !config.getRawConfig().flags?.allow_raw_resources && @@ -158,10 +156,7 @@ export async function createResource( ) ); } - return await createRawResource( - { req, res, next }, - { orgId } - ); + return await createRawResource({ req, res, next }, { orgId }); } } catch (error) { logger.error(error); @@ -198,15 +193,14 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; // Validate domain and construct full domain - const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain); - + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + subdomain + ); + if (!domainResult.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - domainResult.error - ) - ); + return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); } const { fullDomain, subdomain: finalSubdomain } = domainResult; @@ -228,6 +222,22 @@ async function createHttpResource( ); } + if (build != "oss") { + const existingLoginPages = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)); + + if (existingLoginPages.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Login page with that domain already exists" + ) + ); + } + } + let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); @@ -285,6 +295,10 @@ async function createHttpResource( ); } + if (build != "oss") { + await createCertificate(domainId, fullDomain, db); + } + return response(res, { data: resource, success: true, @@ -332,7 +346,7 @@ async function createRawResource( name, http, protocol, - proxyPort, + proxyPort // enableProxy }) .returning(); diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index affd7625..7cb83d8b 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -18,7 +18,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH"]), + match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]), value: z.string().min(1), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index ba01f63b..605e5ca6 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -14,7 +14,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; const getExchangeTokenParams = z .object({ diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 006495a7..f6c8c596 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -1,17 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { - resourcePassword, - resourcePincode, - resources -} from "@server/db"; +import { resourcePassword, resourcePincode, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { build } from "@server/build"; const getResourceAuthInfoSchema = z .object({ @@ -52,19 +49,36 @@ export async function getResourceAuthInfo( const { resourceGuid } = parsedParams.data; - const [result] = await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .where(eq(resources.resourceGuid, resourceGuid)) - .limit(1); + const isGuidInteger = /^\d+$/.test(resourceGuid); + + const [result] = + isGuidInteger && build === "saas" + ? await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.resourceId, Number(resourceGuid))) + .limit(1) + : await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.resourceGuid, resourceGuid)) + .limit(1); const resource = result?.resources; const pincode = result?.resourcePincode; diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index d1d4a655..5ff485d2 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index d8553c8c..83af3c7a 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; -import { response } from "@server/lib"; +import { response } from "@server/lib/response"; import stoi from "@server/lib/stoi"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index bb0a6b55..83fcf6f1 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, loginPage } from "@server/db"; import { domains, Org, @@ -20,8 +20,10 @@ import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; +import { createCertificate } from "../private/certificates/createCertificate"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateHeaders } from "@server/lib/validators"; +import { build } from "@server/build"; const updateResourceParamsSchema = z .object({ @@ -47,7 +49,10 @@ const updateHttpResourceBodySchema = z tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.number().int().positive().nullable().optional(), - headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() + .optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -234,14 +239,15 @@ async function updateHttpResource( const domainId = updateData.domainId; // Validate domain and construct full domain - const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain); - + const domainResult = await validateAndConstructDomain( + domainId, + resource.orgId, + updateData.subdomain + ); + if (!domainResult.success) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - domainResult.error - ) + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) ); } @@ -266,6 +272,22 @@ async function updateHttpResource( ) ); } + + if (build != "oss") { + const existingLoginPages = await db + .select() + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)); + + if (existingLoginPages.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Login page with that domain already exists" + ) + ); + } + } } // update the full domain if it has changed @@ -278,6 +300,10 @@ async function updateHttpResource( // Update the subdomain in the update data updateData.subdomain = finalSubdomain; + + if (build != "oss") { + await createCertificate(domainId, fullDomain, db); + } } let headers = null; diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index c2b6a47a..06061da9 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -30,7 +30,7 @@ const updateResourceRuleParamsSchema = z const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]).optional(), value: z.string().min(1).optional(), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 9d3ab692..5ffa6954 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -42,15 +42,15 @@ const createSiteSchema = z address: z.string().optional(), type: z.enum(["newt", "wireguard", "local"]) }) - .strict() - .refine((data) => { - if (data.type === "local") { - return !config.getRawConfig().flags?.disable_local_sites; - } else if (data.type === "wireguard") { - return !config.getRawConfig().flags?.disable_basic_wireguard_sites; - } - return true; - }); + .strict(); +// .refine((data) => { +// if (data.type === "local") { +// return !config.getRawConfig().flags?.disable_local_sites; +// } else if (data.type === "wireguard") { +// return !config.getRawConfig().flags?.disable_basic_wireguard_sites; +// } +// return true; +// }); export type CreateSiteBody = z.infer; diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index b2655ff6..694556f7 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,4 +1,4 @@ -import { db, newts } from "@server/db"; +import { db, exitNodes, newts } from "@server/db"; import { orgs, roleSites, sites, userSites } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -105,11 +105,15 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { type: sites.type, online: sites.online, address: sites.address, - newtVersion: newts.version + newtVersion: newts.version, + exitNodeId: sites.exitNodeId, + exitNodeName: exitNodes.name, + exitNodeEndpoint: exitNodes.endpoint }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(newts, eq(newts.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId)) .where( and( inArray(sites.siteId, accessibleSiteIds), diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 58d44744..46d3c53b 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -74,6 +74,12 @@ export async function pickSiteDefaults( const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + if (!randomExitNode) { + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "No available exit node") + ); + } + // TODO: this probably can be optimized... // list all of the sites on that exit node const sitesQuery = await db @@ -86,6 +92,7 @@ export async function pickSiteDefaults( // TODO: we need to lock this subnet for some time so someone else does not take it const subnets = sitesQuery .map((site) => site.subnet) + .filter((subnet) => subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)) .filter((subnet) => subnet !== null); // exclude the exit node address by replacing after the / with a site block size subnets.push( diff --git a/server/routers/supporterKey/hideSupporterKey.ts b/server/routers/supporterKey/hideSupporterKey.ts index f9d4e89b..e5441259 100644 --- a/server/routers/supporterKey/hideSupporterKey.ts +++ b/server/routers/supporterKey/hideSupporterKey.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import config from "@server/lib/config"; export type HideSupporterKeyResponse = { diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts index 94d0815b..0e958889 100644 --- a/server/routers/supporterKey/isSupporterKeyVisible.ts +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -2,12 +2,13 @@ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import config from "@server/lib/config"; import { db } from "@server/db"; import { count } from "drizzle-orm"; import { users } from "@server/db"; import license from "@server/license/license"; +import { build } from "@server/build"; export type IsSupporterKeyVisibleResponse = { visible: boolean; @@ -44,6 +45,10 @@ export async function isSupporterKeyVisible( } } + if (config.getRawPrivateConfig().flags?.hide_supporter_key && build != "oss") { + visible = false; + } + return sendResponse(res, { data: { visible, diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts index a365030a..9d949fb5 100644 --- a/server/routers/supporterKey/validateSupporterKey.ts +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -4,7 +4,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { response as sendResponse } from "@server/lib"; +import { response as sendResponse } from "@server/lib/response"; import { suppressDeprecationWarnings } from "moment"; import { supporterKey } from "@server/db"; import { db } from "@server/db"; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index dd85c888..0b473563 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, TargetHealthCheck, targetHealthCheck } from "@server/db"; import { newts, resources, sites, Target, targets } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -31,6 +31,25 @@ const createTargetSchema = z method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true), + hcEnabled: z.boolean().optional(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.number().int().positive().optional().nullable(), + hcInterval: z.number().int().positive().min(5).optional().nullable(), + hcUnhealthyInterval: z + .number() + .int() + .positive() + .min(5) + .optional() + .nullable(), + hcTimeout: z.number().int().positive().min(1).optional().nullable(), + hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.number().int().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(), @@ -38,7 +57,7 @@ const createTargetSchema = z }) .strict(); -export type CreateTargetResponse = Target; +export type CreateTargetResponse = Target & TargetHealthCheck; registry.registerPath({ method: "put", @@ -143,6 +162,7 @@ export async function createTarget( } let newTarget: Target[] = []; + let healthCheck: TargetHealthCheck[] = []; if (site.type == "local") { newTarget = await db .insert(targets) @@ -165,7 +185,10 @@ export async function createTarget( ); } - const { internalPort, targetIps } = await pickPort(site.siteId!, db); + const { internalPort, targetIps } = await pickPort( + site.siteId!, + db + ); if (!internalPort) { return next( @@ -180,8 +203,40 @@ export async function createTarget( .insert(targets) .values({ resourceId, + siteId: site.siteId, + ip: targetData.ip, + method: targetData.method, + port: targetData.port, internalPort, - ...targetData + enabled: targetData.enabled, + path: targetData.path, + pathMatchType: targetData.pathMatchType + }) + .returning(); + + let hcHeaders = null; + if (targetData.hcHeaders) { + hcHeaders = JSON.stringify(targetData.hcHeaders); + } + + healthCheck = await db + .insert(targetHealthCheck) + .values({ + targetId: newTarget[0].targetId, + hcEnabled: targetData.hcEnabled ?? false, + hcPath: targetData.hcPath ?? null, + hcScheme: targetData.hcScheme ?? null, + hcMode: targetData.hcMode ?? null, + hcHostname: targetData.hcHostname ?? null, + hcPort: targetData.hcPort ?? null, + hcInterval: targetData.hcInterval ?? null, + hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, + hcTimeout: targetData.hcTimeout ?? null, + hcHeaders: hcHeaders, + hcFollowRedirects: targetData.hcFollowRedirects ?? null, + hcMethod: targetData.hcMethod ?? null, + hcStatus: targetData.hcStatus ?? null, + hcHealth: "unknown" }) .returning(); @@ -205,6 +260,7 @@ export async function createTarget( await addTargets( newt.newtId, newTarget, + healthCheck, resource.protocol, resource.proxyPort ); @@ -213,7 +269,10 @@ export async function createTarget( } return response(res, { - data: newTarget[0], + data: { + ...newTarget[0], + ...healthCheck[0] + }, success: true, error: false, message: "Target created successfully", diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index b0691087..864c02eb 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Target } from "@server/db"; +import { db, Target, targetHealthCheck, TargetHealthCheck } from "@server/db"; import { targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -16,7 +16,9 @@ const getTargetSchema = z }) .strict(); -type GetTargetResponse = Target; +type GetTargetResponse = Target & Omit & { + hcHeaders: { name: string; value: string; }[] | null; +}; registry.registerPath({ method: "get", @@ -62,8 +64,29 @@ export async function getTarget( ); } + const [targetHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + // Parse hcHeaders from JSON string back to array + let parsedHcHeaders = null; + if (targetHc?.hcHeaders) { + try { + parsedHcHeaders = JSON.parse(targetHc.hcHeaders); + } catch (error) { + // If parsing fails, keep as string for backward compatibility + parsedHcHeaders = targetHc.hcHeaders; + } + } + return response(res, { - data: target[0], + data: { + ...target[0], + ...targetHc, + hcHeaders: parsedHcHeaders + }, success: true, error: false, message: "Target retrieved successfully", diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts new file mode 100644 index 00000000..d726b5af --- /dev/null +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -0,0 +1,114 @@ +import { db, targets, resources, sites, targetHealthCheck } from "@server/db"; +import { MessageHandler } from "../ws"; +import { Newt } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import logger from "@server/logger"; +import { unknown } from "zod"; + +interface TargetHealthStatus { + status: string; + lastCheck: string; + checkCount: number; + lastError?: string; + config: { + id: string; + hcEnabled: boolean; + hcPath?: string; + hcScheme?: string; + hcMode?: string; + hcHostname?: string; + hcPort?: number; + hcInterval?: number; + hcUnhealthyInterval?: number; + hcTimeout?: number; + hcHeaders?: any; + hcMethod?: string; + }; +} + +interface HealthcheckStatusMessage { + targets: Record; +} + +export const handleHealthcheckStatusMessage: MessageHandler = async (context) => { + const { message, client: c } = context; + const newt = c as Newt; + + logger.info("Handling healthcheck status message"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no site ID"); + return; + } + + const data = message.data as HealthcheckStatusMessage; + + if (!data.targets) { + logger.warn("No targets data in healthcheck status message"); + return; + } + + try { + let successCount = 0; + let errorCount = 0; + + // Process each target status update + for (const [targetId, healthStatus] of Object.entries(data.targets)) { + logger.debug(`Processing health status for target ${targetId}: ${healthStatus.status}${healthStatus.lastError ? ` (${healthStatus.lastError})` : ''}`); + + // Verify the target belongs to this newt's site before updating + // This prevents unauthorized updates to targets from other sites + const targetIdNum = parseInt(targetId); + if (isNaN(targetIdNum)) { + logger.warn(`Invalid target ID: ${targetId}`); + errorCount++; + continue; + } + + const [targetCheck] = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and( + eq(targets.targetId, targetIdNum), + eq(sites.siteId, newt.siteId) + ) + ) + .limit(1); + + if (!targetCheck) { + logger.warn(`Target ${targetId} not found or does not belong to site ${newt.siteId}`); + errorCount++; + continue; + } + + // Update the target's health status in the database + await db + .update(targetHealthCheck) + .set({ + hcHealth: healthStatus.status + }) + .where(eq(targetHealthCheck.targetId, targetIdNum)) + .execute(); + + logger.debug(`Updated health status for target ${targetId} to ${healthStatus.status}`); + successCount++; + } + + logger.debug(`Health status update complete: ${successCount} successful, ${errorCount} errors out of ${Object.keys(data.targets).length} targets`); + } catch (error) { + logger.error("Error processing healthcheck status message:", error); + } + + return; +}; diff --git a/server/routers/target/index.ts b/server/routers/target/index.ts index dc1323f7..7d023bbd 100644 --- a/server/routers/target/index.ts +++ b/server/routers/target/index.ts @@ -3,3 +3,4 @@ export * from "./createTarget"; export * from "./deleteTarget"; export * from "./updateTarget"; export * from "./listTargets"; +export * from "./handleHealthcheckStatusMessage"; diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 4a1d99a0..178ec967 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -1,4 +1,4 @@ -import { db, sites } from "@server/db"; +import { db, sites, targetHealthCheck } from "@server/db"; import { targets } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -45,6 +45,20 @@ function queryTargets(resourceId: number) { resourceId: targets.resourceId, siteId: targets.siteId, siteType: sites.type, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcMethod: targetHealthCheck.hcMethod, + hcStatus: targetHealthCheck.hcStatus, + hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, @@ -52,13 +66,21 @@ function queryTargets(resourceId: number) { }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) .where(eq(targets.resourceId, resourceId)); return baseQuery; } +type TargetWithParsedHeaders = Omit>[0], 'hcHeaders'> & { + hcHeaders: { name: string; value: string; }[] | null; +}; + export type ListTargetsResponse = { - targets: Awaited>; + targets: TargetWithParsedHeaders[]; pagination: { total: number; limit: number; offset: number }; }; @@ -113,9 +135,26 @@ export async function listTargets( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + // Parse hcHeaders from JSON string back to array for each target + const parsedTargetsList = targetsList.map(target => { + let parsedHcHeaders = null; + if (target.hcHeaders) { + try { + parsedHcHeaders = JSON.parse(target.hcHeaders); + } catch (error) { + // If parsing fails, keep as string for backward compatibility + parsedHcHeaders = target.hcHeaders; + } + } + return { + ...target, + hcHeaders: parsedHcHeaders + }; + }); + return response(res, { data: { - targets: targetsList, + targets: parsedTargetsList, pagination: { total: totalCount, limit, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1e47ce96..af629729 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, targetHealthCheck } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -13,6 +13,7 @@ import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { vs } from "@react-email/components"; const updateTargetParamsSchema = z .object({ @@ -27,6 +28,25 @@ const updateTargetBodySchema = z method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional(), + hcEnabled: z.boolean().optional().nullable(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.number().int().positive().optional().nullable(), + hcInterval: z.number().int().positive().min(5).optional().nullable(), + hcUnhealthyInterval: z + .number() + .int() + .positive() + .min(5) + .optional() + .nullable(), + hcTimeout: z.number().int().positive().min(1).optional().nullable(), + hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.number().int().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(), @@ -171,12 +191,43 @@ export async function updateTarget( const [updatedTarget] = await db .update(targets) .set({ - ...parsedBody.data, - internalPort + siteId: parsedBody.data.siteId, + ip: parsedBody.data.ip, + method: parsedBody.data.method, + port: parsedBody.data.port, + internalPort, + enabled: parsedBody.data.enabled, + path: parsedBody.data.path, + pathMatchType: parsedBody.data.pathMatchType }) .where(eq(targets.targetId, targetId)) .returning(); + let hcHeaders = null; + if (parsedBody.data.hcHeaders) { + hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); + } + + const [updatedHc] = await db + .update(targetHealthCheck) + .set({ + hcEnabled: parsedBody.data.hcEnabled || false, + hcPath: parsedBody.data.hcPath, + hcScheme: parsedBody.data.hcScheme, + hcMode: parsedBody.data.hcMode, + hcHostname: parsedBody.data.hcHostname, + hcPort: parsedBody.data.hcPort, + hcInterval: parsedBody.data.hcInterval, + hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, + hcTimeout: parsedBody.data.hcTimeout, + hcHeaders: hcHeaders, + hcFollowRedirects: parsedBody.data.hcFollowRedirects, + hcMethod: parsedBody.data.hcMethod, + hcStatus: parsedBody.data.hcStatus + }) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); + if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { @@ -194,13 +245,17 @@ export async function updateTarget( await addTargets( newt.newtId, [updatedTarget], + [updatedHc], resource.protocol, resource.proxyPort ); } } return response(res, { - data: updatedTarget, + data: { + ...updatedTarget, + ...updatedHc + }, success: true, error: false, message: "Target updated successfully", diff --git a/server/routers/traefik/index.ts b/server/routers/traefik/index.ts index 5630028c..6f5bd4f0 100644 --- a/server/routers/traefik/index.ts +++ b/server/routers/traefik/index.ts @@ -1 +1 @@ -export * from "./getTraefikConfig"; \ No newline at end of file +export * from "./traefikConfigProvider"; \ No newline at end of file diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts new file mode 100644 index 00000000..50ec0770 --- /dev/null +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -0,0 +1,61 @@ +import { Request, Response } from "express"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import config from "@server/lib/config"; +import { build } from "@server/build"; +import { getTraefikConfig } from "@server/lib/traefik"; +import { getCurrentExitNodeId } from "@server/lib/exitNodes"; + +const badgerMiddlewareName = "badger"; + +export async function traefikConfigProvider( + _: Request, + res: Response +): Promise { + try { + // First query to get resources with site and org info + // Get the current exit node name from config + const currentExitNodeId = await getCurrentExitNodeId(); + + const traefikConfig = await getTraefikConfig( + currentExitNodeId, + config.getRawConfig().traefik.site_types, + build == "oss", // filter out the namespace domains in open source + build != "oss" // generate the login pages on the cloud and hybrid + ); + + if (traefikConfig?.http?.middlewares) { + // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING + traefikConfig.http.middlewares[badgerMiddlewareName] = { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v1", + `http://${ + config.getRawConfig().server.internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, + userSessionCookieName: + config.getRawConfig().server.session_cookie_name, + + // deprecated + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } + }; + } + + return res.status(HttpCode.OK).json(traefikConfig); + } catch (e) { + logger.error(`Failed to build Traefik config: ${e}`); + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: "Failed to build Traefik config" + }); + } +} \ No newline at end of file diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 73bed018..b553bd19 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -10,6 +10,8 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { verifySession } from "@server/auth/sessions/verifySession"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; const acceptInviteBodySchema = z .object({ @@ -131,6 +133,14 @@ export async function acceptInvite( .where(eq(userOrgs.orgId, existingInvite.orgId)); }); + if (totalUsers) { + await usageService.updateDaily( + existingInvite.orgId, + FeatureId.USERS, + totalUsers.length + ); + } + return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, success: true, diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 5b11c923..b8f681c3 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -10,6 +10,11 @@ import { db, UserOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; const paramsSchema = z .object({ @@ -93,6 +98,35 @@ export async function createOrgUser( roleId } = parsedBody.data; + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.USERS); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectUsers = await usageService.checkLimitSet( + orgId, + false, + FeatureId.USERS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectUsers) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User limit exceeded. Please upgrade your plan." + ) + ); + } + } + const [role] = await db .select() .from(roles) @@ -112,6 +146,19 @@ export async function createOrgUser( ) ); } else if (type === "oidc") { + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + if (!idpId) { return next( createHttpError( @@ -218,6 +265,14 @@ export async function createOrgUser( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); }); + + if (orgUsers) { + await usageService.updateDaily( + orgId, + FeatureId.USERS, + orgUsers.length + ); + } } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 174600fc..746a383b 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -17,6 +17,9 @@ import { sendEmail } from "@server/emails"; import SendInviteLink from "@server/emails/templates/SendInviteLink"; import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { build } from "@server/build"; const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); @@ -28,10 +31,7 @@ const inviteUserParamsSchema = z const inviteUserBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), roleId: z.number(), validHours: z.number().gt(0).lte(168), sendEmail: z.boolean().optional(), @@ -99,7 +99,6 @@ export async function inviteUser( regenerate } = parsedBody.data; - // Check if the organization exists const org = await db .select() @@ -112,6 +111,35 @@ export async function inviteUser( ); } + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.USERS); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectUsers = await usageService.checkLimitSet( + orgId, + false, + FeatureId.USERS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectUsers) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User limit exceeded. Please upgrade your plan." + ) + ); + } + } + // Check if the user already exists in the `users` table const existingUser = await db .select() diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index dcd8c6f2..6d0c5359 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -2,13 +2,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources, sites, UserOrg } from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db"; -import { and, eq, exists } from "drizzle-orm"; +import { and, count, eq, exists } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { usageService } from "@server/lib/private/billing/usageService"; +import { FeatureId } from "@server/lib/private/billing"; +import { build } from "@server/build"; +import { UserType } from "@server/types/UserTypes"; const removeUserSchema = z .object({ @@ -115,8 +119,33 @@ export async function removeUserOrg( .select() .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); + + if (build === "saas") { + const [rootUser] = await trx + .select() + .from(users) + .where(eq(users.userId, userId)); + + const [leftInOrgs] = await trx + .select({ count: count() }) + .from(userOrgs) + .where(eq(userOrgs.userId, userId)); + + // if the user is not an internal user and does not belong to any org, delete the entire user + if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) { + await trx.delete(users).where(eq(users.userId, userId)); + } + } }); + if (userCount) { + await usageService.updateDaily( + orgId, + FeatureId.USERS, + userCount.length + ); + } + return response(res, { data: null, success: true, diff --git a/server/routers/ws/index.ts b/server/routers/ws/index.ts index cf95932c..376a960b 100644 --- a/server/routers/ws/index.ts +++ b/server/routers/ws/index.ts @@ -1 +1,24 @@ -export * from "./ws"; \ No newline at end of file +import { build } from "@server/build"; + +// Import both modules +import * as wsModule from "./ws"; +import * as privateWsModule from "./privateWs"; + +// Conditionally export WebSocket implementation based on build type +const wsImplementation = build === "oss" ? wsModule : privateWsModule; + +// Re-export all items from the selected implementation +export const { + router, + handleWSUpgrade, + sendToClient, + broadcastToAllExcept, + connectedClients, + hasActiveConnections, + getActiveNodes, + NODE_ID, + cleanup +} = wsImplementation; + +// Re-export the MessageHandler type (both modules have the same type signature) +export type { MessageHandler } from "./privateWs"; \ No newline at end of file diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 8ca33b8a..5b111eec 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -13,7 +13,10 @@ import { handleOlmPingMessage, startOlmOfflineChecker } from "../olm"; -import { MessageHandler } from "./ws"; +import { handleRemoteExitNodeRegisterMessage, handleRemoteExitNodePingMessage, startRemoteExitNodeOfflineChecker } from "@server/routers/private/remoteExitNode"; +import { MessageHandler } from "./privateWs"; +import { handleHealthcheckStatusMessage } from "../target"; +import { build } from "@server/build"; export const messageHandlers: Record = { "newt/wg/register": handleNewtRegisterMessage, @@ -26,6 +29,13 @@ export const messageHandlers: Record = { "newt/socket/containers": handleDockerContainersMessage, "newt/ping/request": handleNewtPingRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, + "newt/healthcheck/status": handleHealthcheckStatusMessage, + + "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, + "remoteExitNode/ping": handleRemoteExitNodePingMessage, }; startOlmOfflineChecker(); // this is to handle the offline check for olms +if (build != "oss") { + startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes +} \ No newline at end of file diff --git a/server/routers/ws/privateWs.ts b/server/routers/ws/privateWs.ts new file mode 100644 index 00000000..52bb94d8 --- /dev/null +++ b/server/routers/ws/privateWs.ts @@ -0,0 +1,892 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Router, Request, Response } from "express"; +import { Server as HttpServer } from "http"; +import { WebSocket, WebSocketServer } from "ws"; +import { IncomingMessage } from "http"; +import { Socket } from "net"; +import { + Newt, + newts, + NewtSession, + olms, + Olm, + OlmSession, + RemoteExitNode, + RemoteExitNodeSession, + remoteExitNodes +} from "@server/db"; +import { eq } from "drizzle-orm"; +import { db } from "@server/db"; +import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; +import { messageHandlers } from "./messageHandlers"; +import logger from "@server/logger"; +import redisManager from "@server/db/private/redis"; +import { v4 as uuidv4 } from "uuid"; +import { validateRemoteExitNodeSessionToken } from "@server/auth/sessions/privateRemoteExitNode"; +import { rateLimitService } from "@server/db/private/rateLimit"; + +const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection setup + +// Custom interfaces +interface WebSocketRequest extends IncomingMessage { + token?: string; +} + +type ClientType = "newt" | "olm" | "remoteExitNode"; + +interface AuthenticatedWebSocket extends WebSocket { + client?: Newt | Olm | RemoteExitNode; + clientType?: ClientType; + connectionId?: string; + isFullyConnected?: boolean; + pendingMessages?: Buffer[]; +} + +interface TokenPayload { + client: Newt | Olm | RemoteExitNode; + session: NewtSession | OlmSession | RemoteExitNodeSession; + clientType: ClientType; +} + +interface WSMessage { + type: string; + data: any; +} + +interface HandlerResponse { + message: WSMessage; + broadcast?: boolean; + excludeSender?: boolean; + targetClientId?: string; +} + +interface HandlerContext { + message: WSMessage; + senderWs: WebSocket; + client: Newt | Olm | RemoteExitNode | undefined; + clientType: ClientType; + sendToClient: (clientId: string, message: WSMessage) => Promise; + broadcastToAllExcept: ( + message: WSMessage, + excludeClientId?: string + ) => Promise; + connectedClients: Map; +} + +interface RedisMessage { + type: "direct" | "broadcast"; + targetClientId?: string; + excludeClientId?: string; + message: WSMessage; + fromNodeId: string; +} + +export type MessageHandler = ( + context: HandlerContext +) => Promise; + +// Helper function to process a single message +const processMessage = async ( + ws: AuthenticatedWebSocket, + data: Buffer, + clientId: string, + clientType: ClientType +): Promise => { + try { + const message: WSMessage = JSON.parse(data.toString()); + + logger.debug( + `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}` + ); + + if (!message.type || typeof message.type !== "string") { + throw new Error("Invalid message format: missing or invalid type"); + } + + // Check rate limiting with message type awareness + const rateLimitResult = await rateLimitService.checkRateLimit( + clientId, + message.type, // Pass message type for granular limiting + 100, // max requests per window + 20, // max requests per message type per window + 60 * 1000 // window in milliseconds + ); + if (rateLimitResult.isLimited) { + const reason = + rateLimitResult.reason === "global" + ? "too many messages" + : `too many '${message.type}' messages`; + logger.debug( + `Rate limit exceeded for ${clientType.toUpperCase()} ID: ${clientId} - ${reason}, ignoring message` + ); + + // Send rate limit error to client + // ws.send(JSON.stringify({ + // type: "rate_limit_error", + // data: { + // message: `Rate limit exceeded: ${reason}`, + // messageType: message.type, + // reason: rateLimitResult.reason + // } + // })); + return; + } + + const handler = messageHandlers[message.type]; + if (!handler) { + throw new Error(`Unsupported message type: ${message.type}`); + } + + const response = await handler({ + message, + senderWs: ws, + client: ws.client, + clientType: ws.clientType!, + sendToClient, + broadcastToAllExcept, + connectedClients + }); + + if (response) { + if (response.broadcast) { + await broadcastToAllExcept( + response.message, + response.excludeSender ? clientId : undefined + ); + } else if (response.targetClientId) { + await sendToClient(response.targetClientId, response.message); + } else { + ws.send(JSON.stringify(response.message)); + } + } + } catch (error) { + logger.error("Message handling error:", error); + // ws.send(JSON.stringify({ + // type: "error", + // data: { + // message: error instanceof Error ? error.message : "Unknown error occurred", + // originalMessage: data.toString() + // } + // })); + } +}; + +// Helper function to process pending messages +const processPendingMessages = async ( + ws: AuthenticatedWebSocket, + clientId: string, + clientType: ClientType +): Promise => { + if (!ws.pendingMessages || ws.pendingMessages.length === 0) { + return; + } + + logger.info( + `Processing ${ws.pendingMessages.length} pending messages for ${clientType.toUpperCase()} ID: ${clientId}` + ); + + let jobs = []; + for (const messageData of ws.pendingMessages) { + jobs.push(processMessage(ws, messageData, clientId, clientType)); + } + + await Promise.all(jobs); + + ws.pendingMessages = []; // Clear pending messages to prevent reprocessing +}; + +const router: Router = Router(); +const wss: WebSocketServer = new WebSocketServer({ noServer: true }); + +// Generate unique node ID for this instance +const NODE_ID = uuidv4(); +const REDIS_CHANNEL = "websocket_messages"; + +// Client tracking map (local to this node) +let connectedClients: Map = new Map(); + +// Recovery tracking +let isRedisRecoveryInProgress = false; + +// Helper to get map key +const getClientMapKey = (clientId: string) => clientId; + +// Redis keys (generalized) +const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`; +const getNodeConnectionsKey = (nodeId: string, clientId: string) => + `ws:node:${nodeId}:${clientId}`; + +// Initialize Redis subscription for cross-node messaging +const initializeRedisSubscription = async (): Promise => { + if (!redisManager.isRedisEnabled()) return; + + await redisManager.subscribe( + REDIS_CHANNEL, + async (channel: string, message: string) => { + try { + const redisMessage: RedisMessage = JSON.parse(message); + + // Ignore messages from this node + if (redisMessage.fromNodeId === NODE_ID) return; + + if ( + redisMessage.type === "direct" && + redisMessage.targetClientId + ) { + // Send to specific client on this node + await sendToClientLocal( + redisMessage.targetClientId, + redisMessage.message + ); + } else if (redisMessage.type === "broadcast") { + // Broadcast to all clients on this node except excluded + await broadcastToAllExceptLocal( + redisMessage.message, + redisMessage.excludeClientId + ); + } + } catch (error) { + logger.error("Error processing Redis message:", error); + } + } + ); +}; + +// Simple self-healing recovery function +// Each node is responsible for restoring its own connection state to Redis +// This approach is more efficient than cross-node coordination because: +// 1. Each node knows its own connections (source of truth) +// 2. No network overhead from broadcasting state between nodes +// 3. No race conditions from simultaneous updates +// 4. Redis becomes eventually consistent as each node restores independently +// 5. Simpler logic with better fault tolerance +const recoverConnectionState = async (): Promise => { + if (isRedisRecoveryInProgress) { + logger.debug("Redis recovery already in progress, skipping"); + return; + } + + isRedisRecoveryInProgress = true; + logger.info("Starting Redis connection state recovery..."); + + try { + // Each node simply restores its own local connections to Redis + // This is the source of truth - no need for cross-node coordination + await restoreLocalConnectionsToRedis(); + + logger.info("Redis connection state recovery completed - restored local state"); + } catch (error) { + logger.error("Error during Redis recovery:", error); + } finally { + isRedisRecoveryInProgress = false; + } +}; + +const restoreLocalConnectionsToRedis = async (): Promise => { + if (!redisManager.isRedisEnabled()) return; + + logger.info("Restoring local connections to Redis..."); + let restoredCount = 0; + + try { + // Restore all current local connections to Redis + for (const [clientId, clients] of connectedClients.entries()) { + const validClients = clients.filter(client => client.readyState === WebSocket.OPEN); + + if (validClients.length > 0) { + // Add this node to the client's connection list + await redisManager.sadd(getConnectionsKey(clientId), NODE_ID); + + // Store individual connection details + for (const client of validClients) { + if (client.connectionId) { + await redisManager.hset( + getNodeConnectionsKey(NODE_ID, clientId), + client.connectionId, + Date.now().toString() + ); + } + } + restoredCount++; + } + } + + logger.info(`Restored ${restoredCount} client connections to Redis`); + } catch (error) { + logger.error("Failed to restore local connections to Redis:", error); + } +}; + +// Helper functions for client management +const addClient = async ( + clientType: ClientType, + clientId: string, + ws: AuthenticatedWebSocket +): Promise => { + // Generate unique connection ID + const connectionId = uuidv4(); + ws.connectionId = connectionId; + + // Add to local tracking + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; + existingClients.push(ws); + connectedClients.set(mapKey, existingClients); + + // Add to Redis tracking if enabled + if (redisManager.isRedisEnabled()) { + try { + await redisManager.sadd(getConnectionsKey(clientId), NODE_ID); + await redisManager.hset( + getNodeConnectionsKey(NODE_ID, clientId), + connectionId, + Date.now().toString() + ); + } catch (error) { + logger.error("Failed to add client to Redis tracking (connection still functional locally):", error); + } + } + + logger.info( + `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}` + ); +}; + +const removeClient = async ( + clientType: ClientType, + clientId: string, + ws: AuthenticatedWebSocket +): Promise => { + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; + const updatedClients = existingClients.filter((client) => client !== ws); + if (updatedClients.length === 0) { + connectedClients.delete(mapKey); + + if (redisManager.isRedisEnabled()) { + try { + await redisManager.srem(getConnectionsKey(clientId), NODE_ID); + await redisManager.del(getNodeConnectionsKey(NODE_ID, clientId)); + } catch (error) { + logger.error("Failed to remove client from Redis tracking (cleanup will occur on recovery):", error); + } + } + + logger.info( + `All connections removed for ${clientType.toUpperCase()} ID: ${clientId}` + ); + } else { + connectedClients.set(mapKey, updatedClients); + + if (redisManager.isRedisEnabled() && ws.connectionId) { + try { + await redisManager.hdel( + getNodeConnectionsKey(NODE_ID, clientId), + ws.connectionId + ); + } catch (error) { + logger.error("Failed to remove specific connection from Redis tracking:", error); + } + } + + logger.info( + `Connection removed - ${clientType.toUpperCase()} ID: ${clientId}, Remaining connections: ${updatedClients.length}` + ); + } +}; + +// Local message sending (within this node) +const sendToClientLocal = async ( + clientId: string, + message: WSMessage +): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + if (!clients || clients.length === 0) { + return false; + } + const messageString = JSON.stringify(message); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); + return true; +}; + +const broadcastToAllExceptLocal = async ( + message: WSMessage, + excludeClientId?: string +): Promise => { + connectedClients.forEach((clients, mapKey) => { + const [type, id] = mapKey.split(":"); + if (!(excludeClientId && id === excludeClientId)) { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(message)); + } + }); + } + }); +}; + +// Cross-node message sending (via Redis) +const sendToClient = async ( + clientId: string, + message: WSMessage +): Promise => { + // Try to send locally first + const localSent = await sendToClientLocal(clientId, message); + + // Only send via Redis if the client is not connected locally and Redis is enabled + if (!localSent && redisManager.isRedisEnabled()) { + try { + const redisMessage: RedisMessage = { + type: "direct", + targetClientId: clientId, + message, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); + } catch (error) { + logger.error("Failed to send message via Redis, message may be lost:", error); + // Continue execution - local delivery already attempted + } + } else if (!localSent && !redisManager.isRedisEnabled()) { + // Redis is disabled or unavailable - log that we couldn't deliver to remote nodes + logger.debug(`Could not deliver message to ${clientId} - not connected locally and Redis unavailable`); + } + + return localSent; +}; + +const broadcastToAllExcept = async ( + message: WSMessage, + excludeClientId?: string +): Promise => { + // Broadcast locally + await broadcastToAllExceptLocal(message, excludeClientId); + + // If Redis is enabled, also broadcast via Redis pub/sub to other nodes + if (redisManager.isRedisEnabled()) { + try { + const redisMessage: RedisMessage = { + type: "broadcast", + excludeClientId, + message, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); + } catch (error) { + logger.error("Failed to broadcast message via Redis, remote nodes may not receive it:", error); + // Continue execution - local broadcast already completed + } + } else { + logger.debug("Redis unavailable - broadcast limited to local node only"); + } +}; + +// Check if a client has active connections across all nodes +const hasActiveConnections = async (clientId: string): Promise => { + if (!redisManager.isRedisEnabled()) { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return !!(clients && clients.length > 0); + } + + const activeNodes = await redisManager.smembers( + getConnectionsKey(clientId) + ); + return activeNodes.length > 0; +}; + +// Get all active nodes for a client +const getActiveNodes = async ( + clientType: ClientType, + clientId: string +): Promise => { + if (!redisManager.isRedisEnabled()) { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return clients && clients.length > 0 ? [NODE_ID] : []; + } + + return await redisManager.smembers(getConnectionsKey(clientId)); +}; + +// Token verification middleware +const verifyToken = async ( + token: string, + clientType: ClientType +): Promise => { + try { + if (clientType === "newt") { + const { session, newt } = await validateNewtSessionToken(token); + if (!session || !newt) { + return null; + } + const existingNewt = await db + .select() + .from(newts) + .where(eq(newts.newtId, newt.newtId)); + if (!existingNewt || !existingNewt[0]) { + return null; + } + return { client: existingNewt[0], session, clientType }; + } else if (clientType === "olm") { + const { session, olm } = await validateOlmSessionToken(token); + if (!session || !olm) { + return null; + } + const existingOlm = await db + .select() + .from(olms) + .where(eq(olms.olmId, olm.olmId)); + if (!existingOlm || !existingOlm[0]) { + return null; + } + return { client: existingOlm[0], session, clientType }; + } else if (clientType === "remoteExitNode") { + const { session, remoteExitNode } = + await validateRemoteExitNodeSessionToken(token); + if (!session || !remoteExitNode) { + return null; + } + const existingRemoteExitNode = await db + .select() + .from(remoteExitNodes) + .where( + eq( + remoteExitNodes.remoteExitNodeId, + remoteExitNode.remoteExitNodeId + ) + ); + if (!existingRemoteExitNode || !existingRemoteExitNode[0]) { + return null; + } + return { client: existingRemoteExitNode[0], session, clientType }; + } + + return null; + } catch (error) { + logger.error("Token verification failed:", error); + return null; + } +}; + +const setupConnection = async ( + ws: AuthenticatedWebSocket, + client: Newt | Olm | RemoteExitNode, + clientType: ClientType +): Promise => { + logger.info("Establishing websocket connection"); + if (!client) { + logger.error("Connection attempt without client"); + return ws.terminate(); + } + + ws.client = client; + ws.clientType = clientType; + ws.isFullyConnected = false; + ws.pendingMessages = []; + + // Get client ID first + let clientId: string; + if (clientType === "newt") { + clientId = (client as Newt).newtId; + } else if (clientType === "olm") { + clientId = (client as Olm).olmId; + } else if (clientType === "remoteExitNode") { + clientId = (client as RemoteExitNode).remoteExitNodeId; + } else { + throw new Error(`Unknown client type: ${clientType}`); + } + + // Set up message handler FIRST to prevent race condition + ws.on("message", async (data) => { + if (!ws.isFullyConnected) { + // Queue message for later processing with limits + ws.pendingMessages = ws.pendingMessages || []; + + if (ws.pendingMessages.length >= MAX_PENDING_MESSAGES) { + logger.warn( + `Too many pending messages for ${clientType.toUpperCase()} ID: ${clientId}, dropping oldest message` + ); + ws.pendingMessages.shift(); // Remove oldest message + } + + logger.debug( + `Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)` + ); + ws.pendingMessages.push(data as Buffer); + return; + } + + await processMessage(ws, data as Buffer, clientId, clientType); + }); + + // Set up other event handlers before async operations + ws.on("close", async () => { + // Clear any pending messages to prevent memory leaks + if (ws.pendingMessages) { + ws.pendingMessages = []; + } + await removeClient(clientType, clientId, ws); + logger.info( + `Client disconnected - ${clientType.toUpperCase()} ID: ${clientId}` + ); + }); + + ws.on("error", (error: Error) => { + logger.error( + `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, + error + ); + }); + + try { + await addClient(clientType, clientId, ws); + + // Mark connection as fully established + ws.isFullyConnected = true; + + logger.info( + `WebSocket connection fully established and ready - ${clientType.toUpperCase()} ID: ${clientId}` + ); + + // Process any messages that were queued while connection was being established + await processPendingMessages(ws, clientId, clientType); + } catch (error) { + logger.error( + `Failed to fully establish connection for ${clientType.toUpperCase()} ID: ${clientId}:`, + error + ); + // ws.send(JSON.stringify({ + // type: "connection_error", + // data: { + // message: "Failed to establish connection" + // } + // })); + ws.terminate(); + return; + } +}; + +// Router endpoint +router.get("/ws", (req: Request, res: Response) => { + res.status(200).send("WebSocket endpoint"); +}); + +// WebSocket upgrade handler +const handleWSUpgrade = (server: HttpServer): void => { + server.on( + "upgrade", + async (request: WebSocketRequest, socket: Socket, head: Buffer) => { + try { + const url = new URL( + request.url || "", + `http://${request.headers.host}` + ); + const token = + url.searchParams.get("token") || + request.headers["sec-websocket-protocol"] || + ""; + let clientType = url.searchParams.get( + "clientType" + ) as ClientType; + + if (!clientType) { + clientType = "newt"; + } + + if ( + !token || + !clientType || + !["newt", "olm", "remoteExitNode"].includes(clientType) + ) { + logger.warn( + "Unauthorized connection attempt: invalid token or client type..." + ); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + const tokenPayload = await verifyToken(token, clientType); + if (!tokenPayload) { + logger.debug( + "Unauthorized connection attempt: invalid token..." + ); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade( + request, + socket, + head, + (ws: AuthenticatedWebSocket) => { + setupConnection( + ws, + tokenPayload.client, + tokenPayload.clientType + ); + } + ); + } catch (error) { + logger.error("WebSocket upgrade error:", error); + socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); + socket.destroy(); + } + } + ); +}; + +// Add periodic connection state sync to handle Redis disconnections/reconnections +const startPeriodicStateSync = (): void => { + // Lightweight sync every 5 minutes - just restore our own state + setInterval(async () => { + if (redisManager.isRedisEnabled() && !isRedisRecoveryInProgress) { + try { + await restoreLocalConnectionsToRedis(); + logger.debug("Periodic connection state sync completed"); + } catch (error) { + logger.error("Error during periodic connection state sync:", error); + } + } + }, 5 * 60 * 1000); // 5 minutes + + // Cleanup stale connections every 15 minutes + setInterval(async () => { + if (redisManager.isRedisEnabled()) { + try { + await cleanupStaleConnections(); + logger.debug("Periodic connection cleanup completed"); + } catch (error) { + logger.error("Error during periodic connection cleanup:", error); + } + } + }, 15 * 60 * 1000); // 15 minutes +}; + +const cleanupStaleConnections = async (): Promise => { + if (!redisManager.isRedisEnabled()) return; + + try { + const nodeKeys = await redisManager.getClient()?.keys(`ws:node:${NODE_ID}:*`) || []; + + for (const nodeKey of nodeKeys) { + const connections = await redisManager.hgetall(nodeKey); + const clientId = nodeKey.replace(`ws:node:${NODE_ID}:`, ''); + const localClients = connectedClients.get(clientId) || []; + const localConnectionIds = localClients + .filter(client => client.readyState === WebSocket.OPEN) + .map(client => client.connectionId) + .filter(Boolean); + + // Remove Redis entries for connections that no longer exist locally + for (const [connectionId, timestamp] of Object.entries(connections)) { + if (!localConnectionIds.includes(connectionId)) { + await redisManager.hdel(nodeKey, connectionId); + logger.debug(`Cleaned up stale connection: ${connectionId} for client: ${clientId}`); + } + } + + // If no connections remain for this client, remove from Redis entirely + const remainingConnections = await redisManager.hgetall(nodeKey); + if (Object.keys(remainingConnections).length === 0) { + await redisManager.srem(getConnectionsKey(clientId), NODE_ID); + await redisManager.del(nodeKey); + logger.debug(`Cleaned up empty connection tracking for client: ${clientId}`); + } + } + } catch (error) { + logger.error("Error cleaning up stale connections:", error); + } +}; + +// Initialize Redis subscription when the module is loaded +if (redisManager.isRedisEnabled()) { + initializeRedisSubscription().catch((error) => { + logger.error("Failed to initialize Redis subscription:", error); + }); + + // Register recovery callback with Redis manager + // When Redis reconnects, each node simply restores its own local state + redisManager.onReconnection(async () => { + logger.info("Redis reconnected, starting WebSocket state recovery..."); + await recoverConnectionState(); + }); + + // Start periodic state synchronization + startPeriodicStateSync(); + + logger.info( + `WebSocket handler initialized with Redis support - Node ID: ${NODE_ID}` + ); +} else { + logger.debug( + "WebSocket handler initialized in local mode" + ); +} + +// Cleanup function for graceful shutdown +const cleanup = async (): Promise => { + try { + // Close all WebSocket connections + connectedClients.forEach((clients) => { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.terminate(); + } + }); + }); + + // Clean up Redis tracking for this node + if (redisManager.isRedisEnabled()) { + const keys = + (await redisManager + .getClient() + ?.keys(`ws:node:${NODE_ID}:*`)) || []; + if (keys.length > 0) { + await Promise.all(keys.map((key) => redisManager.del(key))); + } + } + + logger.info("WebSocket cleanup completed"); + } catch (error) { + logger.error("Error during WebSocket cleanup:", error); + } +}; + +// Handle process termination +process.on("SIGTERM", cleanup); +process.on("SIGINT", cleanup); + +export { + router, + handleWSUpgrade, + sendToClient, + broadcastToAllExcept, + connectedClients, + hasActiveConnections, + getActiveNodes, + NODE_ID, + cleanup +}; diff --git a/server/setup/clearStaleData.ts b/server/setup/clearStaleData.ts index 220a64f5..2e54656c 100644 --- a/server/setup/clearStaleData.ts +++ b/server/setup/clearStaleData.ts @@ -1,4 +1,5 @@ -import { db } from "@server/db"; +import { build } from "@server/build"; +import { db, sessionTransferToken } from "@server/db"; import { emailVerificationCodes, newtSessions, @@ -76,4 +77,16 @@ export async function clearStaleData() { } catch (e) { logger.warn("Error clearing expired resourceOtp:", e); } + + if (build !== "oss") { + try { + await db + .delete(sessionTransferToken) + .where( + lt(sessionTransferToken.expiresAt, new Date().getTime()) + ); + } catch (e) { + logger.warn("Error clearing expired sessionTransferToken:", e); + } + } } diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index d7c6793f..80d2139d 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -136,7 +136,7 @@ async function executeScripts() { const pendingMigrations = lastExecuted .map((m) => m) .sort((a, b) => semver.compare(b.version, a.version)); - const startVersion = pendingMigrations[0]?.version ?? "0.0.0"; + const startVersion = pendingMigrations[0]?.version ?? APP_VERSION; console.log(`Starting migrations from version ${startVersion}`); const migrationsToRun = migrations.filter((migration) => diff --git a/src/actions/server.ts b/src/actions/server.ts index 27d6b562..655cbc63 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -1,6 +1,6 @@ "use server"; -import { cookies } from "next/headers"; +import { cookies, headers as reqHeaders } from "next/headers"; import { ResponseT } from "@server/types/Response"; import { pullEnv } from "@app/lib/pullEnv"; @@ -14,7 +14,10 @@ type CookieOptions = { domain?: string; }; -function parseSetCookieString(setCookie: string): { +function parseSetCookieString( + setCookie: string, + host?: string +): { name: string; value: string; options: CookieOptions; @@ -50,16 +53,11 @@ function parseSetCookieString(setCookie: string): { case "max-age": options.maxAge = parseInt(v, 10); break; - case "domain": - options.domain = v; - break; } } if (!options.domain) { - const d = env.app.dashboardUrl - ? "." + new URL(env.app.dashboardUrl).hostname - : undefined; + const d = host ? host : new URL(env.app.dashboardUrl).hostname; if (d) { options.domain = d; } @@ -78,6 +76,9 @@ async function makeApiRequest( const allCookies = await cookies(); const cookieHeader = allCookies.toString(); + const headersList = await reqHeaders(); + const host = headersList.get("host"); + const headers: Record = { "Content-Type": "application/json", "X-CSRF-Token": "x-csrf-protection", @@ -107,7 +108,10 @@ async function makeApiRequest( const rawSetCookie = res.headers.get("set-cookie"); if (rawSetCookie) { try { - const { name, value, options } = parseSetCookieString(rawSetCookie); + const { name, value, options } = parseSetCookieString( + rawSetCookie, + host || undefined + ); const allCookies = await cookies(); allCookies.set(name, value, options); } catch (cookieError) { diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 3ab0b92e..ecc7de01 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -9,6 +9,10 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; import SetLastOrgCookie from "@app/components/SetLastOrgCookie"; +import PrivateSubscriptionStatusProvider from "@app/providers/PrivateSubscriptionStatusProvider"; +import { GetOrgSubscriptionResponse } from "@server/routers/private/billing/getOrgSubscription"; +import { pullEnv } from "@app/lib/pullEnv"; +import { build } from "@server/build"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -17,6 +21,7 @@ export default async function OrgLayout(props: { const cookie = await authCookieHeader(); const params = await props.params; const orgId = params.orgId; + const env = pullEnv(); if (!orgId) { redirect(`/`); @@ -50,10 +55,31 @@ export default async function OrgLayout(props: { redirect(`/`); } + let subscriptionStatus = null; + if (build != "oss") { + try { + const getSubscription = cache(() => + internal.get>( + `/org/${orgId}/billing/subscription`, + cookie + ) + ); + const subRes = await getSubscription(); + subscriptionStatus = subRes.data.data; + } catch (error) { + // If subscription fetch fails, keep subscriptionStatus as null + console.error("Failed to fetch subscription status:", error); + } + } + return ( - <> + {props.children} - + ); } diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx new file mode 100644 index 00000000..8559ddf4 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -0,0 +1,97 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { verifySession } from "@app/lib/auth/verifySession"; +import OrgProvider from "@app/providers/OrgProvider"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { GetOrgResponse } from "@server/routers/org"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import { getTranslations } from 'next-intl/server'; + +type BillingSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function BillingSettingsPage({ + children, + params, +}: BillingSettingsProps) { + const { orgId } = await params; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + let orgUser = null; + try { + const getOrgUser = cache(async () => + internal.get>( + `/org/${orgId}/user/${user.userId}`, + await authCookieHeader(), + ), + ); + const res = await getOrgUser(); + orgUser = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${orgId}`, + await authCookieHeader(), + ), + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t('billing'), + href: `/{orgId}/settings/billing`, + }, + ]; + + return ( + <> + + + + + {children} + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx new file mode 100644 index 00000000..1270c30f --- /dev/null +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -0,0 +1,767 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Progress } from "@/components/ui/progress"; +import { + CreditCard, + Database, + Clock, + AlertCircle, + CheckCircle, + Users, + Calculator, + ExternalLink, + Gift, + Server +} from "lucide-react"; +import { InfoPopup } from "@/components/ui/info-popup"; +import { + GetOrgSubscriptionResponse, + GetOrgUsageResponse +} from "@server/routers/private/billing"; +import { useTranslations } from "use-intl"; +import Link from "next/link"; + +export default function GeneralPage() { + const { org } = useOrgContext(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + // Subscription state + const [subscription, setSubscription] = + useState(null); + const [subscriptionItems, setSubscriptionItems] = useState< + GetOrgSubscriptionResponse["items"] + >([]); + const [subscriptionLoading, setSubscriptionLoading] = useState(true); + + // Example usage data (replace with real usage data if available) + const [usageData, setUsageData] = useState( + [] + ); + const [limitsData, setLimitsData] = useState( + [] + ); + + useEffect(() => { + async function fetchSubscription() { + setSubscriptionLoading(true); + try { + const res = await api.get< + AxiosResponse + >(`/org/${org.org.orgId}/billing/subscription`); + const { subscription, items } = res.data.data; + setSubscription(subscription); + setSubscriptionItems(items); + setHasSubscription( + !!subscription && subscription.status === "active" + ); + } catch (error) { + toast({ + title: t("billingFailedToLoadSubscription"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + setSubscriptionLoading(false); + } + } + fetchSubscription(); + }, [org.org.orgId]); + + useEffect(() => { + async function fetchUsage() { + try { + const res = await api.get>( + `/org/${org.org.orgId}/billing/usage` + ); + const { usage, limits } = res.data.data; + + setUsageData(usage); + setLimitsData(limits); + } catch (error) { + toast({ + title: t("billingFailedToLoadUsage"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + } + } + fetchUsage(); + }, [org.org.orgId]); + + const [hasSubscription, setHasSubscription] = useState(true); + const [isLoading, setIsLoading] = useState(false); + // const [newPricing, setNewPricing] = useState({ + // pricePerGB: mockSubscription.pricePerGB, + // pricePerMinute: mockSubscription.pricePerMinute, + // }) + + const handleStartSubscription = async () => { + setIsLoading(true); + try { + const response = await api.post>( + `/org/${org.org.orgId}/billing/create-checkout-session`, + {} + ); + console.log("Checkout session response:", response.data); + const checkoutUrl = response.data.data; + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + toast({ + title: t("billingFailedToGetCheckoutUrl"), + description: t("billingPleaseTryAgainLater"), + variant: "destructive" + }); + setIsLoading(false); + } + } catch (error) { + toast({ + title: t("billingCheckoutError"), + description: formatAxiosError(error), + variant: "destructive" + }); + setIsLoading(false); + } + }; + + const handleModifySubscription = async () => { + setIsLoading(true); + try { + const response = await api.post>( + `/org/${org.org.orgId}/billing/create-portal-session`, + {} + ); + const portalUrl = response.data.data; + if (portalUrl) { + window.location.href = portalUrl; + } else { + toast({ + title: t("billingFailedToGetPortalUrl"), + description: t("billingPleaseTryAgainLater"), + variant: "destructive" + }); + setIsLoading(false); + } + } catch (error) { + toast({ + title: t("billingPortalError"), + description: formatAxiosError(error), + variant: "destructive" + }); + setIsLoading(false); + } + }; + + // Usage IDs + const SITE_UPTIME = "siteUptime"; + const USERS = "users"; + const EGRESS_DATA_MB = "egressDataMb"; + const DOMAINS = "domains"; + const REMOTE_EXIT_NODES = "remoteExitNodes"; + + // Helper to calculate tiered price + function calculateTieredPrice( + usage: number, + tiersRaw: string | null | undefined + ) { + if (!tiersRaw) return 0; + let tiers: any[] = []; + try { + tiers = JSON.parse(tiersRaw); + } catch { + return 0; + } + let total = 0; + let remaining = usage; + for (const tier of tiers) { + const upTo = tier.up_to === null ? Infinity : Number(tier.up_to); + const unitAmount = + tier.unit_amount !== null + ? Number(tier.unit_amount / 100) + : tier.unit_amount_decimal + ? Number(tier.unit_amount_decimal / 100) + : 0; + const tierQty = Math.min( + remaining, + upTo === Infinity ? remaining : upTo - (usage - remaining) + ); + if (tierQty > 0) { + total += tierQty * unitAmount; + remaining -= tierQty; + } + if (remaining <= 0) break; + } + return total; + } + + function getDisplayPrice(tiersRaw: string | null | undefined) { + //find the first non-zero tier price + if (!tiersRaw) return "$0.00"; + let tiers: any[] = []; + try { + tiers = JSON.parse(tiersRaw); + } catch { + return "$0.00"; + } + if (tiers.length === 0) return "$0.00"; + + // find the first tier with a non-zero price + const firstTier = + tiers.find( + (t) => + t.unit_amount > 0 || + (t.unit_amount_decimal && Number(t.unit_amount_decimal) > 0) + ) || tiers[0]; + const unitAmount = + firstTier.unit_amount !== null + ? Number(firstTier.unit_amount / 100) + : firstTier.unit_amount_decimal + ? Number(firstTier.unit_amount_decimal / 100) + : 0; + return `$${unitAmount.toFixed(4)}`; // ${firstTier.up_to === null ? "per unit" : `per ${firstTier.up_to} units`}`; + } + + // Helper to get included usage amount from subscription tier + function getIncludedUsage(tiersRaw: string | null | undefined) { + if (!tiersRaw) return 0; + let tiers: any[] = []; + try { + tiers = JSON.parse(tiersRaw); + } catch { + return 0; + } + if (tiers.length === 0) return 0; + + // Find the first tier (which represents included usage) + const firstTier = tiers[0]; + if (!firstTier) return 0; + + // If the first tier has a unit_amount of 0, it represents included usage + const isIncludedTier = + (firstTier.unit_amount === 0 || firstTier.unit_amount === null) && + (!firstTier.unit_amount_decimal || + Number(firstTier.unit_amount_decimal) === 0); + + if (isIncludedTier && firstTier.up_to !== null) { + return Number(firstTier.up_to); + } + + return 0; + } + + // Helper to get display value for included usage + function getIncludedUsageDisplay(includedAmount: number, usageType: any) { + if (includedAmount === 0) return "0"; + + if (usageType.id === EGRESS_DATA_MB) { + // Convert MB to GB for data usage + return (includedAmount / 1000).toFixed(2); + } + + if (usageType.id === USERS || usageType.id === DOMAINS) { + // divide by 32 days + return (includedAmount / 32).toFixed(2); + } + + return includedAmount.toString(); + } + + // Helper to get usage, subscription item, and limit by usageId + function getUsageItemAndLimit( + usageData: any[], + subscriptionItems: any[], + limitsData: any[], + usageId: string + ) { + const usage = usageData.find((u) => u.featureId === usageId); + if (!usage) return { usage: 0, item: undefined, limit: undefined }; + const item = subscriptionItems.find((i) => i.meterId === usage.meterId); + const limit = limitsData.find((l) => l.featureId === usageId); + return { usage: usage ?? 0, item, limit }; + } + + // Helper to check if usage exceeds limit + function isOverLimit(usage: any, limit: any, usageType: any) { + if (!limit || !usage) return false; + const currentUsage = usageType.getLimitUsage(usage); + return currentUsage > limit.value; + } + + // Map usage and pricing for each usage type + const usageTypes = [ + { + id: EGRESS_DATA_MB, + label: t("billingDataUsage"), + icon: , + unit: "GB", + unitRaw: "MB", + info: t("billingDataUsageInfo"), + note: "Not counted on self-hosted nodes", + // Convert MB to GB for display and pricing + getDisplay: (v: any) => (v.latestValue / 1000).toFixed(2), + getLimitDisplay: (v: any) => (v.value / 1000).toFixed(2), + getUsage: (v: any) => v.latestValue, + getLimitUsage: (v: any) => v.latestValue + }, + { + id: SITE_UPTIME, + label: t("billingOnlineTime"), + icon: , + unit: "min", + info: t("billingOnlineTimeInfo"), + note: "Not counted on self-hosted nodes", + getDisplay: (v: any) => v.latestValue, + getLimitDisplay: (v: any) => v.value, + getUsage: (v: any) => v.latestValue, + getLimitUsage: (v: any) => v.latestValue + }, + { + id: USERS, + label: t("billingUsers"), + icon: , + unit: "", + unitRaw: "user days", + info: t("billingUsersInfo"), + getDisplay: (v: any) => v.instantaneousValue, + getLimitDisplay: (v: any) => v.value, + getUsage: (v: any) => v.latestValue, + getLimitUsage: (v: any) => v.instantaneousValue + }, + { + id: DOMAINS, + label: t("billingDomains"), + icon: , + unit: "", + unitRaw: "domain days", + info: t("billingDomainInfo"), + getDisplay: (v: any) => v.instantaneousValue, + getLimitDisplay: (v: any) => v.value, + getUsage: (v: any) => v.latestValue, + getLimitUsage: (v: any) => v.instantaneousValue + }, + { + id: REMOTE_EXIT_NODES, + label: t("billingRemoteExitNodes"), + icon: , + unit: "", + unitRaw: "node days", + info: t("billingRemoteExitNodesInfo"), + getDisplay: (v: any) => v.instantaneousValue, + getLimitDisplay: (v: any) => v.value, + getUsage: (v: any) => v.latestValue, + getLimitUsage: (v: any) => v.instantaneousValue + } + ]; + + if (subscriptionLoading) { + return ( +
+ {t("billingLoadingSubscription")} +
+ ); + } + + return ( + +
+ + {subscription?.status === "active" && ( + + )} + {subscription + ? subscription.status.charAt(0).toUpperCase() + + subscription.status.slice(1) + : t("billingFreeTier")} + + + {t("billingPricingCalculatorLink")} + + +
+ + {usageTypes.some((type) => { + const { usage, limit } = getUsageItemAndLimit( + usageData, + subscriptionItems, + limitsData, + type.id + ); + return isOverLimit(usage, limit, type); + }) && ( + + + + {t("billingWarningOverLimit")} + + + )} + + + + + {t("billingUsageLimitsOverview")} + + + {t("billingMonitorUsage")} + + + +
+ {usageTypes.map((type) => { + const { usage, limit } = getUsageItemAndLimit( + usageData, + subscriptionItems, + limitsData, + type.id + ); + const displayUsage = type.getDisplay(usage); + const usageForPricing = type.getLimitUsage(usage); + const overLimit = isOverLimit(usage, limit, type); + const percentage = limit + ? Math.min( + (usageForPricing / limit.value) * 100, + 100 + ) + : 0; + + return ( +
+
+
+ {type.icon} + + {type.label} + + +
+
+ + {displayUsage} {type.unit} + + {limit && ( + + {" "} + /{" "} + {type.getLimitDisplay( + limit + )}{" "} + {type.unit} + + )} +
+
+ {type.note && ( +
+ {type.note} +
+ )} + {limit && ( + 80 + ? "warning" + : "success" + } + /> + )} + {!limit && ( +

+ {t("billingNoLimitConfigured")} +

+ )} +
+ ); + })} +
+
+
+ + {(hasSubscription || + (!hasSubscription && limitsData.length > 0)) && ( + + + + {t("billingIncludedUsage")} + + + {hasSubscription + ? t("billingIncludedUsageDescription") + : t("billingFreeTierIncludedUsage")} + + + +
+ {usageTypes.map((type) => { + const { item, limit } = getUsageItemAndLimit( + usageData, + subscriptionItems, + limitsData, + type.id + ); + + // For subscribed users, show included usage from tiers + // For free users, show the limit as "included" + let includedAmount = 0; + let displayIncluded = "0"; + + if (hasSubscription && item) { + includedAmount = getIncludedUsage( + item.tiers + ); + displayIncluded = getIncludedUsageDisplay( + includedAmount, + type + ); + } else if ( + !hasSubscription && + limit && + limit.value > 0 + ) { + // Show free tier limits as "included" + includedAmount = limit.value; + displayIncluded = + type.getLimitDisplay(limit); + } + + if (includedAmount === 0) return null; + + return ( +
+
+ {type.icon} + + {type.label} + +
+
+
+ {hasSubscription ? ( + + ) : ( + + )} + + {displayIncluded}{" "} + {type.unit} + +
+
+ {hasSubscription + ? t("billingIncluded") + : t("billingFreeTier")} +
+
+
+ ); + })} +
+
+
+ )} + + {hasSubscription && ( + + + + {t("billingEstimatedPeriod")} + + + +
+
+ {usageTypes.map((type) => { + const { usage, item } = + getUsageItemAndLimit( + usageData, + subscriptionItems, + limitsData, + type.id + ); + const displayPrice = getDisplayPrice( + item?.tiers + ); + return ( +
+ {type.label}: + + {type.getUsage(usage)}{" "} + {type.unitRaw || type.unit} x{" "} + {displayPrice} + +
+ ); + })} + {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} + {subscriptionItems + .filter( + (item) => + item.unitAmount && + item.unitAmount > 0 && + !item.tiers && + !item.meterId + ) + .map((item, index) => ( +
+ + {item.name || + t("billingRecurringCharge")} + : + + + $ + {( + (item.unitAmount || 0) / 100 + ).toFixed(2)} + +
+ ))} + +
+ {t("billingEstimatedTotal")} + + $ + {( + usageTypes.reduce((sum, type) => { + const { usage, item } = + getUsageItemAndLimit( + usageData, + subscriptionItems, + limitsData, + type.id + ); + const usageForPricing = + type.getUsage(usage); + const cost = item + ? calculateTieredPrice( + usageForPricing, + item.tiers + ) + : 0; + return sum + cost; + }, 0) + + // Add recurring charges + subscriptionItems + .filter( + (item) => + item.unitAmount && + item.unitAmount > 0 && + !item.tiers && + !item.meterId + ) + .reduce( + (sum, item) => + sum + + (item.unitAmount || 0) / + 100, + 0 + ) + ).toFixed(2)} + +
+
+
+

+ {t("billingNotes")} +

+
+

{t("billingEstimateNote")}

+

{t("billingActualChargesMayVary")}

+

{t("billingBilledAtEnd")}

+
+
+
+ + + + +
+
+ )} + + {!hasSubscription && ( + + +
+ +

+ {t("billingNoActiveSubscription")} +

+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx new file mode 100644 index 00000000..59f7aa85 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -0,0 +1,996 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter, useParams, redirect } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter, + SettingsSectionGrid +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState, useEffect } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import { ListRolesResponse } from "@server/routers/role"; +import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget"; + +export default function GeneralPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { idpId, orgId } = useParams(); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [roleMappingMode, setRoleMappingMode] = useState< + "role" | "expression" + >("role"); + const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); + const { isUnlocked } = useLicenseStatusContext(); + + const [redirectUrl, setRedirectUrl] = useState( + `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback` + ); + const t = useTranslations(); + + // OIDC form schema (full configuration) + const OidcFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + roleMapping: z.string().nullable().optional(), + roleId: z.number().nullable().optional(), + authUrl: z.string().url({ message: t("idpErrorAuthUrlInvalid") }), + tokenUrl: z.string().url({ message: t("idpErrorTokenUrlInvalid") }), + identifierPath: z.string().min(1, { message: t("idpPathRequired") }), + emailPath: z.string().nullable().optional(), + namePath: z.string().nullable().optional(), + scopes: z.string().min(1, { message: t("idpScopeRequired") }), + autoProvision: z.boolean().default(false) + }); + + // Google form schema (simplified) + const GoogleFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + roleMapping: z.string().nullable().optional(), + roleId: z.number().nullable().optional(), + autoProvision: z.boolean().default(false) + }); + + // Azure form schema (simplified with tenant ID) + const AzureFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), + roleMapping: z.string().nullable().optional(), + roleId: z.number().nullable().optional(), + autoProvision: z.boolean().default(false) + }); + + type OidcFormValues = z.infer; + type GoogleFormValues = z.infer; + type AzureFormValues = z.infer; + type GeneralFormValues = + | OidcFormValues + | GoogleFormValues + | AzureFormValues; + + // Get the appropriate schema based on variant + const getFormSchema = () => { + switch (variant) { + case "google": + return GoogleFormSchema; + case "azure": + return AzureFormSchema; + default: + return OidcFormSchema; + } + }; + + const form = useForm({ + resolver: zodResolver(getFormSchema()) as any, // is this right? + defaultValues: { + name: "", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + emailPath: "email", + namePath: "name", + scopes: "openid profile email", + autoProvision: true, + roleMapping: null, + roleId: null, + tenantId: "" + } + }); + + // Update form resolver when variant changes + useEffect(() => { + form.clearErrors(); + // Note: We can't change the resolver dynamically, so we'll handle validation in onSubmit + }, [variant]); + + useEffect(() => { + async function fetchRoles() { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("accessRoleErrorFetch"), + description: formatAxiosError( + e, + t("accessRoleErrorFetchDescription") + ) + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + } + } + + const loadIdp = async ( + availableRoles: { roleId: number; name: string }[] + ) => { + try { + const res = await api.get(`/org/${orgId}/idp/${idpId}`); + if (res.status === 200) { + const data = res.data.data; + const roleMapping = data.idpOrg.roleMapping; + const idpVariant = data.idpOidcConfig?.variant || "oidc"; + setRedirectUrl(res.data.data.redirectUrl); + + // Set the variant + setVariant(idpVariant as "oidc" | "google" | "azure"); + + // Check if roleMapping matches the basic pattern '{role name}' (simple single role) + // This should NOT match complex expressions like 'Admin' || 'Member' + const isBasicRolePattern = + roleMapping && + typeof roleMapping === "string" && + /^'[^']+'$/.test(roleMapping); + + // Determine if roleMapping is a number (roleId) or matches basic pattern + const isRoleId = + !isNaN(Number(roleMapping)) && roleMapping !== ""; + const isRoleName = isBasicRolePattern; + + // Extract role name from basic pattern for matching + let extractedRoleName = null; + if (isRoleName) { + extractedRoleName = roleMapping.slice(1, -1); // Remove quotes + } + + // Try to find matching role by name if we have a basic pattern + let matchingRoleId = undefined; + if (extractedRoleName && availableRoles.length > 0) { + const matchingRole = availableRoles.find( + (role) => role.name === extractedRoleName + ); + if (matchingRole) { + matchingRoleId = matchingRole.roleId; + } + } + + // Extract tenant ID from Azure URLs if present + let tenantId = ""; + if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) { + // Azure URL format: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize + console.log( + "Azure authUrl:", + data.idpOidcConfig.authUrl + ); + const tenantMatch = data.idpOidcConfig.authUrl.match( + /login\.microsoftonline\.com\/([^\/]+)\/oauth2/ + ); + console.log("Tenant match:", tenantMatch); + if (tenantMatch) { + tenantId = tenantMatch[1]; + console.log("Extracted tenantId:", tenantId); + } + } + + // Reset form with appropriate data based on variant + const formData: any = { + name: data.idp.name, + clientId: data.idpOidcConfig.clientId, + clientSecret: data.idpOidcConfig.clientSecret, + autoProvision: data.idp.autoProvision, + roleMapping: roleMapping || null, + roleId: isRoleId + ? Number(roleMapping) + : matchingRoleId || null + }; + + console.log(formData); + + // Add variant-specific fields + if (idpVariant === "oidc") { + formData.authUrl = data.idpOidcConfig.authUrl; + formData.tokenUrl = data.idpOidcConfig.tokenUrl; + formData.identifierPath = + data.idpOidcConfig.identifierPath; + formData.emailPath = + data.idpOidcConfig.emailPath || null; + formData.namePath = data.idpOidcConfig.namePath || null; + formData.scopes = data.idpOidcConfig.scopes; + } else if (idpVariant === "azure") { + formData.tenantId = tenantId; + console.log("Setting tenantId in formData:", tenantId); + } + + form.reset(formData); + + // Set the role mapping mode based on the data + // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern + setRoleMappingMode( + isRoleId || isRoleName ? "role" : "expression" + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + router.push(`/${orgId}/settings/idp`); + } finally { + setInitialLoading(false); + } + }; + + const loadData = async () => { + const rolesRes = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("accessRoleErrorFetch"), + description: formatAxiosError( + e, + t("accessRoleErrorFetchDescription") + ) + }); + return null; + }); + + const availableRoles = + rolesRes?.status === 200 ? rolesRes.data.data.roles : []; + setRoles(availableRoles); + + await loadIdp(availableRoles); + }; + + loadData(); + }, []); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + try { + // Validate against the correct schema based on variant + const schema = getFormSchema(); + const validationResult = schema.safeParse(data); + + if (!validationResult.success) { + // Set form errors + const errors = validationResult.error.flatten().fieldErrors; + Object.keys(errors).forEach((key) => { + const fieldName = key as keyof GeneralFormValues; + const errorMessage = + (errors as any)[key]?.[0] || t("invalidValue"); + form.setError(fieldName, { + type: "manual", + message: errorMessage + }); + }); + setLoading(false); + return; + } + + const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + + // Build payload based on variant + let payload: any = { + name: data.name, + clientId: data.clientId, + clientSecret: data.clientSecret, + autoProvision: data.autoProvision, + roleMapping: + roleMappingMode === "role" + ? `'${roleName}'` + : data.roleMapping || "" + }; + + // Add variant-specific fields + if (variant === "oidc") { + const oidcData = data as OidcFormValues; + payload = { + ...payload, + authUrl: oidcData.authUrl, + tokenUrl: oidcData.tokenUrl, + identifierPath: oidcData.identifierPath, + emailPath: oidcData.emailPath || "", + namePath: oidcData.namePath || "", + scopes: oidcData.scopes + }; + } else if (variant === "azure") { + const azureData = data as AzureFormValues; + // Construct URLs dynamically for Azure provider + const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`; + payload = { + ...payload, + authUrl: authUrl, + tokenUrl: tokenUrl, + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } else if (variant === "google") { + // Google uses predefined URLs + payload = { + ...payload, + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } + + const res = await api.post( + `/org/${orgId}/idp/${idpId}/oidc`, + payload + ); + + if (res.status === 200) { + toast({ + title: t("success"), + description: t("idpUpdatedDescription") + }); + router.refresh(); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + if (initialLoading) { + return null; + } + + return ( + <> + + + + + {t("idpTitle")} + + + {t("idpSettingsDescription")} + + + + + + + {t("redirectUrl")} + + + + + + + + + + + {t("redirectUrlAbout")} + + + {t("redirectUrlAboutDescription")} + + + + {/* IDP Type Indicator */} +
+ + {t("idpTypeLabel")}: + + +
+ +
+ + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + + )} + /> + + +
+
+
+ + {/* Auto Provision Settings */} + + + + {t("idpAutoProvisionUsers")} + + + {t("idpAutoProvisionUsersDescription")} + + + + +
+ + { + form.setValue( + "autoProvision", + checked + ); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + // Clear roleId and roleMapping when mode changes + form.setValue("roleId", null); + form.setValue("roleMapping", null); + }} + roles={roles} + roleIdFieldName="roleId" + roleMappingFieldName="roleMapping" + /> + + +
+
+
+ + {/* Google Configuration */} + {variant === "google" && ( + + + + {t("idpGoogleConfiguration")} + + + {t("idpGoogleConfigurationDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + {/* Azure Configuration */} + {variant === "azure" && ( + + + + {t("idpAzureConfiguration")} + + + {t("idpAzureConfigurationDescription")} + + + + +
+ + ( + + + {t("idpTenantId")} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpAzureClientSecretDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + {/* OIDC Configuration */} + {variant === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + +
+ + + + + {t("idpJmespathAbout")} + + + {t( + "idpJmespathAboutDescription" + )} + + {t( + "idpJmespathAboutDescriptionLink" + )}{" "} + + + + + + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+
+ )} +
+ +
+ +
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx new file mode 100644 index 00000000..4846dd56 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx @@ -0,0 +1,63 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { internal } from "@app/lib/api"; +import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string; idpId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + const t = await getTranslations(); + + let idp = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/idp/${params.idpId}`, + await authCookieHeader() + ); + idp = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/idp`); + } + + const navItems: HorizontalTabs = [ + { + title: t("general"), + href: `/${params.orgId}/settings/idp/${params.idpId}/general` + } + ]; + + return ( + <> + + +
+ {children} +
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx new file mode 100644 index 00000000..1d53035c --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx @@ -0,0 +1,21 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { redirect } from "next/navigation"; + +export default async function IdpPage(props: { + params: Promise<{ orgId: string; idpId: string }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/idp/${params.idpId}/general`); +} diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx new file mode 100644 index 00000000..03f83afe --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -0,0 +1,870 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { createElement, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget"; +import { AxiosResponse } from "axios"; +import { ListRolesResponse } from "@server/routers/role"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const [createLoading, setCreateLoading] = useState(false); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [roleMappingMode, setRoleMappingMode] = useState< + "role" | "expression" + >("role"); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const params = useParams(); + + const createIdpFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + type: z.enum(["oidc", "google", "azure"]), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + authUrl: z + .string() + .url({ message: t("idpErrorAuthUrlInvalid") }) + .optional(), + tokenUrl: z + .string() + .url({ message: t("idpErrorTokenUrlInvalid") }) + .optional(), + identifierPath: z + .string() + .min(1, { message: t("idpPathRequired") }) + .optional(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z + .string() + .min(1, { message: t("idpScopeRequired") }) + .optional(), + tenantId: z.string().optional(), + autoProvision: z.boolean().default(false), + roleMapping: z.string().nullable().optional(), + roleId: z.number().nullable().optional() + }); + + type CreateIdpFormValues = z.infer; + + interface ProviderTypeOption { + id: "oidc" | "google" | "azure"; + title: string; + description: string; + icon?: React.ReactNode; + } + + const providerTypes: ReadonlyArray = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: t("idpOidcDescription") + }, + { + id: "google", + title: t("idpGoogleTitle"), + description: t("idpGoogleDescription"), + icon: ( + {t("idpGoogleAlt")} + ) + }, + { + id: "azure", + title: t("idpAzureTitle"), + description: t("idpAzureDescription"), + icon: ( + {t("idpAzureAlt")} + ) + } + ]; + + const form = useForm({ + resolver: zodResolver(createIdpFormSchema), + defaultValues: { + name: "", + type: "oidc", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + namePath: "name", + emailPath: "email", + scopes: "openid profile email", + tenantId: "", + autoProvision: false, + roleMapping: null, + roleId: null + } + }); + + // Fetch roles on component mount + useEffect(() => { + async function fetchRoles() { + const res = await api + .get< + AxiosResponse + >(`/org/${params.orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("accessRoleErrorFetch"), + description: formatAxiosError( + e, + t("accessRoleErrorFetchDescription") + ) + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + } + } + + fetchRoles(); + }, []); + + // Handle provider type changes and set defaults + const handleProviderChange = (value: "oidc" | "google" | "azure") => { + form.setValue("type", value); + + if (value === "google") { + // Set Google defaults + form.setValue( + "authUrl", + "https://accounts.google.com/o/oauth2/v2/auth" + ); + form.setValue("tokenUrl", "https://oauth2.googleapis.com/token"); + form.setValue("identifierPath", "email"); + form.setValue("emailPath", "email"); + form.setValue("namePath", "name"); + form.setValue("scopes", "openid profile email"); + } else if (value === "azure") { + // Set Azure Entra ID defaults (URLs will be constructed dynamically) + form.setValue( + "authUrl", + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" + ); + form.setValue( + "tokenUrl", + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" + ); + form.setValue("identifierPath", "email"); + form.setValue("emailPath", "email"); + form.setValue("namePath", "name"); + form.setValue("scopes", "openid profile email"); + form.setValue("tenantId", ""); + } else { + // Reset to OIDC defaults + form.setValue("authUrl", ""); + form.setValue("tokenUrl", ""); + form.setValue("identifierPath", "sub"); + form.setValue("namePath", "name"); + form.setValue("emailPath", "email"); + form.setValue("scopes", "openid profile email"); + } + }; + + async function onSubmit(data: CreateIdpFormValues) { + setCreateLoading(true); + + try { + // Construct URLs dynamically for Azure provider + let authUrl = data.authUrl; + let tokenUrl = data.tokenUrl; + + if (data.type === "azure" && data.tenantId) { + authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId); + tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); + } + + const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + + const payload = { + name: data.name, + clientId: data.clientId, + clientSecret: data.clientSecret, + authUrl: authUrl, + tokenUrl: tokenUrl, + identifierPath: data.identifierPath, + emailPath: data.emailPath, + namePath: data.namePath, + autoProvision: data.autoProvision, + roleMapping: + roleMappingMode === "role" + ? `'${roleName}'` + : data.roleMapping || "", + scopes: data.scopes, + variant: data.type + }; + + // Use the appropriate endpoint based on provider type + const endpoint = "oidc"; + const res = await api.put( + `/org/${params.orgId}/idp/${endpoint}`, + payload + ); + + if (res.status === 201) { + toast({ + title: t("success"), + description: t("idpCreatedDescription") + }); + router.push( + `/${params.orgId}/settings/idp/${res.data.data.idpId}` + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setCreateLoading(false); + } + } + + return ( + <> +
+ + +
+ + + + + + {t("idpTitle")} + + + {t("idpCreateSettingsDescription")} + + + + +
+ + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + + )} + /> + + +
+
+
+ + + + + {t("idpType")} + + + {t("idpTypeDescription")} + + + + { + handleProviderChange( + value as "oidc" | "google" | "azure" + ); + }} + cols={3} + /> + + + + {/* Auto Provision Settings */} + + + + {t("idpAutoProvisionUsers")} + + + {t("idpAutoProvisionUsersDescription")} + + + + +
+ + { + form.setValue( + "autoProvision", + checked + ); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + // Clear roleId and roleMapping when mode changes + form.setValue("roleId", null); + form.setValue("roleMapping", null); + }} + roles={roles} + roleIdFieldName="roleId" + roleMappingFieldName="roleMapping" + /> + + +
+
+
+ + {form.watch("type") === "google" && ( + + + + {t("idpGoogleConfigurationTitle")} + + + {t("idpGoogleConfigurationDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + {form.watch("type") === "azure" && ( + + + + {t("idpAzureConfigurationTitle")} + + + {t("idpAzureConfigurationDescription")} + + + + +
+ + ( + + + {t("idpTenantIdLabel")} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
+
+
+ )} + + {form.watch("type") === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + + + + + + {t("idpOidcConfigureAlert")} + + + {t("idpOidcConfigureAlertDescription")} + + +
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + +
+ + + + + {t("idpJmespathAbout")} + + + {t( + "idpJmespathAboutDescription" + )}{" "} + + {t( + "idpJmespathAboutDescriptionLink" + )}{" "} + + + + + + ( + + + {t("idpJmespathLabel")} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+ )} +
+ +
+ + +
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx new file mode 100644 index 00000000..1032e66c --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { internal, priv } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable"; +import { getTranslations } from "next-intl/server"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { cache } from "react"; +import { + GetOrgSubscriptionResponse, + GetOrgTierResponse +} from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; +import { build } from "@server/build"; + +type OrgIdpPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function OrgIdpPage(props: OrgIdpPageProps) { + const params = await props.params; + + let idps: IdpRow[] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/idp`, + await authCookieHeader() + ); + idps = res.data.data.idps; + } catch (e) { + console.error(e); + } + + const t = await getTranslations(); + + let subscriptionStatus: GetOrgTierResponse | null = null; + try { + const getSubscription = cache(() => + priv.get>( + `/org/${params.orgId}/billing/tier` + ) + ); + const subRes = await getSubscription(); + subscriptionStatus = subRes.data.data; + } catch {} + const subscribed = subscriptionStatus?.tier === TierId.STANDARD; + + return ( + <> + + + {build === "saas" && !subscribed ? ( + + + {t("idpDisabled")} {t("subscriptionRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesDataTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesDataTable.tsx new file mode 100644 index 00000000..4a1db4ea --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesDataTable.tsx @@ -0,0 +1,55 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + createRemoteExitNode?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function ExitNodesDataTable({ + columns, + data, + createRemoteExitNode, + onRefresh, + isRefreshing +}: DataTableProps) { + + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx new file mode 100644 index 00000000..11e0bcfc --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -0,0 +1,319 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ExitNodesDataTable } from "./ExitNodesDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useEffect } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { Badge } from "@app/components/ui/badge"; + +export type RemoteExitNodeRow = { + id: string; + exitNodeId: number | null; + name: string; + address: string; + endpoint: string; + orgId: string; + type: string | null; + online: boolean; + dateCreated: string; + version?: string; +}; + +type ExitNodesTableProps = { + remoteExitNodes: RemoteExitNodeRow[]; + orgId: string; +}; + +export default function ExitNodesTable({ + remoteExitNodes, + orgId +}: ExitNodesTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedNode, setSelectedNode] = useState( + null + ); + const [rows, setRows] = useState(remoteExitNodes); + const [isRefreshing, setIsRefreshing] = useState(false); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + useEffect(() => { + setRows(remoteExitNodes); + }, [remoteExitNodes]); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const deleteRemoteExitNode = (remoteExitNodeId: string) => { + api.delete(`/org/${orgId}/remote-exit-node/${remoteExitNodeId}`) + .catch((e) => { + console.error(t("remoteExitNodeErrorDelete"), e); + toast({ + variant: "destructive", + title: t("remoteExitNodeErrorDelete"), + description: formatAxiosError( + e, + t("remoteExitNodeErrorDelete") + ) + }); + }) + .then(() => { + setIsDeleteModalOpen(false); + + const newRows = rows.filter( + (row) => row.id !== remoteExitNodeId + ); + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "online", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ {t("online")} +
+ ); + } else { + return ( + +
+ {t("offline")} +
+ ); + } + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + return ( + + {originalRow.type === "remoteExitNode" + ? "Remote Exit Node" + : originalRow.type} + + ); + } + }, + { + accessorKey: "address", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "endpoint", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "version", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + return originalRow.version || "-"; + } + }, + { + id: "actions", + cell: ({ row }) => { + const nodeRow = row.original; + return ( +
+ + + + + + { + setSelectedNode(nodeRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedNode && ( + { + setIsDeleteModalOpen(val); + setSelectedNode(null); + }} + dialog={ +
+

+ {t("remoteExitNodeQuestionRemove", { + selectedNode: + selectedNode?.name || selectedNode?.id + })} +

+ +

{t("remoteExitNodeMessageRemove")}

+ +

{t("remoteExitNodeMessageConfirm")}

+
+ } + buttonText={t("remoteExitNodeConfirmDelete")} + onConfirm={async () => + deleteRemoteExitNode(selectedNode!.id) + } + string={selectedNode.name} + title={t("remoteExitNodeDelete")} + /> + )} + + + router.push(`/${orgId}/settings/remote-exit-nodes/create`) + } + onRefresh={refreshData} + isRefreshing={isRefreshing} + /> + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx new file mode 100644 index 00000000..b5835e1b --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export default function GeneralPage() { + return <>; +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx new file mode 100644 index 00000000..653444e8 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -0,0 +1,59 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { internal } from "@app/lib/api"; +import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; +import RemoteExitNodeProvider from "@app/providers/PrivateRemoteExitNodeProvider"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ remoteExitNodeId: string; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + + let remoteExitNode = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/remote-exit-node/${params.remoteExitNodeId}`, + await authCookieHeader() + ); + remoteExitNode = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/remote-exit-nodes`); + } + + const t = await getTranslations(); + + return ( + <> + + + +
{children}
+
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx new file mode 100644 index 00000000..badf0971 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -0,0 +1,23 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { redirect } from "next/navigation"; + +export default async function RemoteExitNodePage(props: { + params: Promise<{ orgId: string; remoteExitNodeId: string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/general` + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx new file mode 100644 index 00000000..5daaa493 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -0,0 +1,379 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + QuickStartRemoteExitNodeResponse, + PickRemoteExitNodeDefaultsResponse +} from "@server/routers/private/remoteExitNode"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { StrategySelect } from "@app/components/StrategySelect"; + +export default function CreateRemoteExitNodePage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const t = useTranslations(); + + const [isLoading, setIsLoading] = useState(false); + const [defaults, setDefaults] = + useState(null); + const [createdNode, setCreatedNode] = + useState(null); + const [strategy, setStrategy] = useState<"adopt" | "generate">("adopt"); + + const createRemoteExitNodeFormSchema = z + .object({ + remoteExitNodeId: z.string().optional(), + secret: z.string().optional() + }) + .refine( + (data) => { + if (strategy === "adopt") { + return data.remoteExitNodeId && data.secret; + } + return true; + }, + { + message: t("remoteExitNodeCreate.validation.adoptRequired"), + path: ["remoteExitNodeId"] + } + ); + + type CreateRemoteExitNodeFormValues = z.infer< + typeof createRemoteExitNodeFormSchema + >; + + const form = useForm({ + resolver: zodResolver(createRemoteExitNodeFormSchema), + defaultValues: {} + }); + + // Check for query parameters and prefill form + useEffect(() => { + const remoteExitNodeId = searchParams.get("remoteExitNodeId"); + const remoteExitNodeSecret = searchParams.get("remoteExitNodeSecret"); + + if (remoteExitNodeId && remoteExitNodeSecret) { + setStrategy("adopt"); + form.setValue("remoteExitNodeId", remoteExitNodeId); + form.setValue("secret", remoteExitNodeSecret); + } + }, []); + + useEffect(() => { + const loadDefaults = async () => { + try { + const response = await api.get< + AxiosResponse + >(`/org/${orgId}/pick-remote-exit-node-defaults`); + setDefaults(response.data.data); + } catch (error) { + toast({ + title: t("error"), + description: t( + "remoteExitNodeCreate.errors.loadDefaultsFailed" + ), + variant: "destructive" + }); + } + }; + + // Only load defaults when strategy is "generate" + if (strategy === "generate") { + loadDefaults(); + } + }, [strategy]); + + const onSubmit = async (data: CreateRemoteExitNodeFormValues) => { + if (strategy === "generate" && !defaults) { + toast({ + title: t("error"), + description: t("remoteExitNodeCreate.errors.defaultsNotLoaded"), + variant: "destructive" + }); + return; + } + + if (strategy === "adopt" && (!data.remoteExitNodeId || !data.secret)) { + toast({ + title: t("error"), + description: t("remoteExitNodeCreate.validation.adoptRequired"), + variant: "destructive" + }); + return; + } + + setIsLoading(true); + try { + const response = await api.put< + AxiosResponse + >(`/org/${orgId}/remote-exit-node`, { + remoteExitNodeId: + strategy === "generate" + ? defaults!.remoteExitNodeId + : data.remoteExitNodeId!, + secret: + strategy === "generate" ? defaults!.secret : data.secret! + }); + setCreatedNode(response.data.data); + + router.push(`/${orgId}/settings/remote-exit-nodes`); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError( + error, + t("remoteExitNodeCreate.errors.createFailed") + ), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + return ( + <> +
+ + +
+ +
+ + + + + {t("remoteExitNodeCreate.strategy.title")} + + + {t("remoteExitNodeCreate.strategy.description")} + + + + { + setStrategy(value); + // Clear adopt fields when switching to generate + if (value === "generate") { + form.setValue("remoteExitNodeId", ""); + form.setValue("secret", ""); + } + }} + cols={2} + /> + + + + {strategy === "adopt" && ( + + + + {t("remoteExitNodeCreate.adopt.title")} + + + {t( + "remoteExitNodeCreate.adopt.description" + )} + + + + +
+
+ ( + + + {t( + "remoteExitNodeCreate.adopt.nodeIdLabel" + )} + + + + + + {t( + "remoteExitNodeCreate.adopt.nodeIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "remoteExitNodeCreate.adopt.secretLabel" + )} + + + + + + {t( + "remoteExitNodeCreate.adopt.secretDescription" + )} + + + + )} + /> +
+
+
+
+
+ )} + + {strategy === "generate" && ( + + + + {t("remoteExitNodeCreate.generate.title")} + + + {t( + "remoteExitNodeCreate.generate.description" + )} + + + + + + + + {t( + "remoteExitNodeCreate.generate.saveCredentialsTitle" + )} + + + {t( + "remoteExitNodeCreate.generate.saveCredentialsDescription" + )} + + + + + )} +
+ +
+ + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx new file mode 100644 index 00000000..b18df692 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -0,0 +1,72 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListRemoteExitNodesResponse } from "@server/routers/private/remoteExitNode"; +import { AxiosResponse } from "axios"; +import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; + +type RemoteExitNodesPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function RemoteExitNodesPage( + props: RemoteExitNodesPageProps +) { + const params = await props.params; + let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/remote-exit-nodes`, await authCookieHeader()); + remoteExitNodes = res.data.data.remoteExitNodes; + } catch (e) {} + + const t = await getTranslations(); + + const remoteExitNodeRows: RemoteExitNodeRow[] = remoteExitNodes.map( + (node) => { + return { + name: node.name, + id: node.remoteExitNodeId, + exitNodeId: node.exitNodeId, + address: node.address?.split("/")[0] || "-", + endpoint: node.endpoint || "-", + online: node.online, + type: node.type, + dateCreated: node.dateCreated, + version: node.version || undefined, + orgId: params.orgId + }; + } + ); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 2df8413f..3e6d2458 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -47,6 +47,8 @@ import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import Image from "next/image"; +import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext"; +import { TierId } from "@server/lib/private/billing/tiers"; type UserType = "internal" | "oidc"; @@ -74,6 +76,9 @@ export default function Page() { const api = createApiClient({ env }); const t = useTranslations(); + const subscription = usePrivateSubscriptionStatusContext(); + const subscribed = subscription?.getTier() === TierId.STANDARD; + const [selectedOption, setSelectedOption] = useState("internal"); const [inviteLink, setInviteLink] = useState(null); const [loading, setLoading] = useState(false); @@ -227,8 +232,14 @@ export default function Page() { } async function fetchIdps() { + if (build === "saas" && !subscribed) { + return; + } + const res = await api - .get>("/idp") + .get< + AxiosResponse + >(build === "saas" ? `/org/${orgId}/idp` : "/idp") .catch((e) => { console.error(e); toast({ @@ -430,7 +441,7 @@ export default function Page() {
- {!inviteLink && build !== "saas" && dataLoaded ? ( + {!inviteLink ? ( diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c4bb3ccc..a7948536 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,10 +1,12 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import AuthPageSettings, { AuthPageSettingsRef } from "@app/components/private/AuthPageSettings"; + import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { Form, FormControl, @@ -15,6 +17,7 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; + import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -38,7 +41,7 @@ import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -// Updated schema to include subnet field +// Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), subnet: z.string().optional() @@ -58,6 +61,7 @@ export default function GeneralPage() { const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); + const authPageSettingsRef = useRef(null); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -121,28 +125,33 @@ export default function GeneralPage() { async function onSubmit(data: GeneralFormValues) { setLoadingSave(true); - await api - .post(`/org/${org?.org.orgId}`, { - name: data.name, + + try { + // Update organization + await api.post(`/org/${org?.org.orgId}`, { + name: data.name // subnet: data.subnet // Include subnet in the API request - }) - .then(() => { - toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") - }); - router.refresh(); - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) - }); - }) - .finally(() => { - setLoadingSave(false); }); + + // Also save auth page settings if they have unsaved changes + if (build === "saas" && authPageSettingsRef.current?.hasUnsavedChanges()) { + await authPageSettingsRef.current.saveAuthSettings(); + } + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } finally { + setLoadingSave(false); + } } return ( @@ -207,7 +216,9 @@ export default function GeneralPage() { name="subnet" render={({ field }) => ( - Subnet + + {t("subnet")} + - The subnet for this - organization's network - configuration. + {t("subnetDescription")} )} @@ -228,18 +237,23 @@ export default function GeneralPage() { - - - - {build === "oss" && ( + + {build === "saas" && } + + {/* Save Button */} +
+ +
+ + {build !== "saas" && ( @@ -262,6 +276,7 @@ export default function GeneralPage() { )} +
); } diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 7db530dd..d35af6e6 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -27,7 +27,7 @@ import { orgNavSections } from "@app/app/navigation"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: `Settings - Pangolin`, + title: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, description: "" }; diff --git a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx index d53cb0c0..ae8e52ab 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx @@ -58,6 +58,9 @@ import { SelectValue } from "@app/components/ui/select"; import { Separator } from "@app/components/ui/separator"; +import { build } from "@server/build"; +import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext"; +import { TierId } from "@server/lib/private/billing/tiers"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -94,6 +97,9 @@ export default function ResourceAuthenticationPage() { const router = useRouter(); const t = useTranslations(); + const subscription = usePrivateSubscriptionStatusContext(); + const subscribed = subscription?.getTier() === TierId.STANDARD; + const [pageLoading, setPageLoading] = useState(true); const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( @@ -178,7 +184,7 @@ export default function ResourceAuthenticationPage() { AxiosResponse<{ idps: { idpId: number; name: string }[]; }> - >("/idp") + >(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp") ]); setAllRoles( @@ -223,12 +229,23 @@ export default function ResourceAuthenticationPage() { })) ); - setAllIdps( - idpsResponse.data.data.idps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })) - ); + if (build === "saas") { + if (subscribed) { + setAllIdps( + idpsResponse.data.data.idps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })) + ); + } + } else { + setAllIdps( + idpsResponse.data.data.idps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })) + ); + } if ( autoLoginEnabled && diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index b37cfba9..a4277f6b 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -79,6 +79,7 @@ import { import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import HealthCheckDialog from "@/components/HealthCheckDialog"; import { DockerManager, DockerState } from "@app/lib/docker"; import { Container } from "@server/routers/site"; import { @@ -98,50 +99,64 @@ import { } from "@app/components/ui/command"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { HeadersInput } from "@app/components/HeadersInput"; -import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { Badge } from "@app/components/ui/badge"; -const addTargetSchema = z.object({ - ip: z.string().refine(isTargetValid), - method: z.string().nullable(), - port: z.coerce.number().int().positive(), - siteId: z.number().int().positive(), - path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() -}).refine( - (data) => { - // If path is provided, pathMatchType must be provided - if (data.path && !data.pathMatchType) { - return false; - } - // If pathMatchType is provided, path must be provided - if (data.pathMatchType && !data.path) { - return false; - } - // Validate path based on pathMatchType - if (data.path && data.pathMatchType) { - switch (data.pathMatchType) { - case "exact": - case "prefix": - // Path should start with / - return data.path.startsWith("/"); - case "regex": - // Validate regex - try { - new RegExp(data.path); - return true; - } catch { - return false; - } +const addTargetSchema = z + .object({ + ip: z.string().refine(isTargetValid), + method: z.string().nullable(), + port: z.coerce.number().int().positive(), + siteId: z.number().int().positive(), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable() + }) + .refine( + (data) => { + // If path is provided, pathMatchType must be provided + if (data.path && !data.pathMatchType) { + return false; } + // If pathMatchType is provided, path must be provided + if (data.pathMatchType && !data.path) { + return false; + } + // Validate path based on pathMatchType + if (data.path && data.pathMatchType) { + switch (data.pathMatchType) { + case "exact": + case "prefix": + // Path should start with / + return data.path.startsWith("/"); + case "regex": + // Validate regex + try { + new RegExp(data.path); + return true; + } catch { + return false; + } + } + } + return true; + }, + { + message: "Invalid path configuration" } - return true; - }, - { - message: "Invalid path configuration" - } -) + ) .refine( (data) => { // If rewritePath is provided, rewritePathType must be provided @@ -229,6 +244,10 @@ export default function ReverseProxyTargets(props: { const [proxySettingsLoading, setProxySettingsLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); const router = useRouter(); const proxySettingsSchema = z.object({ @@ -246,7 +265,9 @@ export default function ReverseProxyTargets(props: { message: t("proxyErrorInvalidHeader") } ), - headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable() + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() }); const tlsSettingsSchema = z.object({ @@ -280,7 +301,7 @@ export default function ReverseProxyTargets(props: { path: null, pathMatchType: null, rewritePath: null, - rewritePathType: null, + rewritePathType: null } as z.infer }); @@ -463,7 +484,21 @@ export default function ReverseProxyTargets(props: { enabled: true, targetId: new Date().getTime(), new: true, - resourceId: resource.resourceId + resourceId: resource.resourceId, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null }; setTargets([...targets, newTarget]); @@ -474,7 +509,7 @@ export default function ReverseProxyTargets(props: { path: null, pathMatchType: null, rewritePath: null, - rewritePathType: null, + rewritePathType: null }); } @@ -494,16 +529,36 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); } + function updateTargetHealthCheck(targetId: number, config: any) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...config, + updated: true + } + : target + ) + ); + } + + const openHealthCheckDialog = (target: LocalTarget) => { + console.log(target); + setSelectedTargetForHealthCheck(target); + setHealthCheckDialogOpen(true); + }; + async function saveAllSettings() { try { setTargetsLoading(true); @@ -518,6 +573,17 @@ export default function ReverseProxyTargets(props: { method: target.method, enabled: target.enabled, siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcMethod: target.hcMethod || null, + hcStatus: target.hcStatus || null, path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, @@ -598,16 +664,20 @@ export default function ReverseProxyTargets(props: { accessorKey: "path", header: t("matchPath"), cell: ({ row }) => { - const hasPathMatch = !!(row.original.path || row.original.pathMatchType); + const hasPathMatch = !!( + row.original.path || row.original.pathMatchType + ); return hasPathMatch ? (
updateTarget(row.original.targetId, config)} + onChange={(config) => + updateTarget(row.original.targetId, config) + } trigger={ @@ -646,9 +717,11 @@ export default function ReverseProxyTargets(props: { updateTarget(row.original.targetId, config)} + onChange={(config) => + updateTarget(row.original.targetId, config) + } trigger={ @@ -896,7 +976,7 @@ export default function ReverseProxyTargets(props: { updateTarget(row.original.targetId, { ...row.original, rewritePath: null, - rewritePathType: null, + rewritePathType: null }); }} > @@ -907,9 +987,11 @@ export default function ReverseProxyTargets(props: { updateTarget(row.original.targetId, config)} + onChange={(config) => + updateTarget(row.original.targetId, config) + } trigger={ +
+ ) : ( + + {t("healthCheckNotAvailable")} + + )} + + ); + } + }, { accessorKey: "enabled", header: t("enabled"), @@ -1034,21 +1189,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -1114,34 +1269,34 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" + "newt" ? (() => { - const dockerState = - getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })() + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() : null; })()}
@@ -1369,12 +1524,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1544,9 +1699,7 @@ export default function ReverseProxyTargets(props: { { field.onChange( value @@ -1588,6 +1741,56 @@ export default function ReverseProxyTargets(props: { {t("saveSettings")} + + {selectedTargetForHealthCheck && ( + { + if (selectedTargetForHealthCheck) { + console.log(config); + updateTargetHealthCheck( + selectedTargetForHealthCheck.targetId, + config + ); + } + }} + /> + )} ); } diff --git a/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx index 284573b2..8d5ad7d3 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx @@ -58,7 +58,7 @@ import { import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react"; +import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react"; import { InfoSection, InfoSections, @@ -73,6 +73,20 @@ import { import { Switch } from "@app/components/ui/switch"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { COUNTRIES } from "@server/db/countries"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; // Schema for rule validation const addRuleSchema = z.object({ @@ -98,9 +112,13 @@ export default function ResourceRules(props: { const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); + const [openCountrySelect, setOpenCountrySelect] = useState(false); + const [countrySelectValue, setCountrySelectValue] = useState(""); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); const router = useRouter(); const t = useTranslations(); - + const env = useEnvContext(); + const isMaxmindAvailable = env.env.server.maxmind_db_path && env.env.server.maxmind_db_path.length > 0; const RuleAction = { ACCEPT: t('alwaysAllow'), @@ -111,7 +129,8 @@ export default function ResourceRules(props: { const RuleMatch = { PATH: t('path'), IP: "IP", - CIDR: t('ipAddressRange') + CIDR: t('ipAddressRange'), + GEOIP: t('country') } as const; const addRuleForm = useForm({ @@ -193,6 +212,15 @@ export default function ResourceRules(props: { setLoading(false); return; } + if (data.match === "GEOIP" && !COUNTRIES.some(c => c.code === data.value)) { + toast({ + variant: "destructive", + title: t('rulesErrorInvalidCountry'), + description: t('rulesErrorInvalidCountryDescription') || "Invalid country code." + }); + setLoading(false); + return; + } // find the highest priority and add one let priority = data.priority; @@ -242,6 +270,8 @@ export default function ResourceRules(props: { return t('rulesMatchIpAddress'); case "PATH": return t('rulesMatchUrl'); + case "GEOIP": + return t('rulesMatchCountry'); } } @@ -461,8 +491,8 @@ export default function ResourceRules(props: { cell: ({ row }) => ( ) @@ -480,15 +513,61 @@ export default function ResourceRules(props: { accessorKey: "value", header: t('value'), cell: ({ row }) => ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> + row.original.match === "GEOIP" ? ( + + + + + + + + + {t('noCountryFound')} + + {COUNTRIES.map((country) => ( + { + updateRule(row.original.ruleId, { value: country.code }); + }} + > + + {country.name} ({country.code}) + + ))} + + + + + + ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) ) }, { @@ -650,9 +729,7 @@ export default function ResourceRules(props: { @@ -692,7 +774,55 @@ export default function ResourceRules(props: { } /> - + {addRuleForm.watch("match") === "GEOIP" ? ( + + + + + + + + + {t('noCountryFound')} + + {COUNTRIES.map((country) => ( + { + field.onChange(country.code); + setOpenAddRuleCountrySelect(false); + }} + > + + {country.name} ({country.code}) + + ))} + + + + + + ) : ( + + )} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index bb19cc79..1810f09e 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -340,7 +340,21 @@ export default function Page() { enabled: true, targetId: new Date().getTime(), new: true, - resourceId: 0 // Will be set when resource is created + resourceId: 0, // Will be set when resource is created + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null }; setTargets([...targets, newTarget]); @@ -446,6 +460,18 @@ export default function Page() { method: target.method, enabled: target.enabled, siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcMethod: target.hcMethod || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcFollowRedirects: + target.hcFollowRedirects || null, + hcStatus: target.hcStatus || null, path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index c2990c78..78fbfc0d 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -42,10 +42,7 @@ import { FaFreebsd, FaWindows } from "react-icons/fa"; -import { - SiNixos, - SiKubernetes -} from "react-icons/si"; +import { SiNixos, SiKubernetes } from "react-icons/si"; import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { generateKeypair } from "../[niceId]/wireguardConfig"; @@ -56,6 +53,7 @@ import { CreateSiteResponse, PickSiteDefaultsResponse } from "@server/routers/site"; +import { ListRemoteExitNodesResponse } from "@server/routers/private/remoteExitNode"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; @@ -73,6 +71,13 @@ interface TunnelTypeOption { disabled?: boolean; } +interface RemoteExitNodeOption { + id: string; + title: string; + description: string; + disabled?: boolean; +} + type Commands = { mac: Record; linux: Record; @@ -115,7 +120,8 @@ export default function Page() { method: z.enum(["newt", "wireguard", "local"]), copied: z.boolean(), clientAddress: z.string().optional(), - acceptClients: z.boolean() + acceptClients: z.boolean(), + exitNodeId: z.number().optional() }) .refine( (data) => { @@ -123,12 +129,25 @@ export default function Page() { // return data.copied; return true; } - return true; + // For local sites, require exitNodeId + return build == "saas" ? data.exitNodeId !== undefined : true; }, { message: t("sitesConfirmCopy"), path: ["copied"] } + ) + .refine( + (data) => { + if (data.method === "local" && build == "saas") { + return data.exitNodeId !== undefined; + } + return true; + }, + { + message: t("remoteExitNodeRequired"), + path: ["exitNodeId"] + } ); type CreateSiteFormValues = z.infer; @@ -148,7 +167,10 @@ export default function Page() { { id: "wireguard" as SiteType, title: t("siteWg"), - description: build == "saas" ? t("siteWgDescriptionSaas") : t("siteWgDescription"), + description: + build == "saas" + ? t("siteWgDescriptionSaas") + : t("siteWgDescription"), disabled: true } ]), @@ -158,7 +180,10 @@ export default function Page() { { id: "local" as SiteType, title: t("local"), - description: build == "saas" ? t("siteLocalDescriptionSaas") : t("siteLocalDescription") + description: + build == "saas" + ? t("siteLocalDescriptionSaas") + : t("siteLocalDescription") } ]) ]); @@ -184,6 +209,13 @@ export default function Page() { const [siteDefaults, setSiteDefaults] = useState(null); + const [remoteExitNodeOptions, setRemoteExitNodeOptions] = useState< + ReadonlyArray + >([]); + const [selectedExitNodeId, setSelectedExitNodeId] = useState< + string | undefined + >(); + const hydrateWireGuardConfig = ( privateKey: string, publicKey: string, @@ -320,7 +352,7 @@ WantedBy=default.target` nixos: { All: [ `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` - ], + ] // aarch64: [ // `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` // ] @@ -432,7 +464,8 @@ WantedBy=default.target` copied: false, method: "newt", clientAddress: "", - acceptClients: false + acceptClients: false, + exitNodeId: undefined } }); @@ -482,6 +515,22 @@ WantedBy=default.target` address: clientAddress }; } + if (data.method === "local" && build == "saas") { + if (!data.exitNodeId) { + toast({ + variant: "destructive", + title: t("siteErrorCreate"), + description: t("remoteExitNodeRequired") + }); + setCreateLoading(false); + return; + } + + payload = { + ...payload, + exitNodeId: data.exitNodeId + }; + } const res = await api .put< @@ -533,7 +582,7 @@ WantedBy=default.target` currentNewtVersion = latestVersion; setNewtVersion(latestVersion); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { + if (error instanceof Error && error.name === "AbortError") { console.error(t("newtErrorFetchTimeout")); } else { console.error( @@ -558,8 +607,10 @@ WantedBy=default.target` await api .get(`/org/${orgId}/pick-site-defaults`) .catch((e) => { - // update the default value of the form to be local method - form.setValue("method", "local"); + // update the default value of the form to be local method only if local sites are not disabled + if (!env.flags.disableLocalSites) { + form.setValue("method", "local"); + } }) .then((res) => { if (res && res.status === 200) { @@ -602,6 +653,37 @@ WantedBy=default.target` } }); + if (build === "saas") { + // Fetch remote exit nodes for local sites + try { + const remoteExitNodesRes = await api.get< + AxiosResponse + >(`/org/${orgId}/remote-exit-nodes`); + + if ( + remoteExitNodesRes && + remoteExitNodesRes.status === 200 + ) { + const exitNodes = + remoteExitNodesRes.data.data.remoteExitNodes; + + // Convert to options for StrategySelect + const exitNodeOptions: RemoteExitNodeOption[] = + exitNodes + .filter((node) => node.exitNodeId !== null) + .map((node) => ({ + id: node.exitNodeId!.toString(), + title: node.name, + description: `${node.address?.split("/")[0] || "N/A"} - ${node.endpoint || "N/A"}` + })); + + setRemoteExitNodeOptions(exitNodeOptions); + } + } catch (error) { + console.error("Failed to fetch remote exit nodes:", error); + } + } + setLoadingPage(false); }; @@ -613,6 +695,18 @@ WantedBy=default.target` form.setValue("acceptClients", acceptClients); }, [acceptClients, form]); + // Sync form exitNodeId value with local state + useEffect(() => { + if (build !== "saas") { + // dont update the form + return; + } + form.setValue( + "exitNodeId", + selectedExitNodeId ? parseInt(selectedExitNodeId) : undefined + ); + }, [selectedExitNodeId, form]); + return ( <>
@@ -920,7 +1014,7 @@ WantedBy=default.target`
)} + + {build == "saas" && + form.watch("method") === "local" && ( + + + + {t("remoteExitNodeSelection")} + + + {t( + "remoteExitNodeSelectionDescription" + )} + + + + {remoteExitNodeOptions.length > 0 ? ( + { + setSelectedExitNodeId( + value + ); + }} + cols={1} + /> + ) : ( + + + + {t( + "noRemoteExitNodesAvailable" + )} + + + {t( + "noRemoteExitNodesAvailableDescription" + )} + + + )} + + + )}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index a854083c..b95c7666 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -52,6 +52,8 @@ export default async function SitesPage(props: SitesPageProps) { online: site.online, newtVersion: site.newtVersion || undefined, newtUpdateAvailable: site.newtUpdateAvailable || false, + exitNodeName: site.exitNodeName || undefined, + exitNodeEndpoint: site.exitNodeEndpoint || undefined, }; }); diff --git a/src/app/auth/(private)/org/page.tsx b/src/app/auth/(private)/org/page.tsx new file mode 100644 index 00000000..f9d1854e --- /dev/null +++ b/src/app/auth/(private)/org/page.tsx @@ -0,0 +1,193 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { formatAxiosError, priv } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { cache } from "react"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { pullEnv } from "@app/lib/pullEnv"; +import { LoginFormIDP } from "@app/components/LoginForm"; +import { ListOrgIdpsResponse } from "@server/routers/private/orgIdp"; +import { build } from "@server/build"; +import { headers } from "next/headers"; +import { + GetLoginPageResponse, + LoadLoginPageResponse +} from "@server/routers/private/loginPage"; +import IdpLoginButtons from "@app/components/private/IdpLoginButtons"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Button } from "@app/components/ui/button"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { GetSessionTransferTokenRenponse } from "@server/routers/auth/privateGetSessionTransferToken"; +import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession"; +import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken"; +import { GetOrgTierResponse } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; + +export const dynamic = "force-dynamic"; + +export default async function OrgAuthPage(props: { + params: Promise<{}>; + searchParams: Promise<{ token?: string }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const env = pullEnv(); + + const authHeader = await authCookieHeader(); + + if (searchParams.token) { + return ; + } + + const getUser = cache(verifySession); + const user = await getUser({ skipCheckVerifyEmail: true }); + + const allHeaders = await headers(); + const host = allHeaders.get("host"); + + const t = await getTranslations(); + + const expectedHost = env.app.dashboardUrl.split("//")[1]; + let loginPage: LoadLoginPageResponse | undefined; + if (host !== expectedHost) { + try { + const res = await priv.get>( + `/login-page?fullDomain=${host}` + ); + + if (res && res.status === 200) { + loginPage = res.data.data; + } + } catch (e) {} + + if (!loginPage) { + redirect(env.app.dashboardUrl); + } + + let subscriptionStatus: GetOrgTierResponse | null = null; + try { + const getSubscription = cache(() => + priv.get>( + `/org/${loginPage!.orgId}/billing/tier` + ) + ); + const subRes = await getSubscription(); + subscriptionStatus = subRes.data.data; + } catch {} + const subscribed = subscriptionStatus?.tier === TierId.STANDARD; + + if (build === "saas" && !subscribed) { + redirect(env.app.dashboardUrl); + } + + if (user) { + let redirectToken: string | undefined; + try { + const res = await priv.post< + AxiosResponse + >(`/get-session-transfer-token`, {}, authHeader); + + if (res && res.status === 200) { + const newToken = res.data.data.token; + + redirectToken = newToken; + } + } catch (e) { + console.error( + formatAxiosError(e, "Failed to get transfer token") + ); + } + + if (redirectToken) { + redirect( + `${env.app.dashboardUrl}/auth/org?token=${redirectToken}` + ); + } + } + } else { + redirect(env.app.dashboardUrl); + } + + let loginIdps: LoginFormIDP[] = []; + if (build === "saas") { + const idpsRes = await cache( + async () => + await priv.get>( + `/org/${loginPage!.orgId}/idp` + ) + )(); + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.variant + })) as LoginFormIDP[]; + } + + return ( +
+
+ + {t("poweredBy")}{" "} + + {env.branding.appName || "Pangolin"} + + +
+ + + {t("orgAuthSignInTitle")} + + {loginIdps.length > 0 + ? t("orgAuthChooseIdpDescription") + : ""} + + + + {loginIdps.length > 0 ? ( + + ) : ( +
+

+ {t("orgAuthNoIdpConfigured")} +

+ + + +
+ )} +
+
+
+ ); +} diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 2ff8d09a..5dfa72c3 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -1,10 +1,13 @@ -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import ValidateOidcToken from "@app/components/ValidateOidcToken"; import { cache } from "react"; -import { priv } from "@app/lib/api"; +import { formatAxiosError, priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { GetIdpResponse } from "@server/routers/idp"; import { getTranslations } from "next-intl/server"; +import { pullEnv } from "@app/lib/pullEnv"; +import { LoadLoginPageResponse } from "@server/routers/private/loginPage"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; @@ -33,10 +36,34 @@ export default async function Page(props: { return
{t('idpErrorNotFound')}
; } + const allHeaders = await headers(); + const host = allHeaders.get("host"); + const env = pullEnv(); + const expectedHost = env.app.dashboardUrl.split("//")[1]; + let loginPage: LoadLoginPageResponse | undefined; + if (host !== expectedHost) { + try { + const res = await priv.get>( + `/login-page?idpId=${foundIdp.idpId}&fullDomain=${host}` + ); + + if (res && res.status === 200) { + loginPage = res.data.data; + } + } catch (e) { + console.error(formatAxiosError(e)); + } + + if (!loginPage) { + redirect(env.app.dashboardUrl); + } + } + return ( <> await priv.get>("/idp") - )(); - const loginIdps = idpsRes.data.data.idps.map((idp) => ({ - idpId: idp.idpId, - name: idp.name, - variant: idp.variant - })) as LoginFormIDP[]; + let loginIdps: LoginFormIDP[] = []; + if (build !== "saas") { + const idpsRes = await cache( + async () => await priv.get>("/idp") + )(); + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.type + })) as LoginFormIDP[]; + } const t = await getTranslations(); diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index b221f44a..3eaf1e86 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -3,7 +3,7 @@ import { GetExchangeTokenResponse } from "@server/routers/resource"; import ResourceAuthPortal from "@app/components/ResourceAuthPortal"; -import { internal, priv } from "@app/lib/api"; +import { formatAxiosError, internal, priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; @@ -15,7 +15,13 @@ import AccessToken from "@app/components/AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; +import { ListOrgIdpsResponse } from "@server/routers/private/orgIdp"; import AutoLoginHandler from "@app/components/AutoLoginHandler"; +import { build } from "@server/build"; +import { headers } from "next/headers"; +import { GetLoginPageResponse } from "@server/routers/private/loginPage"; +import { GetOrgTierResponse } from "@server/routers/private/billing"; +import { TierId } from "@server/lib/private/billing/tiers"; export const dynamic = "force-dynamic"; @@ -55,6 +61,45 @@ export default async function ResourceAuthPage(props: { ); } + let subscriptionStatus: GetOrgTierResponse | null = null; + if (build == "saas") { + try { + const getSubscription = cache(() => + priv.get>( + `/org/${authInfo.orgId}/billing/tier` + ) + ); + const subRes = await getSubscription(); + subscriptionStatus = subRes.data.data; + } catch {} + } + const subscribed = subscriptionStatus?.tier === TierId.STANDARD; + + const allHeaders = await headers(); + const host = allHeaders.get("host"); + + const expectedHost = env.app.dashboardUrl.split("//")[1]; + if (host !== expectedHost) { + if (build === "saas" && !subscribed) { + redirect(env.app.dashboardUrl); + } + + let loginPage: GetLoginPageResponse | undefined; + try { + const res = await priv.get>( + `/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}` + ); + + if (res && res.status === 200) { + loginPage = res.data.data; + } + } catch (e) {} + + if (!loginPage) { + redirect(env.app.dashboardUrl); + } + } + let redirectUrl = authInfo.url; if (searchParams.redirect) { try { @@ -136,13 +181,31 @@ export default async function ResourceAuthPage(props: { ); } - const idpsRes = await cache( - async () => await priv.get>("/idp") - )(); - const loginIdps = idpsRes.data.data.idps.map((idp) => ({ - idpId: idp.idpId, - name: idp.name - })) as LoginFormIDP[]; + let loginIdps: LoginFormIDP[] = []; + if (build === "saas") { + if (subscribed) { + const idpsRes = await cache( + async () => + await priv.get>( + `/org/${authInfo!.orgId}/idp` + ) + )(); + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.variant + })) as LoginFormIDP[]; + } + } else { + const idpsRes = await cache( + async () => await priv.get>("/idp") + )(); + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.type + })) as LoginFormIDP[]; + } if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) { const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId); @@ -152,6 +215,7 @@ export default async function ResourceAuthPage(props: { resourceId={authInfo.resourceId} skipToIdpId={authInfo.skipToIdpId} redirectUrl={redirectUrl} + orgId={build == "saas" ? authInfo.orgId : undefined} /> ); } @@ -178,6 +242,7 @@ export default async function ResourceAuthPage(props: { }} redirect={redirectUrl} idps={loginIdps} + orgId={build === "saas" ? authInfo.orgId : undefined} />
)} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8b9c681..48170da5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import { Inter } from "next/font/google"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { pullEnv } from "@app/lib/pullEnv"; +import ThemeDataProvider from "@app/providers/PrivateThemeDataProvider"; +import SplashImage from "@app/components/private/SplashImage"; import SupportStatusProvider from "@app/providers/SupporterStatusProvider"; import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -17,13 +19,24 @@ import { getLocale } from "next-intl/server"; import { Toaster } from "@app/components/ui/toaster"; export const metadata: Metadata = { - title: `Dashboard - Pangolin`, + title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, description: "", + + ...(process.env.BRANDING_FAVICON_PATH + ? { + icons: { + icon: [ + { + url: process.env.BRANDING_FAVICON_PATH as string + } + ] + } + } + : {}) }; export const dynamic = "force-dynamic"; -// const font = Figtree({ subsets: ["latin"] }); const font = Inter({ subsets: ["latin"] }); export default async function RootLayout({ @@ -62,25 +75,44 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > - - - + + - {/* Main content */} -
-
- - {children} + + {/* Main content */} +
+
+ + + {children} + + +
-
- - - - + + + + + ); -} \ No newline at end of file +} + +function loadBrandingColors() { + // this is loaded once on the server and not included in pullEnv + // so we don't need to parse the json every time pullEnv is called + if (process.env.BRANDING_COLORS) { + try { + return JSON.parse(process.env.BRANDING_COLORS); + } catch (e) { + console.error("Failed to parse BRANDING_COLORS", e); + } + } +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index f77bf3a9..369de1d4 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -14,6 +14,7 @@ import { User, Globe, // Added from 'dev' branch MonitorUp, // Added from 'dev' branch + Server, Zap } from "lucide-react"; @@ -57,6 +58,15 @@ export const orgNavSections = ( } ] : []), + ...(build == "saas" + ? [ + { + title: "sidebarRemoteExitNodes", + href: "/{orgId}/settings/remote-exit-nodes", + icon: + } + ] + : []), { title: "sidebarDomains", href: "/{orgId}/settings/domains", @@ -82,6 +92,15 @@ export const orgNavSections = ( href: "/{orgId}/settings/access/invitations", icon: }, + ...(build == "saas" + ? [ + { + title: "sidebarIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + } + ] + : []), { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", @@ -97,6 +116,15 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, + ...(build == "saas" + ? [ + { + title: "sidebarBilling", + href: "/{orgId}/settings/billing", + icon: + } + ] + : []), { title: "sidebarSettings", href: "/{orgId}/settings/general", diff --git a/src/app/page.tsx b/src/app/page.tsx index 06b6b61c..2db1b6b1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -32,7 +32,7 @@ export default async function Page(props: { let complete = false; try { const setupRes = await internal.get< - AxiosResponse + AxiosResponse >(`/auth/initial-setup-complete`, await authCookieHeader()); complete = setupRes.data.data.complete; } catch (e) {} @@ -83,7 +83,10 @@ export default async function Page(props: { if (lastOrgExists) { redirect(`/${lastOrgCookie}`); } else { - const ownedOrg = orgs.find((org) => org.isOwner); + let ownedOrg = orgs.find((org) => org.isOwner); + if (!ownedOrg) { + ownedOrg = orgs[0]; + } if (ownedOrg) { redirect(`/${ownedOrg.orgId}`); } else { diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx index 06dd3300..d2854c0c 100644 --- a/src/app/setup/layout.tsx +++ b/src/app/setup/layout.tsx @@ -12,7 +12,7 @@ import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; export const metadata: Metadata = { - title: `Setup - Pangolin`, + title: `Setup - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, description: "" }; diff --git a/src/components/AutoLoginHandler.tsx b/src/components/AutoLoginHandler.tsx index f7183076..2391ece6 100644 --- a/src/components/AutoLoginHandler.tsx +++ b/src/components/AutoLoginHandler.tsx @@ -21,12 +21,14 @@ type AutoLoginHandlerProps = { resourceId: number; skipToIdpId: number; redirectUrl: string; + orgId?: string; }; export default function AutoLoginHandler({ resourceId, skipToIdpId, - redirectUrl + redirectUrl, + orgId }: AutoLoginHandlerProps) { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -44,7 +46,8 @@ export default function AutoLoginHandler({ try { const response = await generateOidcUrlProxy( skipToIdpId, - redirectUrl + redirectUrl, + orgId ); if (response.error) { @@ -83,7 +86,9 @@ export default function AutoLoginHandler({ {t("autoLoginTitle")} - {t("autoLoginDescription")} + + {t("autoLoginDescription")} + {loading && ( diff --git a/src/components/BrandingLogo.tsx b/src/components/BrandingLogo.tsx index 34771333..4a5330c8 100644 --- a/src/components/BrandingLogo.tsx +++ b/src/components/BrandingLogo.tsx @@ -27,10 +27,12 @@ export default function BrandingLogo(props: BrandingLogoProps) { } if (lightOrDark === "light") { - return "/logo/word_mark_black.png"; + return ( + env.branding.logo?.lightPath || "/logo/word_mark_black.png" + ); } - return "/logo/word_mark_white.png"; + return env.branding.logo?.darkPath || "/logo/word_mark_white.png"; } const path = getPath(); diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index 2a98ab0b..7be37db5 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -38,7 +38,10 @@ export default function DashboardLoginForm({
- +

{getSubtitle()}

diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index c14374d5..dccef529 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -32,6 +32,7 @@ import { createApiClient, formatAxiosError } from "@/lib/api"; import { useEnvContext } from "@/hooks/useEnvContext"; import { toast } from "@/hooks/useToast"; import { ListDomainsResponse } from "@server/routers/domain/listDomains"; +import { CheckDomainAvailabilityResponse } from "@server/routers/domain/privateCheckDomainNamespaceAvailability"; import { AxiosResponse } from "axios"; import { cn } from "@/lib/cn"; import { useTranslations } from "next-intl"; @@ -155,7 +156,10 @@ export default function DomainPicker2({ fullDomain: firstOrgDomain.baseDomain, baseDomain: firstOrgDomain.baseDomain }); - } else if ((build === "saas" || build === "enterprise") && !hideFreeDomain) { + } else if ( + (build === "saas" || build === "enterprise") && + !hideFreeDomain + ) { // If no organization domains, select the provided domain option const domainOptionText = build === "enterprise" @@ -198,7 +202,21 @@ export default function DomainPicker2({ .toLowerCase() .replace(/\./g, "-") .replace(/[^a-z0-9-]/g, "") - .replace(/-+/g, "-"); + .replace(/-+/g, "-") // Replace multiple consecutive dashes with single dash + .replace(/^-|-$/g, ""); // Remove leading/trailing dashes + + if (build != "oss") { + const response = await api.get< + AxiosResponse + >( + `/domain/check-namespace-availability?subdomain=${encodeURIComponent(checkSubdomain)}` + ); + + if (response.status === 200) { + const { options } = response.data.data; + setAvailableOptions(options); + } + } } catch (error) { console.error("Failed to check domain availability:", error); setAvailableOptions([]); @@ -272,13 +290,16 @@ export default function DomainPicker2({ toast({ variant: "destructive", title: t("domainPickerInvalidSubdomain"), - description: t("domainPickerInvalidSubdomainRemoved", { sub }), + description: t("domainPickerInvalidSubdomainRemoved", { sub }) }); return ""; } const ok = validateByDomainType(sanitized, { - type: base.type === "provided-search" ? "provided-search" : "organization", + type: + base.type === "provided-search" + ? "provided-search" + : "organization", domainType: base.domainType }); @@ -286,7 +307,10 @@ export default function DomainPicker2({ toast({ variant: "destructive", title: t("domainPickerInvalidSubdomain"), - description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }), + description: t("domainPickerInvalidSubdomainCannotMakeValid", { + sub, + domain: base.domain + }) }); return ""; } @@ -294,7 +318,10 @@ export default function DomainPicker2({ if (sub !== sanitized) { toast({ title: t("domainPickerSubdomainSanitized"), - description: t("domainPickerSubdomainCorrected", { sub, sanitized }), + description: t("domainPickerSubdomainCorrected", { + sub, + sanitized + }) }); } @@ -365,7 +392,8 @@ export default function DomainPicker2({ onDomainChange?.({ domainId: option.domainId || "", domainNamespaceId: option.domainNamespaceId, - type: option.type === "provided-search" ? "provided" : "organization", + type: + option.type === "provided-search" ? "provided" : "organization", subdomain: sub || undefined, fullDomain, baseDomain: option.domain @@ -389,12 +417,16 @@ export default function DomainPicker2({ }); }; - const isSubdomainValid = selectedBaseDomain && subdomainInput - ? validateByDomainType(subdomainInput, { - type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", - domainType: selectedBaseDomain.domainType - }) - : true; + const isSubdomainValid = + selectedBaseDomain && subdomainInput + ? validateByDomainType(subdomainInput, { + type: + selectedBaseDomain.type === "provided-search" + ? "provided-search" + : "organization", + domainType: selectedBaseDomain.domainType + }) + : true; const showSubdomainInput = selectedBaseDomain && @@ -415,7 +447,6 @@ export default function DomainPicker2({ const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; - return (
@@ -434,16 +465,16 @@ export default function DomainPicker2({ showProvidedDomainSearch ? "" : showSubdomainInput - ? "" - : t("domainPickerNotAvailableForCname") + ? "" + : t("domainPickerNotAvailableForCname") } disabled={ !showSubdomainInput && !showProvidedDomainSearch } className={cn( !isSubdomainValid && - subdomainInput && - "border-red-500 focus:border-red-500" + subdomainInput && + "border-red-500 focus:border-red-500" )} onChange={(e) => { if (showProvidedDomainSearch) { @@ -453,11 +484,13 @@ export default function DomainPicker2({ } }} /> - {showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && ( -

- {t("domainPickerInvalidSubdomainStructure")} -

- )} + {showSubdomainInput && + subdomainInput && + !isValidSubdomainStructure(subdomainInput) && ( +

+ {t("domainPickerInvalidSubdomainStructure")} +

+ )} {showSubdomainInput && !subdomainInput && (

{t("domainPickerEnterSubdomainOrLeaveBlank")} @@ -483,7 +516,7 @@ export default function DomainPicker2({ {selectedBaseDomain ? (

{selectedBaseDomain.type === - "organization" ? null : ( + "organization" ? null : ( )} @@ -557,8 +590,12 @@ export default function DomainPicker2({ {orgDomain.type.toUpperCase()}{" "} •{" "} {orgDomain.verified - ? t("domainPickerVerified") - : t("domainPickerUnverified")} + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )}
{(build === "saas" || - build === "enterprise") && !hideFreeDomain && ( + build === "enterprise") && + !hideFreeDomain && ( )} )} - {(build === "saas" || - build === "enterprise") && !hideFreeDomain && ( + {(build === "saas" || build === "enterprise") && + !hideFreeDomain && ( @@ -602,9 +642,13 @@ export default function DomainPicker2({ id: "provided-search", domain: build === - "enterprise" - ? t("domainPickerProvidedDomain") - : t("domainPickerFreeProvidedDomain"), + "enterprise" + ? t( + "domainPickerProvidedDomain" + ) + : t( + "domainPickerFreeProvidedDomain" + ), type: "provided-search" }) } @@ -615,9 +659,14 @@ export default function DomainPicker2({
- {build === "enterprise" - ? t("domainPickerProvidedDomain") - : t("domainPickerFreeProvidedDomain")} + {build === + "enterprise" + ? t( + "domainPickerProvidedDomain" + ) + : t( + "domainPickerFreeProvidedDomain" + )} {t( @@ -644,6 +693,15 @@ export default function DomainPicker2({
+ {/*showProvidedDomainSearch && build === "saas" && ( + + + + {t("domainPickerNotWorkSelfHosted")} + + + )*/} + {showProvidedDomainSearch && (
{isChecking && ( @@ -693,7 +751,7 @@ export default function DomainPicker2({ htmlFor={option.domainNamespaceId} data-state={ selectedProvidedDomain?.domainNamespaceId === - option.domainNamespaceId + option.domainNamespaceId ? "checked" : "unchecked" } diff --git a/src/components/HealthCheckDialog.tsx b/src/components/HealthCheckDialog.tsx new file mode 100644 index 00000000..1942e1d8 --- /dev/null +++ b/src/components/HealthCheckDialog.tsx @@ -0,0 +1,580 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@/components/Credenza"; +import { toast } from "@/hooks/useToast"; +import { useTranslations } from "next-intl"; + +type HealthCheckConfig = { + hcEnabled: boolean; + hcPath: string; + hcMethod: string; + hcInterval: number; + hcTimeout: number; + hcStatus: number | null; + hcHeaders?: { name: string; value: string }[] | null; + hcScheme?: string; + hcHostname: string; + hcPort: number; + hcFollowRedirects: boolean; + hcMode: string; + hcUnhealthyInterval: number; +}; + +type HealthCheckDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; + targetId: number; + targetAddress: string; + targetMethod?: string; + initialConfig?: Partial; + onChanges: (config: HealthCheckConfig) => Promise; +}; + +export default function HealthCheckDialog({ + open, + setOpen, + targetId, + targetAddress, + targetMethod, + initialConfig, + onChanges +}: HealthCheckDialogProps) { + const t = useTranslations(); + + const healthCheckSchema = z.object({ + hcEnabled: z.boolean(), + hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }), + hcMethod: z + .string() + .min(1, { message: t("healthCheckMethodRequired") }), + hcInterval: z + .number() + .int() + .positive() + .min(5, { message: t("healthCheckIntervalMin") }), + hcTimeout: z + .number() + .int() + .positive() + .min(1, { message: t("healthCheckTimeoutMin") }), + hcStatus: z.number().int().positive().min(100).optional().nullable(), + hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), + hcScheme: z.string().optional(), + hcHostname: z.string(), + hcPort: z.number().positive().gt(0).lte(65535), + hcFollowRedirects: z.boolean(), + hcMode: z.string(), + hcUnhealthyInterval: z.number().int().positive().min(5) + }); + + const form = useForm>({ + resolver: zodResolver(healthCheckSchema), + defaultValues: {} + }); + + useEffect(() => { + if (!open) return; + + // Determine default scheme from target method + const getDefaultScheme = () => { + if (initialConfig?.hcScheme) { + return initialConfig.hcScheme; + } + // Default to target method if it's http or https, otherwise default to http + if (targetMethod === "https") { + return "https"; + } + return "http"; + }; + + form.reset({ + hcEnabled: initialConfig?.hcEnabled, + hcPath: initialConfig?.hcPath, + hcMethod: initialConfig?.hcMethod, + hcInterval: initialConfig?.hcInterval, + hcTimeout: initialConfig?.hcTimeout, + hcStatus: initialConfig?.hcStatus, + hcHeaders: initialConfig?.hcHeaders, + hcScheme: getDefaultScheme(), + hcHostname: initialConfig?.hcHostname, + hcPort: initialConfig?.hcPort, + hcFollowRedirects: initialConfig?.hcFollowRedirects, + hcMode: initialConfig?.hcMode, + hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval + }); + }, [open]); + + const watchedEnabled = form.watch("hcEnabled"); + + const handleFieldChange = async (fieldName: string, value: any) => { + try { + const currentValues = form.getValues(); + const updatedValues = { ...currentValues, [fieldName]: value }; + await onChanges({ + ...updatedValues, + hcStatus: updatedValues.hcStatus || null + }); + } catch (error) { + toast({ + title: t("healthCheckError"), + description: t("healthCheckErrorDescription"), + variant: "destructive" + }); + } + }; + + return ( + + + + {t("configureHealthCheck")} + + {t("configureHealthCheckDescription", { + target: targetAddress + })} + + + +
+ + {/* Enable Health Checks */} + ( + +
+ + {t("enableHealthChecks")} + + + {t( + "enableHealthChecksDescription" + )} + +
+ + { + field.onChange(value); + handleFieldChange( + "hcEnabled", + value + ); + }} + /> + +
+ )} + /> + + {watchedEnabled && ( +
+
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcHostname", + e.target + .value + ); + }} + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcPort", + value + ); + }} + /> + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcPath", + e.target + .value + ); + }} + /> + + + + )} + /> +
+ + {/* HTTP Method */} + ( + + + {t("httpMethod")} + + + + + )} + /> + + {/* Check Interval, Timeout, and Retry Attempts */} +
+ ( + + + {t( + "healthyIntervalSeconds" + )} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcInterval", + value + ); + }} + /> + + + + )} + /> + + ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcUnhealthyInterval", + value + ); + }} + /> + + + + )} + /> + + ( + + + {t("timeoutSeconds")} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcTimeout", + value + ); + }} + /> + + + + )} + /> + + + {t("timeIsInSeconds")} + +
+ + {/* Expected Response Codes */} + ( + + + {t("expectedResponseCodes")} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcStatus", + value + ); + }} + /> + + + {t( + "expectedResponseCodesDescription" + )} + + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + { + field.onChange(value); + handleFieldChange( + "hcHeaders", + value + ); + }} + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> +
+ )} + + +
+ + + +
+
+ ); +} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 2584b259..36db7628 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -6,6 +6,10 @@ import Link from "next/link"; import ProfileIcon from "@app/components/ProfileIcon"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; import { useTheme } from "next-themes"; +import BrandingLogo from "./BrandingLogo"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Badge } from "./ui/badge"; +import { build } from "@server/build"; interface LayoutHeaderProps { showTopBar: boolean; @@ -14,6 +18,7 @@ interface LayoutHeaderProps { export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { const { theme } = useTheme(); const [path, setPath] = useState(""); + const { env } = useEnvContext(); useEffect(() => { function getPath() { @@ -44,16 +49,18 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
- {path && ( - Pangolin - )} + + {/* {build === "saas" && ( + Cloud Beta + )} */}
{showTopBar && ( diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 5da9adb2..dafa31a9 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -57,6 +57,16 @@ export function LayoutSidebar({ setSidebarStateCookie(isSidebarCollapsed); }, [isSidebarCollapsed]); + function loadFooterLinks(): { text: string; href?: string }[] | undefined { + if (env.branding.footer) { + try { + return JSON.parse(env.branding.footer); + } catch (e) { + console.error("Failed to parse BRANDING_FOOTER", e); + } + } + } + return (
{!isSidebarCollapsed && (
-
- - {!isUnlocked() - ? t("communityEdition") - : t("commercialEdition")} - - -
- {env?.app?.version && ( -
- - v{env.app.version} - - -
+ {loadFooterLinks() ? ( + <> + {loadFooterLinks()!.map((link, index) => ( +
+ {link.href ? ( +
+ + {link.text} + + +
+ ) : ( +
+ {link.text} +
+ )} +
+ ))} + + ) : ( + <> +
+ + {!isUnlocked() + ? t("communityEdition") + : t("commercialEdition")} + + +
+ {env?.app?.version && ( +
+ + v{env.app.version} + + +
+ )} + )}
)} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 437cc8b7..c55ad871 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -22,10 +22,7 @@ import { CardTitle } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; -import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api"; import { LockIcon, FingerprintIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { @@ -49,6 +46,9 @@ import { } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; +// @ts-ignore +import { loadReoScript } from "reodotdev"; +import { build } from "@server/build"; export type LoginFormIDP = { idpId: number; @@ -60,13 +60,18 @@ type LoginFormProps = { redirect?: string; onLogin?: () => void | Promise; idps?: LoginFormIDP[]; + orgId?: string; }; -export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { +export default function LoginForm({ + redirect, + onLogin, + idps, + orgId +}: LoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); - const api = createApiClient({ env }); const [error, setError] = useState(null); @@ -77,10 +82,32 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false); const t = useTranslations(); - const currentHost = typeof window !== "undefined" ? window.location.hostname : ""; + const currentHost = + typeof window !== "undefined" ? window.location.hostname : ""; const expectedHost = new URL(env.app.dashboardUrl).host; const isExpectedHost = currentHost === expectedHost; + const [reo, setReo] = useState(undefined); + useEffect(() => { + async function init() { + if (env.app.environment !== "prod") { + return; + } + try { + const clientID = env.server.reoClientId; + const reoClient = await loadReoScript({ clientID }); + await reoClient.init({ clientID }); + setReo(reoClient); + } catch (e) { + console.error("Failed to load Reo script", e); + } + } + + if (build == "saas") { + init(); + } + }, []); + const formSchema = z.object({ email: z.string().email({ message: t("emailInvalid") }), password: z.string().min(8, { message: t("passwordRequirementsChars") }) @@ -183,26 +210,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } } catch (e: any) { - if (e.isAxiosError) { - setError( - formatAxiosError( - e, - t("securityKeyAuthError", { - defaultValue: - "Failed to authenticate with security key" - }) - ) - ); - } else { - console.error(e); - setError( - e.message || - t("securityKeyAuthError", { - defaultValue: - "Failed to authenticate with security key" - }) - ); - } + console.error(e); + setError( + t("securityKeyAuthError", { + defaultValue: + "An unexpected error occurred. Please try again." + }) + ); } finally { setLoading(false); setShowSecurityKeyPrompt(false); @@ -224,6 +238,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { code }); + try { + const identity = { + username: email, + type: "email" // can be one of email, github, linkedin, gmail, userID, + }; + if (reo) { + reo.identify(identity); + } + } catch (e) { + console.error("Reo identify error:", e); + } + if (response.error) { setError(response.message); return; @@ -253,7 +279,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { if (data.emailVerificationRequired) { if (!isExpectedHost) { - setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl })); + setError( + t("emailVerificationRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); return; } if (redirect) { @@ -266,7 +296,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { if (data.twoFactorSetupRequired) { if (!isExpectedHost) { - setError(t("twoFactorSetupRequired", { dashboardUrl: env.app.dashboardUrl })); + setError( + t("twoFactorSetupRequired", { + dashboardUrl: env.app.dashboardUrl + }) + ); return; } const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`; @@ -278,25 +312,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { await onLogin(); } } catch (e: any) { - if (e.isAxiosError) { - const errorMessage = formatAxiosError( - e, - t("loginError", { - defaultValue: "Failed to log in" - }) - ); - setError(errorMessage); - return; - } else { - console.error(e); - setError( - e.message || - t("loginError", { - defaultValue: "Failed to log in" - }) - ); - return; - } + console.error(e); + setError( + t("loginError", { + defaultValue: + "An unexpected error occurred. Please try again." + }) + ); } finally { setLoading(false); } @@ -307,7 +329,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { try { const data = await generateOidcUrlProxy( idpId, - redirect || "/" + redirect || "/", + orgId ); const url = data.data?.redirectUrl; if (data.error) { @@ -527,7 +550,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{idps.map((idp) => { - const effectiveType = idp.variant || idp.name.toLowerCase(); + const effectiveType = + idp.variant || idp.name.toLowerCase(); return ( + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + return ( +
+ {originalRow.exitNodeName} + {build == "saas" && originalRow.exitNodeName && + ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && ( + Cloud + )} +
+ ); + }, + }, ...(env.flags.enableClients ? [{ accessorKey: "address", header: ({ column }: { column: Column }) => { diff --git a/src/components/ValidateOidcToken.tsx b/src/components/ValidateOidcToken.tsx index 7ba8e145..d4d9678d 100644 --- a/src/components/ValidateOidcToken.tsx +++ b/src/components/ValidateOidcToken.tsx @@ -2,7 +2,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; import { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -26,6 +25,7 @@ type ValidateOidcTokenParams = { expectedState: string | undefined; stateCookie: string | undefined; idp: { name: string }; + loginPageId?: number; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { @@ -44,7 +44,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { async function validate() { setLoading(true); - console.log(t('idpOidcTokenValidating'), { + console.log(t("idpOidcTokenValidating"), { code: props.code, expectedState: props.expectedState, stateCookie: props.stateCookie @@ -59,7 +59,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { props.idpId, props.code || "", props.expectedState || "", - props.stateCookie || "" + props.stateCookie || "", + props.loginPageId ); if (response.error) { @@ -78,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { const redirectUrl = data.redirectUrl; if (!redirectUrl) { - router.push("/"); + router.push(env.app.dashboardUrl); } setLoading(false); @@ -89,8 +90,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { } else { router.push(data.redirectUrl); } - } catch (e) { - setError(formatAxiosError(e, t('idpErrorOidcTokenValidating'))); + } catch (e: any) { + console.error(e); + setError( + t("idpErrorOidcTokenValidating", { + defaultValue: "An unexpected error occurred. Please try again." + }) + ); } finally { setLoading(false); } @@ -103,20 +109,24 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
- {t('idpConnectingTo', {name: props.idp.name})} - {t('idpConnectingToDescription')} + + {t("idpConnectingTo", { name: props.idp.name })} + + + {t("idpConnectingToDescription")} + {loading && (
- {t('idpConnectingToProcess')} + {t("idpConnectingToProcess")}
)} {!loading && !error && (
- {t('idpConnectingToFinished')} + {t("idpConnectingToFinished")}
)} {error && ( @@ -124,7 +134,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { - {t('idpErrorConnectingTo', {name: props.idp.name})} + {t("idpErrorConnectingTo", { + name: props.idp.name + })} {error} diff --git a/src/components/private/AuthPageSettings.tsx b/src/components/private/AuthPageSettings.tsx new file mode 100644 index 00000000..1917c609 --- /dev/null +++ b/src/components/private/AuthPageSettings.tsx @@ -0,0 +1,538 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { useTranslations } from "next-intl"; +import { GetLoginPageResponse } from "@server/routers/private/loginPage"; +import { ListDomainsResponse } from "@server/routers/domain"; +import { DomainRow } from "@app/components/DomainsTable"; +import { toUnicode } from "punycode"; +import { Globe, Trash2 } from "lucide-react"; +import CertificateStatus from "@app/components/private/CertificateStatus"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext"; +import { TierId } from "@server/lib/private/billing/tiers"; +import { build } from "@server/build"; + +// Auth page form schema +const AuthPageFormSchema = z.object({ + authPageDomainId: z.string().optional(), + authPageSubdomain: z.string().optional() +}); + +type AuthPageFormValues = z.infer; + +interface AuthPageSettingsProps { + onSaveSuccess?: () => void; + onSaveError?: (error: any) => void; +} + +export interface AuthPageSettingsRef { + saveAuthSettings: () => Promise; + hasUnsavedChanges: () => boolean; +} + +const AuthPageSettings = forwardRef(({ + onSaveSuccess, + onSaveError +}, ref) => { + const { org } = useOrgContext(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const t = useTranslations(); + + const subscription = usePrivateSubscriptionStatusContext(); + const subscribed = subscription?.getTier() === TierId.STANDARD; + + // Auth page domain state + const [loginPage, setLoginPage] = useState( + null + ); + const [loginPageExists, setLoginPageExists] = useState(false); + const [editDomainOpen, setEditDomainOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState([]); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + } | null>(null); + const [loadingLoginPage, setLoadingLoginPage] = useState(true); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [loadingSave, setLoadingSave] = useState(false); + + const form = useForm({ + resolver: zodResolver(AuthPageFormSchema), + defaultValues: { + authPageDomainId: loginPage?.domainId || "", + authPageSubdomain: loginPage?.subdomain || "" + }, + mode: "onChange" + }); + + // Expose save function to parent component + useImperativeHandle(ref, () => ({ + saveAuthSettings: async () => { + await form.handleSubmit(onSubmit)(); + }, + hasUnsavedChanges: () => hasUnsavedChanges + }), [form, hasUnsavedChanges]); + + // Fetch login page and domains data + useEffect(() => { + if (build !== "saas") { + return; + } + + const fetchLoginPage = async () => { + try { + const res = await api.get>( + `/org/${org?.org.orgId}/login-page` + ); + if (res.status === 200) { + setLoginPage(res.data.data); + setLoginPageExists(true); + // Update form with login page data + form.setValue( + "authPageDomainId", + res.data.data.domainId || "" + ); + form.setValue( + "authPageSubdomain", + res.data.data.subdomain || "" + ); + } + } catch (err) { + // Login page doesn't exist yet, that's okay + setLoginPage(null); + setLoginPageExists(false); + } finally { + setLoadingLoginPage(false); + } + }; + + const fetchDomains = async () => { + try { + const res = await api.get>( + `/org/${org?.org.orgId}/domains/` + ); + if (res.status === 200) { + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain) + })); + setBaseDomains(domains); + } + } catch (err) { + console.error("Failed to fetch domains:", err); + } + }; + + if (org?.org.orgId) { + fetchLoginPage(); + fetchDomains(); + } + }, []); + + // Handle domain selection from modal + function handleDomainSelection(domain: { + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + }) { + form.setValue("authPageDomainId", domain.domainId); + form.setValue("authPageSubdomain", domain.subdomain || ""); + setEditDomainOpen(false); + + // Update loginPage state to show the selected domain immediately + const sanitizedSubdomain = domain.subdomain + ? finalizeSubdomainSanitize(domain.subdomain) + : ""; + + const sanitizedFullDomain = sanitizedSubdomain + ? `${sanitizedSubdomain}.${domain.baseDomain}` + : domain.baseDomain; + + // Only update loginPage state if a login page already exists + if (loginPageExists && loginPage) { + setLoginPage({ + ...loginPage, + domainId: domain.domainId, + subdomain: sanitizedSubdomain, + fullDomain: sanitizedFullDomain + }); + } + + setHasUnsavedChanges(true); + } + + // Clear auth page domain + function clearAuthPageDomain() { + form.setValue("authPageDomainId", ""); + form.setValue("authPageSubdomain", ""); + setLoginPage(null); + setHasUnsavedChanges(true); + } + + async function onSubmit(data: AuthPageFormValues) { + setLoadingSave(true); + + try { + // Handle auth page domain + if (data.authPageDomainId) { + if (build !== "saas" || (build === "saas" && subscribed)) { + const sanitizedSubdomain = data.authPageSubdomain + ? finalizeSubdomainSanitize(data.authPageSubdomain) + : ""; + + if (loginPageExists) { + // Login page exists on server - need to update it + // First, we need to get the loginPageId from the server since loginPage might be null locally + let loginPageId: number; + + if (loginPage) { + // We have the loginPage data locally + loginPageId = loginPage.loginPageId; + } else { + // User cleared selection locally, but login page still exists on server + // We need to fetch it to get the loginPageId + const fetchRes = await api.get< + AxiosResponse + >(`/org/${org?.org.orgId}/login-page`); + loginPageId = fetchRes.data.data.loginPageId; + } + + // Update existing auth page domain + const updateRes = await api.post( + `/org/${org?.org.orgId}/login-page/${loginPageId}`, + { + domainId: data.authPageDomainId, + subdomain: sanitizedSubdomain || null + } + ); + + if (updateRes.status === 201) { + setLoginPage(updateRes.data.data); + setLoginPageExists(true); + } + } else { + // No login page exists on server - create new one + const createRes = await api.put( + `/org/${org?.org.orgId}/login-page`, + { + domainId: data.authPageDomainId, + subdomain: sanitizedSubdomain || null + } + ); + + if (createRes.status === 201) { + setLoginPage(createRes.data.data); + setLoginPageExists(true); + } + } + } + } else if (loginPageExists) { + // Delete existing auth page domain if no domain selected + let loginPageId: number; + + if (loginPage) { + // We have the loginPage data locally + loginPageId = loginPage.loginPageId; + } else { + // User cleared selection locally, but login page still exists on server + // We need to fetch it to get the loginPageId + const fetchRes = await api.get< + AxiosResponse + >(`/org/${org?.org.orgId}/login-page`); + loginPageId = fetchRes.data.data.loginPageId; + } + + await api.delete( + `/org/${org?.org.orgId}/login-page/${loginPageId}` + ); + setLoginPage(null); + setLoginPageExists(false); + } + + setHasUnsavedChanges(false); + router.refresh(); + onSaveSuccess?.(); + } catch (e) { + toast({ + variant: "destructive", + title: t("authPageErrorUpdate"), + description: formatAxiosError(e, t("authPageErrorUpdateMessage")) + }); + onSaveError?.(e); + } finally { + setLoadingSave(false); + } + } + + return ( + <> + + + + {t("authPage")} + + + {t("authPageDescription")} + + + + {build === "saas" && !subscribed ? ( + + + {t("orgAuthPageDisabled")}{" "} + {t("subscriptionRequiredToUse")} + + + ) : null} + + + {loadingLoginPage ? ( +
+
+ {t("loading")} +
+
+ ) : ( +
+ +
+ +
+ + + {loginPage && + !loginPage.domainId ? ( + + ) : loginPage?.fullDomain ? ( + + {`${window.location.protocol}//${loginPage.fullDomain}`} + + ) : form.watch( + "authPageDomainId" + ) ? ( + // Show selected domain from form state when no loginPage exists yet + (() => { + const selectedDomainId = + form.watch( + "authPageDomainId" + ); + const selectedSubdomain = + form.watch( + "authPageSubdomain" + ); + const domain = + baseDomains.find( + (d) => + d.domainId === + selectedDomainId + ); + if (domain) { + const sanitizedSubdomain = + selectedSubdomain + ? finalizeSubdomainSanitize( + selectedSubdomain + ) + : ""; + const fullDomain = + sanitizedSubdomain + ? `${sanitizedSubdomain}.${domain.baseDomain}` + : domain.baseDomain; + return fullDomain; + } + return t("noDomainSet"); + })() + ) : ( + t("noDomainSet") + )} + +
+ + {form.watch("authPageDomainId") && ( + + )} +
+
+ + {/* Certificate Status */} + {(build !== "saas" || + (build === "saas" && subscribed)) && + loginPage?.domainId && + loginPage?.fullDomain && + !hasUnsavedChanges && ( + + )} + + {!form.watch("authPageDomainId") && ( +
+ {t( + "addDomainToEnableCustomAuthPages" + )} +
+ )} +
+
+ + )} +
+
+
+ + {/* Domain Picker Modal */} + setEditDomainOpen(setOpen)} + > + + + + {loginPage + ? t("editAuthPageDomain") + : t("setAuthPageDomain")} + + + {t("selectDomainForOrgAuthPage")} + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain, + baseDomain: res.baseDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + + ); +}); + +AuthPageSettings.displayName = 'AuthPageSettings'; + +export default AuthPageSettings; \ No newline at end of file diff --git a/src/components/private/AutoProvisionConfigWidget.tsx b/src/components/private/AutoProvisionConfigWidget.tsx new file mode 100644 index 00000000..35800ccc --- /dev/null +++ b/src/components/private/AutoProvisionConfigWidget.tsx @@ -0,0 +1,185 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage +} from "@app/components/ui/form"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Input } from "@app/components/ui/input"; +import { useTranslations } from "next-intl"; +import { Control, FieldValues, Path } from "react-hook-form"; + +type Role = { + roleId: number; + name: string; +}; + +type AutoProvisionConfigWidgetProps = { + control: Control; + autoProvision: boolean; + onAutoProvisionChange: (checked: boolean) => void; + roleMappingMode: "role" | "expression"; + onRoleMappingModeChange: (mode: "role" | "expression") => void; + roles: Role[]; + roleIdFieldName: Path; + roleMappingFieldName: Path; +}; + +export default function AutoProvisionConfigWidget({ + control, + autoProvision, + onAutoProvisionChange, + roleMappingMode, + onRoleMappingModeChange, + roles, + roleIdFieldName, + roleMappingFieldName +}: AutoProvisionConfigWidgetProps) { + const t = useTranslations(); + + return ( +
+
+ + + {t("idpAutoProvisionUsersDescription")} + +
+ + {autoProvision && ( +
+
+ + {t("roleMapping")} + + + {t("roleMappingDescription")} + + + +
+ + +
+
+ + +
+
+
+ + {roleMappingMode === "role" ? ( + ( + + + + {t("selectRoleDescription")} + + + + )} + /> + ) : ( + ( + + + + + + {t("roleMappingExpressionDescription")} + + + + )} + /> + )} +
+ )} +
+ ); +} diff --git a/src/components/private/CertificateStatus.tsx b/src/components/private/CertificateStatus.tsx new file mode 100644 index 00000000..1b872371 --- /dev/null +++ b/src/components/private/CertificateStatus.tsx @@ -0,0 +1,156 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { Button } from "@/components/ui/button"; +import { RotateCw } from "lucide-react"; +import { useCertificate } from "@app/hooks/privateUseCertificate"; +import { useTranslations } from "next-intl"; + +type CertificateStatusProps = { + orgId: string; + domainId: string; + fullDomain: string; + autoFetch?: boolean; + showLabel?: boolean; + className?: string; + onRefresh?: () => void; + polling?: boolean; + pollingInterval?: number; +}; + +export default function CertificateStatus({ + orgId, + domainId, + fullDomain, + autoFetch = true, + showLabel = true, + className = "", + onRefresh, + polling = false, + pollingInterval = 5000 +}: CertificateStatusProps) { + const t = useTranslations(); + const { cert, certLoading, certError, refreshing, refreshCert } = useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch, + polling, + pollingInterval + }); + + const handleRefresh = async () => { + await refreshCert(); + onRefresh?.(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "valid": + return "text-green-500"; + case "pending": + case "requested": + return "text-yellow-500"; + case "expired": + case "failed": + return "text-red-500"; + default: + return "text-muted-foreground"; + } + }; + + const shouldShowRefreshButton = (status: string, updatedAt: string) => { + return ( + status === "failed" || + status === "expired" || + (status === "requested" && + updatedAt && new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000) + ); + }; + + if (certLoading) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + {t("loading")} + +
+ ); + } + + if (certError) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + {certError} + +
+ ); + } + + if (!cert) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + {t("none", { defaultValue: "None" })} + +
+ ); + } + + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + + {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} + {shouldShowRefreshButton(cert.status, cert.updatedAt) && ( + + )} + + +
+ ); +} diff --git a/src/components/private/IdpLoginButtons.tsx b/src/components/private/IdpLoginButtons.tsx new file mode 100644 index 00000000..95e9a9f3 --- /dev/null +++ b/src/components/private/IdpLoginButtons.tsx @@ -0,0 +1,135 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import { generateOidcUrlProxy, type GenerateOidcUrlResponse } from "@app/actions/server"; +import { redirect as redirectTo } from "next/navigation"; + +export type LoginFormIDP = { + idpId: number; + name: string; + variant?: string; +}; + +type IdpLoginButtonsProps = { + idps: LoginFormIDP[]; + redirect?: string; + orgId?: string; +}; + +export default function IdpLoginButtons({ + idps, + redirect, + orgId +}: IdpLoginButtonsProps) { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const t = useTranslations(); + + async function loginWithIdp(idpId: number) { + setLoading(true); + setError(null); + + let redirectToUrl: string | undefined; + try { + const response = await generateOidcUrlProxy( + idpId, + redirect || "/", + orgId + ); + + if (response.error) { + setError(response.message); + setLoading(false); + return; + } + + const data = response.data; + console.log("Redirecting to:", data?.redirectUrl); + if (data?.redirectUrl) { + redirectToUrl = data.redirectUrl; + } + } catch (e: any) { + console.error(e); + setError( + t("loginError", { + defaultValue: "An unexpected error occurred. Please try again." + }) + ); + setLoading(false); + } + + if (redirectToUrl) { + redirectTo(redirectToUrl); + } + } + + if (!idps || idps.length === 0) { + return null; + } + + return ( +
+ {error && ( + + {error} + + )} + +
+ {idps.map((idp) => { + const effectiveType = idp.variant || idp.name.toLowerCase(); + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/private/OrgIdpDataTable.tsx b/src/components/private/OrgIdpDataTable.tsx new file mode 100644 index 00000000..c98a6234 --- /dev/null +++ b/src/components/private/OrgIdpDataTable.tsx @@ -0,0 +1,45 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onAdd?: () => void; +} + +export function IdpDataTable({ + columns, + data, + onAdd +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/private/OrgIdpTable.tsx b/src/components/private/OrgIdpTable.tsx new file mode 100644 index 00000000..f0e4d6c9 --- /dev/null +++ b/src/components/private/OrgIdpTable.tsx @@ -0,0 +1,219 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { IdpDataTable } from "@app/components/private/OrgIdpDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useRouter } from "next/navigation"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; + +export type IdpRow = { + idpId: number; + name: string; + type: string; + variant?: string; +}; + +type Props = { + idps: IdpRow[]; + orgId: string; +}; + +export default function IdpTable({ idps, orgId }: Props) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedIdp, setSelectedIdp] = useState(null); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const t = useTranslations(); + + const deleteIdp = async (idpId: number) => { + try { + await api.delete(`/org/${orgId}/idp/${idpId}`); + toast({ + title: t("success"), + description: t("idpDeletedDescription") + }); + setIsDeleteModalOpen(false); + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + + const columns: ColumnDef[] = [ + { + accessorKey: "idpId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + const variant = row.original.variant; + return ( + + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const siteRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedIdp(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedIdp && ( + { + setIsDeleteModalOpen(val); + setSelectedIdp(null); + }} + dialog={ +
+

+ {t("idpQuestionRemove", { + name: selectedIdp.name + })} +

+

+ {t("idpMessageRemove")} +

+

{t("idpMessageConfirm")}

+
+ } + buttonText={t("idpConfirmDelete")} + onConfirm={async () => deleteIdp(selectedIdp.idpId)} + string={selectedIdp.name} + title={t("idpDelete")} + /> + )} + + router.push(`/${orgId}/settings/idp/create`)} + /> + + ); +} diff --git a/src/components/private/RegionSelector.tsx b/src/components/private/RegionSelector.tsx new file mode 100644 index 00000000..56dde743 --- /dev/null +++ b/src/components/private/RegionSelector.tsx @@ -0,0 +1,101 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { useTranslations } from "next-intl"; + +type Region = { + value: string; + label: string; + flag: string; +}; + +const regions: Region[] = [ + { + value: "us", + label: "North America", + flag: "" + }, + { + value: "eu", + label: "Europe", + flag: "" + } +]; + +export default function RegionSelector() { + const [selectedRegion, setSelectedRegion] = useState("us"); + const t = useTranslations(); + + const handleRegionChange = (value: string) => { + setSelectedRegion(value); + const region = regions.find((r) => r.value === value); + if (region) { + console.log(`Selected region: ${region.label}`); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/private/SplashImage.tsx b/src/components/private/SplashImage.tsx new file mode 100644 index 00000000..a2063692 --- /dev/null +++ b/src/components/private/SplashImage.tsx @@ -0,0 +1,57 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePathname } from "next/navigation"; +import Image from "next/image"; + +type SplashImageProps = { + children: React.ReactNode; +}; + +export default function SplashImage({ children }: SplashImageProps) { + const pathname = usePathname(); + const { env } = useEnvContext(); + + function showBackgroundImage() { + if (!env.branding.background_image_path) { + return false; + } + const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"]; + for (const prefix of pathsPrefixes) { + if (pathname.startsWith(prefix)) { + return true; + } + } + return false; + } + + return ( + <> + {showBackgroundImage() && ( + Background + )} + + {children} + + ); +} diff --git a/src/components/private/ValidateSessionTransferToken.tsx b/src/components/private/ValidateSessionTransferToken.tsx new file mode 100644 index 00000000..116785d8 --- /dev/null +++ b/src/components/private/ValidateSessionTransferToken.tsx @@ -0,0 +1,84 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession"; + +type ValidateSessionTransferTokenParams = { + token: string; +}; + +export default function ValidateSessionTransferToken( + props: ValidateSessionTransferTokenParams +) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const t = useTranslations(); + + useEffect(() => { + async function validate() { + setLoading(true); + + let doRedirect = false; + try { + const res = await api.post< + AxiosResponse + >(`/auth/transfer-session-token`, { + token: props.token + }); + + if (res && res.status === 200) { + doRedirect = true; + } + } catch (e) { + console.error(e); + setError(formatAxiosError(e, "Failed to validate token")); + } finally { + setLoading(false); + } + + if (doRedirect) { + redirect(env.app.dashboardUrl); + } + } + + validate(); + }, []); + + return ( +
+ {error && ( + + + + {error} + + + )} +
+ ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 2c30ee73..dff7777c 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -14,7 +14,9 @@ const alertVariants = cva( "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", - info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500" + info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500", + warning: + "border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500" } }, defaultVariants: { diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index ca64fc06..7a1d7a23 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -9,10 +9,13 @@ import { ToastTitle, ToastViewport } from "@/components/ui/toast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; export function Toaster() { const { toasts } = useToast(); + const { env } = useEnvContext(); + return ( {toasts.map(function ({ diff --git a/src/contexts/privateRemoteExitNodeContext.ts b/src/contexts/privateRemoteExitNodeContext.ts new file mode 100644 index 00000000..65567b7c --- /dev/null +++ b/src/contexts/privateRemoteExitNodeContext.ts @@ -0,0 +1,24 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode"; +import { createContext } from "react"; + +type RemoteExitNodeContextType = { + remoteExitNode: GetRemoteExitNodeResponse; + updateRemoteExitNode: (updatedRemoteExitNode: Partial) => void; +}; + +const RemoteExitNodeContext = createContext(undefined); + +export default RemoteExitNodeContext; diff --git a/src/contexts/privateSubscriptionStatusContext.ts b/src/contexts/privateSubscriptionStatusContext.ts new file mode 100644 index 00000000..aa99c21f --- /dev/null +++ b/src/contexts/privateSubscriptionStatusContext.ts @@ -0,0 +1,28 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { GetOrgSubscriptionResponse } from "@server/routers/private/billing"; +import { createContext } from "react"; + +type SubscriptionStatusContextType = { + subscriptionStatus: GetOrgSubscriptionResponse | null; + updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void; + isActive: () => boolean; + getTier: () => string | null; +}; + +const PrivateSubscriptionStatusContext = createContext< + SubscriptionStatusContextType | undefined +>(undefined); + +export default PrivateSubscriptionStatusContext; diff --git a/src/hooks/privateUseCertificate.ts b/src/hooks/privateUseCertificate.ts new file mode 100644 index 00000000..7956c3c7 --- /dev/null +++ b/src/hooks/privateUseCertificate.ts @@ -0,0 +1,135 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { AxiosResponse } from "axios"; +import { GetCertificateResponse } from "@server/routers/private/certificates"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; + +type UseCertificateProps = { + orgId: string; + domainId: string; + fullDomain: string; + autoFetch?: boolean; + polling?: boolean; + pollingInterval?: number; +}; + +type UseCertificateReturn = { + cert: GetCertificateResponse | null; + certLoading: boolean; + certError: string | null; + refreshing: boolean; + fetchCert: () => Promise; + refreshCert: () => Promise; + clearCert: () => void; +}; + +export function useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch = true, + polling = false, + pollingInterval = 5000 +}: UseCertificateProps): UseCertificateReturn { + const api = createApiClient(useEnvContext()); + + const [cert, setCert] = useState(null); + const [certLoading, setCertLoading] = useState(false); + const [certError, setCertError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const fetchCert = useCallback(async (showLoading = true) => { + if (!orgId || !domainId || !fullDomain) return; + + if (showLoading) { + setCertLoading(true); + } + setCertError(null); + try { + const res = await api.get>( + `/org/${orgId}/certificate/${domainId}/${fullDomain}` + ); + const certData = res.data.data; + if (certData) { + setCert(certData); + } + } catch (error: any) { + console.error("Failed to fetch certificate:", error); + setCertError("Failed to fetch certificate"); + } finally { + if (showLoading) { + setCertLoading(false); + } + } + }, [api, orgId, domainId, fullDomain]); + + const refreshCert = useCallback(async () => { + if (!cert) return; + + setRefreshing(true); + setCertError(null); + try { + await api.post( + `/org/${orgId}/certificate/${cert.certId}/restart`, + {} + ); + // Update status to pending + setTimeout(() => { + setCert({ ...cert, status: "pending" }); + }, 500); + } catch (error: any) { + console.error("Failed to restart certificate:", error); + setCertError("Failed to restart certificate"); + } finally { + setRefreshing(false); + } + }, [api, orgId, cert]); + + const clearCert = useCallback(() => { + setCert(null); + setCertError(null); + }, []); + + // Auto-fetch on mount if enabled + useEffect(() => { + if (autoFetch && orgId && domainId && fullDomain) { + fetchCert(); + } + }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); + + // Polling effect + useEffect(() => { + if (!polling || !orgId || !domainId || !fullDomain) return; + + const interval = setInterval(() => { + fetchCert(false); // Don't show loading for polling + }, pollingInterval); + + return () => clearInterval(interval); + }, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]); + + return { + cert, + certLoading, + certError, + refreshing, + fetchCert, + refreshCert, + clearCert + }; +} diff --git a/src/hooks/privateUseRemoteExitNodeContext.ts b/src/hooks/privateUseRemoteExitNodeContext.ts new file mode 100644 index 00000000..8decdb36 --- /dev/null +++ b/src/hooks/privateUseRemoteExitNodeContext.ts @@ -0,0 +1,29 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext"; +import { build } from "@server/build"; +import { useContext } from "react"; + +export function useRemoteExitNodeContext() { + if (build == "oss") { + return null; + } + const context = useContext(RemoteExitNodeContext); + if (context === undefined) { + throw new Error("useRemoteExitNodeContext must be used within a RemoteExitNodeProvider"); + } + return context; +} diff --git a/src/hooks/privateUseSubscriptionStatusContext.ts b/src/hooks/privateUseSubscriptionStatusContext.ts new file mode 100644 index 00000000..a6a53ac9 --- /dev/null +++ b/src/hooks/privateUseSubscriptionStatusContext.ts @@ -0,0 +1,29 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext"; +import { build } from "@server/build"; +import { useContext } from "react"; + +export function usePrivateSubscriptionStatusContext() { + if (build == "oss") { + return null; + } + const context = useContext(PrivateSubscriptionStatusContext); + if (context === undefined) { + throw new Error( + "usePrivateSubscriptionStatusContext must be used within an PrivateSubscriptionStatusProvider" + ); + } + return context; +} diff --git a/src/lib/privateThemeColors.ts b/src/lib/privateThemeColors.ts new file mode 100644 index 00000000..0543d68a --- /dev/null +++ b/src/lib/privateThemeColors.ts @@ -0,0 +1,87 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +const defaultTheme = { + light: { + background: "oklch(0.99 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.6717 0.1946 41.93)", + "primary-foreground": "oklch(0.98 0.016 73.684)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.705 0.213 47.604)", + radius: "0.65rem", + "chart-1": "oklch(0.646 0.222 41.116)", + "chart-2": "oklch(0.6 0.118 184.704)", + "chart-3": "oklch(0.398 0.07 227.392)", + "chart-4": "oklch(0.828 0.189 84.429)", + "chart-5": "oklch(0.769 0.188 70.08)" + }, + dark: { + background: "oklch(0.20 0.006 285.885)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.6717 0.1946 41.93)", + "primary-foreground": "oklch(0.98 0.016 73.684)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.646 0.222 41.116)", + "chart-1": "oklch(0.488 0.243 264.376)", + "chart-2": "oklch(0.696 0.17 162.48)", + "chart-3": "oklch(0.769 0.188 70.08)", + "chart-4": "oklch(0.627 0.265 303.9)", + "chart-5": "oklch(0.645 0.246 16.439)" + } +}; + +export default function setGlobalColorTheme( + themeMode: "light" | "dark", + colors: { + light: Record; + dark: Record; + } +) { + const merged = { + light: { ...defaultTheme.light, ...colors.light }, + dark: { ...defaultTheme.dark, ...colors.dark } + }; + + const theme = merged[themeMode] as { + [key: string]: string; + }; + + for (const key in theme) { + document.documentElement.style.setProperty(`--${key}`, theme[key]); + } +} diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 39d8b66a..dfe22a87 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -13,10 +13,13 @@ export function pullEnv(): Env { resourceAccessTokenHeadersId: process.env .RESOURCE_ACCESS_TOKEN_HEADERS_ID as string, resourceAccessTokenHeadersToken: process.env - .RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string + .RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string, + reoClientId: process.env.REO_CLIENT_ID as string, + maxmind_db_path: process.env.MAXMIND_DB_PATH as string }, app: { environment: process.env.ENVIRONMENT as string, + sandbox_mode: process.env.SANDBOX_MODE === "true" ? true : false, version: process.env.APP_VERSION as string, dashboardUrl: process.env.DASHBOARD_URL as string }, @@ -47,5 +50,52 @@ export function pullEnv(): Env { hideSupporterKey: process.env.HIDE_SUPPORTER_KEY === "true" ? true : false }, + + branding: { + appName: process.env.BRANDING_APP_NAME as string, + background_image_path: process.env.BACKGROUND_IMAGE_PATH as string, + logo: { + lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, + darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, + authPage: { + width: parseInt( + process.env.BRANDING_LOGO_AUTH_WIDTH as string + ), + height: parseInt( + process.env.BRANDING_LOGO_AUTH_HEIGHT as string + ) + }, + navbar: { + width: parseInt( + process.env.BRANDING_LOGO_NAVBAR_WIDTH as string + ), + height: parseInt( + process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string + ) + } + }, + loginPage: { + titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string, + subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string + }, + signupPage: { + titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string, + subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string + }, + resourceAuthPage: { + showLogo: + process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true" + ? true + : false, + hidePoweredBy: + process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true" + ? true + : false, + titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string, + subtitleText: process.env + .RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string + }, + footer: process.env.BRANDING_FOOTER as string + } }; } diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 66686619..2c3c2479 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -1,6 +1,7 @@ export type Env = { app: { environment: string; + sandbox_mode: boolean; version: string; dashboardUrl: string; }; @@ -12,6 +13,8 @@ export type Env = { resourceSessionRequestParam: string; resourceAccessTokenHeadersId: string; resourceAccessTokenHeadersToken: string; + reoClientId?: string; + maxmind_db_path?: string; }; email: { emailEnabled: boolean; @@ -25,5 +28,36 @@ export type Env = { disableBasicWireguardSites: boolean; enableClients: boolean; hideSupporterKey: boolean; - } + }, + branding: { + appName?: string; + background_image_path?: string; + logo?: { + lightPath?: string; + darkPath?: string; + authPage?: { + width?: number; + height?: number; + }; + navbar?: { + width?: number; + height?: number; + } + }, + loginPage?: { + titleText?: string; + subtitleText?: string; + }, + signupPage?: { + titleText?: string; + subtitleText?: string; + }, + resourceAuthPage?: { + showLogo?: boolean; + hidePoweredBy?: boolean; + titleText?: string; + subtitleText?: string; + }, + footer?: string; + }; }; diff --git a/src/lib/types/privateThemeTypes.tsx b/src/lib/types/privateThemeTypes.tsx new file mode 100644 index 00000000..de0b2d2b --- /dev/null +++ b/src/lib/types/privateThemeTypes.tsx @@ -0,0 +1,13 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..41c6d1b3 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { build } from '@server/build'; + +export function middleware(request: NextRequest) { + // If build is OSS, block access to private routes + if (build === 'oss') { + const pathname = request.nextUrl.pathname; + + // Define private route patterns that should be blocked in OSS build + const privateRoutes = [ + '/settings/billing', + '/settings/remote-exit-nodes', + '/settings/idp', + '/auth/org' + ]; + + // Check if current path matches any private route pattern + const isPrivateRoute = privateRoutes.some(route => + pathname.includes(route) + ); + + if (isPrivateRoute) { + // Return 404 to make it seem like the route doesn't exist + return new NextResponse(null, { status: 404 }); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api|_next/static|_next/image|favicon.ico).*)', + ], +}; \ No newline at end of file diff --git a/src/providers/PrivateRemoteExitNodeProvider.tsx b/src/providers/PrivateRemoteExitNodeProvider.tsx new file mode 100644 index 00000000..8dfd8f1a --- /dev/null +++ b/src/providers/PrivateRemoteExitNodeProvider.tsx @@ -0,0 +1,56 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext"; +import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type RemoteExitNodeProviderProps = { + children: React.ReactNode; + remoteExitNode: GetRemoteExitNodeResponse; +}; + +export function RemoteExitNodeProvider({ + children, + remoteExitNode: serverRemoteExitNode +}: RemoteExitNodeProviderProps) { + const [remoteExitNode, setRemoteExitNode] = useState(serverRemoteExitNode); + + const t = useTranslations(); + + const updateRemoteExitNode = (updatedRemoteExitNode: Partial) => { + if (!remoteExitNode) { + throw new Error(t('remoteExitNodeErrorNoUpdate')); + } + setRemoteExitNode((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedRemoteExitNode + }; + }); + }; + + return ( + + {children} + + ); +} + +export default RemoteExitNodeProvider; diff --git a/src/providers/PrivateSubscriptionStatusProvider.tsx b/src/providers/PrivateSubscriptionStatusProvider.tsx new file mode 100644 index 00000000..9f4c5cd2 --- /dev/null +++ b/src/providers/PrivateSubscriptionStatusProvider.tsx @@ -0,0 +1,84 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext"; +import { getTierPriceSet } from "@server/lib/private/billing/tiers"; +import { GetOrgSubscriptionResponse } from "@server/routers/private/billing"; +import { useState } from "react"; + +interface ProviderProps { + children: React.ReactNode; + subscriptionStatus: GetOrgSubscriptionResponse | null; + env: string; + sandbox_mode: boolean; +} + +export function PrivateSubscriptionStatusProvider({ + children, + subscriptionStatus, + env, + sandbox_mode +}: ProviderProps) { + const [subscriptionStatusState, setSubscriptionStatusState] = + useState(subscriptionStatus); + + const updateSubscriptionStatus = (updatedSubscriptionStatus: GetOrgSubscriptionResponse) => { + setSubscriptionStatusState((prev) => { + return { + ...updatedSubscriptionStatus + }; + }); + }; + + const isActive = () => { + if (subscriptionStatus?.subscription?.status === "active") { + return true; + } + return false; + }; + + const getTier = () => { + const tierPriceSet = getTierPriceSet(env, sandbox_mode); + + if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) { + // Iterate through tiers in order (earlier keys are higher tiers) + for (const [tierId, priceId] of Object.entries(tierPriceSet)) { + // Check if any subscription item matches this tier's price ID + const matchingItem = subscriptionStatus.items.find(item => item.priceId === priceId); + if (matchingItem) { + return tierId; + } + } + } + + console.log("No matching tier found"); + return null; + }; + + return ( + + {children} + + ); +} + +export default PrivateSubscriptionStatusProvider; diff --git a/src/providers/PrivateThemeDataProvider.tsx b/src/providers/PrivateThemeDataProvider.tsx new file mode 100644 index 00000000..a8da4551 --- /dev/null +++ b/src/providers/PrivateThemeDataProvider.tsx @@ -0,0 +1,59 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +"use client"; + +import setGlobalColorTheme from "@app/lib/privateThemeColors"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +type ThemeColorStateProps = { + children: React.ReactNode; + colors: any; +}; + +export default function ThemeDataProvider({ + children, + colors +}: ThemeColorStateProps) { + const [isMounted, setIsMounted] = useState(false); + const { theme } = useTheme(); + + useEffect(() => { + if (!colors) { + setIsMounted(true); + return; + } + + let lightOrDark = theme; + + if (theme === "system" || !theme) { + lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + } + + setGlobalColorTheme(lightOrDark as "light" | "dark", colors); + + if (!isMounted) { + setIsMounted(true); + } + }, [theme]); + + if (!isMounted) { + return null; + } + + return <>{children}; +}