diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 00000000..ed54cead --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,96 @@ +--- +# Codacy Configuration File +# https://docs.codacy.com/repositories-configure/codacy-configuration-file/ + +comment: + enabled: false + +github_checks: + annotations: true + +file_extensions: + - ".ts" + - ".js" + - ".svelte" + - ".scss" + - ".css" + +duplication: + exclude_paths: + - "tests/**" + +engines: + eslint: + config_file: eslintrc.config.js + +# Exclude patterns - ignore test files and build artifacts from analysis +exclude_paths: + - "tests/**" + - "**/*.test.ts" + - "**/*.test.js" + - "**/*.spec.ts" + - "**/*.spec.js" + - ".svelte-kit/**" + - "build/**" + - "dist/**" + - "coverage/**" + - "playwright-report/**" + - "node_modules/**" + - "**/*.config.ts" + - "**/*.config.js" + - "**/*.d.ts" + - "static/**" + - ".github/**" + +# Language-specific engines configuration +engines: + # ESLint configuration (if you want to customize) + eslint: + enabled: true + exclude_paths: + - "tests/**" + + # TypeScript/TSLint + tslint: + enabled: true + exclude_paths: + - "tests/**" + +# Additional configuration +duplication: + enabled: true + exclude_paths: + - "tests/**" + +# Complexity and code style checks +metrics: + enabled: true + exclude_paths: + - "tests/**" + +# Coverage configuration +coverage: + # Enable coverage tracking + enabled: true + + # Exclude test files from coverage reports + exclude_paths: + - "tests/**" + - "**/*.test.ts" + - "**/*.spec.ts" + + # Coverage thresholds (optional but recommended) + status: + # Project-wide coverage threshold + project: + default: + enabled: true + target: 80% + threshold: 10% # Allow 5% decrease + + # Diff/patch coverage threshold (new code in PRs) + patch: + default: + enabled: true + target: 80% + threshold: 20% # Allow 10% decrease for patches diff --git a/.github/docs/app-customization.md b/.github/docs/app-customization.md index d4d2d33b..5055e501 100644 --- a/.github/docs/app-customization.md +++ b/.github/docs/app-customization.md @@ -157,6 +157,21 @@ environment: - **Example:** `NTB_ALLOWED_DNS_SERVERS='8.8.8.8,1.1.1.1,9.9.9.9'` - **Note:** Only used when `NTB_ALLOW_CUSTOM_DNS='false'` +### Analytics Settings + +- **`NTB_ANALYTICS_DOMAIN`** + - **Description:** Domain for analytics tracking (for Plausible or similar) + - **Default:** `networking-toolbox.as93.net` + - **Example:** `NTB_ANALYTICS_DOMAIN='myapp.example.com'` + - **Disable:** Set to `false` to disable analytics: `NTB_ANALYTICS_DOMAIN='false'` + +- **`NTB_ANALYTICS_DSN`** + - **Description:** URL to the analytics script + - **Default:** `https://no-track.as93.net/js/script.js` + - **Example:** `NTB_ANALYTICS_DSN='https://plausible.io/js/script.js'` + - **Disable:** Set to `false` to disable analytics: `NTB_ANALYTICS_DSN='false'` + - **Note:** Analytics is disabled if either `NTB_ANALYTICS_DOMAIN` or `NTB_ANALYTICS_DSN` is set to `false` + --- ## Example Configurations @@ -174,6 +189,8 @@ NTB_HOMEPAGE_LAYOUT='categories' NTB_ALLOW_CUSTOM_DNS='false' NTB_BLOCK_PRIVATE_DNS_IPS='true' NTB_ALLOWED_DNS_SERVERS='8.8.8.8,1.1.1.1' +NTB_ANALYTICS_DOMAIN='false' +NTB_ANALYTICS_DSN='false' ``` ### Full Customization @@ -190,5 +207,15 @@ NTB_DEFAULT_LANGUAGE='en' NTB_SHOW_TIPS_ON_HOMEPAGE='true' NTB_ALLOW_CUSTOM_DNS='true' NTB_BLOCK_PRIVATE_DNS_IPS='true' +NTB_ANALYTICS_DOMAIN='myapp.example.com' +NTB_ANALYTICS_DSN='https://plausible.io/js/script.js' +``` + +### Self-Hosted (No Analytics) +```bash +NTB_SITE_TITLE='Internal Network Tools' +NTB_DEFAULT_THEME='dark' +NTB_ANALYTICS_DOMAIN='false' +NTB_ANALYTICS_DSN='false' ``` diff --git a/src/lib/components/page-specific/about/DeployingSection.svelte b/src/lib/components/page-specific/about/DeployingSection.svelte index e7739e40..ad592635 100644 --- a/src/lib/components/page-specific/about/DeployingSection.svelte +++ b/src/lib/components/page-specific/about/DeployingSection.svelte @@ -52,13 +52,15 @@

Customizing

You can customize the branding of your instance, by setting a few environment variables. All are optional.

