Skip to content

Commit 463af15

Browse files
committed
feat: support signed urls
1 parent d2aec21 commit 463af15

File tree

9 files changed

+426
-16
lines changed

9 files changed

+426
-16
lines changed

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
"require": "./lib/index.cjs",
1111
"default": "./lib/index.js"
1212
},
13+
"./signed": {
14+
"source": "./src/signed/index.ts",
15+
"import": "./lib/signed/index.js",
16+
"require": "./lib/signed/index.cjs",
17+
"default": "./lib/signed/index.js"
18+
},
1319
"./lib/types/*": {
1420
"import": "./lib/compat/empty.js",
1521
"require": "./lib/compat/empty.cjs",
@@ -91,5 +97,9 @@
9197
"content",
9298
"image-url"
9399
],
100+
"dependencies": {
101+
"@noble/ed25519": "^3.0.0",
102+
"@noble/hashes": "^2.0.0"
103+
},
94104
"packageManager": "[email protected]"
95105
}

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: 38 additions & 16 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
@@ -44,35 +47,50 @@ function rewriteSpecName(key: string) {
4447
/**
4548
* @public
4649
*/
47-
export default 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>
85+
}
86+
87+
/**
88+
* @public
89+
*/
90+
export default function urlBuilder(
91+
options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
92+
) {
93+
return createBuilder(ImageUrlBuilder, options)
7694
}
7795

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

src/signed/signed-builder.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
// public signedOptions: Partial<ImageUrlSigningOptions>
30+
31+
constructor(parent: ImageSignedUrlBuilder | null, options: ImageUrlSignedBuilderOptions) {
32+
super(parent, options)
33+
}
34+
35+
override withOptions(
36+
options: Partial<ImageUrlBuilderOptionsWithAliases & ImageUrlSigningOptions>
37+
): this {
38+
const newOptions = this.constructNewOptions(options)
39+
return new ImageSignedUrlBuilder(this, {...newOptions}) as this
40+
}
41+
42+
expiry(expiry: string | Date) {
43+
return this.withOptions({expiry})
44+
}
45+
46+
signingKey(keyId: string, privateKey: string) {
47+
return this.withOptions({keyId, privateKey})
48+
}
49+
50+
signedUrl() {
51+
const {expiry, keyId, privateKey, ...rest} = this.options
52+
const signedOptions = {expiry, keyId, privateKey}
53+
assertValidSignedOptions(signedOptions)
54+
return signedUrlForImage(rest, signedOptions)
55+
}
56+
}
57+
58+
/**
59+
* @public
60+
*/
61+
export default function urlBuilder(
62+
options?: SanityClientLike | SanityProjectDetails | SanityModernClientLike
63+
) {
64+
return createBuilder(ImageSignedUrlBuilder, options)
65+
}

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 default 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)