Skip to content
Open
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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"@poppinss/matchit": "^3.2.0",
"@poppinss/middleware": "^3.2.7",
"@poppinss/qs": "^6.15.0",
"@poppinss/types": "^1.2.1",
"@poppinss/utils": "^7.0.1",
"@sindresorhus/is": "^8.1.0",
"content-disposition": "^1.1.0",
Expand Down
3 changes: 1 addition & 2 deletions src/client/url_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export { createURL, findRoute }
*/
export function createUrlBuilder<Routes extends LookupList>(
routesLoader:
| { [domain: string]: ClientRouteJSON[] }
| (() => { [domain: string]: ClientRouteJSON[] }),
{ [domain: string]: ClientRouteJSON[] } | (() => { [domain: string]: ClientRouteJSON[] }),
searchParamsStringifier: (qs: Record<string, any>) => string,
defaultOptions?: URLOptions
): UrlFor<Routes> {
Expand Down
8 changes: 2 additions & 6 deletions src/router/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@ export class Route<Controller extends Constructor<any> = any> extends Macroable
pattern: string
methods: string[]
handler:
| RouteFn
| string
| [LazyImport<Controller> | Controller, GetControllerHandlers<Controller>?]
RouteFn | string | [LazyImport<Controller> | Controller, GetControllerHandlers<Controller>?]
globalMatchers: RouteMatchers
}
) {
Expand Down Expand Up @@ -174,9 +172,7 @@ export class Route<Controller extends Constructor<any> = any> extends Macroable
*/
#resolveRouteHandle(
handler:
| RouteFn
| string
| [LazyImport<Controller> | Controller, GetControllerHandlers<Controller>?]
RouteFn | string | [LazyImport<Controller> | Controller, GetControllerHandlers<Controller>?]
) {
/**
* Convert magic string to handle method call
Expand Down
31 changes: 26 additions & 5 deletions src/router/signed_url_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
import type { Encryption } from '@boringnode/encryption'

import { type Router } from './main.ts'
import { createSignedURL } from '../helpers.ts'
import { type UrlFor, type LookupList, type SignedURLOptions } from '../types/url_builder.ts'
import { createSignedURL, parseRoute } from '../helpers.ts'
import { type LookupList, type SignedURLOptions, type SignedUrlFor } from '../types/url_builder.ts'

/**
* Creates the URLBuilder helper for making signed URLs
Expand All @@ -24,15 +24,34 @@ export function createSignedUrlBuilder<Routes extends LookupList>(
router: Router,
encryption: Encryption,
searchParamsStringifier: (qs: Record<string, any>) => string
): UrlFor<Routes, SignedURLOptions> {
): SignedUrlFor<Routes> {
let domainsList: string[]

function createSignedUrlForRoutePattern(
identifier: string,
params: any,
options?: SignedURLOptions
) {
return createSignedURL(
identifier,
parseRoute(identifier),
searchParamsStringifier,
encryption,
params,
options
)
}

function createSignedUrlForRoute(
identifier: string,
params: any,
options?: SignedURLOptions,
method?: string
) {
if (options?.disableRouteLookup) {
return createSignedUrlForRoutePattern(identifier, params, options)
}

if (!domainsList) {
domainsList = Object.keys(router.toJSON()).filter((domain) => domain !== 'root')
}
Expand All @@ -50,8 +69,10 @@ export function createSignedUrlBuilder<Routes extends LookupList>(
)
}

const signedRoute: UrlFor<Routes, SignedURLOptions> = function route(
...[identifier, params, options]
const signedRoute: SignedUrlFor<Routes> = function route(
identifier: string,
params?: unknown,
options?: SignedURLOptions
) {
return createSignedUrlForRoute(identifier, params, options)
}
Expand Down
11 changes: 2 additions & 9 deletions src/types/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ export type StoreRouteHandler =
* Middleware representation stored with route information
*/
export type StoreRouteMiddleware =
| MiddlewareFn
| ({ name?: string; args?: any[] } & ParsedGlobalMiddleware)
MiddlewareFn | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware)

/**
* Route storage structure for a specific HTTP method containing tokens and route mappings
Expand Down Expand Up @@ -188,13 +187,7 @@ export type RouteJSON = Pick<ClientRouteJSON, 'name' | 'methods' | 'domain' | 'p
* Standard RESTful resource action names for CRUD operations
*/
export type ResourceActionNames =
| 'create'
| 'index'
| 'store'
| 'show'
| 'edit'
| 'update'
| 'destroy'
'create' | 'index' | 'store' | 'show' | 'edit' | 'update' | 'destroy'

/**
* @deprecated Options for URL generation (use URLBuilder instead)
Expand Down
27 changes: 27 additions & 0 deletions src/types/url_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* file that was distributed with this source code.
*/

import type { InferRouteParams } from '@poppinss/types'

