chore: git cache cleanup
This commit is contained in:
66
frontend/src/utils/__tests__/compareHosts.test.ts
Normal file
66
frontend/src/utils/__tests__/compareHosts.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import compareHosts from '../compareHosts'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
const hostA: ProxyHost = {
|
||||
uuid: 'a',
|
||||
name: 'Alpha',
|
||||
domain_names: 'alpha.com',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 80,
|
||||
forward_scheme: 'http',
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
certificate: null,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
const hostB: ProxyHost = {
|
||||
uuid: 'b',
|
||||
name: 'Beta',
|
||||
domain_names: 'beta.com',
|
||||
forward_host: '127.0.0.2',
|
||||
forward_port: 8080,
|
||||
forward_scheme: 'http',
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
certificate: null,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
describe('compareHosts', () => {
|
||||
it('returns 0 for unknown sort column (default case)', () => {
|
||||
const compareAny = compareHosts as unknown as (a: ProxyHost, b: ProxyHost, sortColumn: string, sortDirection: 'asc' | 'desc') => number
|
||||
const res = compareAny(hostA, hostB, 'unknown', 'asc')
|
||||
expect(res).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts by name', () => {
|
||||
expect(compareHosts(hostA, hostB, 'name', 'asc')).toBeLessThan(0)
|
||||
expect(compareHosts(hostB, hostA, 'name', 'asc')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('sorts by domain', () => {
|
||||
expect(compareHosts(hostA, hostB, 'domain', 'asc')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('sorts by forward', () => {
|
||||
expect(compareHosts(hostA, hostB, 'forward', 'asc')).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
480
frontend/src/utils/__tests__/crowdsecExport.test.ts
Normal file
480
frontend/src/utils/__tests__/crowdsecExport.test.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
buildCrowdsecExportFilename,
|
||||
promptCrowdsecFilename,
|
||||
downloadCrowdsecExport,
|
||||
} from '../crowdsecExport'
|
||||
|
||||
describe('crowdsecExport', () => {
|
||||
describe('buildCrowdsecExportFilename', () => {
|
||||
it('should generate filename with ISO timestamp', () => {
|
||||
const filename = buildCrowdsecExportFilename()
|
||||
expect(filename).toMatch(
|
||||
/^crowdsec-export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*\.tar\.gz$/
|
||||
)
|
||||
})
|
||||
|
||||
it('should replace colons with hyphens in timestamp', () => {
|
||||
const filename = buildCrowdsecExportFilename()
|
||||
expect(filename).not.toContain(':')
|
||||
})
|
||||
|
||||
it('should always end with .tar.gz', () => {
|
||||
const filename = buildCrowdsecExportFilename()
|
||||
expect(filename.endsWith('.tar.gz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should start with crowdsec-export-', () => {
|
||||
const filename = buildCrowdsecExportFilename()
|
||||
expect(filename).toMatch(/^crowdsec-export-/)
|
||||
})
|
||||
|
||||
it('should include milliseconds in timestamp', () => {
|
||||
const filename = buildCrowdsecExportFilename()
|
||||
// ISO format includes milliseconds: 2025-12-15T10:30:45.123Z
|
||||
expect(filename).toMatch(/\d{3}/)
|
||||
})
|
||||
|
||||
it('should generate unique filenames for consecutive calls', () => {
|
||||
const filename1 = buildCrowdsecExportFilename()
|
||||
const filename2 = buildCrowdsecExportFilename()
|
||||
// They might be the same if called in the same millisecond, but should be close
|
||||
expect(filename1).toBeTruthy()
|
||||
expect(filename2).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptCrowdsecFilename', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should return null when user cancels', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue(null)
|
||||
const result = promptCrowdsecFilename('default.tar.gz')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return default filename when user provides empty string', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue(' ')
|
||||
const result = promptCrowdsecFilename('default.tar.gz')
|
||||
expect(result).toBe('default.tar.gz')
|
||||
})
|
||||
|
||||
it('should sanitize user input by replacing slashes', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup/prod/config')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-prod-config.tar.gz')
|
||||
})
|
||||
|
||||
it('should replace spaces with hyphens', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('crowdsec backup 2025')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('crowdsec-backup-2025.tar.gz')
|
||||
})
|
||||
|
||||
it('should append .tar.gz if missing', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('my-backup')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('my-backup.tar.gz')
|
||||
})
|
||||
|
||||
it('should not double-append .tar.gz', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('my-backup.tar.gz')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('my-backup.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle case-insensitive .tar.gz extension', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('my-backup.TAR.GZ')
|
||||
const result = promptCrowdsecFilename()
|
||||
// Implementation checks lowercase, so uppercase extension gets .tar.gz appended
|
||||
expect(result).toBe('my-backup.TAR.GZ')
|
||||
})
|
||||
|
||||
it('should trim whitespace from user input', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue(' my-backup ')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('my-backup.tar.gz')
|
||||
})
|
||||
|
||||
it('should use generated default when no default provided', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toMatch(/^crowdsec-export-.*\.tar\.gz$/)
|
||||
})
|
||||
|
||||
it('should handle backslashes (Windows paths)', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup\\windows\\path')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-windows-path.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive slashes', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup///prod')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-prod.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive spaces', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup prod')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-prod.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle mixed slashes and spaces', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup / prod \\ test')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup---prod---test.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle special characters', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup@prod#2025')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup@prod#2025.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle undefined return from prompt', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue(undefined as unknown as string | null)
|
||||
const result = promptCrowdsecFilename('default.tar.gz')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadCrowdsecExport', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let clickSpy: ReturnType<typeof vi.fn>
|
||||
let removeSpy: ReturnType<typeof vi.fn>
|
||||
let appendChildSpy: ReturnType<typeof vi.fn>
|
||||
let anchorElement: {
|
||||
href: string
|
||||
download: string
|
||||
click: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url-12345')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
})
|
||||
|
||||
clickSpy = vi.fn()
|
||||
removeSpy = vi.fn()
|
||||
anchorElement = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: clickSpy,
|
||||
remove: removeSpy,
|
||||
}
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
if (tag === 'a') {
|
||||
return anchorElement as unknown as HTMLAnchorElement
|
||||
}
|
||||
return document.createElement(tag)
|
||||
})
|
||||
|
||||
appendChildSpy = vi.fn((node: Node) => node)
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(appendChildSpy as (node: Node) => Node)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should create blob URL and trigger download', () => {
|
||||
const blob = new Blob(['test data'], { type: 'application/gzip' })
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url-12345')
|
||||
})
|
||||
|
||||
it('should set correct filename on anchor element', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'my-backup.tar.gz')
|
||||
|
||||
expect(anchorElement.download).toBe('my-backup.tar.gz')
|
||||
})
|
||||
|
||||
it('should set href to blob URL', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(anchorElement.href).toBe('blob:mock-url-12345')
|
||||
})
|
||||
|
||||
it('should append anchor to body', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(appendChildSpy).toHaveBeenCalledWith(anchorElement)
|
||||
})
|
||||
|
||||
it('should clean up by removing anchor element', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(removeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should revoke object URL after download', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url-12345')
|
||||
})
|
||||
|
||||
it('should handle large blob data', () => {
|
||||
const largeData = new Array(1000000).fill('x').join('')
|
||||
const blob = new Blob([largeData], { type: 'application/gzip' })
|
||||
downloadCrowdsecExport(blob, 'large-export.tar.gz')
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle blob with custom type', () => {
|
||||
const blob = new Blob(['data'], { type: 'application/x-gzip' })
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(expect.any(Blob))
|
||||
})
|
||||
|
||||
it('should handle filename with special characters', () => {
|
||||
const blob = new Blob(['data'])
|
||||
downloadCrowdsecExport(blob, 'backup-2025-12-15@10:30.tar.gz')
|
||||
|
||||
expect(anchorElement.download).toBe('backup-2025-12-15@10:30.tar.gz')
|
||||
})
|
||||
|
||||
it('should execute steps in correct order', () => {
|
||||
const blob = new Blob(['data'])
|
||||
const callOrder: string[] = []
|
||||
|
||||
createObjectURLSpy.mockImplementation(() => {
|
||||
callOrder.push('createObjectURL')
|
||||
return 'blob:mock-url'
|
||||
})
|
||||
appendChildSpy.mockImplementation(() => {
|
||||
callOrder.push('appendChild')
|
||||
})
|
||||
clickSpy.mockImplementation(() => {
|
||||
callOrder.push('click')
|
||||
})
|
||||
removeSpy.mockImplementation(() => {
|
||||
callOrder.push('remove')
|
||||
})
|
||||
revokeObjectURLSpy.mockImplementation(() => {
|
||||
callOrder.push('revokeObjectURL')
|
||||
})
|
||||
|
||||
downloadCrowdsecExport(blob, 'test.tar.gz')
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'createObjectURL',
|
||||
'appendChild',
|
||||
'click',
|
||||
'remove',
|
||||
'revokeObjectURL',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('security: path traversal prevention', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should sanitize directory traversal attempts', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('../../etc/passwd')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('..-..-etc-passwd.tar.gz')
|
||||
expect(result).not.toContain('../')
|
||||
})
|
||||
|
||||
it('should handle absolute paths', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('/etc/crowdsec/backup')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('-etc-crowdsec-backup.tar.gz')
|
||||
expect(result).not.toMatch(/^\//)
|
||||
})
|
||||
|
||||
it('should handle Windows absolute paths', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('C:\\Windows\\System32\\backup')
|
||||
const result = promptCrowdsecFilename()
|
||||
// Backslashes are replaced with hyphens, but case is preserved
|
||||
expect(result).toBe('C:-Windows-System32-backup.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle null bytes', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup\0malicious')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toContain('backup')
|
||||
// Null bytes should be handled by sanitization
|
||||
})
|
||||
|
||||
it('should handle mixed attack vectors', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('../../../etc/passwd\0.tar.gz')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).not.toContain('../')
|
||||
expect(result!.endsWith('.tar.gz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle URL-encoded path traversal', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('%2e%2e%2f%2e%2e%2f')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toContain('%2e')
|
||||
expect(result!.endsWith('.tar.gz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not allow overwriting system files via filename', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('/etc/passwd')
|
||||
const result = promptCrowdsecFilename()
|
||||
// After sanitization, leading slash should be replaced
|
||||
expect(result).toBe('-etc-passwd.tar.gz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should handle very long filenames', () => {
|
||||
const longName = 'a'.repeat(300)
|
||||
vi.mocked(window.prompt).mockReturnValue(longName)
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toContain('a'.repeat(100)) // Should still work
|
||||
expect(result!.endsWith('.tar.gz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup-日本語-2025')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-日本語-2025.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup-🔒-secure')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup-🔒-secure.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle only special characters', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('///\\\\\\')
|
||||
const result = promptCrowdsecFilename('default.tar.gz')
|
||||
// Slashes become hyphens, resulting in single hyphen after sanitization
|
||||
expect(result).toBe('-.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle filename with only spaces', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue(' ')
|
||||
const result = promptCrowdsecFilename('default.tar.gz')
|
||||
expect(result).toBe('default.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle filename with .tar.gz in the middle', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup.tar.gz.old')
|
||||
const result = promptCrowdsecFilename()
|
||||
expect(result).toBe('backup.tar.gz.old.tar.gz')
|
||||
})
|
||||
|
||||
it('should handle case variations of .tar.gz', () => {
|
||||
vi.mocked(window.prompt).mockReturnValue('backup.Tar.Gz')
|
||||
const result = promptCrowdsecFilename()
|
||||
// Implementation uses lowercase check, so mixed case doesn't match
|
||||
expect(result).toBe('backup.Tar.Gz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration: full export workflow', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let promptSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
})
|
||||
|
||||
promptSpy = vi.fn()
|
||||
vi.stubGlobal('prompt', promptSpy)
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
if (tag === 'a') {
|
||||
return {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as unknown as HTMLAnchorElement
|
||||
}
|
||||
return document.createElement(tag)
|
||||
})
|
||||
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(() => null as unknown as HTMLElement)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should complete full workflow: generate → prompt → download', () => {
|
||||
// 1. Generate default filename
|
||||
const defaultFilename = buildCrowdsecExportFilename()
|
||||
expect(defaultFilename).toMatch(/^crowdsec-export-.*\.tar\.gz$/)
|
||||
|
||||
// 2. User provides custom filename
|
||||
promptSpy.mockReturnValue('my-backup')
|
||||
const customFilename = promptCrowdsecFilename(defaultFilename)
|
||||
expect(customFilename).toBe('my-backup.tar.gz')
|
||||
|
||||
// 3. Download with custom filename
|
||||
const blob = new Blob(['export data'], { type: 'application/gzip' })
|
||||
downloadCrowdsecExport(blob, customFilename!)
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalled()
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle user cancellation', () => {
|
||||
const defaultFilename = buildCrowdsecExportFilename()
|
||||
promptSpy.mockReturnValue(null)
|
||||
const result = promptCrowdsecFilename(defaultFilename)
|
||||
|
||||
expect(result).toBeNull()
|
||||
// Download should not be triggered when user cancels
|
||||
})
|
||||
|
||||
it('should use default filename when user provides empty input', () => {
|
||||
const defaultFilename = buildCrowdsecExportFilename()
|
||||
promptSpy.mockReturnValue('')
|
||||
const result = promptCrowdsecFilename(defaultFilename)
|
||||
|
||||
expect(result).toBe(defaultFilename)
|
||||
})
|
||||
})
|
||||
})
|
||||
40
frontend/src/utils/__tests__/passwordStrength.test.ts
Normal file
40
frontend/src/utils/__tests__/passwordStrength.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculatePasswordStrength } from '../passwordStrength'
|
||||
|
||||
describe('calculatePasswordStrength', () => {
|
||||
it('returns score 0 for empty password', () => {
|
||||
const result = calculatePasswordStrength('')
|
||||
expect(result.score).toBe(0)
|
||||
expect(result.label).toBe('Empty')
|
||||
})
|
||||
|
||||
it('returns low score for short password', () => {
|
||||
const result = calculatePasswordStrength('short')
|
||||
expect(result.score).toBeLessThan(2)
|
||||
})
|
||||
|
||||
it('returns higher score for longer password', () => {
|
||||
const result = calculatePasswordStrength('longerpassword')
|
||||
expect(result.score).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('rewards complexity (numbers, symbols, uppercase)', () => {
|
||||
const simple = calculatePasswordStrength('password123')
|
||||
const complex = calculatePasswordStrength('Password123!')
|
||||
|
||||
expect(complex.score).toBeGreaterThan(simple.score)
|
||||
})
|
||||
|
||||
it('returns max score for strong password', () => {
|
||||
const result = calculatePasswordStrength('CorrectHorseBatteryStaple1!')
|
||||
expect(result.score).toBe(4)
|
||||
expect(result.label).toBe('Strong')
|
||||
})
|
||||
|
||||
it('provides feedback for weak passwords', () => {
|
||||
const result = calculatePasswordStrength('123456')
|
||||
expect(result.feedback).toBeDefined()
|
||||
// The feedback is an array of strings
|
||||
expect(result.feedback.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
143
frontend/src/utils/__tests__/proxyHostsHelpers.test.ts
Normal file
143
frontend/src/utils/__tests__/proxyHostsHelpers.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
formatSettingLabel,
|
||||
settingHelpText,
|
||||
settingKeyToField,
|
||||
applyBulkSettingsToHosts,
|
||||
} from '../proxyHostsHelpers'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
describe('proxyHostsHelpers', () => {
|
||||
describe('formatSettingLabel', () => {
|
||||
it('returns correct labels for known keys', () => {
|
||||
expect(formatSettingLabel('ssl_forced')).toBe('Force SSL')
|
||||
expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support')
|
||||
expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled')
|
||||
expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains')
|
||||
expect(formatSettingLabel('block_exploits')).toBe('Block Exploits')
|
||||
expect(formatSettingLabel('websocket_support')).toBe('Websockets Support')
|
||||
expect(formatSettingLabel('enable_standard_headers')).toBe('Standard Proxy Headers')
|
||||
})
|
||||
it('returns key for unknown keys', () => {
|
||||
expect(formatSettingLabel('unknown_key')).toBe('unknown_key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingHelpText', () => {
|
||||
it('returns correct help text for known keys', () => {
|
||||
expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic')
|
||||
expect(settingHelpText('http2_support')).toContain('Enable HTTP/2')
|
||||
expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation')
|
||||
})
|
||||
it('returns empty string for unknown keys', () => {
|
||||
expect(settingHelpText('unknown_key')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingKeyToField', () => {
|
||||
it('returns correct field for known keys', () => {
|
||||
expect(settingKeyToField('ssl_forced')).toBe('ssl_forced')
|
||||
expect(settingKeyToField('websocket_support')).toBe('websocket_support')
|
||||
})
|
||||
it('returns key for unknown keys', () => {
|
||||
expect(settingKeyToField('unknown_key')).toBe('unknown_key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBulkSettingsToHosts', () => {
|
||||
const mockHosts: ProxyHost[] = [
|
||||
{ uuid: 'h1', is_enabled: true } as unknown as ProxyHost,
|
||||
{ uuid: 'h2', is_enabled: false } as unknown as ProxyHost
|
||||
]
|
||||
const mockUpdateHost = vi.fn()
|
||||
const mockSetProgress = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('applies settings to specified hosts', async () => {
|
||||
mockUpdateHost.mockResolvedValue({} as ProxyHost)
|
||||
|
||||
const result = await applyBulkSettingsToHosts({
|
||||
hosts: mockHosts,
|
||||
hostUUIDs: ['h1'],
|
||||
keysToApply: ['ssl_forced'],
|
||||
bulkApplySettings: {
|
||||
ssl_forced: { apply: true, value: true }
|
||||
},
|
||||
updateHost: mockUpdateHost,
|
||||
setApplyProgress: mockSetProgress
|
||||
})
|
||||
|
||||
expect(result).toEqual({ errors: 0, completed: 1 })
|
||||
expect(mockUpdateHost).toHaveBeenCalledWith('h1', expect.objectContaining({
|
||||
uuid: 'h1',
|
||||
ssl_forced: true
|
||||
}))
|
||||
expect(mockUpdateHost).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetProgress).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles errors during update', async () => {
|
||||
mockUpdateHost.mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
const result = await applyBulkSettingsToHosts({
|
||||
hosts: mockHosts,
|
||||
hostUUIDs: ['h1'],
|
||||
keysToApply: ['ssl_forced'],
|
||||
bulkApplySettings: {
|
||||
ssl_forced: { apply: true, value: true }
|
||||
},
|
||||
updateHost: mockUpdateHost
|
||||
})
|
||||
|
||||
expect(result).toEqual({ errors: 1, completed: 1 })
|
||||
})
|
||||
|
||||
it('handles missing hosts', async () => {
|
||||
const result = await applyBulkSettingsToHosts({
|
||||
hosts: mockHosts,
|
||||
// h3 doesn't exist
|
||||
hostUUIDs: ['h3'],
|
||||
keysToApply: ['ssl_forced'],
|
||||
bulkApplySettings: {
|
||||
ssl_forced: { apply: true, value: true }
|
||||
},
|
||||
updateHost: mockUpdateHost
|
||||
})
|
||||
|
||||
expect(result).toEqual({ errors: 1, completed: 1 })
|
||||
expect(mockUpdateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles multiple hosts and settings', async () => {
|
||||
mockUpdateHost.mockResolvedValue({} as ProxyHost)
|
||||
|
||||
const result = await applyBulkSettingsToHosts({
|
||||
hosts: mockHosts,
|
||||
hostUUIDs: ['h1', 'h2'],
|
||||
keysToApply: ['ssl_forced', 'http2_support'],
|
||||
bulkApplySettings: {
|
||||
ssl_forced: { apply: true, value: true },
|
||||
http2_support: { apply: true, value: false }
|
||||
},
|
||||
updateHost: mockUpdateHost,
|
||||
setApplyProgress: mockSetProgress
|
||||
})
|
||||
|
||||
expect(result).toEqual({ errors: 0, completed: 2 })
|
||||
expect(mockUpdateHost).toHaveBeenCalledWith('h1', expect.objectContaining({
|
||||
uuid: 'h1',
|
||||
ssl_forced: true,
|
||||
http2_support: false
|
||||
}))
|
||||
expect(mockUpdateHost).toHaveBeenCalledWith('h2', expect.objectContaining({
|
||||
uuid: 'h2',
|
||||
ssl_forced: true,
|
||||
http2_support: false
|
||||
}))
|
||||
expect(mockUpdateHost).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
40
frontend/src/utils/__tests__/toast.test.ts
Normal file
40
frontend/src/utils/__tests__/toast.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast, toastCallbacks } from '../toast'
|
||||
|
||||
describe('toast util', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure callbacks set is empty before each test
|
||||
toastCallbacks.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastCallbacks.clear()
|
||||
})
|
||||
|
||||
it('calls registered callbacks for each toast type', () => {
|
||||
const mock = vi.fn()
|
||||
toastCallbacks.add(mock)
|
||||
|
||||
toast.success('ok')
|
||||
toast.error('bad')
|
||||
toast.info('info')
|
||||
toast.warning('warn')
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(4)
|
||||
expect(mock.mock.calls[0][0]).toMatchObject({ message: 'ok', type: 'success' })
|
||||
expect(mock.mock.calls[1][0]).toMatchObject({ message: 'bad', type: 'error' })
|
||||
expect(mock.mock.calls[2][0]).toMatchObject({ message: 'info', type: 'info' })
|
||||
expect(mock.mock.calls[3][0]).toMatchObject({ message: 'warn', type: 'warning' })
|
||||
})
|
||||
|
||||
it('provides incrementing ids', () => {
|
||||
const mock = vi.fn()
|
||||
toastCallbacks.add(mock)
|
||||
// send multiple messages
|
||||
toast.success('one')
|
||||
toast.success('two')
|
||||
const firstId = mock.mock.calls[0][0].id
|
||||
const secondId = mock.mock.calls[1][0].id
|
||||
expect(secondId).toBeGreaterThan(firstId)
|
||||
})
|
||||
})
|
||||
89
frontend/src/utils/__tests__/validation.test.ts
Normal file
89
frontend/src/utils/__tests__/validation.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
isValidEmail,
|
||||
isIPv4,
|
||||
isPrivateOrDockerIP,
|
||||
isLikelyDockerContainerIP,
|
||||
} from '../validation'
|
||||
|
||||
describe('validation utils', () => {
|
||||
describe('isValidEmail', () => {
|
||||
it('returns true for valid emails', () => {
|
||||
expect(isValidEmail('test@example.com')).toBe(true)
|
||||
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
|
||||
expect(isValidEmail('user+regex@domain.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for invalid emails', () => {
|
||||
expect(isValidEmail('invalid')).toBe(false)
|
||||
expect(isValidEmail('invalid@')).toBe(false)
|
||||
expect(isValidEmail('@domain.com')).toBe(false)
|
||||
expect(isValidEmail('user@domain')).toBe(false)
|
||||
expect(isValidEmail('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIPv4', () => {
|
||||
it('returns true for valid IPv4 addresses', () => {
|
||||
expect(isIPv4('192.168.1.1')).toBe(true)
|
||||
expect(isIPv4('10.0.0.1')).toBe(true)
|
||||
expect(isIPv4('0.0.0.0')).toBe(true)
|
||||
expect(isIPv4('255.255.255.255')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for invalid IPv4 addresses', () => {
|
||||
expect(isIPv4('256.0.0.1')).toBe(false)
|
||||
expect(isIPv4('1.2.3')).toBe(false)
|
||||
expect(isIPv4('1.2.3.4.5')).toBe(false)
|
||||
expect(isIPv4('1.2.3.4.')).toBe(false)
|
||||
expect(isIPv4('abc')).toBe(false)
|
||||
expect(isIPv4('192.168.1.a')).toBe(false)
|
||||
expect(isIPv4('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPrivateOrDockerIP', () => {
|
||||
it('returns true for private IP ranges', () => {
|
||||
expect(isPrivateOrDockerIP('10.0.0.1')).toBe(true)
|
||||
expect(isPrivateOrDockerIP('10.255.255.255')).toBe(true)
|
||||
expect(isPrivateOrDockerIP('192.168.0.1')).toBe(true)
|
||||
expect(isPrivateOrDockerIP('192.168.255.255')).toBe(true)
|
||||
expect(isPrivateOrDockerIP('172.16.0.1')).toBe(true) // Start of 172.16.x.x
|
||||
expect(isPrivateOrDockerIP('172.31.255.255')).toBe(true) // End of 172.31.x.x
|
||||
})
|
||||
|
||||
it('returns false for public or non-private IP ranges', () => {
|
||||
expect(isPrivateOrDockerIP('8.8.8.8')).toBe(false)
|
||||
expect(isPrivateOrDockerIP('1.1.1.1')).toBe(false)
|
||||
expect(isPrivateOrDockerIP('172.15.0.1')).toBe(false) // Below 172.16...
|
||||
expect(isPrivateOrDockerIP('172.32.0.1')).toBe(false) // Above 172.31...
|
||||
expect(isPrivateOrDockerIP('192.167.1.1')).toBe(false)
|
||||
expect(isPrivateOrDockerIP('192.169.1.1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for invalid IPs', () => {
|
||||
expect(isPrivateOrDockerIP('invalid')).toBe(false)
|
||||
expect(isPrivateOrDockerIP('999.999.999.999')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLikelyDockerContainerIP', () => {
|
||||
it('returns true for likely Docker IPs', () => {
|
||||
// Docker default bridge: 172.17.x.x
|
||||
expect(isLikelyDockerContainerIP('172.17.0.2')).toBe(true)
|
||||
// Docker user-defined: 172.18.x.x - 172.31.x.x
|
||||
expect(isLikelyDockerContainerIP('172.18.0.1')).toBe(true)
|
||||
expect(isLikelyDockerContainerIP('172.31.255.255')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-Docker IPs', () => {
|
||||
expect(isLikelyDockerContainerIP('172.16.0.1')).toBe(false) // Private but often not Docker default
|
||||
expect(isLikelyDockerContainerIP('192.168.1.1')).toBe(false)
|
||||
expect(isLikelyDockerContainerIP('10.0.0.1')).toBe(false)
|
||||
expect(isLikelyDockerContainerIP('8.8.8.8')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for invalid IPs', () => {
|
||||
expect(isLikelyDockerContainerIP('invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
6
frontend/src/utils/cn.ts
Normal file
6
frontend/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
32
frontend/src/utils/compareHosts.ts
Normal file
32
frontend/src/utils/compareHosts.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
|
||||
type SortColumn = 'name' | 'domain' | 'forward'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export function compareHosts(a: ProxyHost, b: ProxyHost, sortColumn: SortColumn, sortDirection: SortDirection) {
|
||||
let aVal: string
|
||||
let bVal: string
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
aVal = (a.name || a.domain_names.split(',')[0] || '').toLowerCase()
|
||||
bVal = (b.name || b.domain_names.split(',')[0] || '').toLowerCase()
|
||||
break
|
||||
case 'domain':
|
||||
aVal = (a.domain_names.split(',')[0] || '').toLowerCase()
|
||||
bVal = (b.domain_names.split(',')[0] || '').toLowerCase()
|
||||
break
|
||||
case 'forward':
|
||||
aVal = `${a.forward_host}:${a.forward_port}`.toLowerCase()
|
||||
bVal = `${b.forward_host}:${b.forward_port}`.toLowerCase()
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
|
||||
return 0
|
||||
}
|
||||
|
||||
export default compareHosts
|
||||
24
frontend/src/utils/crowdsecExport.ts
Normal file
24
frontend/src/utils/crowdsecExport.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const buildCrowdsecExportFilename = (): string => {
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
||||
return `crowdsec-export-${timestamp}.tar.gz`
|
||||
}
|
||||
|
||||
export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => {
|
||||
const input = window.prompt('Name your CrowdSec export archive', defaultName)
|
||||
if (input === null || typeof input === 'undefined') return null
|
||||
const trimmed = typeof input === 'string' ? input.trim() : ''
|
||||
const candidate = trimmed || defaultName
|
||||
const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-')
|
||||
return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz`
|
||||
}
|
||||
|
||||
export const downloadCrowdsecExport = (blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
80
frontend/src/utils/passwordStrength.ts
Normal file
80
frontend/src/utils/passwordStrength.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface PasswordStrength {
|
||||
score: number; // 0-4
|
||||
label: string;
|
||||
color: string; // Tailwind color class prefix (e.g., 'red', 'yellow', 'green')
|
||||
feedback: string[];
|
||||
}
|
||||
|
||||
export function calculatePasswordStrength(password: string): PasswordStrength {
|
||||
let score = 0;
|
||||
const feedback: string[] = [];
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
score: 0,
|
||||
label: 'Empty',
|
||||
color: 'gray',
|
||||
feedback: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Length check
|
||||
if (password.length < 8) {
|
||||
feedback.push('Too short (min 8 chars)');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (password.length >= 12) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Complexity checks
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (varietyCount >= 3) {
|
||||
score += 1;
|
||||
}
|
||||
if (varietyCount === 4) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Penalties
|
||||
if (varietyCount < 2 && password.length >= 8) {
|
||||
feedback.push('Add more variety (uppercase, numbers, symbols)');
|
||||
}
|
||||
|
||||
// Cap score at 4
|
||||
score = Math.min(score, 4);
|
||||
|
||||
// Determine label and color
|
||||
let label = 'Very Weak';
|
||||
let color = 'red';
|
||||
|
||||
switch (score) {
|
||||
case 0:
|
||||
case 1:
|
||||
label = 'Weak';
|
||||
color = 'red';
|
||||
break;
|
||||
case 2:
|
||||
label = 'Fair';
|
||||
color = 'yellow';
|
||||
break;
|
||||
case 3:
|
||||
label = 'Good';
|
||||
color = 'green';
|
||||
break;
|
||||
case 4:
|
||||
label = 'Strong';
|
||||
color = 'green';
|
||||
break;
|
||||
}
|
||||
|
||||
return { score, label, color, feedback };
|
||||
}
|
||||
109
frontend/src/utils/proxyHostsHelpers.ts
Normal file
109
frontend/src/utils/proxyHostsHelpers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
|
||||
export function formatSettingLabel(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'Force SSL'
|
||||
case 'http2_support':
|
||||
return 'HTTP/2 Support'
|
||||
case 'hsts_enabled':
|
||||
return 'HSTS Enabled'
|
||||
case 'hsts_subdomains':
|
||||
return 'HSTS Subdomains'
|
||||
case 'block_exploits':
|
||||
return 'Block Exploits'
|
||||
case 'websocket_support':
|
||||
return 'Websockets Support'
|
||||
case 'enable_standard_headers':
|
||||
return 'Standard Proxy Headers'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
export function settingHelpText(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'Redirect all HTTP traffic to HTTPS.'
|
||||
case 'http2_support':
|
||||
return 'Enable HTTP/2 for improved performance.'
|
||||
case 'hsts_enabled':
|
||||
return 'Send HSTS header to enforce HTTPS.'
|
||||
case 'hsts_subdomains':
|
||||
return 'Include subdomains in HSTS policy.'
|
||||
case 'block_exploits':
|
||||
return 'Add common exploit-mitigation headers and rules.'
|
||||
case 'websocket_support':
|
||||
return 'Enable websocket proxying support.'
|
||||
case 'enable_standard_headers':
|
||||
return 'Add X-Real-IP and X-Forwarded-* headers for client IP detection.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function settingKeyToField(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'ssl_forced'
|
||||
case 'http2_support':
|
||||
return 'http2_support'
|
||||
case 'hsts_enabled':
|
||||
return 'hsts_enabled'
|
||||
case 'hsts_subdomains':
|
||||
return 'hsts_subdomains'
|
||||
case 'block_exploits':
|
||||
return 'block_exploits'
|
||||
case 'websocket_support':
|
||||
return 'websocket_support'
|
||||
case 'enable_standard_headers':
|
||||
return 'enable_standard_headers'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBulkSettingsToHosts(options: {
|
||||
hosts: ProxyHost[]
|
||||
hostUUIDs: string[]
|
||||
keysToApply: string[]
|
||||
bulkApplySettings: Record<string, { apply: boolean; value: boolean }>
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => Promise<ProxyHost>
|
||||
setApplyProgress?: (p: { current: number; total: number } | null) => void
|
||||
}) {
|
||||
const { hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress } = options
|
||||
let completed = 0
|
||||
let errors = 0
|
||||
setApplyProgress?.({ current: 0, total: hostUUIDs.length })
|
||||
|
||||
for (const uuid of hostUUIDs) {
|
||||
const patch: Partial<ProxyHost> = {}
|
||||
for (const key of keysToApply) {
|
||||
const field = settingKeyToField(key) as keyof ProxyHost
|
||||
;(patch as unknown as Record<string, unknown>)[field as string] = bulkApplySettings[key].value
|
||||
}
|
||||
|
||||
const host = hosts.find(h => h.uuid === uuid)
|
||||
if (!host) {
|
||||
errors++
|
||||
completed++
|
||||
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
|
||||
continue
|
||||
}
|
||||
|
||||
const merged: Partial<ProxyHost> = { ...host, ...patch }
|
||||
try {
|
||||
await updateHost(uuid, merged)
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
|
||||
completed++
|
||||
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
|
||||
}
|
||||
|
||||
setApplyProgress?.(null)
|
||||
return { errors, completed }
|
||||
}
|
||||
|
||||
export default {}
|
||||
29
frontend/src/utils/toast.ts
Normal file
29
frontend/src/utils/toast.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
let toastId = 0
|
||||
export const toastCallbacks = new Set<(toast: Toast) => void>()
|
||||
|
||||
export const toast = {
|
||||
success: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'success' }))
|
||||
},
|
||||
error: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'error' }))
|
||||
},
|
||||
info: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'info' }))
|
||||
},
|
||||
warning: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'warning' }))
|
||||
},
|
||||
}
|
||||
50
frontend/src/utils/validation.ts
Normal file
50
frontend/src/utils/validation.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid IPv4 address
|
||||
*/
|
||||
export const isIPv4 = (value: string): boolean => {
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
if (!ipv4Regex.test(value)) return false
|
||||
const parts = value.split('.')
|
||||
return parts.every(part => {
|
||||
const num = parseInt(part, 10)
|
||||
return num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP is in a private/Docker range that may change on restart
|
||||
*/
|
||||
export const isPrivateOrDockerIP = (ip: string): boolean => {
|
||||
if (!isIPv4(ip)) return false
|
||||
const parts = ip.split('.').map(p => parseInt(p, 10))
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (parts[0] === 10) return true
|
||||
|
||||
// 172.16.0.0/12 (172.16.x.x - 172.31.x.x) - includes Docker bridge networks
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (parts[0] === 192 && parts[1] === 168) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value looks like a raw IP that could be a Docker container IP
|
||||
*/
|
||||
export const isLikelyDockerContainerIP = (value: string): boolean => {
|
||||
if (!isIPv4(value)) return false
|
||||
const parts = value.split('.').map(p => parseInt(p, 10))
|
||||
|
||||
// Docker default bridge: 172.17.x.x
|
||||
// Docker user-defined: 172.18.x.x - 172.31.x.x
|
||||
if (parts[0] === 172 && parts[1] >= 17 && parts[1] <= 31) return true
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user