diff --git a/install/config/config.yml b/install/config/config.yml index 5a86a930..b86f7890 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -13,6 +13,8 @@ managed: app: dashboard_url: "https://{{.DashboardDomain}}" log_level: "info" + telemetry: + anonymous_usage: true domains: domain1: diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 3717855e..e8c85487 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", - "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "internationaldomaindetected": "International Domain Detected", + "willbestoredas": "Will be stored as:" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index fc03108a..fe9530d3 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", - "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "internationaldomaindetected": "Detekována mezinárodní doména", + "willbestoredas": "Bude uloženo jako:" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 062b61af..c79a5f64 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Weiterleitung zur Anmeldung...", "autoLoginError": "Fehler bei der automatischen Anmeldung", "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", - "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL." + "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", + "internationaldomaindetected": "Internationale Domäne erkannt", + "willbestoredas": "Wird gespeichert als:" } diff --git a/messages/en-US.json b/messages/en-US.json index 0b4a7534..dbfa817e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1059,6 +1059,7 @@ "actionGetSiteResource": "Get Site Resource", "actionListSiteResources": "List Site Resources", "actionUpdateSiteResource": "Update Site Resource", + "actionListInvitations": "List Invitations", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "searchProgress": "Search...", @@ -1457,5 +1458,43 @@ "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", - "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "managedSelfHosted": { + "title": "Managed Self-Hosted", + "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", + "introTitle": "Managed Self-Hosted Pangolin", + "introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.", + "introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:", + "benefitSimplerOperations": { + "title": "Simpler operations", + "description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box." + }, + "benefitAutomaticUpdates": { + "title": "Automatic updates", + "description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time." + }, + "benefitLessMaintenance": { + "title": "Less maintenance", + "description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud." + }, + "benefitCloudFailover": { + "title": "Cloud failover", + "description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online." + }, + "benefitHighAvailability": { + "title": "High availability (PoPs)", + "description": "You can also attach multiple nodes to your account for redundancy and better performance." + }, + "benefitFutureEnhancements": { + "title": "Future enhancements", + "description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust." + }, + "docsAlert": { + "text": "Learn more about the Managed Self-Hosted option in our", + "documentation": "documentation" + }, + "convertButton": "Convert This Node to Managed Self-Hosted" + }, + "internationaldomaindetected": "International Domain Detected", + "willbestoredas": "Will be stored as:" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 2b49d9bb..0bb67191 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirigiendo al inicio de sesión...", "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." + "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.", + "internationaldomaindetected": "Dominio internacional detectado", + "willbestoredas": "Se almacenará como: " } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ee08d77b..28bcce6a 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirection vers la connexion...", "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." + "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.", + "internationaldomaindetected": "Domaine international détecté", + "willbestoredas": "Sera stocké comme:" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 83708597..b41079c3 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Reindirizzamento al login...", "autoLoginError": "Errore di Accesso Automatico", "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", - "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione." + "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.", + "internationaldomaindetected": "Rilevato dominio internazionale", + "willbestoredas": "Verrà archiviato come:" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index b13dd19d..4b27bf55 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "로그인으로 리디렉션 중...", "autoLoginError": "자동 로그인 오류", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", - "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패." + "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.", + "internationaldomaindetected": "국제 도메인 감지됨", + "willbestoredas": "다음과 같이 저장됩니다." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 76c28cb5..91e0be86 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting naar inloggen...", "autoLoginError": "Auto Login Fout", "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", - "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt." + "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.", + "internationaldomaindetected": "Internationaal Domein Gedetecteerd", + "willbestoredas": "Wordt opgeslagen als:" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index be33709c..9491a94d 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Przekierowanie do logowania...", "autoLoginError": "Błąd automatycznego logowania", "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", - "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania." + "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.", + "internationaldomaindetected": "Wykryto domenę międzynarodową", + "willbestoredas": "Będzie przechowywane jako:" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 4efd0237..050280fa 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecionando para login...", "autoLoginError": "Erro de Login Automático", "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", - "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação." + "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.", + "internationaldomaindetected": "Domínio internacional detetado", + "willbestoredas": "Será armazenado como:" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index ed44702d..eef9aad6 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Перенаправление к входу...", "autoLoginError": "Ошибка автоматического входа", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", - "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации." + "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.", + "internationaldomaindetected": "Обнаружен международный домен", + "willbestoredas": "Будет сохранен как:" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 89de6876..09115e9a 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...", "autoLoginError": "Otomatik Giriş Hatası", "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", - "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı." + "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.", + "internationaldomaindetected": "Uluslararası Etki Alanı Algılandı", + "willbestoredas": "Şu şekilde saklanacaktır:" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 06cd8549..dee4cff1 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "重定向到登录...", "autoLoginError": "自动登录错误", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", - "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。" + "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。", + "internationaldomaindetected": "检测到国际域名", + "willbestoredas": "将存储为:" } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 916c7e7e..918fa4c4 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -179,6 +179,7 @@ export const configSchema = z .default("/var/dynamic/router_config.yml"), static_domains: z.array(z.string()).optional().default([]), site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]), + allow_raw_resources: z.boolean().optional().default(true), file_mode: z.boolean().optional().default(false) }) .optional() diff --git a/server/lib/remoteCertificates/certificates.ts b/server/lib/remoteCertificates/certificates.ts index db6fa6ad..9a4ce001 100644 --- a/server/lib/remoteCertificates/certificates.ts +++ b/server/lib/remoteCertificates/certificates.ts @@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set) Array<{ id: number; domain: string; + wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set): Prom Array<{ id: number; domain: string; + wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: Date | null; diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index cf1b40c8..0888ff31 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const subdomainSchema = z .string() .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, + /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, "Invalid subdomain format" ) .min(1, "Subdomain must be at least 1 character long") @@ -12,7 +12,8 @@ export const subdomainSchema = z export const tlsNameSchema = z .string() .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, + /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/, "Invalid subdomain format" ) - .transform((val) => val.toLowerCase()); \ No newline at end of file + .transform((val) => val.toLowerCase()); + diff --git a/server/lib/traefikConfig.test.ts b/server/lib/traefikConfig.test.ts new file mode 100644 index 00000000..55d19647 --- /dev/null +++ b/server/lib/traefikConfig.test.ts @@ -0,0 +1,235 @@ +import { assertEquals } from "@test/assert"; +import { isDomainCoveredByWildcard } from "./traefikConfig"; + +function runTests() { + console.log('Running wildcard domain coverage tests...'); + + // Test case 1: Basic wildcard certificate at example.com + const basicWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: true }] + ]); + + // Should match first-level subdomains + assertEquals( + isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match level1.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('api.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match api.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('www.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match www.example.com' + ); + + // Should match the root domain (exact match) + assertEquals( + isDomainCoveredByWildcard('example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match example.com itself' + ); + + // Should NOT match second-level subdomains + assertEquals( + isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match level2.level1.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com' + ); + + // Should NOT match different domains + assertEquals( + isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match test.otherdomain.com' + ); + + assertEquals( + isDomainCoveredByWildcard('notexample.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match notexample.com' + ); + + // Test case 2: Multiple wildcard certificates + const multipleWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: true }], + ['test.org', { exists: true, wildcard: true }], + ['api.service.net', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts), + true, + 'Should match subdomain of first wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts), + true, + 'Should match subdomain of second wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts), + true, + 'Should match subdomain of third wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts), + false, + 'Should NOT match multi-level subdomain of third wildcard cert' + ); + + // Test exact domain matches for multiple certs + assertEquals( + isDomainCoveredByWildcard('example.com', multipleWildcardCerts), + true, + 'Should match exact domain of first wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('test.org', multipleWildcardCerts), + true, + 'Should match exact domain of second wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts), + true, + 'Should match exact domain of third wildcard cert' + ); + + // Test case 3: Non-wildcard certificates (should not match anything) + const nonWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: false }], + ['specific.domain.com', { exists: true, wildcard: false }] + ]); + + assertEquals( + isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts), + false, + 'Non-wildcard cert should not match subdomains' + ); + + assertEquals( + isDomainCoveredByWildcard('example.com', nonWildcardCerts), + false, + 'Non-wildcard cert should not match even exact domain via this function' + ); + + // Test case 4: Non-existent certificates (should not match) + const nonExistentCerts = new Map([ + ['example.com', { exists: false, wildcard: true }], + ['missing.com', { exists: false, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('sub.example.com', nonExistentCerts), + false, + 'Non-existent wildcard cert should not match' + ); + + // Test case 5: Edge cases with special domain names + const specialDomainCerts = new Map([ + ['localhost', { exists: true, wildcard: true }], + ['127-0-0-1.nip.io', { exists: true, wildcard: true }], + ['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain + ]); + + assertEquals( + isDomainCoveredByWildcard('app.localhost', specialDomainCerts), + true, + 'Should match subdomain of localhost wildcard' + ); + + assertEquals( + isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts), + true, + 'Should match subdomain of nip.io wildcard' + ); + + assertEquals( + isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts), + true, + 'Should match subdomain of IDN wildcard' + ); + + // Test case 6: Empty input and edge cases + const emptyCerts = new Map(); + + assertEquals( + isDomainCoveredByWildcard('any.domain.com', emptyCerts), + false, + 'Empty certificate map should not match any domain' + ); + + // Test case 7: Domains with single character components + const singleCharCerts = new Map([ + ['a.com', { exists: true, wildcard: true }], + ['x.y.z', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('b.a.com', singleCharCerts), + true, + 'Should match single character subdomain' + ); + + assertEquals( + isDomainCoveredByWildcard('w.x.y.z', singleCharCerts), + true, + 'Should match single character subdomain of multi-part domain' + ); + + assertEquals( + isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts), + false, + 'Should NOT match multi-level subdomain of single char domain' + ); + + // Test case 8: Domains with numbers and hyphens + const numericCerts = new Map([ + ['api-v2.service-1.com', { exists: true, wildcard: true }], + ['123.456.net', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts), + true, + 'Should match subdomain with hyphens and numbers' + ); + + assertEquals( + isDomainCoveredByWildcard('test.123.456.net', numericCerts), + true, + 'Should match subdomain with numeric components' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts), + false, + 'Should NOT match multi-level subdomain with hyphens and numbers' + ); + + console.log('All wildcard domain coverage tests passed!'); +} + +// Run all tests +try { + runTests(); +} catch (error) { + console.error('Test failed:', error); + process.exit(1); +} diff --git a/server/lib/traefikConfig.ts b/server/lib/traefikConfig.ts index 03656506..e16b93d2 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -29,6 +29,7 @@ export class TraefikConfigManager { exists: boolean; lastModified: Date | null; expiresAt: Date | null; + wildcard: boolean | null; } >(); @@ -115,6 +116,7 @@ export class TraefikConfigManager { exists: boolean; lastModified: Date | null; expiresAt: Date | null; + wildcard: boolean; } > > { @@ -136,13 +138,16 @@ export class TraefikConfigManager { const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); const lastUpdatePath = path.join(domainDir, ".last_update"); + const wildcardPath = path.join(domainDir, ".wildcard"); const certExists = await this.fileExists(certPath); const keyExists = await this.fileExists(keyPath); const lastUpdateExists = await this.fileExists(lastUpdatePath); + const wildcardExists = await this.fileExists(wildcardPath); let lastModified: Date | null = null; const expiresAt: Date | null = null; + let wildcard = false; if (lastUpdateExists) { try { @@ -161,10 +166,26 @@ export class TraefikConfigManager { } } + // Check if this is a wildcard certificate + if (wildcardExists) { + try { + const wildcardContent = fs + .readFileSync(wildcardPath, "utf8") + .trim(); + wildcard = wildcardContent === "true"; + } catch (error) { + logger.warn( + `Could not read wildcard file for ${domain}:`, + error + ); + } + } + state.set(domain, { exists: certExists && keyExists, lastModified, - expiresAt + expiresAt, + wildcard }); } } catch (error) { @@ -192,19 +213,36 @@ export class TraefikConfigManager { return true; } - // Fetch if domains have changed + // Filter out domains covered by wildcard certificates + const domainsNeedingCerts = new Set(); + for (const domain of currentDomains) { + if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + domainsNeedingCerts.add(domain); + } + } + + // Fetch if domains needing certificates have changed + const lastDomainsNeedingCerts = new Set(); + for (const domain of this.lastKnownDomains) { + if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + lastDomainsNeedingCerts.add(domain); + } + } + if ( - this.lastKnownDomains.size !== currentDomains.size || - !Array.from(this.lastKnownDomains).every((domain) => - currentDomains.has(domain) + domainsNeedingCerts.size !== lastDomainsNeedingCerts.size || + !Array.from(domainsNeedingCerts).every((domain) => + lastDomainsNeedingCerts.has(domain) ) ) { - logger.info("Fetching certificates due to domain changes"); + logger.info( + "Fetching certificates due to domain changes (after wildcard filtering)" + ); return true; } // Check if any local certificates are missing or appear to be outdated - for (const domain of currentDomains) { + for (const domain of domainsNeedingCerts) { const localState = this.lastLocalCertificateState.get(domain); if (!localState || !localState.exists) { logger.info( @@ -273,6 +311,7 @@ export class TraefikConfigManager { let validCertificates: Array<{ id: number; domain: string; + wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -280,23 +319,50 @@ export class TraefikConfigManager { }> = []; if (this.shouldFetchCertificates(domains)) { - // Get valid certificates for active domains - if (config.isManagedMode()) { - validCertificates = - await getValidCertificatesForDomainsHybrid(domains); - } else { - validCertificates = - await getValidCertificatesForDomains(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)) { + domainsToFetch.add(domain); + } else { + logger.debug( + `Domain ${domain} is covered by existing wildcard certificate, skipping fetch` + ); + } } - this.lastCertificateFetch = new Date(); - this.lastKnownDomains = new Set(domains); - logger.info( - `Fetched ${validCertificates.length} certificates from remote` - ); + if (domainsToFetch.size > 0) { + // Get valid certificates for domains not covered by wildcards + if (config.isManagedMode()) { + validCertificates = + await getValidCertificatesForDomainsHybrid( + domainsToFetch + ); + } else { + validCertificates = + await getValidCertificatesForDomains( + domainsToFetch + ); + } + this.lastCertificateFetch = new Date(); + this.lastKnownDomains = new Set(domains); - // Download and decrypt new certificates - await this.processValidCertificates(validCertificates); + logger.info( + `Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)` + ); + + // Download and decrypt new certificates + await this.processValidCertificates(validCertificates); + } else { + logger.info( + "All domains are covered by existing wildcard certificates, no fetch needed" + ); + this.lastCertificateFetch = new Date(); + this.lastKnownDomains = new Set(domains); + } + + // Always ensure all existing certificates (including wildcards) are in the config + await this.updateDynamicConfigFromLocalCerts(domains); } else { const timeSinceLastFetch = this.lastCertificateFetch ? Math.round( @@ -544,7 +610,11 @@ export class TraefikConfigManager { // Clear existing certificates and rebuild from local state dynamicConfig.tls.certificates = []; + // Keep track of certificates we've already added to avoid duplicates + const addedCertPaths = new Set(); + for (const domain of domains) { + // First, try to find an exact match certificate const localState = this.lastLocalCertificateState.get(domain); if (localState && localState.exists) { const domainDir = path.join( @@ -554,11 +624,47 @@ export class TraefikConfigManager { const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); - const certEntry = { - certFile: certPath, - keyFile: keyPath - }; - dynamicConfig.tls.certificates.push(certEntry); + if (!addedCertPaths.has(certPath)) { + const certEntry = { + certFile: certPath, + keyFile: keyPath + }; + dynamicConfig.tls.certificates.push(certEntry); + addedCertPaths.add(certPath); + } + continue; + } + + // If no exact match, check for wildcard certificates that cover this domain + for (const [certDomain, certState] of this.lastLocalCertificateState) { + if (certState.exists && certState.wildcard) { + // Check if this wildcard certificate covers the domain + if (domain.endsWith("." + certDomain)) { + // Verify it's only one level deep (wildcard only covers one level) + const prefix = domain.substring( + 0, + domain.length - ("." + certDomain).length + ); + if (!prefix.includes(".")) { + const domainDir = path.join( + config.getRawConfig().traefik.certificates_path, + certDomain + ); + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + + if (!addedCertPaths.has(certPath)) { + const certEntry = { + certFile: certPath, + keyFile: keyPath + }; + dynamicConfig.tls.certificates.push(certEntry); + addedCertPaths.add(certPath); + } + break; // Found a wildcard that covers this domain + } + } + } } } @@ -577,6 +683,7 @@ export class TraefikConfigManager { validCertificates: Array<{ id: number; domain: string; + wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -651,15 +758,24 @@ export class TraefikConfigManager { "utf8" ); + // Check if this is a wildcard certificate and store it + const wildcardPath = path.join(domainDir, ".wildcard"); + fs.writeFileSync( + wildcardPath, + cert.wildcard ? "true" : "false", + "utf8" + ); + logger.info( - `Certificate updated for domain: ${cert.domain}` + `Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}` ); // Update local state tracking this.lastLocalCertificateState.set(cert.domain, { exists: true, lastModified: new Date(), - expiresAt: cert.expiresAt + expiresAt: cert.expiresAt, + wildcard: cert.wildcard }); } @@ -810,14 +926,8 @@ export class TraefikConfigManager { this.lastLocalCertificateState.delete(dirName); // Remove from dynamic config - const certFilePath = path.join( - domainDir, - "cert.pem" - ); - const keyFilePath = path.join( - domainDir, - "key.pem" - ); + const certFilePath = path.join(domainDir, "cert.pem"); + const keyFilePath = path.join(domainDir, "key.pem"); const before = dynamicConfig.tls.certificates.length; dynamicConfig.tls.certificates = dynamicConfig.tls.certificates.filter( @@ -894,14 +1004,58 @@ export class TraefikConfigManager { monitorInterval: number; lastCertificateFetch: Date | null; localCertificateCount: number; + wildcardCertificates: string[]; + domainsCoveredByWildcards: string[]; } { + const wildcardCertificates: string[] = []; + const domainsCoveredByWildcards: string[] = []; + + // Find wildcard certificates + for (const [domain, state] of this.lastLocalCertificateState) { + if (state.exists && state.wildcard) { + wildcardCertificates.push(domain); + } + } + + // Find domains covered by wildcards + for (const domain of this.activeDomains) { + if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + domainsCoveredByWildcards.push(domain); + } + } + return { isRunning: this.isRunning, activeDomains: Array.from(this.activeDomains), monitorInterval: config.getRawConfig().traefik.monitor_interval || 5000, lastCertificateFetch: this.lastCertificateFetch, - localCertificateCount: this.lastLocalCertificateState.size + localCertificateCount: this.lastLocalCertificateState.size, + wildcardCertificates, + domainsCoveredByWildcards }; } } + +/** + * Check if a domain is covered by existing wildcard certificates + */ +export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map): boolean { + for (const [certDomain, state] of lastLocalCertificateState) { + if (state.exists && state.wildcard) { + // If stored as example.com but is wildcard, check subdomains + if (domain.endsWith("." + certDomain)) { + // Check that it's only one level deep (wildcard only covers one level) + const prefix = domain.substring( + 0, + domain.length - ("." + certDomain).length + ); + // If prefix contains a dot, it's more than one level deep + if (!prefix.includes(".")) { + return true; + } + } + } + } + return false; +} diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index cfcbd475..98681644 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -22,7 +22,7 @@ export async function verifyRoleAccess( ); } - const { roleIds } = req.body; + const roleIds = req.body?.roleIds; const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); if (allRoleIds.length === 0) { diff --git a/server/routers/external.ts b/server/routers/external.ts index 91c185d2..0ca31117 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -82,6 +82,7 @@ authenticated.delete( "/org/:orgId", verifyOrgAccess, verifyUserIsOrgOwner, + verifyUserHasAction(ActionsEnum.deleteOrg), org.deleteOrg ); diff --git a/server/routers/integration.ts b/server/routers/integration.ts index cb38e441..79453732 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -221,6 +221,13 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/invitations", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listInvitations), + user.listInvitations +); + authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 76e2ad79..63e9abb0 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -49,19 +49,7 @@ export async function deleteOrg( } const { orgId } = parsedParams.data; - // Check if the user has permission to list sites - const hasPermission = await checkUserActionPermission( - ActionsEnum.deleteOrg, - req - ); - if (!hasPermission) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have permission to perform this action" - ) - ); - } + const [org] = await db .select() .from(orgs) diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 01991763..7ea76d21 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { apiKeys, roleResources, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -74,13 +74,18 @@ export async function setResourceRoles( const { resourceId } = parsedParams.data; - const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - if (!orgId) { + if (!resource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Organization not found" + "Resource not found" ) ); } @@ -92,7 +97,7 @@ export async function setResourceRoles( .where( and( eq(roles.name, "Admin"), - eq(roles.orgId, orgId) + eq(roles.orgId, resource.orgId) ) ) .limit(1); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 3a4dd885..9d3ab692 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -272,7 +272,7 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/0" + subnet: "0.0.0.0/32" }) .returning(); } diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index f9a67432..1a55f2bd 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import { db, exitNodes } from "@server/db"; -import { and, eq, inArray, or, isNull, ne } from "drizzle-orm"; +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"; @@ -149,7 +149,10 @@ export async function getTraefikConfig( eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), - inArray(sites.type, siteTypes) + 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), ) ); diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index bd6d9901..27f5e612 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -58,18 +58,23 @@ export async function addUserRole( ); } - const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + // get the role + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); - if (!orgId) { + if (!role) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") ); } const existingUser = await db .select() .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) .limit(1); if (existingUser.length === 0) { @@ -93,7 +98,7 @@ export async function addUserRole( const roleExists = await db .select() .from(roles) - .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) + .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) .limit(1); if (roleExists.length === 0) { @@ -108,7 +113,7 @@ export async function addUserRole( const newUserRole = await db .update(userOrgs) .set({ roleId }) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) .returning(); return response(res, { diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx index 31bf82f1..e609a8ac 100644 --- a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx +++ b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx @@ -7,12 +7,13 @@ import { FormField, FormItem, FormLabel, - FormMessage + FormMessage, + FormDescription } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { @@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain"; import { StrategySelect } from "@app/components/StrategySelect"; import { AxiosResponse } from "axios"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon, AlertTriangle } from "lucide-react"; +import { InfoIcon, AlertTriangle, Globe } from "lucide-react"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, @@ -43,9 +44,58 @@ import { } from "@app/components/InfoSection"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { build } from "@server/build"; +import { toASCII, toUnicode } from 'punycode'; + + +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + +function fromPunycode(domain: string): string { + try { + const parts = toUnicode(domain); + return parts; + } catch (error) { + return domain; + } +} + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split('.'); + for (const part of parts) { + if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} const formSchema = z.object({ - baseDomain: z.string().min(1, "Domain is required"), + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), type: z.enum(["ns", "cname", "wildcard"]) }); @@ -109,8 +159,14 @@ export default function CreateDomainForm({ } } - const domainType = form.watch("type"); const baseDomain = form.watch("baseDomain"); + const domainInputValue = form.watch("baseDomain") || ""; + + const punycodePreview = useMemo(() => { + if (!domainInputValue) return ""; + const punycode = toPunycode(domainInputValue); + return punycode !== domainInputValue.toLowerCase() ? punycode : ""; + }, [domainInputValue]); let domainOptions: any = []; if (build == "enterprise" || build == "saas") { @@ -182,10 +238,23 @@ export default function CreateDomainForm({ {t("domain")} + {punycodePreview && ( + + + + {t("internationaldomaindetected")} + +
+

