From 2e8d685a53257f66c94d1bf5c56c8724c4541720 Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Thu, 7 Nov 2024 13:42:13 +0800 Subject: [PATCH 1/6] fix: package json exports --- packages/validators/package.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/validators/package.json b/packages/validators/package.json index 4d90700..4c31862 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -4,6 +4,10 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, "./url": { "types": "./dist/url/index.d.ts", "default": "./dist/url/index.js" @@ -19,6 +23,9 @@ }, "typesVersions": { "*": { + ".": [ + "./dist/index.d.ts" + ], "url": [ "./dist/url/index.d.ts" ], From 9072b3930ecdef0538233ba59d972f167acea3c4 Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Thu, 7 Nov 2024 14:34:30 +0800 Subject: [PATCH 2/6] bump package version to 1.2.7 --- packages/validators/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validators/package.json b/packages/validators/package.json index 4c31862..6a90fda 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/starter-kitty-validators", - "version": "1.2.6", + "version": "1.2.7", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { From 5742eb99c906cedc746d3adca468da664da6208c Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Tue, 19 Nov 2024 11:07:16 +0800 Subject: [PATCH 3/6] fix: replace node: imports --- apps/docs/.vitepress/utils.ts | 4 ++-- packages/eslint-config/index.js | 2 +- packages/safe-fs/src/__tests__/fs.test.ts | 4 ++-- packages/safe-fs/src/getter.ts | 2 +- packages/safe-fs/src/index.ts | 2 +- packages/safe-fs/src/sanitizers.ts | 4 ++-- packages/safe-fs/vitest.config.ts | 2 +- packages/safe-fs/vitest.setup.ts | 4 ++-- packages/validators/src/__tests__/path.test.ts | 2 +- packages/validators/src/path/options.ts | 2 +- packages/validators/src/path/schema.ts | 2 +- packages/validators/src/path/utils.ts | 2 +- packages/validators/vitest.config.ts | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/docs/.vitepress/utils.ts b/apps/docs/.vitepress/utils.ts index 565a82a..6940e41 100644 --- a/apps/docs/.vitepress/utils.ts +++ b/apps/docs/.vitepress/utils.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs' -import path from 'node:path' +import fs from 'fs' +import path from 'path' export const scanDir = (dir: string) => { let res = fs.readdirSync(path.resolve(__dirname, `../${dir}`)).filter(item => !item.startsWith('.')) as string[] diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index cfe18a2..dc53b40 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -1,4 +1,4 @@ -const { resolve } = require("node:path"); +const { resolve } = require("path"); const project = resolve(process.cwd(), "tsconfig.json"); diff --git a/packages/safe-fs/src/__tests__/fs.test.ts b/packages/safe-fs/src/__tests__/fs.test.ts index f17b73d..3e80b2f 100644 --- a/packages/safe-fs/src/__tests__/fs.test.ts +++ b/packages/safe-fs/src/__tests__/fs.test.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs' -import path from 'node:path' +import fs from 'fs' +import path from 'path' import { vol } from 'memfs' import { beforeEach, describe, expect, it } from 'vitest' diff --git a/packages/safe-fs/src/getter.ts b/packages/safe-fs/src/getter.ts index be4071e..094cf93 100644 --- a/packages/safe-fs/src/getter.ts +++ b/packages/safe-fs/src/getter.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs' +import fs from 'fs' import PARAMS_TO_SANITIZE from '@/params' import { sanitizePath } from '@/sanitizers' diff --git a/packages/safe-fs/src/index.ts b/packages/safe-fs/src/index.ts index e5e5779..58d46f5 100644 --- a/packages/safe-fs/src/index.ts +++ b/packages/safe-fs/src/index.ts @@ -4,7 +4,7 @@ * @packageDocumentation */ -import * as fs from 'node:fs' +import * as fs from 'fs' import { createGetter } from '@/getter' diff --git a/packages/safe-fs/src/sanitizers.ts b/packages/safe-fs/src/sanitizers.ts index 3dcd009..054f821 100644 --- a/packages/safe-fs/src/sanitizers.ts +++ b/packages/safe-fs/src/sanitizers.ts @@ -1,5 +1,5 @@ -import { PathLike } from 'node:fs' -import path from 'node:path' +import { PathLike } from 'fs' +import path from 'path' const LEADING_DOT_SLASH_REGEX = /^(\.\.(\/|\\|$))+/ diff --git a/packages/safe-fs/vitest.config.ts b/packages/safe-fs/vitest.config.ts index 9ff6101..fe822c3 100644 --- a/packages/safe-fs/vitest.config.ts +++ b/packages/safe-fs/vitest.config.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' export default { resolve: { diff --git a/packages/safe-fs/vitest.setup.ts b/packages/safe-fs/vitest.setup.ts index 6319d9d..6b12795 100644 --- a/packages/safe-fs/vitest.setup.ts +++ b/packages/safe-fs/vitest.setup.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' -vi.mock('node:fs', async () => { +vi.mock('fs', async () => { const memfs: { fs: typeof fs } = await vi.importActual('memfs') return { @@ -10,7 +10,7 @@ vi.mock('node:fs', async () => { } }) -vi.mock('node:fs/promises', async () => { +vi.mock('fs/promises', async () => { const memfs: { fs: typeof fs } = await vi.importActual('memfs') return { diff --git a/packages/validators/src/__tests__/path.test.ts b/packages/validators/src/__tests__/path.test.ts index dd4c6ba..ff3c843 100644 --- a/packages/validators/src/__tests__/path.test.ts +++ b/packages/validators/src/__tests__/path.test.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' import { describe, expect, it } from 'vitest' import { ZodError } from 'zod' diff --git a/packages/validators/src/path/options.ts b/packages/validators/src/path/options.ts index 2712c66..3e93ad4 100644 --- a/packages/validators/src/path/options.ts +++ b/packages/validators/src/path/options.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' import { z } from 'zod' diff --git a/packages/validators/src/path/schema.ts b/packages/validators/src/path/schema.ts index a177074..6bb6a25 100644 --- a/packages/validators/src/path/schema.ts +++ b/packages/validators/src/path/schema.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' import { z } from 'zod' diff --git a/packages/validators/src/path/utils.ts b/packages/validators/src/path/utils.ts index 4b21f18..5f48e04 100644 --- a/packages/validators/src/path/utils.ts +++ b/packages/validators/src/path/utils.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' export const isSafePath = (absPath: string, basePath: string): boolean => { // check for poison null bytes diff --git a/packages/validators/vitest.config.ts b/packages/validators/vitest.config.ts index 0733414..d7eb39e 100644 --- a/packages/validators/vitest.config.ts +++ b/packages/validators/vitest.config.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path from 'path' export default { resolve: { From 0c79e953e3b5eb13ab6c73ed2a55a627f6376f04 Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Tue, 19 Nov 2024 11:24:40 +0800 Subject: [PATCH 4/6] chore: bump version number --- packages/validators/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validators/package.json b/packages/validators/package.json index 6a90fda..5975217 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/starter-kitty-validators", - "version": "1.2.7", + "version": "1.2.8", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { From 3ea595bf86c3fd1fbb6bf08a6370218db3264f03 Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Tue, 19 Nov 2024 11:32:47 +0800 Subject: [PATCH 5/6] chore: linting --- packages/safe-fs/src/__tests__/fs.test.ts | 3 +-- packages/validators/src/__tests__/path.test.ts | 1 - packages/validators/src/path/options.ts | 1 - packages/validators/src/path/schema.ts | 1 - 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/safe-fs/src/__tests__/fs.test.ts b/packages/safe-fs/src/__tests__/fs.test.ts index 3e80b2f..3fd5664 100644 --- a/packages/safe-fs/src/__tests__/fs.test.ts +++ b/packages/safe-fs/src/__tests__/fs.test.ts @@ -1,7 +1,6 @@ import fs from 'fs' -import path from 'path' - import { vol } from 'memfs' +import path from 'path' import { beforeEach, describe, expect, it } from 'vitest' import { createGetter } from '@/getter' diff --git a/packages/validators/src/__tests__/path.test.ts b/packages/validators/src/__tests__/path.test.ts index ff3c843..c5c41ce 100644 --- a/packages/validators/src/__tests__/path.test.ts +++ b/packages/validators/src/__tests__/path.test.ts @@ -1,5 +1,4 @@ import path from 'path' - import { describe, expect, it } from 'vitest' import { ZodError } from 'zod' diff --git a/packages/validators/src/path/options.ts b/packages/validators/src/path/options.ts index 3e93ad4..46d9bcc 100644 --- a/packages/validators/src/path/options.ts +++ b/packages/validators/src/path/options.ts @@ -1,5 +1,4 @@ import path from 'path' - import { z } from 'zod' /** diff --git a/packages/validators/src/path/schema.ts b/packages/validators/src/path/schema.ts index 6bb6a25..1df1ab2 100644 --- a/packages/validators/src/path/schema.ts +++ b/packages/validators/src/path/schema.ts @@ -1,5 +1,4 @@ import path from 'path' - import { z } from 'zod' import { ParsedPathValidatorOptions } from '@/path/options' From 6429ebc8f2844ff3f0d17d8d3580f9833e9396cc Mon Sep 17 00:00:00 2001 From: zhongliang02 Date: Tue, 19 Nov 2024 15:10:18 +0800 Subject: [PATCH 6/6] feat: simplify common redirect url pattern --- apps/docs/examples/validators.md | 26 ++++-- etc/starter-kitty-validators.api.md | 14 +++ packages/validators/package.json | 2 +- packages/validators/src/__tests__/url.test.ts | 84 +++++++++++++++++- packages/validators/src/url/index.ts | 88 ++++++++++++++++--- 5 files changed, 195 insertions(+), 19 deletions(-) diff --git a/apps/docs/examples/validators.md b/apps/docs/examples/validators.md index 6a1cc08..4c9194d 100644 --- a/apps/docs/examples/validators.md +++ b/apps/docs/examples/validators.md @@ -52,20 +52,32 @@ Validating a post-login redirect URL provided in a query parameter: ```javascript import { UrlValidator } from '@opengovsg/starter-kitty-validators/url' +const validator = new RelUrlValidator(window.location.origin) +``` + +```javascript +const fallbackUrl = '/home' +window.location.pathname = validator.parsePathname(redirectUrl, fallbackUrl) + +// alternatively +router.push(validator.parsePathname(redirectUrl, fallbackUrl)) +``` + +For more control you can create the UrlValidator instance yourself and invoke .parse + +```javascript +import { UrlValidator } from '@opengovsg/starter-kitty-validators/url' + const validator = new UrlValidator({ whitelist: { protocols: ['http', 'https', 'mailto'], hosts: ['open.gov.sg'], }, }) -``` -```javascript -try { - router.push(validator.parse(redirectUrl)) -} catch (error) { - router.push('/home') -} +... + +validator.parse(userInput) ``` Using the validator as part of a Zod schema to validate the URL and fall back to a default URL if the URL is invalid: diff --git a/etc/starter-kitty-validators.api.md b/etc/starter-kitty-validators.api.md index 6878be4..9052b61 100644 --- a/etc/starter-kitty-validators.api.md +++ b/etc/starter-kitty-validators.api.md @@ -37,6 +37,11 @@ export interface PathValidatorOptions { basePath: string; } +// @public +export class RelUrlValidator extends UrlValidator { + constructor(origin: string | URL); +} + // @public export class UrlValidationError extends Error { constructor(message: string); @@ -45,7 +50,16 @@ export class UrlValidationError extends Error { // @public export class UrlValidator { constructor(options?: UrlValidatorOptions); + parse(url: string, fallbackUrl: T): URL | T; + // (undocumented) parse(url: string): URL; + // (undocumented) + parse(url: string, fallbackUrl: undefined): URL; + parsePathname(url: string, fallbackUrl: T): string; + // (undocumented) + parsePathname(url: string): string; + // (undocumented) + parsePathname(url: string, fallbackUrl: undefined): string; } // @public diff --git a/packages/validators/package.json b/packages/validators/package.json index 5975217..4c6b771 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/starter-kitty-validators", - "version": "1.2.8", + "version": "1.2.9", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/validators/src/__tests__/url.test.ts b/packages/validators/src/__tests__/url.test.ts index 87bb612..fad1d8e 100644 --- a/packages/validators/src/__tests__/url.test.ts +++ b/packages/validators/src/__tests__/url.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { OptionsError } from '@/common/errors' -import { createUrlSchema, UrlValidator } from '@/index' +import { createUrlSchema, RelUrlValidator, UrlValidator } from '@/index' import { UrlValidationError } from '@/url/errors' describe('UrlValidator with default options', () => { @@ -171,6 +171,88 @@ describe('UrlValidator with invalid options', () => { }) }) +describe('RelUrlValidator with string origin', () => { + const validator = new RelUrlValidator('https://a.com') + + it('should parse a valid absolute URL', () => { + const url = validator.parse('https://a.com/hello') + expect(url).toBeInstanceOf(URL) + }) + + it('should throw an error on invalid URL', () => { + expect(() => validator.parse('https://b.com/hello')).toThrow(UrlValidationError) + }) + + it('should parse a valid relative URL', () => { + const url = validator.parse('hello') + expect(url).toBeInstanceOf(URL) + expect(url.href).toStrictEqual('https://a.com/hello') + }) + + it('should parse a valid relative URL', () => { + const url = validator.parse('/hello') + expect(url).toBeInstanceOf(URL) + expect(url.href).toStrictEqual('https://a.com/hello') + }) + + it('should parse a valid relative URL', () => { + const url = validator.parse('/hello?q=3') + expect(url).toBeInstanceOf(URL) + expect(url.href).toStrictEqual('https://a.com/hello?q=3') + }) + + it('should throw an error when the protocol is not http or https', () => { + expect(() => validator.parse('ftp://a.com')).toThrow(UrlValidationError) + }) +}) + +describe('UrlValidatorOptions.parsePathname', () => { + const validator = new RelUrlValidator('https://a.com') + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('hello') + expect(pathname).toStrictEqual('/hello') + }) + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('/hello') + expect(pathname).toStrictEqual('/hello') + }) + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('/hello?q=3#123') + expect(pathname).toStrictEqual('/hello') + }) + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('/hello?q=3#123/what') + expect(pathname).toStrictEqual('/hello') + }) + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('https://a.com/hello?q=3#123/what') + expect(pathname).toStrictEqual('/hello') + }) + + it('should extract the pathname of a valid URL', () => { + const pathname = validator.parsePathname('https://a.com/hello/world') + expect(pathname).toStrictEqual('/hello/world') + }) + + it('should throw an error when the URL is on a different domain', () => { + expect(() => validator.parsePathname('https://b.com/hello/')).toThrow(UrlValidationError) + }) + + it('should throw an error when the path is a NextJS dynamic path', () => { + expect(() => validator.parsePathname('https://a.com/hello/[id]?id=3')).toThrow(UrlValidationError) + }) + + it('should fallback to fallbackUrl if it is provided', () => { + const pathname = validator.parsePathname('https://b.com/hello', 'bye') + expect(pathname).toStrictEqual('bye') + }) +}) + describe('createUrlSchema', () => { it('should create a schema with default options', () => { const schema = createUrlSchema() diff --git a/packages/validators/src/url/index.ts b/packages/validators/src/url/index.ts index aaf7bb0..610b753 100644 --- a/packages/validators/src/url/index.ts +++ b/packages/validators/src/url/index.ts @@ -34,24 +34,19 @@ export class UrlValidator { * @public */ constructor(options: UrlValidatorOptions = defaultOptions) { - const result = optionsSchema.safeParse({ ...defaultOptions, ...options }) - if (result.success) { - this.schema = toSchema(result.data) - return - } - throw new OptionsError(fromError(result.error).toString()) + this.schema = createUrlSchema(options) } /** - * Parses a URL string. + * Parses a URL string * * @param url - The URL to validate - * @throws {@link UrlValidationError} If the URL is invalid + * @throws {@link UrlValidationError} if the URL is invalid. * @returns The URL object if the URL is valid * - * @public + * @internal */ - parse(url: string): URL { + #parse(url: string): URL { const result = this.schema.safeParse(url) if (result.success) { return result.data @@ -59,9 +54,82 @@ export class UrlValidator { if (result.error instanceof ZodError) { throw new UrlValidationError(fromError(result.error).toString()) } else { + // should only be UrlValidationError throw result.error } } + + /** + * Parses a URL string with a fallback option. + * + * @param url - The URL to validate + * @param fallbackUrl - The fallback URL to return if the URL is invalid. This is NOT validated. + * @throws {@link UrlValidationError} if the URL is invalid and fallbackUrl is not provided. + * @returns The URL object if the URL is valid, else the fallbackUrl (if provided). + * + * @public + */ + parse(url: string, fallbackUrl: T): URL | T + parse(url: string): URL + parse(url: string, fallbackUrl: undefined): URL + parse(url: string, fallbackUrl?: T): URL | T { + try { + return this.#parse(url) + } catch (error) { + if (error instanceof UrlValidationError && fallbackUrl !== undefined) { + // URL validation failed, return the fallback URL + // This is NOT validated. + return fallbackUrl + } + // otherwise rethrow + throw error + } + } + + /** + * Parses a URL string and returns the pathname with a fallback option. + * + * @param url - The URL to validate and extract pathname from + * @param fallbackUrl - The fallback URL to use if the URL is invalid. This is NOT validated. + * @throws {@link UrlValidationError} if the URL is invalid and fallbackUrl is not provided. + * @returns The pathname of the URL or the fallback URL + * + * @public + */ + parsePathname(url: string, fallbackUrl: T): string + parsePathname(url: string): string + parsePathname(url: string, fallbackUrl: undefined): string + parsePathname(url: string, fallbackUrl?: T): string { + const parsedUrl = fallbackUrl ? this.parse(url, fallbackUrl) : this.parse(url) + if (parsedUrl instanceof URL) return parsedUrl.pathname + return parsedUrl + } +} + +/** + * Parses URLs according to WHATWG standards and validates against a given origin. + * + * @public + */ +export class RelUrlValidator extends UrlValidator { + /** + * Creates a new RelUrlValidator instance which only allows relative URLs. + * + * @param origin - The base origin against which relative URLs will be resolved. Must be a valid absolute URL (e.g., 'https://example.com'). + * @throws TypeError If the provided origin is not a valid URL. + * + * @public + */ + constructor(origin: string | URL) { + const urlObject = new URL(origin) + super({ + baseOrigin: urlObject.origin, + whitelist: { + protocols: ['http', 'https'], + hosts: [urlObject.host], + }, + }) + } } /**