Skip to content

Commit 7bac3b9

Browse files
committed
feat: support signed urls
1 parent e2062d4 commit 7bac3b9

File tree

8 files changed

+423
-17
lines changed

8 files changed

+423
-17
lines changed

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
"import": "./lib/index.js",
1010
"default": "./lib/index.js"
1111
},
12+
"./signed": {
13+
"source": "./src/signed/index.ts",
14+
"import": "./lib/signed/index.js",
15+
"require": "./lib/signed/index.cjs",
16+
"default": "./lib/signed/index.js"
17+
},
1218
"./package.json": "./package.json"
1319
},
1420
"main": "./lib/index.js",
@@ -79,5 +85,9 @@
7985
"content",
8086
"image-url"
8187
],
88+
"dependencies": {
89+
"@noble/ed25519": "^3.0.0",
90+
"@noble/hashes": "^2.0.0"
91+
},
8292
"packageManager": "[email protected]"
8393
}

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/builder.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ function isSanityClientLike(
2929
return client && 'clientConfig' in client ? typeof client.clientConfig === 'object' : false
3030
}
3131

32-
function rewriteSpecName(key: string) {
32+
/**
33+
* @internal
34+
*/
35+
export function rewriteSpecName(key: string) {
3336
const specs = SPEC_NAME_TO_URL_NAME_MAPPINGS
3437
for (const entry of specs) {
3538
const [specName, param] = entry
@@ -42,42 +45,57 @@ function rewriteSpecName(key: string) {
4245
}
4346

4447
/**
45-
* @public
48+
* @internal
4649
*/
47-
export function urlBuilder(
48-
options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
49-
) {
50-
// Did we get a modernish client?
51-
if (isSanityModernClientLike(options)) {
50+
export function createBuilder<C extends typeof ImageUrlBuilder>(
51+
Builder: C,
52+
_options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
53+
): InstanceType<C> {
54+
let options: ConstructorParameters<C>[1] = {}
55+
56+
if (isSanityModernClientLike(_options)) {
5257
// Inherit config from client
53-
const {apiHost: apiUrl, projectId, dataset} = options.config()
58+
const {apiHost: apiUrl, projectId, dataset} = _options.config()
5459
const apiHost = apiUrl || 'https://api.sanity.io'
55-
return new ImageUrlBuilder(null, {
60+
options = {
5661
baseUrl: apiHost.replace(/^https:\/\/api\./, 'https://cdn.'),
5762
projectId,
5863
dataset,
59-
})
64+
}
6065
}
6166

6267
// Did we get a SanityClient?
63-
if (isSanityClientLike(options)) {
68+
else if (isSanityClientLike(_options)) {
6469
// Inherit config from client
65-
const {apiHost: apiUrl, projectId, dataset} = options.clientConfig
70+
const {apiHost: apiUrl, projectId, dataset} = _options.clientConfig
6671
const apiHost = apiUrl || 'https://api.sanity.io'
67-
return new ImageUrlBuilder(null, {
72+
options = {
6873
baseUrl: apiHost.replace(/^https:\/\/api\./, 'https://cdn.'),
6974
projectId,
7075
dataset,
71-
})
76+
}
7277
}
7378

7479
// Or just accept the options as given
75-
return new ImageUrlBuilder(null, options || {})
80+
else {
81+
options = _options || {}
82+
}
83+
84+
return new Builder(null, options) as InstanceType<C>
7685
}
7786

7887
/**
7988
* @internal
8089
*/
90+
export function urlBuilder(
91+
options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
92+
) {
93+
return createBuilder(ImageUrlBuilder, options)
94+
}
95+
96+
/**
97+
* @public
98+
*/
8199
export class ImageUrlBuilder {
82100
public options: ImageUrlBuilderOptions
83101

@@ -87,7 +105,7 @@ export class ImageUrlBuilder {
87105
: {...(options || {})} // Copy options
88106
}
89107

90-
withOptions(options: Partial<ImageUrlBuilderOptionsWithAliases>) {
108+
protected constructNewOptions(options: Partial<ImageUrlBuilderOptionsWithAliases>) {
91109
const baseUrl = options.baseUrl || this.options.baseUrl
92110

93111
const newOptions: {[key: string]: any} = {baseUrl}
@@ -97,8 +115,12 @@ export class ImageUrlBuilder {
97115
newOptions[specKey] = options[key]
98116
}
99117
}
118+
return {baseUrl, ...newOptions}
119+
}
100120

101-
return new ImageUrlBuilder(this, {baseUrl, ...newOptions})
121+
withOptions(options: Partial<ImageUrlBuilderOptionsWithAliases>): this {
122+
const newOptions = this.constructNewOptions(options)
123+
return new ImageUrlBuilder(this, newOptions) as this
102124
}
103125

104126
// The image to be represented. Accepts a Sanity 'image'-document, 'asset'-document or

src/signed/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export {urlBuilder} from './signed-builder'
2+
3+
export type {
4+
AutoMode,
5+
CropMode,
6+
CropSpec,
7+
FitMode,
8+
HotspotSpec,
9+
ImageFormat,
10+
ImageUrlBuilderOptions,
11+
ImageUrlBuilderOptionsWithAliases,
12+
ImageUrlBuilderOptionsWithAsset,
13+
Orientation,
14+
SanityAsset,
15+
SanityClientLike,
16+
SanityImageCrop,
17+
SanityImageDimensions,
18+
SanityImageFitResult,
19+
SanityImageHotspot,
20+
SanityImageObject,
21+
SanityImageRect,
22+
SanityImageSource,
23+
SanityImageWithAssetStub,
24+
SanityModernClientLike,
25+
SanityProjectDetails,
26+
SanityReference,
27+
} from '../types'
28+
29+
export type {ImageUrlSigningOptions, ImageUrlSignedBuilderOptions} from './types'

src/signed/signed-builder.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {createBuilder, ImageUrlBuilder, rewriteSpecName} from '../builder'
2+
import {signedUrlForImage} from './signedUrlForImage'
3+
import type {ImageUrlSignedBuilderOptions, ImageUrlSigningOptions} from './types'
4+
import type {
5+
ImageUrlBuilderOptions,
6+
ImageUrlBuilderOptionsWithAliases,
7+
SanityClientLike,
8+
SanityModernClientLike,
9+
SanityProjectDetails,
10+
} from '../types'
11+
12+
function assertValidSignedOptions(
13+
opts: Partial<ImageUrlSigningOptions>
14+
): asserts opts is ImageUrlSigningOptions {
15+
if (typeof opts.keyId !== 'string') {
16+
throw new Error('Cannot call `signedUrl()` without `keyId`')
17+
}
18+
19+
if (typeof opts.privateKey !== 'string') {
20+
throw new Error('Cannot call `signedUrl()` without `privateKey`')
21+
}
22+
}
23+
24+
/**
25+
* @public
26+
*/
27+
export class ImageSignedUrlBuilder extends ImageUrlBuilder {
28+
public declare options: ImageUrlBuilderOptions & Partial<ImageUrlSigningOptions>
29+
30+
constructor(parent: ImageSignedUrlBuilder | null, options: ImageUrlSignedBuilderOptions) {
31+
super(parent, options)
32+
}
33+
34+
override withOptions(
35+
options: Partial<ImageUrlBuilderOptionsWithAliases & ImageUrlSigningOptions>
36+
): this {
37+
const newOptions = this.constructNewOptions(options)
38+
return new ImageSignedUrlBuilder(this, {...newOptions}) as this
39+
}
40+
41+
expiry(expiry: string | Date) {
42+
return this.withOptions({expiry})
43+
}
44+
45+
signingKey(keyId: string, privateKey: string) {
46+
return this.withOptions({keyId, privateKey})
47+
}
48+
49+
signedUrl() {
50+
const {expiry, keyId, privateKey, ...rest} = this.options
51+
const signedOptions = {expiry, keyId, privateKey}
52+
assertValidSignedOptions(signedOptions)
53+
return signedUrlForImage(rest, signedOptions)
54+
}
55+
}
56+
57+
/**
58+
* @public
59+
*/
60+
export function urlBuilder(
61+
options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
62+
) {
63+
return createBuilder(ImageSignedUrlBuilder, options)
64+
}

src/signed/signedUrlForImage.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type {ImageUrlBuilderOptions} from '../types'
2+
import type {ImageUrlSigningOptions} from './types'
3+
import {etc, hashes, sign} from '@noble/ed25519'
4+
import {sha512} from '@noble/hashes/sha2.js'
5+
import {urlForImage} from '../urlForImage'
6+
7+
hashes.sha512 = sha512
8+
9+
// Swap alphabet, keep '=' padding
10+
function normalizeBase64Url(base64: string): string {
11+
return base64.replace(/\+/g, '-').replace(/\//g, '_')
12+
}
13+
14+
// Go's base64.URLEncoding uses a URL-safe alphabet WITH '=' padding. To match,
15+
// we need only URL-safe chars, to allow between 0 to 2 '=' at the end, and
16+
// ensure length is a multiple of 4
17+
function toBase64UrlWithPadding(bytes: Uint8Array): string {
18+
let b64: string
19+
20+
if (typeof Buffer !== 'undefined') {
21+
// Node: Uint8Array → Buffer → base64
22+
b64 = Buffer.from(bytes).toString('base64')
23+
} else {
24+
// Browser: Uint8Array → binary string → btoa
25+
// @todo Do we want to allow signing in the browser?
26+
const bin = String.fromCharCode(...bytes)
27+
b64 = btoa(bin)
28+
}
29+
// Ensure padding, although likely unnecessary as an Ed25519 signature is
30+
// always 64 bytes / 88 chars when encoded in b64
31+
if (b64.length % 4) b64 += '='.repeat(4 - (b64.length % 4))
32+
33+
return normalizeBase64Url(b64)
34+
}
35+
36+
function normalizeExpiry(expiry: string | Date | undefined): string | undefined {
37+
if (!expiry) {
38+
return undefined
39+
}
40+
41+
let date: Date
42+
if (expiry instanceof Date) {
43+
date = expiry
44+
} else {
45+
date = new Date(expiry)
46+
if (isNaN(date.getTime())) {
47+
throw new Error('Invalid expiry date format')
48+
}
49+
}
50+
51+
const now = new Date()
52+
if (date.getTime() < now.getTime()) {
53+
throw new Error('Expiry date must be in the future')
54+
}
55+
56+
// Format as 'YYYY-MM-DDTHH:mm:ssZ' (strip milliseconds)
57+
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
58+
}
59+
60+
export function signedUrlForImage(
61+
options: ImageUrlBuilderOptions,
62+
signingOptions: ImageUrlSigningOptions
63+
): string {
64+
const {expiry, keyId, privateKey} = signingOptions
65+
// Get the base URL without any signing specific parameters
66+
const baseUrl = urlForImage(options)
67+
// Support expiry as Date or string
68+
const expiryString = normalizeExpiry(expiry)
69+
// Append keyid and expiry (if present), in that order
70+
const sep = baseUrl.includes('?') ? '&' : '?'
71+
const query = [`keyid=${keyId}`, expiryString && `expiry=${expiryString}`]
72+
.filter(Boolean)
73+
.join('&')
74+
const urlToSign = `${baseUrl}${sep}${query}`
75+
// Encode the URL as bytes
76+
const urlBytes = new TextEncoder().encode(urlToSign)
77+
// Encode the private key as bytes
78+
const privateKeyBytes = etc.hexToBytes(privateKey)
79+
// Get the signed URL as bytes
80+
const signatureBytes = sign(urlBytes, privateKeyBytes)
81+
// Convert the signature to a URL-safe base64 string
82+
const signatureBase64 = toBase64UrlWithPadding(signatureBytes)
83+
// Construct and return the full signed URL
84+
return `${urlToSign}&signature=${signatureBase64}`
85+
}

src/signed/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type {ImageUrlBuilderOptions} from '../types'
2+
3+
/**
4+
* @public
5+
*/
6+
export interface ImageUrlSigningOptions {
7+
keyId: string
8+
privateKey: string
9+
expiry?: string | Date
10+
}
11+
12+
/**
13+
* @public
14+
*/
15+
export type ImageUrlSignedBuilderOptions = ImageUrlBuilderOptions & Partial<ImageUrlSigningOptions>

0 commit comments

Comments
 (0)