mirror of
https://github.com/denoland/std.git
synced 2024-11-22 04:59:05 +00:00
264 lines
7.5 KiB
TypeScript
264 lines
7.5 KiB
TypeScript
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
|
|
// This module is browser compatible.
|
|
|
|
/**
|
|
* Provides functions for dealing with and matching ETags, including
|
|
* {@linkcode eTag} 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
|
|
* {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag | MDN}.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { encodeBase64 as base64Encode } from "@std/encoding/base64";
|
|
|
|
/**
|
|
* Just the part of {@linkcode Deno.FileInfo} that is required to calculate an `ETag`,
|
|
* so partial or user generated file information can be passed.
|
|
*/
|
|
export interface FileInfo {
|
|
/** The last modification time of the file. This corresponds to the `mtime`
|
|
* field from `stat` on Linux/Mac OS and `ftLastWriteTime` on Windows. This
|
|
* may not be available on all platforms. */
|
|
mtime: Date | null;
|
|
/** The size of the file, in bytes. */
|
|
size: number;
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
const DEFAULT_ALGORITHM: AlgorithmIdentifier = "SHA-256";
|
|
|
|
/** Options for {@linkcode eTag}. */
|
|
export interface ETagOptions {
|
|
/**
|
|
* A digest algorithm to use to calculate the etag.
|
|
*
|
|
* @default {"SHA-256"}
|
|
*/
|
|
algorithm?: AlgorithmIdentifier;
|
|
|
|
/**
|
|
* Override the default behavior of calculating the `ETag`, either forcing
|
|
* a tag to be labelled weak or not.
|
|
*
|
|
* Defaults to `true` when the entity is a `FileInfo` and `false` otherwise.
|
|
*/
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate an ETag for string or `Uint8Array` entities. This returns a
|
|
* {@linkcode https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value | strong tag}
|
|
* of the form `"<ascii chars>"`, which guarantees the byte-for-byte equality of the resource.
|
|
*
|
|
* You can optionally set true to the `weak` option to get a weak tag.
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { eTag } from "@std/http/etag";
|
|
* import { assert } from "@std/assert";
|
|
*
|
|
* const body = "hello deno!";
|
|
*
|
|
* const etag = await eTag(body);
|
|
* assert(etag);
|
|
*
|
|
* const res = new Response(body, { headers: { etag } });
|
|
* ```
|
|
*
|
|
* @param entity The entity to get the ETag for.
|
|
* @param options Various additional options.
|
|
* @returns The calculated ETag.
|
|
*/
|
|
export async function eTag(
|
|
entity: string | Uint8Array,
|
|
options?: ETagOptions,
|
|
): Promise<string>;
|
|
/**
|
|
* Calculate an ETag for file information entity. This returns a
|
|
* {@linkcode https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#w | weak tag}
|
|
* of the form `W\"<ascii chars>"`, which guarantees the equivalence of the resource,
|
|
* not the byte-for-byte equality.
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { eTag } from "@std/http/etag";
|
|
* import { assert } from "@std/assert";
|
|
*
|
|
* const fileInfo = await Deno.stat("README.md");
|
|
*
|
|
* const etag = await eTag(fileInfo);
|
|
* assert(etag);
|
|
*
|
|
* const body = (await Deno.open("README.md")).readable;
|
|
*
|
|
* const res = new Response(body, { headers: { etag } });
|
|
* ```
|
|
*
|
|
* @param entity The entity to get the ETag for.
|
|
* @param options Various additional options.
|
|
* @returns The calculated ETag.
|
|
*/
|
|
export async function eTag(
|
|
entity: FileInfo,
|
|
options?: ETagOptions,
|
|
): Promise<string | undefined>;
|
|
export async function eTag(
|
|
entity: string | Uint8Array | FileInfo,
|
|
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;
|
|
}
|
|
|
|
const STAR_REGEXP = /^\s*\*\s*$/;
|
|
const COMMA_REGEXP = /\s*,\s*/;
|
|
|
|
/** 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.
|
|
*
|
|
* @example Usage
|
|
* ```ts no-eval
|
|
* import {
|
|
* eTag,
|
|
* ifMatch,
|
|
* } from "@std/http/etag";
|
|
* import { assert } from "@std/assert";
|
|
*
|
|
* const body = "hello deno!";
|
|
*
|
|
* Deno.serve(async (req) => {
|
|
* const ifMatchValue = req.headers.get("if-match");
|
|
* const etag = await eTag(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"});
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* @param value the If-Match header value.
|
|
* @param etag the ETag to check against.
|
|
* @returns whether or not the parameters match.
|
|
*/
|
|
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 (STAR_REGEXP.test(value)) {
|
|
return true;
|
|
}
|
|
const tags = value.split(COMMA_REGEXP);
|
|
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.
|
|
*
|
|
* @example Usage
|
|
* ```ts no-eval
|
|
* import {
|
|
* eTag,
|
|
* ifNoneMatch,
|
|
* } from "@std/http/etag";
|
|
* import { assert } from "@std/assert";
|
|
*
|
|
* const body = "hello deno!";
|
|
*
|
|
* Deno.serve(async (req) => {
|
|
* const ifNoneMatchValue = req.headers.get("if-none-match");
|
|
* const etag = await eTag(body);
|
|
* assert(etag);
|
|
* if (!ifNoneMatch(ifNoneMatchValue, etag)) {
|
|
* return new Response(null, { status: 304, headers: { etag } });
|
|
* } else {
|
|
* return new Response(body, { status: 200, headers: { etag } });
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* @param value the If-None-Match header value.
|
|
* @param etag the ETag to check against.
|
|
* @returns whether or not the parameters do not match.
|
|
*/
|
|
export function ifNoneMatch(
|
|
value: string | null,
|
|
etag: string | undefined,
|
|
): boolean {
|
|
if (!value || !etag) {
|
|
return true;
|
|
}
|
|
if (STAR_REGEXP.test(value)) {
|
|
return false;
|
|
}
|
|
etag = etag.startsWith("W/") ? etag.slice(2) : etag;
|
|
const tags = value.split(COMMA_REGEXP).map((tag) =>
|
|
tag.startsWith("W/") ? tag.slice(2) : tag
|
|
);
|
|
return !tags.includes(etag);
|
|
}
|