Skip to content

Commit 15b38f9

Browse files
committed
feat: support signed urls
1 parent 05bed0c commit 15b38f9

File tree

7 files changed

+374
-17
lines changed

7 files changed

+374
-17
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/builder.ts",
15+
"import": "./lib/signed/index.js",
16+
"require": "./lib/signed/index.cjs",
17+
"default": "./lib/signed/index.js"
18+
},
1319
"./lib/types/types": {
1420
"import": "./lib/empty.js",
1521
"require": "./lib/empty.cjs",
@@ -96,5 +102,9 @@
96102
"content",
97103
"image-url"
98104
],
105+
"dependencies": {
106+
"@noble/ed25519": "^3.0.0",
107+
"@noble/hashes": "^2.0.0"
108+
},
99109
"packageManager": "[email protected]"
100110
}

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-builder.ts

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

src/signedUrlForImage.ts

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

src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export type ImageUrlBuilderOptions = Partial<SanityProjectDetails> & {
3030
frame?: number
3131
}
3232

33-
export type ImageUrlBuilderOptionsWithAliases = ImageUrlBuilderOptions & {
33+
export type ImageUrlBuilderAliases = {
3434
w?: number
3535
h?: number
3636
q?: number
@@ -46,6 +46,8 @@ export type ImageUrlBuilderOptionsWithAliases = ImageUrlBuilderOptions & {
4646
[key: string]: any
4747
}
4848

49+
export type ImageUrlBuilderOptionsWithAliases = ImageUrlBuilderOptions & ImageUrlBuilderAliases
50+
4951
export type ImageUrlBuilderOptionsWithAsset = ImageUrlBuilderOptions & {
5052
asset: {
5153
id: string
@@ -160,3 +162,11 @@ export interface HotspotSpec {
160162
right: number
161163
bottom: number
162164
}
165+
166+
export interface ImageUrlSigningOptions {
167+
keyId: string
168+
privateKey: string
169+
expiry?: string | Date
170+
}
171+
172+
export type ImageUrlSignedBuilderOptions = ImageUrlBuilderOptions & Partial<ImageUrlSigningOptions>

0 commit comments

Comments
 (0)