Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: simplify redirect url pattern #31

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/.vitepress/utils.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down
26 changes: 19 additions & 7 deletions apps/docs/examples/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions etc/starter-kitty-validators.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -45,7 +50,16 @@ export class UrlValidationError extends Error {
// @public
export class UrlValidator {
constructor(options?: UrlValidatorOptions);
parse<T extends string | URL>(url: string, fallbackUrl: T): URL | T;
// (undocumented)
parse(url: string): URL;
// (undocumented)
parse(url: string, fallbackUrl: undefined): URL;
parsePathname<T extends string | URL>(url: string, fallbackUrl: T): string;
// (undocumented)
parsePathname(url: string): string;
// (undocumented)
parsePathname(url: string, fallbackUrl: undefined): string;
}

// @public
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-config/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { resolve } = require("node:path");
const { resolve } = require("path");

const project = resolve(process.cwd(), "tsconfig.json");

Expand Down
5 changes: 2 additions & 3 deletions packages/safe-fs/src/__tests__/fs.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'

import fs from 'fs'
import { vol } from 'memfs'
import path from 'path'
import { beforeEach, describe, expect, it } from 'vitest'

import { createGetter } from '@/getter'
Expand Down
2 changes: 1 addition & 1 deletion packages/safe-fs/src/getter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import fs from 'node:fs'
import fs from 'fs'

import PARAMS_TO_SANITIZE from '@/params'
import { sanitizePath } from '@/sanitizers'
Expand Down
2 changes: 1 addition & 1 deletion packages/safe-fs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @packageDocumentation
*/

import * as fs from 'node:fs'
import * as fs from 'fs'

import { createGetter } from '@/getter'

Expand Down
4 changes: 2 additions & 2 deletions packages/safe-fs/src/sanitizers.ts
Original file line number Diff line number Diff line change
@@ -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 = /^(\.\.(\/|\\|$))+/

Expand Down
2 changes: 1 addition & 1 deletion packages/safe-fs/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'node:path'
import path from 'path'

export default {
resolve: {
Expand Down
4 changes: 2 additions & 2 deletions packages/safe-fs/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/validators/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opengovsg/starter-kitty-validators",
"version": "1.2.7",
"version": "1.2.9",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
Expand Down
3 changes: 1 addition & 2 deletions packages/validators/src/__tests__/path.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from 'node:path'

import path from 'path'
import { describe, expect, it } from 'vitest'
import { ZodError } from 'zod'

Expand Down
84 changes: 83 additions & 1 deletion packages/validators/src/__tests__/url.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions packages/validators/src/path/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from 'node:path'

import path from 'path'
import { z } from 'zod'

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/validators/src/path/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from 'node:path'

import path from 'path'
import { z } from 'zod'

import { ParsedPathValidatorOptions } from '@/path/options'
Expand Down
2 changes: 1 addition & 1 deletion packages/validators/src/path/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
88 changes: 78 additions & 10 deletions packages/validators/src/url/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,102 @@ 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
}
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<T extends string | URL>(url: string, fallbackUrl: T): URL | T
parse(url: string): URL
parse(url: string, fallbackUrl: undefined): URL
parse<T extends string | URL>(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<T extends string | URL>(url: string, fallbackUrl: T): string
parsePathname(url: string): string
parsePathname(url: string, fallbackUrl: undefined): string
parsePathname<T extends string | URL>(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],
},
})
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/validators/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'node:path'
import path from 'path'

export default {
resolve: {
Expand Down
Loading