{t("willbestoredas")} {punycodePreview}

+
+
+
+
+ )} )} @@ -206,66 +275,73 @@ export default function CreateDomainForm({
{createdDomain.nsRecords && - createdDomain.nsRecords.length > 0 && ( -
-

- {t("createDomainNsRecords")} -

- - - - {t("createDomainRecord")} - - -
-
- - {t( - "createDomainType" - )} - - - NS - -
-
- - {t( - "createDomainName" - )} - - - {baseDomain} - -
- - {t( - "createDomainValue" - )} - - {createdDomain.nsRecords.map( - ( - nsRecord, - index - ) => ( -
- + createdDomain.nsRecords.length > 0 && ( +
+

+ {t("createDomainNsRecords")} +

+ + + + {t("createDomainRecord")} + + +
+
+ + {t( + "createDomainType" + )} + + + NS + +
+
+ + {t( + "createDomainName" + )} + +
+ + {fromPunycode(baseDomain)} + + {fromPunycode(baseDomain) !== baseDomain && ( + + ({baseDomain}) + + )}
- ) - )} -
- - - -
- )} +
+ + {t( + "createDomainValue" + )} + + {createdDomain.nsRecords.map( + ( + nsRecord, + index + ) => ( +
+ +
+ ) + )} +
+ + + +
+ )} {createdDomain.cnameRecords && createdDomain.cnameRecords.length > 0 && ( @@ -307,11 +383,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - cnameRecord.baseDomain - } - +
+ + {fromPunycode(cnameRecord.baseDomain)} + + {fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && ( + + ({cnameRecord.baseDomain}) + + )} +
@@ -374,11 +455,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - aRecord.baseDomain - } - +
+ + {fromPunycode(aRecord.baseDomain)} + + {fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && ( + + ({aRecord.baseDomain}) + + )} +
@@ -390,7 +476,7 @@ export default function CreateDomainForm({ { aRecord.value } - +
@@ -440,11 +526,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - txtRecord.baseDomain - } - +
+ + {fromPunycode(txtRecord.baseDomain)} + + {fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && ( + + ({txtRecord.baseDomain}) + + )} +
@@ -513,4 +604,4 @@ export default function CreateDomainForm({ ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index d20e431f..c85fe10d 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org"; import { redirect } from "next/navigation"; import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; +import { toUnicode } from 'punycode'; type Props = { params: Promise<{ orgId: string }>; @@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) { const res = await internal.get< AxiosResponse >(`/org/${params.orgId}/domains`, await authCookieHeader()); - domains = res.data.data.domains as DomainRow[]; + + const rawDomains = res.data.data.domains as DomainRow[]; + + domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); } catch (e) { console.error(e); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx index 0764d740..171f5683 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toUnicode } from "punycode"; interface DomainOption { baseDomain: string; @@ -91,7 +92,7 @@ export default function CustomDomainInput({ key={option.domainId} value={option.domainId} > - .{option.baseDomain} + .{toUnicode(option.baseDomain)} ))} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index af7d96fc..8da95ec0 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -12,15 +12,19 @@ import { } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { toUnicode } from 'punycode'; type ResourceInfoBoxType = {}; -export default function ResourceInfoBox({}: ResourceInfoBoxType) { +export default function ResourceInfoBox({ }: ResourceInfoBoxType) { const { resource, authInfo } = useResourceContext(); const t = useTranslations(); - const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + + const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`; + + return ( @@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist ? ( + authInfo.pincode || + authInfo.sso || + authInfo.whitelist ? (
{t("protected")} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 8c5ee667..ce8f29a7 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -53,6 +53,9 @@ import { import DomainPicker from "@app/components/DomainPicker"; import { Globe } from "lucide-react"; import { build } from "@server/build"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { DomainRow } from "../../../domains/DomainsTable"; +import { toASCII, toUnicode } from "punycode"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -79,12 +82,13 @@ export default function GeneralForm() { const [loadingPage, setLoadingPage] = useState(true); const [resourceFullDomain, setResourceFullDomain] = useState( - `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); const [selectedDomain, setSelectedDomain] = useState<{ domainId: string; subdomain?: string; fullDomain: string; + baseDomain: string; } | null>(null); const GeneralFormSchema = z @@ -153,7 +157,11 @@ export default function GeneralForm() { }); if (res?.status === 200) { - const domains = res.data.data.domains; + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); setBaseDomains(domains); setFormKey((key) => key + 1); } @@ -178,7 +186,7 @@ export default function GeneralForm() { { enabled: data.enabled, name: data.name, - subdomain: data.subdomain, + subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, domainId: data.domainId, proxyPort: data.proxyPort, // ...(!resource.http && { @@ -317,10 +325,10 @@ export default function GeneralForm() { .target .value ? parseInt( - e - .target - .value - ) + e + .target + .value + ) : undefined ) } @@ -441,7 +449,8 @@ export default function GeneralForm() { const selected = { domainId: res.domainId, subdomain: res.subdomain, - fullDomain: res.fullDomain + fullDomain: res.fullDomain, + baseDomain: res.baseDomain }; setSelectedDomain(selected); }} @@ -454,18 +463,23 @@ export default function GeneralForm() { @@ -865,18 +878,18 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; })()}
@@ -942,11 +955,22 @@ export default function ReverseProxyTargets(props: { name="ip" render={({ field }) => ( - - {t("targetAddr")} - + {t("targetAddr")} - + { + const parsed = parseHostTarget(e.target.value); + if (parsed) { + addTargetForm.setValue("method", parsed.protocol); + addTargetForm.setValue("ip", parsed.host); + addTargetForm.setValue("port", parsed.port); + } else { + field.onBlur(); + } + }} + /> @@ -1048,12 +1072,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 438b8917..782b3135 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement"; import { isTargetValid } from "@server/lib/validators"; import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; +import { parseHostTarget } from "@app/lib/parseHostTarget"; +import { toASCII, toUnicode } from 'punycode'; +import { DomainRow } from "../../domains/DomainsTable"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -164,12 +167,12 @@ export default function Page() { ...(!env.flags.allowRawResources ? [] : [ - { - id: "raw" as ResourceType, - title: t("resourceRaw"), - description: t("resourceRawDescription") - } - ]) + { + id: "raw" as ResourceType, + title: t("resourceRaw"), + description: t("resourceRawDescription") + } + ]) ]; const baseForm = useForm({ @@ -301,11 +304,11 @@ export default function Page() { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -326,7 +329,7 @@ export default function Page() { if (isHttp) { const httpData = httpForm.getValues(); Object.assign(payload, { - subdomain: httpData.subdomain, + subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined, domainId: httpData.domainId, protocol: "tcp" }); @@ -468,7 +471,11 @@ export default function Page() { }); if (res?.status === 200) { - const domains = res.data.data.domains; + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); setBaseDomains(domains); // if (domains.length) { // httpForm.setValue("domainId", domains[0].domainId); @@ -520,7 +527,7 @@ export default function Page() { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -589,31 +596,31 @@ export default function Page() { }, ...(baseForm.watch("http") ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -622,12 +629,23 @@ export default function Page() { - updateTarget(row.original.targetId, { - ...row.original, - ip: e.target.value - }) - } + onBlur={(e) => { + const parsed = parseHostTarget(e.target.value); + + if (parsed) { + updateTarget(row.original.targetId, { + ...row.original, + method: parsed.protocol, + ip: parsed.host, + port: parsed.port ? Number(parsed.port) : undefined, + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + ip: e.target.value, + }); + } + }} /> ) }, @@ -909,10 +927,10 @@ export default function Page() { .target .value ? parseInt( - e - .target - .value - ) + e + .target + .value + ) : undefined ) } @@ -1015,21 +1033,21 @@ export default function Page() { 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" + )} @@ -1097,18 +1115,18 @@ export default function Page() { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; })()}
@@ -1176,21 +1194,25 @@ export default function Page() { )} ( - - {t( - "targetAddr" - )} - + {t("targetAddr")} { + const parsed = parseHostTarget(e.target.value); + if (parsed) { + addTargetForm.setValue("method", parsed.protocol); + addTargetForm.setValue("ip", parsed.host); + addTargetForm.setValue("port", parsed.port); + } else { + field.onBlur(); + } + }} /> @@ -1270,12 +1292,12 @@ export default function Page() { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index d5af500b..f8ef5397 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; +import { toUnicode } from "punycode"; type ResourcesPageProps = { params: Promise<{ orgId: string }>; @@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) { id: resource.resourceId, name: resource.name, orgId: params.orgId, - domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + + + domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, protocol: resource.protocol, proxyPort: resource.proxyPort, http: resource.http, diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 44891980..18c989ab 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -67,6 +67,7 @@ import { } from "@app/components/ui/collapsible"; import AccessTokenSection from "./AccessTokenUsage"; import { useTranslations } from "next-intl"; +import { toUnicode } from 'punycode'; type FormProps = { open: boolean; @@ -159,7 +160,7 @@ export default function CreateShareLinkForm({ .map((r) => ({ resourceId: r.resourceId, name: r.name, - resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` + resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` })) ); } diff --git a/src/app/admin/managed/page.tsx b/src/app/admin/managed/page.tsx index 3d96ce8f..63244d3b 100644 --- a/src/app/admin/managed/page.tsx +++ b/src/app/admin/managed/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { SettingsContainer, SettingsSection, @@ -18,30 +20,27 @@ import { ExternalLink } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export default function ManagedPage() { + const t = useTranslations(); -export default async function ManagedPage() { return ( <>

- Managed Self-Hosted Pangolin is a - deployment option designed for people who want - simplicity and extra reliability while still keeping - their data private and self-hosted. + {t("managedSelfHosted.introTitle")}{" "} + {t("managedSelfHosted.introDescription")}

- With this option, you still run your own Pangolin - node — your tunnels, SSL termination, and traffic - all stay on your server. The difference is that - management and monitoring are handled through our - cloud dashboard, which unlocks a number of benefits: + {t("managedSelfHosted.introDetail")}

@@ -50,13 +49,14 @@ export default async function ManagedPage() {

- Simpler operations + {t( + "managedSelfHosted.benefitSimplerOperations.title" + )}

- No need to run your own mail server - or set up complex alerting. You'll - get health checks and downtime - alerts out of the box. + {t( + "managedSelfHosted.benefitSimplerOperations.description" + )}

@@ -65,13 +65,14 @@ export default async function ManagedPage() {

- Automatic updates + {t( + "managedSelfHosted.benefitAutomaticUpdates.title" + )}

- The cloud dashboard evolves quickly, - so you get new features and bug - fixes without having to manually - pull new containers every time. + {t( + "managedSelfHosted.benefitAutomaticUpdates.description" + )}

@@ -80,12 +81,14 @@ export default async function ManagedPage() {

- Less maintenance + {t( + "managedSelfHosted.benefitLessMaintenance.title" + )}

- No database migrations, backups, or - extra infrastructure to manage. We - handle that in the cloud. + {t( + "managedSelfHosted.benefitLessMaintenance.description" + )}

@@ -96,13 +99,14 @@ export default async function ManagedPage() {

- Cloud failover + {t( + "managedSelfHosted.benefitCloudFailover.title" + )}

- If your node goes down, your tunnels - can temporarily fail over to our - cloud points of presence until you - bring it back online. + {t( + "managedSelfHosted.benefitCloudFailover.description" + )}

@@ -110,12 +114,14 @@ export default async function ManagedPage() {

- High availability (PoPs) + {t( + "managedSelfHosted.benefitHighAvailability.title" + )}

- You can also attach multiple nodes - to your account for redundancy and - better performance. + {t( + "managedSelfHosted.benefitHighAvailability.description" + )}

@@ -124,13 +130,14 @@ export default async function ManagedPage() {

- Future enhancements + {t( + "managedSelfHosted.benefitFutureEnhancements.title" + )}

- We're planning to add more - analytics, alerting, and management - tools to make your deployment even - more robust. + {t( + "managedSelfHosted.benefitFutureEnhancements.description" + )}

@@ -141,15 +148,14 @@ export default async function ManagedPage() { variant="neutral" className="flex items-center gap-1" > - Read the docs to learn more about the Managed - Self-Hosted option in our{" "} + {t("managedSelfHosted.docsAlert.text")}{" "} - documentation + {t("managedSelfHosted.docsAlert.documentation")} . @@ -157,13 +163,13 @@ export default async function ManagedPage() {
diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 0df7b810..2e0c11e2 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -48,6 +48,7 @@ export default async function InvitePage(props: { ) .catch((e) => { error = formatAxiosError(e); + console.error(error); }); if (res && res.status === 200) { @@ -55,13 +56,13 @@ export default async function InvitePage(props: { } function cardType() { - if (error.includes(t('inviteErrorWrongUser'))) { + if (error.includes("Invite is not for this user")) { return "wrong_user"; } else if ( - error.includes(t('inviteErrorUserNotExists')) + error.includes("User does not exist. Please create an account first.") ) { return "user_does_not_exist"; - } else if (error.includes(t('inviteErrorLoginRequired'))) { + } else if (error.includes("You must be logged in to accept an invite")) { return "not_logged_in"; } else { return "rejected"; diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 1fc856c9..f00292ee 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -37,6 +37,13 @@ import { cn } from "@/lib/cn"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + sanitizeInputRaw, + finalizeSubdomainSanitize, + validateByDomainType, + isValidSubdomainStructure +} from "@/lib/subdomain-utils"; +import { toUnicode } from "punycode"; type OrganizationDomain = { domainId: string; @@ -120,6 +127,7 @@ export default function DomainPicker2({ ) .map((domain) => ({ ...domain, + baseDomain: toUnicode(domain.baseDomain), type: domain.type as "ns" | "cname" | "wildcard" })); setOrganizationDomains(domains); @@ -255,108 +263,64 @@ export default function DomainPicker2({ const dropdownOptions = generateDropdownOptions(); - const validateSubdomain = ( - subdomain: string, - baseDomain: DomainOption - ): boolean => { - if (!baseDomain) return false; + const finalizeSubdomain = (sub: string, base: DomainOption): string => { + const sanitized = finalizeSubdomainSanitize(sub); - if (baseDomain.type === "provided-search") { - return /^[a-zA-Z0-9-]+$/.test(subdomain); + if (!sanitized) { + toast({ + variant: "destructive", + title: "Invalid subdomain", + description: `The input "${sub}" was removed because it's not valid.`, + }); + return ""; } - if (baseDomain.type === "organization") { - if (baseDomain.domainType === "cname") { - return subdomain === ""; - } else if (baseDomain.domainType === "ns") { - return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); - } else if (baseDomain.domainType === "wildcard") { - return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); - } + const ok = validateByDomainType(sanitized, { + type: base.type === "provided-search" ? "provided-search" : "organization", + domainType: base.domainType + }); + + if (!ok) { + toast({ + variant: "destructive", + title: "Invalid subdomain", + description: `"${sub}" could not be made valid for ${base.domain}.`, + }); + return ""; } - return false; - }; - - // Handle base domain selection - const handleBaseDomainSelect = (option: DomainOption) => { - setSelectedBaseDomain(option); - setOpen(false); - - if (option.domainType === "cname") { - setSubdomainInput(""); - } - - if (option.type === "provided-search") { - setUserInput(""); - setAvailableOptions([]); - setSelectedProvidedDomain(null); - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain + if (sub !== sanitized) { + toast({ + title: "Subdomain sanitized", + description: `"${sub}" was corrected to "${sanitized}"`, }); } - if (option.type === "organization") { - if (option.domainType === "cname") { - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain - }); - } else { - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain - }); - } - } + return sanitized; }; const handleSubdomainChange = (value: string) => { - const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); - setSubdomainInput(validInput); - + const raw = sanitizeInputRaw(value); + setSubdomainInput(raw); setSelectedProvidedDomain(null); - if (selectedBaseDomain && selectedBaseDomain.type === "organization") { - const isValid = validateSubdomain(validInput, selectedBaseDomain); - if (isValid) { - const fullDomain = validInput - ? `${validInput}.${selectedBaseDomain.domain}` - : selectedBaseDomain.domain; - onDomainChange?.({ - domainId: selectedBaseDomain.domainId!, - type: "organization", - subdomain: validInput || undefined, - fullDomain: fullDomain, - baseDomain: selectedBaseDomain.domain - }); - } else if (validInput === "") { - onDomainChange?.({ - domainId: selectedBaseDomain.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: selectedBaseDomain.domain, - baseDomain: selectedBaseDomain.domain - }); - } + if (selectedBaseDomain?.type === "organization") { + const fullDomain = raw + ? `${raw}.${selectedBaseDomain.domain}` + : selectedBaseDomain.domain; + + onDomainChange?.({ + domainId: selectedBaseDomain.domainId!, + type: "organization", + subdomain: raw || undefined, + fullDomain, + baseDomain: selectedBaseDomain.domain + }); } }; const handleProvidedDomainInputChange = (value: string) => { - const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); - setUserInput(validInput); - - // Clear selected domain when user types + setUserInput(value); if (selectedProvidedDomain) { setSelectedProvidedDomain(null); onDomainChange?.({ @@ -369,6 +333,43 @@ export default function DomainPicker2({ } }; + const handleBaseDomainSelect = (option: DomainOption) => { + let sub = subdomainInput; + + if (sub && sub.trim() !== "") { + sub = finalizeSubdomain(sub, option) || ""; + setSubdomainInput(sub); + } else { + sub = ""; + setSubdomainInput(""); + } + + if (option.type === "provided-search") { + setUserInput(""); + setAvailableOptions([]); + setSelectedProvidedDomain(null); + } + + setSelectedBaseDomain(option); + setOpen(false); + + if (option.domainType === "cname") { + sub = ""; + setSubdomainInput(""); + } + + const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; + + onDomainChange?.({ + domainId: option.domainId || "", + domainNamespaceId: option.domainNamespaceId, + type: option.type === "provided-search" ? "provided" : "organization", + subdomain: sub || undefined, + fullDomain, + baseDomain: option.domain + }); + }; + const handleProvidedDomainSelect = (option: AvailableOption) => { setSelectedProvidedDomain(option); @@ -380,15 +381,19 @@ export default function DomainPicker2({ domainId: option.domainId, domainNamespaceId: option.domainNamespaceId, type: "provided", - subdomain: subdomain, + subdomain, fullDomain: option.fullDomain, - baseDomain: baseDomain + baseDomain }); }; - const isSubdomainValid = selectedBaseDomain - ? validateSubdomain(subdomainInput, selectedBaseDomain) + const isSubdomainValid = selectedBaseDomain && subdomainInput + ? validateByDomainType(subdomainInput, { + type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", + domainType: selectedBaseDomain.domainType + }) : true; + const showSubdomainInput = selectedBaseDomain && selectedBaseDomain.type === "organization" && @@ -396,7 +401,7 @@ export default function DomainPicker2({ const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; - const sortedAvailableOptions = availableOptions.sort((a, b) => { + const sortedAvailableOptions = [...availableOptions].sort((a, b) => { const comparison = a.fullDomain.localeCompare(b.fullDomain); return sortOrder === "asc" ? comparison : -comparison; }); @@ -408,6 +413,7 @@ export default function DomainPicker2({ const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; + return (
@@ -426,16 +432,16 @@ export default function DomainPicker2({ showProvidedDomainSearch ? "" : showSubdomainInput - ? "" - : t("domainPickerNotAvailableForCname") + ? "" + : t("domainPickerNotAvailableForCname") } disabled={ !showSubdomainInput && !showProvidedDomainSearch } className={cn( !isSubdomainValid && - subdomainInput && - "border-red-500" + subdomainInput && + "border-red-500 focus:border-red-500" )} onChange={(e) => { if (showProvidedDomainSearch) { @@ -445,6 +451,11 @@ export default function DomainPicker2({ } }} /> + {showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && ( +

+ This subdomain contains invalid characters or structure. It will be sanitized automatically when you save. +

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

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

{selectedBaseDomain.type === - "organization" ? null : ( + "organization" ? null : ( )} @@ -564,67 +575,67 @@ export default function DomainPicker2({ {(build === "saas" || build === "enterprise") && ( - - )} + + )} )} {(build === "saas" || build === "enterprise") && ( - - - - handleBaseDomainSelect({ - id: "provided-search", - domain: - build === - "enterprise" + + + + handleBaseDomainSelect({ + id: "provided-search", + domain: + build === + "enterprise" + ? "Provided Domain" + : "Free Provided Domain", + type: "provided-search" + }) + } + className="mx-2 rounded-md" + > +
+ +
+
+ + {build === "enterprise" ? "Provided Domain" - : "Free Provided Domain", - type: "provided-search" - }) - } - className="mx-2 rounded-md" - > -
- -
-
- - {build === "enterprise" - ? "Provided Domain" - : "Free Provided Domain"} - - - {t( - "domainPickerSearchForAvailableDomains" + : "Free Provided Domain"} + + + {t( + "domainPickerSearchForAvailableDomains" + )} + +
+ -
- -
-
-
- )} + /> +
+
+
+ )} @@ -680,7 +691,7 @@ export default function DomainPicker2({ htmlFor={option.domainNamespaceId} data-state={ selectedProvidedDomain?.domainNamespaceId === - option.domainNamespaceId + option.domainNamespaceId ? "checked" : "unchecked" } @@ -760,4 +771,4 @@ function debounce any>( func(...args); }, wait); }; -} +} \ No newline at end of file diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index cdec0d98..0cf0ae78 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import SupporterStatus from "@app/components/SupporterStatus"; import { Button } from "@app/components/ui/button"; -import { Menu, Server } from "lucide-react"; +import { ExternalLink, Menu, Server } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -117,7 +117,15 @@ export function LayoutMobileMenu({ {env?.app?.version && (
- v{env.app.version} + + v{env.app.version} + +
)}
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index cfc21144..b563d9ac 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import SupporterStatus from "@app/components/SupporterStatus"; import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react"; +import { FaDiscord, FaGithub } from "react-icons/fa"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -151,7 +152,7 @@ export function LayoutSidebar({ {!isUnlocked() ? t("communityEdition") : t("commercialEdition")} - +
@@ -165,9 +166,28 @@ export function LayoutSidebar({
+
+ + Discord + + +
{env?.app?.version && (
- v{env.app.version} + + v{env.app.version} + +
)}
diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index a0d34b4b..d8f9b59f 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) { [t('actionUpdateOrg')]: "updateOrg", [t('actionGetOrgUser')]: "getOrgUser", [t('actionInviteUser')]: "inviteUser", + [t('actionListInvitations')]: "listInvitations", [t('actionRemoveUser')]: "removeUser", [t('actionListUsers')]: "listUsers", [t('actionListOrgDomains')]: "listOrgDomains" diff --git a/src/lib/parseHostTarget.ts b/src/lib/parseHostTarget.ts new file mode 100644 index 00000000..c79c7aa3 --- /dev/null +++ b/src/lib/parseHostTarget.ts @@ -0,0 +1,15 @@ +export function parseHostTarget(input: string) { + try { + const normalized = input.match(/^https?:\/\//) ? input : `http://${input}`; + const url = new URL(normalized); + + const protocol = url.protocol.replace(":", ""); // http | https + const host = url.hostname; + const port = url.port ? parseInt(url.port, 10) : protocol === "https" ? 443 : 80; + + return { protocol, host, port }; + } catch { + return null; + } +} + diff --git a/src/lib/subdomain-utils.ts b/src/lib/subdomain-utils.ts new file mode 100644 index 00000000..5a82e930 --- /dev/null +++ b/src/lib/subdomain-utils.ts @@ -0,0 +1,63 @@ + +export type DomainType = "organization" | "provided" | "provided-search"; + +export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots) +export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard +export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum + + +export function sanitizeInputRaw(input: string): string { + if (!input) return ""; + return input + .toLowerCase() + .normalize("NFC") // normalize Unicode + .replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen +} + +export function finalizeSubdomainSanitize(input: string): string { + if (!input) return ""; + return input + .toLowerCase() + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode + .replace(/\.{2,}/g, ".") // collapse multiple dots + .replace(/^-+|-+$/g, "") // strip leading/trailing hyphens + .replace(/^\.+|\.+$/g, "") // strip leading/trailing dots + .replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos +} + + +export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean { + if (!domainType) return false; + + if (domainType.type === "provided-search") { + return SINGLE_LABEL_RE.test(subdomain); + } + + if (domainType.type === "organization") { + if (domainType.domainType === "cname") { + return subdomain === ""; + } else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") { + if (subdomain === "") return true; + if (!MULTI_LABEL_RE.test(subdomain)) return false; + const labels = subdomain.split("."); + return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l)); + } + } + return false; +} + + + +export const isValidSubdomainStructure = (input: string): boolean => { + const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(? regex.test(label)); +}; + + + +