-
Notifications
You must be signed in to change notification settings - Fork 632
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
366 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. | ||
|
||
/** Provides functions for dealing with and matching ETags, including | ||
* {@linkcode calculate} to calculate an etag for a given entity, | ||
* {@linkcode ifMatch} for validating if an ETag matches against a `If-Match` | ||
* header and {@linkcode ifNoneMatch} for validating an Etag against an | ||
* `If-None-Match` header. | ||
* | ||
* See further information on the `ETag` header on | ||
* [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag). | ||
* | ||
* @module | ||
*/ | ||
|
||
import { encode as base64Encode } from "../encoding/base64.ts"; | ||
|
||
/** Just the part of `Deno.FileInfo` that is required to calculate an `ETag`, | ||
* so partial or user generated file information can be passed. */ | ||
export interface FileInfo { | ||
mtime: Date | null; | ||
size: number; | ||
} | ||
|
||
type Entity = string | Uint8Array | FileInfo; | ||
|
||
const encoder = new TextEncoder(); | ||
|
||
const DEFAULT_ALGORITHM: AlgorithmIdentifier = "SHA-256"; | ||
const ENV_PERM_STATUS = | ||
Deno.permissions.querySync?.({ name: "env", variable: "DENO_DEPLOYMENT_ID" }) | ||
.state ?? "granted"; // for deno deploy | ||
const DENO_DEPLOYMENT_ID = ENV_PERM_STATUS === "granted" | ||
? Deno.env.get("DENO_DEPLOYMENT_ID") | ||
: undefined; | ||
const HASHED_DENO_DEPLOYMENT_ID = DENO_DEPLOYMENT_ID | ||
? crypto.subtle.digest(DEFAULT_ALGORITHM, encoder.encode(DENO_DEPLOYMENT_ID)) | ||
.then((hash) => | ||
`${DENO_DEPLOYMENT_ID.length.toString(16)}-${ | ||
base64Encode(hash).substring(0, 27) | ||
}` | ||
) | ||
: undefined; | ||
|
||
export interface ETagOptions { | ||
/** A digest algorithm to use to calculate the etag. Defaults to | ||
* `"FNV32A"`. */ | ||
algorithm?: AlgorithmIdentifier; | ||
|
||
/** Override the default behavior of calculating the `ETag`, either forcing | ||
* a tag to be labelled weak or not. */ | ||
weak?: boolean; | ||
} | ||
|
||
function isFileInfo(value: unknown): value is FileInfo { | ||
return Boolean( | ||
value && typeof value === "object" && "mtime" in value && "size" in value, | ||
); | ||
} | ||
|
||
async function calcEntity( | ||
entity: string | Uint8Array, | ||
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions, | ||
) { | ||
// a short circuit for zero length entities | ||
if (entity.length === 0) { | ||
return `0-47DEQpj8HBSa+/TImW+5JCeuQeR`; | ||
} | ||
|
||
if (typeof entity === "string") { | ||
entity = encoder.encode(entity); | ||
} | ||
|
||
const hash = base64Encode(await crypto.subtle.digest(algorithm, entity)) | ||
.substring(0, 27); | ||
|
||
return `${entity.length.toString(16)}-${hash}`; | ||
} | ||
|
||
async function calcFileInfo( | ||
fileInfo: FileInfo, | ||
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions, | ||
) { | ||
if (fileInfo.mtime) { | ||
const hash = base64Encode( | ||
await crypto.subtle.digest( | ||
algorithm, | ||
encoder.encode(fileInfo.mtime.toJSON()), | ||
), | ||
).substring(0, 27); | ||
return `${fileInfo.size.toString(16)}-${hash}`; | ||
} | ||
return HASHED_DENO_DEPLOYMENT_ID; | ||
} | ||
|
||
/** Calculate an ETag for an entity. When the entity is a specific set of data | ||
* it will be fingerprinted as a "strong" tag, otherwise if it is just file | ||
* information, it will be calculated as a weak tag. | ||
* | ||
* ```ts | ||
* import { calculate } from "https://deno.land/std@$STD_VERSION/http/etag.ts"; | ||
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts" | ||
* | ||
* const body = "hello deno!"; | ||
* | ||
* const etag = await calculate(body); | ||
* assert(etag); | ||
* | ||
* const res = new Response(body, { headers: { etag } }); | ||
* ``` | ||
*/ | ||
export async function calculate( | ||
entity: Entity, | ||
options: ETagOptions = {}, | ||
): Promise<string | undefined> { | ||
const weak = options.weak ?? isFileInfo(entity); | ||
const tag = | ||
await (isFileInfo(entity) | ||
? calcFileInfo(entity, options) | ||
: calcEntity(entity, options)); | ||
|
||
return tag ? weak ? `W/"${tag}"` : `"${tag}"` : undefined; | ||
} | ||
|
||
/** A helper function that takes the value from the `If-Match` header and a | ||
* calculated etag for the target. By using strong comparison, return `true` if | ||
* the values match, otherwise `false`. | ||
* | ||
* See MDN's [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) | ||
* article for more information on how to use this function. | ||
* | ||
* ```ts | ||
* import { | ||
* calculate, | ||
* ifMatch, | ||
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts"; | ||
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts"; | ||
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts" | ||
* | ||
* const body = "hello deno!"; | ||
* | ||
* await serve(async (req) => { | ||
* const ifMatchValue = req.headers.get("if-match"); | ||
* const etag = await calculate(body); | ||
* assert(etag); | ||
* if (!ifMatchValue || ifMatch(ifMatchValue, etag)) { | ||
* return new Response(body, { status: 200, headers: { etag } }); | ||
* } else { | ||
* return new Response(null, { status: 412, statusText: "Precondition Failed"}); | ||
* } | ||
* }); | ||
* ``` | ||
*/ | ||
export function ifMatch( | ||
value: string | null, | ||
etag: string | undefined, | ||
): boolean { | ||
// Weak tags cannot be matched and return false. | ||
if (!value || !etag || etag.startsWith("W/")) { | ||
return false; | ||
} | ||
if (value.trim() === "*") { | ||
return true; | ||
} | ||
const tags = value.split(/\s*,\s*/); | ||
return tags.includes(etag); | ||
} | ||
|
||
/** A helper function that takes the value from the `If-None-Match` header and | ||
* a calculated etag for the target entity and returns `false` if the etag for | ||
* the entity matches the supplied value, otherwise `true`. | ||
* | ||
* See MDN's [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) | ||
* article for more information on how to use this function. | ||
* | ||
* ```ts | ||
* import { | ||
* calculate, | ||
* ifNoneMatch, | ||
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts"; | ||
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts"; | ||
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts" | ||
* | ||
* const body = "hello deno!"; | ||
* | ||
* await serve(async (req) => { | ||
* const ifNoneMatchValue = req.headers.get("if-none-match"); | ||
* const etag = await calculate(body); | ||
* assert(etag); | ||
* if (!ifNoneMatch(ifNoneMatchValue, etag)) { | ||
* return new Response(null, { status: 304, headers: { etag } }); | ||
* } else { | ||
* return new Response(body, { status: 200, headers: { etag } }); | ||
* } | ||
* }); | ||
* ``` | ||
*/ | ||
export function ifNoneMatch( | ||
value: string | null, | ||
etag: string | undefined, | ||
): boolean { | ||
if (!value || !etag) { | ||
return true; | ||
} | ||
if (value.trim() === "*") { | ||
return false; | ||
} | ||
etag = etag.startsWith("W/") ? etag.slice(2) : etag; | ||
const tags = value.split(/\s*,\s*/).map((tag) => | ||
tag.startsWith("W/") ? tag.slice(2) : tag | ||
); | ||
return !tags.includes(etag); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. | ||
|
||
import { assert, assertEquals } from "../testing/asserts.ts"; | ||
|
||
import { calculate, ifMatch, ifNoneMatch } from "./etag.ts"; | ||
|
||
const encoder = new TextEncoder(); | ||
|
||
Deno.test({ | ||
name: "etag - calculate - string - empty", | ||
async fn() { | ||
const actual = await calculate(""); | ||
assertEquals(actual, `"0-47DEQpj8HBSa+/TImW+5JCeuQeR"`); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - calculate - string", | ||
async fn() { | ||
const actual = await calculate("hello deno"); | ||
assertEquals(actual, `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - calculate - Uint8Array - empty", | ||
async fn() { | ||
const actual = await calculate(new Uint8Array()); | ||
assertEquals(actual, `"0-47DEQpj8HBSa+/TImW+5JCeuQeR"`); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - calculate - Uint8Array", | ||
async fn() { | ||
const actual = await calculate(encoder.encode("hello deno")); | ||
assertEquals(actual, `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - calculate - Deno.FileInfo", | ||
async fn() { | ||
const fixture: Deno.FileInfo = { | ||
isFile: true, | ||
isDirectory: false, | ||
isSymlink: false, | ||
size: 1024, | ||
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)), | ||
atime: null, | ||
birthtime: null, | ||
dev: 0, | ||
ino: null, | ||
mode: null, | ||
nlink: null, | ||
uid: null, | ||
gid: null, | ||
rdev: null, | ||
blksize: null, | ||
blocks: null, | ||
}; | ||
const actual = await calculate(fixture); | ||
assertEquals(actual, `W/"400-H0YzXysQPV20qNisAZMuvAEVuHV"`); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - ifMatch", | ||
async fn() { | ||
assert(!ifMatch(`"abcdefg"`, await calculate("hello deno"))); | ||
assert( | ||
ifMatch(`"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, await calculate("hello deno")), | ||
); | ||
assert( | ||
ifMatch( | ||
`"abcdefg", "a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, | ||
await calculate("hello deno"), | ||
), | ||
); | ||
assert(ifMatch("*", await calculate("hello deno"))); | ||
assert( | ||
!ifMatch( | ||
"*", | ||
await calculate({ | ||
size: 1024, | ||
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)), | ||
}), | ||
), | ||
); | ||
}, | ||
}); | ||
|
||
Deno.test({ | ||
name: "etag - ifNoneMatch", | ||
async fn() { | ||
assert(ifNoneMatch(`"abcdefg"`, await calculate("hello deno"))); | ||
assert( | ||
!ifNoneMatch( | ||
`"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, | ||
await calculate("hello deno"), | ||
), | ||
); | ||
assert( | ||
!ifNoneMatch( | ||
`"abcdefg", "a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, | ||
await calculate("hello deno"), | ||
), | ||
); | ||
assert(!ifNoneMatch("*", await calculate("hello deno"))); | ||
assert( | ||
!ifNoneMatch( | ||
`W/"400-H0YzXysQPV20qNisAZMuvAEVuHV"`, | ||
await calculate({ | ||
size: 1024, | ||
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)), | ||
}), | ||
), | ||
); | ||
assert( | ||
!ifNoneMatch( | ||
`"400-H0YzXysQPV20qNisAZMuvAEVuHV"`, | ||
await calculate({ | ||
size: 1024, | ||
mtime: new Date(Date.UTC(96, 1, 2, 3, 4, 5, 6)), | ||
}), | ||
), | ||
); | ||
}, | ||
}); |
Oops, something went wrong.