import {
type UrlFor,
type LookupList,
Expand All @@ -25,8 +27,33 @@ export type SignedURLOptions = URLOptions & {
expiresIn?: string | number
/** Purpose identifier for the signed URL */
purpose?: string
/** Make a signed URL using a route pattern without performing a route lookup */
disableRouteLookup?: boolean
}

/**
* Configuration options for signed URL generation without performing a route lookup
*/
export type SignedURLPatternOptions = SignedURLOptions & {
disableRouteLookup: true
}

type SignedURLPatternParams<Identifier extends string> = string extends Identifier
? any[] | Record<string, any> | undefined
: keyof InferRouteParams<Identifier> extends never
? undefined
: InferRouteParams<Identifier>

/**
* URL builder helper for creating signed URLs.
*/
export type SignedUrlFor<Routes extends LookupList> = UrlFor<Routes, SignedURLOptions> &
(<Identifier extends string>(
identifier: Identifier,
params: SignedURLPatternParams<Identifier>,
options: SignedURLPatternOptions
) => string)

/**
* Utility type to extract routes for a specific HTTP method from the routes collection
*/
Expand Down
117 changes: 117 additions & 0 deletions tests/router/url_builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { URLBuilderFactory } from '../../factories/url_builder_factory.ts'
import {
type UrlFor,
type URLOptions,
type SignedUrlFor,
type GetRoutesForMethod,
type RouteBuilderArguments,
} from '../../src/types/url_builder.ts'
Expand Down Expand Up @@ -290,6 +291,36 @@ test.group('URLBuilder', () => {
assert.isTrue(request.hasValidSignature())
})

test('create and verify signed URLs with route lookup disabled', async ({ assert }) => {
const encryption = new EncryptionFactory().create()
const { signedUrlFor } = new URLBuilderFactory().merge({ encryption }).create()

const signedUrl = signedUrlFor(
'/files/:key',
{ key: 'invoice.pdf' },
{
disableRouteLookup: true,
prefixUrl: 'http://localhost:4000',
qs: {
download: true,
},
}
)

const url = new URL(signedUrl)
const request = new HttpRequestFactory()
.merge({
encryption,
url: `${url.pathname}${url.search}`,
})
.create()

assert.equal(url.origin, 'http://localhost:4000')
assert.equal(url.pathname, '/files/invoice.pdf')
assert.equal(url.searchParams.get('download'), 'true')
assert.isTrue(request.hasValidSignature())
})

test('raise error when unable to lookup route', ({ assert }) => {
const router = new RouterFactory().create()
const { urlFor } = new URLBuilderFactory().merge({ router }).create()
Expand Down Expand Up @@ -684,4 +715,90 @@ test.group('URLBuilder | types', () => {
]
>()
})

test('accept route patterns when signed URL route lookup is disabled', ({ expectTypeOf }) => {
type Routes = {
ALL: {
'users.show': {
params: { id: string }
paramsTuple: [string]
}
}
GET: {
'users.show': {
params: { id: string }
paramsTuple: [string]
}
}
}

type RouteBuilder = SignedUrlFor<Routes>
const assertSignedUrlForTypes = (signedUrlFor: RouteBuilder) => {
expectTypeOf(
signedUrlFor(
'/files/:key',
{ key: 'invoice.pdf' },
{
disableRouteLookup: true,
prefixUrl: 'http://localhost:4000',
}
)
).toEqualTypeOf<string>()

expectTypeOf(
signedUrlFor(
'/files/:key?',
{},
{
disableRouteLookup: true,
}
)
).toEqualTypeOf<string>()

expectTypeOf(
signedUrlFor(
'/files/*',
{ '*': ['invoices', 'invoice.pdf'] },
{
disableRouteLookup: true,
}
)
).toEqualTypeOf<string>()

expectTypeOf(
signedUrlFor('/health', undefined, {
disableRouteLookup: true,
})
).toEqualTypeOf<string>()

const pattern: string = '/files/:key'

expectTypeOf(
signedUrlFor(
pattern,
{ key: 'invoice.pdf' },
{
disableRouteLookup: true,
}
)
).toEqualTypeOf<string>()
}

const assertInvalidSignedUrlForTypes = (signedUrlFor: RouteBuilder) => {
// @ts-expect-error Missing "key" param inferred from the route pattern
signedUrlFor('/files/:key', {}, { disableRouteLookup: true })

// @ts-expect-error Unknown "slug" param is not accepted for the route pattern
signedUrlFor('/files/:key', { slug: 'invoice.pdf' }, { disableRouteLookup: true })

// @ts-expect-error Wildcard params must be provided as an array of strings
signedUrlFor('/files/*', { '*': 'invoice.pdf' }, { disableRouteLookup: true })

// @ts-expect-error Patterns without params expect undefined params
signedUrlFor('/health', {}, { disableRouteLookup: true })
}

expectTypeOf(assertSignedUrlForTypes).returns.toEqualTypeOf<void>()
expectTypeOf(assertInvalidSignedUrlForTypes).returns.toEqualTypeOf<void>()
})
})