diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index 30e90872ec27..217991778ecc 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -6,19 +6,9 @@ permissions: on: pull_request: types: [opened, synchronize, reopened] - paths: - - "apps/web/**/*.ts" - - "apps/web/**/*.tsx" - - "apps/web/locales/**/*.json" - - "scan-translations.ts" push: branches: - main - paths: - - "apps/web/**/*.ts" - - "apps/web/**/*.tsx" - - "apps/web/locales/**/*.json" - - "scan-translations.ts" jobs: validate-translations: @@ -33,30 +23,38 @@ jobs: egress-policy: audit - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check for relevant changes + id: changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + filters: | + translations: + - 'apps/web/**/*.ts' + - 'apps/web/**/*.tsx' + - 'apps/web/locales/**/*.json' + - 'packages/surveys/src/**/*.{ts,tsx}' + - 'packages/surveys/locales/**/*.json' + - 'packages/email/**/*.{ts,tsx}' - name: Setup Node.js 22.x + if: steps.changes.outputs.translations == 'true' uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af with: node-version: 22.x - name: Install pnpm + if: steps.changes.outputs.translations == 'true' uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Install dependencies + if: steps.changes.outputs.translations == 'true' run: pnpm install --config.platform=linux --config.architecture=x64 - name: Validate translation keys - run: | - echo "" - echo "🔍 Validating translation keys..." - echo "" - pnpm run scan-translations - - - name: Summary - if: success() - run: | - echo "" - echo "✅ Translation validation completed successfully!" - echo "" + if: steps.changes.outputs.translations == 'true' + run: pnpm run scan-translations + + - name: Skip (no translation-related changes) + if: steps.changes.outputs.translations != 'true' + run: echo "No translation-related files changed — skipping validation." diff --git a/.husky/pre-commit b/.husky/pre-commit index a88bfb1c05cc..e02c24e2b5c2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,40 +1 @@ -# Load environment variables from .env files -if [ -f .env ]; then - set -a - . .env - set +a -fi - -pnpm lint-staged - -# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set -if [ -n "$LINGODOTDEV_API_KEY" ]; then - echo "" - echo "🌍 Running Lingo.dev translation workflow..." - echo "" - - # Run translation generation and validation - if pnpm run i18n; then - echo "" - echo "✅ Translation validation passed" - echo "" - # Add updated locale files to git - git add apps/web/locales/*.json - else - echo "" - echo "❌ Translation validation failed!" - echo "" - echo "Please fix the translation issues above before committing:" - echo " • Add missing translation keys to your locale files" - echo " • Remove unused translation keys" - echo "" - echo "Or run 'pnpm i18n' to see the detailed report" - echo "" - exit 1 - fi -else - echo "" - echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set" - echo " (This is expected for community contributors)" - echo "" -fi \ No newline at end of file +pnpm lint-staged \ No newline at end of file diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx index 5fcf9f8ffe94..ac26aa05097a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx @@ -30,7 +30,7 @@ export const NotificationSwitch = ({ const isChecked = notificationType === "unsubscribedOrganizationIds" ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) - : notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true; + : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true; const handleSwitchChange = async () => { setIsLoading(true); @@ -49,8 +49,11 @@ export const NotificationSwitch = ({ ]; } } else { - updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] = - !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; + updatedNotificationSettings[notificationType] = { + ...updatedNotificationSettings[notificationType], + [surveyOrProjectOrOrganizationId]: + !updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId], + }; } const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ @@ -78,7 +81,7 @@ export const NotificationSwitch = ({ ) { switch (notificationType) { case "alert": - if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) { + if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) { handleSwitchChange(); toast.success( t( diff --git a/apps/web/app/middleware/endpoint-validator.test.ts b/apps/web/app/middleware/endpoint-validator.test.ts index 2e349e977a29..7e032e1a034d 100644 --- a/apps/web/app/middleware/endpoint-validator.test.ts +++ b/apps/web/app/middleware/endpoint-validator.test.ts @@ -257,6 +257,7 @@ describe("endpoint-validator", () => { expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false); expect(isAuthProtectedRoute("/")).toBe(false); expect(isAuthProtectedRoute("/s/survey123")).toBe(false); + expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false); expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false); expect(isAuthProtectedRoute("/health")).toBe(false); }); @@ -312,6 +313,19 @@ describe("endpoint-validator", () => { expect(isPublicDomainRoute("/c")).toBe(false); expect(isPublicDomainRoute("/contact/token")).toBe(false); }); + + test("should return true for pretty URL survey routes", () => { + expect(isPublicDomainRoute("/p/pretty123")).toBe(true); + expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true); + expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true); + expect(isPublicDomainRoute("/p/abc123def456")).toBe(true); + }); + + test("should return false for malformed pretty URL survey routes", () => { + expect(isPublicDomainRoute("/p/")).toBe(false); + expect(isPublicDomainRoute("/p")).toBe(false); + expect(isPublicDomainRoute("/pretty/123")).toBe(false); + }); test("should return true for client API routes", () => { expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true); @@ -375,6 +389,8 @@ describe("endpoint-validator", () => { expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false); expect(isAdminDomainRoute("/c/jwt-token")).toBe(false); expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false); + expect(isAdminDomainRoute("/p/pretty123")).toBe(false); + expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false); expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false); expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false); }); @@ -390,6 +406,7 @@ describe("endpoint-validator", () => { test("should allow public routes on public domain", () => { expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true); expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true); + expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true); expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true); expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true); expect(isRouteAllowedForDomain("/health", true)).toBe(true); @@ -426,6 +443,8 @@ describe("endpoint-validator", () => { expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false); expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false); expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false); + expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false); + expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false); expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false); expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false); }); @@ -440,6 +459,8 @@ describe("endpoint-validator", () => { test("should handle paths with query parameters and fragments", () => { expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true); expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true); + expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true); + expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true); expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false); expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true); }); @@ -450,6 +471,7 @@ describe("endpoint-validator", () => { describe("URL parsing edge cases", () => { test("should handle paths with query parameters", () => { expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true); expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true); expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false); expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true); @@ -458,12 +480,14 @@ describe("endpoint-validator", () => { test("should handle paths with fragments", () => { expect(isPublicDomainRoute("/s/survey123#section")).toBe(true); expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true); expect(isPublicDomainRoute("/environments/123#overview")).toBe(false); expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true); }); test("should handle trailing slashes", () => { expect(isPublicDomainRoute("/s/survey123/")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123/")).toBe(true); expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true); expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({ isManagementApi: true, @@ -478,6 +502,9 @@ describe("endpoint-validator", () => { expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true); expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true); expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true); + expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true); }); test("should handle nested client API routes", () => { @@ -529,6 +556,7 @@ describe("endpoint-validator", () => { test("should handle special characters in survey IDs", () => { expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true); expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true); + expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true); }); }); @@ -536,6 +564,7 @@ describe("endpoint-validator", () => { test("should properly validate malicious or injection-like URLs", () => { // SQL injection-like attempts expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format + expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true); expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({ isManagementApi: true, authenticationMethod: AuthenticationMethod.ApiKey, @@ -543,10 +572,12 @@ describe("endpoint-validator", () => { // Path traversal attempts expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern + expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true); expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true); // XSS-like attempts expect(isPublicDomainRoute("/s/")).toBe(true); + expect(isPublicDomainRoute("/p/")).toBe(true); expect(isClientSideApiRoute("/api/v1/client/")).toEqual({ isClientSideApi: true, isRateLimited: true, @@ -556,6 +587,7 @@ describe("endpoint-validator", () => { test("should handle URL encoding", () => { expect(isPublicDomainRoute("/s/survey%20123")).toBe(true); expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true); + expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true); expect(isAuthProtectedRoute("/environments%2F123")).toBe(true); expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({ isManagementApi: true, @@ -591,6 +623,7 @@ describe("endpoint-validator", () => { // These should not match due to case sensitivity expect(isPublicDomainRoute("/S/survey123")).toBe(false); expect(isPublicDomainRoute("/C/jwt-token")).toBe(false); + expect(isPublicDomainRoute("/P/pretty123")).toBe(false); expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({ isClientSideApi: false, isRateLimited: true, diff --git a/apps/web/app/middleware/route-config.ts b/apps/web/app/middleware/route-config.ts index 2891e0c3c214..189283bbdf26 100644 --- a/apps/web/app/middleware/route-config.ts +++ b/apps/web/app/middleware/route-config.ts @@ -7,6 +7,7 @@ const PUBLIC_ROUTES = { SURVEY_ROUTES: [ /^\/s\/[^/]+/, // /s/[surveyId] - survey pages /^\/c\/[^/]+/, // /c/[jwt] - contact survey pages + /^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages ], // API routes accessible from public domain diff --git a/docs/self-hosting/configuration/domain-configuration.mdx b/docs/self-hosting/configuration/domain-configuration.mdx index 7e427205ab75..14f48e1dbb44 100644 --- a/docs/self-hosting/configuration/domain-configuration.mdx +++ b/docs/self-hosting/configuration/domain-configuration.mdx @@ -85,6 +85,7 @@ When PUBLIC_URL is configured, the following routes are automatically served fro - `/s/{surveyId}` - Individual survey access - `/c/{jwt}` - Personalized link survey access (JWT-based access) +- `/p/{survey-slug}` - Pretty URL survey access - Embedded survey endpoints #### API Routes