diff --git a/src/lib/config/customizable-settings.ts b/src/lib/config/customizable-settings.ts index b1cfcbf4..dca21189 100644 --- a/src/lib/config/customizable-settings.ts +++ b/src/lib/config/customizable-settings.ts @@ -110,6 +110,31 @@ export const ALLOWED_DNS_SERVERS = env.NTB_ALLOWED_DNS_SERVERS ? env.NTB_ALLOWED_DNS_SERVERS.split(',').map((ip) => ip.trim()) : DEFAULT_TRUSTED_DNS_SERVERS; +/** + * Analytics Settings + * Configure analytics tracking for self-hosted instances + */ + +/** + * Analytics domain (for Plausible or similar analytics) + * Set to 'false' to disable analytics entirely + * Default: 'networking-toolbox.as93.net' + */ +export const ANALYTICS_DOMAIN = env.NTB_ANALYTICS_DOMAIN ?? 'networking-toolbox.as93.net'; + +/** + * Analytics script URL (for Plausible or similar analytics) + * Set to 'false' to disable analytics entirely + * Default: 'https://no-track.as93.net/js/script.js' + */ +export const ANALYTICS_DSN = env.NTB_ANALYTICS_DSN ?? 'https://no-track.as93.net/js/script.js'; + +/** + * Check if analytics is enabled + * Analytics is disabled if either ANALYTICS_DOMAIN or ANALYTICS_DSN is set to 'false' + */ +export const ANALYTICS_ENABLED = ANALYTICS_DOMAIN !== 'false' && ANALYTICS_DSN !== 'false'; + /** * Get user settings list with values prioritized as: * 1. User-set value from localStorage @@ -153,5 +178,7 @@ export function getUserSettingsList(): Array<{ name: string; value: string }> { { name: 'NTB_ALLOW_CUSTOM_DNS', value: env.NTB_ALLOW_CUSTOM_DNS || '' }, { name: 'NTB_BLOCK_PRIVATE_DNS_IPS', value: env.NTB_BLOCK_PRIVATE_DNS_IPS || '' }, { name: 'NTB_ALLOWED_DNS_SERVERS', value: env.NTB_ALLOWED_DNS_SERVERS || '' }, + { name: 'NTB_ANALYTICS_DOMAIN', value: env.NTB_ANALYTICS_DOMAIN || '' }, + { name: 'NTB_ANALYTICS_DSN', value: env.NTB_ANALYTICS_DSN || '' }, ]; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8692cd78..1ebff0fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -24,6 +24,7 @@ import { ALL_PAGES } from '$lib/constants/nav'; import { initializeOfflineSupport } from '$lib/stores/offline'; import { bookmarks } from '$lib/stores/bookmarks'; + import { ANALYTICS_ENABLED, ANALYTICS_DOMAIN, ANALYTICS_DSN } from '$lib/config/customizable-settings'; import Header from '$lib/components/furniture/Header.svelte'; import SubHeader from '$lib/components/furniture/SubHeader.svelte'; @@ -400,8 +401,10 @@ })} {/if} - - + + {#if ANALYTICS_ENABLED} + + {/if} diff --git a/tests/unit/lib/config/customizable-settings.test.ts b/tests/unit/lib/config/customizable-settings.test.ts new file mode 100644 index 00000000..34ea06f1 --- /dev/null +++ b/tests/unit/lib/config/customizable-settings.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('customizable-settings - Analytics Configuration', () => { + beforeEach(() => { + vi.resetModules(); + }); + + describe('ANALYTICS_DOMAIN', () => { + it('should use default domain when env var is not set', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: {}, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DOMAIN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DOMAIN).toBe('networking-toolbox.as93.net'); + }); + + it('should use custom domain when env var is set', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'custom-domain.example.com', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DOMAIN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DOMAIN).toBe('custom-domain.example.com'); + }); + + it('should accept "false" as a value to disable analytics', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'false', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DOMAIN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DOMAIN).toBe('false'); + }); + }); + + describe('ANALYTICS_DSN', () => { + it('should use default DSN when env var is not set', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: {}, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DSN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DSN).toBe('https://no-track.as93.net/js/script.js'); + }); + + it('should use custom DSN when env var is set', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DSN: 'https://plausible.io/js/script.js', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DSN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DSN).toBe('https://plausible.io/js/script.js'); + }); + + it('should accept "false" as a value to disable analytics', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DSN: 'false', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_DSN } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_DSN).toBe('false'); + }); + }); + + describe('ANALYTICS_ENABLED', () => { + it('should be true when both domain and DSN use defaults', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: {}, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_ENABLED } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_ENABLED).toBe(true); + }); + + it('should be true when both domain and DSN are custom values', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'custom.example.com', + NTB_ANALYTICS_DSN: 'https://analytics.example.com/script.js', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_ENABLED } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_ENABLED).toBe(true); + }); + + it('should be false when domain is set to "false"', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'false', + NTB_ANALYTICS_DSN: 'https://no-track.as93.net/js/script.js', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_ENABLED } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_ENABLED).toBe(false); + }); + + it('should be false when DSN is set to "false"', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'networking-toolbox.as93.net', + NTB_ANALYTICS_DSN: 'false', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_ENABLED } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_ENABLED).toBe(false); + }); + + it('should be false when both domain and DSN are set to "false"', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'false', + NTB_ANALYTICS_DSN: 'false', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ANALYTICS_ENABLED } = await import('$lib/config/customizable-settings'); + expect(ANALYTICS_ENABLED).toBe(false); + }); + }); + + describe('getUserSettingsList - Analytics', () => { + it('should include analytics settings in the list', async () => { + const mockLocalStorage: Record = {}; + + global.localStorage = { + getItem: (key: string) => mockLocalStorage[key] || null, + setItem: (key: string, value: string) => { + mockLocalStorage[key] = value; + }, + removeItem: (key: string) => { + delete mockLocalStorage[key]; + }, + clear: () => { + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }, + length: Object.keys(mockLocalStorage).length, + key: (index: number) => Object.keys(mockLocalStorage)[index] || null, + } as Storage; + + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ANALYTICS_DOMAIN: 'test.example.com', + NTB_ANALYTICS_DSN: 'https://test.example.com/script.js', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: true, + })); + + const { getUserSettingsList } = await import('$lib/config/customizable-settings'); + const settings = getUserSettingsList(); + + const analyticsDomain = settings.find((s) => s.name === 'NTB_ANALYTICS_DOMAIN'); + const analyticsDsn = settings.find((s) => s.name === 'NTB_ANALYTICS_DSN'); + + expect(analyticsDomain).toBeDefined(); + expect(analyticsDomain?.value).toBe('test.example.com'); + expect(analyticsDsn).toBeDefined(); + expect(analyticsDsn?.value).toBe('https://test.example.com/script.js'); + }); + + it('should return empty string when analytics env vars are not set', async () => { + const mockLocalStorage: Record = {}; + + global.localStorage = { + getItem: (key: string) => mockLocalStorage[key] || null, + setItem: (key: string, value: string) => { + mockLocalStorage[key] = value; + }, + removeItem: (key: string) => { + delete mockLocalStorage[key]; + }, + clear: () => { + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }, + length: Object.keys(mockLocalStorage).length, + key: (index: number) => Object.keys(mockLocalStorage)[index] || null, + } as Storage; + + vi.doMock('$env/dynamic/public', () => ({ + env: {}, + })); + vi.doMock('$app/environment', () => ({ + browser: true, + })); + + const { getUserSettingsList } = await import('$lib/config/customizable-settings'); + const settings = getUserSettingsList(); + + const analyticsDomain = settings.find((s) => s.name === 'NTB_ANALYTICS_DOMAIN'); + const analyticsDsn = settings.find((s) => s.name === 'NTB_ANALYTICS_DSN'); + + expect(analyticsDomain?.value).toBe(''); + expect(analyticsDsn?.value).toBe(''); + }); + }); + + describe('getUserCustomization - Error Handling', () => { + it('should return null when localStorage throws an error', async () => { + global.localStorage = { + getItem: () => { + throw new Error('localStorage error'); + }, + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + } as Storage; + + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_SITE_TITLE: 'Test Site', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: true, + })); + + // Re-import to trigger getUserCustomization with error + const { SITE_TITLE } = await import('$lib/config/customizable-settings'); + // Should fall back to env var when localStorage fails + expect(SITE_TITLE).toBe('Test Site'); + }); + + it('should handle invalid JSON in localStorage gracefully', async () => { + global.localStorage = { + getItem: () => 'invalid-json{', + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + } as Storage; + + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_SITE_TITLE: 'Fallback Site', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: true, + })); + + const { SITE_TITLE } = await import('$lib/config/customizable-settings'); + // Should fall back to env var when JSON parsing fails + expect(SITE_TITLE).toBe('Fallback Site'); + }); + }); + + describe('ALLOWED_DNS_SERVERS', () => { + it('should split and trim custom DNS servers from env var', async () => { + vi.doMock('$env/dynamic/public', () => ({ + env: { + NTB_ALLOWED_DNS_SERVERS: '8.8.8.8, 1.1.1.1 , 9.9.9.9', + }, + })); + vi.doMock('$app/environment', () => ({ + browser: false, + })); + + const { ALLOWED_DNS_SERVERS } = await import('$lib/config/customizable-settings'); + expect(ALLOWED_DNS_SERVERS).toEqual(['8.8.8.8', '1.1.1.1', '9.9.9.9']); + }); + }); + + describe('getUserSettingsList - Error Handling', () => { + it('should handle localStorage errors when getting individual values', async () => { + let callCount = 0; + global.localStorage = { + getItem: () => { + callCount++; + if (callCount > 2) { + throw new Error('localStorage error'); + } + return null; + }, + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + } as Storage; + + vi.doMock('$env/dynamic/public', () => ({ + env: {}, + })); + vi.doMock('$app/environment', () => ({ + browser: true, + })); + + const { getUserSettingsList } = await import('$lib/config/customizable-settings'); + const settings = getUserSettingsList(); + + // Should still return the settings list even if some localStorage calls fail + expect(settings).toBeDefined(); + expect(Array.isArray(settings)).toBe(true); + }); + }); +});