diff --git a/packages/miniflare/src/plugins/r2/errors.ts b/packages/miniflare/src/plugins/r2/errors.ts new file mode 100644 index 000000000000..df3e623d7d16 --- /dev/null +++ b/packages/miniflare/src/plugins/r2/errors.ts @@ -0,0 +1,174 @@ +import { Response } from "undici"; +import { R2Object } from "./r2Object"; +import { CfHeader } from "./router"; + +enum Status { + BadRequest = 400, + NotFound = 404, + PreconditionFailed = 412, + RangeNotSatisfiable = 416, + InternalError = 500, +} +enum CfCode { + InternalError = 10001, + NoSuchObjectKey = 10007, + EntityTooLarge = 100100, + InvalidDigest = 10014, + InvalidObjectName = 10020, + InvalidMaxKeys = 10022, + InvalidArgument = 10029, + PreconditionFailed = 10031, + BadDigest = 10037, + InvalidRange = 10039, +} + +export class R2Error extends Error { + status: number; + v4Code: number; + object?: R2Object; + constructor(status: number, message: string, v4Code: number) { + super(message); + this.name = "R2Error"; + this.status = status; + this.v4Code = v4Code; + } + + toResponse() { + if (this.object !== undefined) { + const { metadataSize, value } = this.object.encode(); + return new Response(value, { + status: this.status, + headers: { + [CfHeader.MetadataSize]: `${metadataSize}`, + "Content-Type": "application/json", + [CfHeader.Error]: JSON.stringify({ + message: this.message, + version: 1, + // Note the lowercase 'c', which the runtime expects + v4code: this.v4Code, + }), + }, + }); + } + return new Response(null, { + status: this.status, + headers: { + [CfHeader.Error]: JSON.stringify({ + message: this.message, + version: 1, + // Note the lowercase 'c', which the runtime expects + v4code: this.v4Code, + }), + }, + }); + } + + context(info: string) { + this.message += ` (${info})`; + return this; + } + + attach(object: R2Object) { + this.object = object; + return this; + } +} + +export class InvalidMetadata extends R2Error { + constructor() { + super( + Status.BadRequest, + "Metadata missing or invalid", + CfCode.InvalidArgument + ); + } +} + +export class InternalError extends R2Error { + constructor() { + super( + Status.InternalError, + "We encountered an internal error. Please try again.", + CfCode.InternalError + ); + } +} +export class NoSuchKey extends R2Error { + constructor() { + super( + Status.NotFound, + "The specified key does not exist.", + CfCode.NoSuchObjectKey + ); + } +} + +export class EntityTooLarge extends R2Error { + constructor() { + super( + Status.BadRequest, + "Your proposed upload exceeds the maximum allowed object size.", + CfCode.EntityTooLarge + ); + } +} + +export class InvalidDigest extends R2Error { + constructor() { + super( + Status.BadRequest, + "The Content-MD5 you specified is not valid.", + CfCode.InvalidDigest + ); + } +} + +export class BadDigest extends R2Error { + constructor() { + super( + Status.BadRequest, + "The Content-MD5 you specified did not match what we received.", + CfCode.BadDigest + ); + } +} + +export class InvalidObjectName extends R2Error { + constructor() { + super( + Status.BadRequest, + "The specified object name is not valid.", + CfCode.InvalidObjectName + ); + } +} + +export class InvalidMaxKeys extends R2Error { + constructor() { + super( + Status.BadRequest, + "MaxKeys params must be positive integer <= 1000.", + CfCode.InvalidMaxKeys + ); + } +} + +export class PreconditionFailed extends R2Error { + constructor() { + super( + Status.PreconditionFailed, + "At least one of the pre-conditions you specified did not hold.", + CfCode.PreconditionFailed + ); + } +} + +export class InvalidRange extends R2Error { + constructor() { + super( + Status.RangeNotSatisfiable, + "The requested range is not satisfiable", + CfCode.InvalidRange + ); + } +} diff --git a/packages/miniflare/src/plugins/r2/gateway.ts b/packages/miniflare/src/plugins/r2/gateway.ts index 516f92c73ca4..4331ec472af0 100644 --- a/packages/miniflare/src/plugins/r2/gateway.ts +++ b/packages/miniflare/src/plugins/r2/gateway.ts @@ -1,24 +1,218 @@ -import { Clock, Storage } from "@miniflare/shared"; +import { RangeStoredValueMeta, Storage } from "@miniflare/shared"; +import { InvalidRange, NoSuchKey } from "./errors"; +import { + R2HTTPMetadata, + R2Object, + R2ObjectBody, + R2ObjectMetadata, + createVersion, +} from "./r2Object"; +import { Validator } from "./validator"; + +// For more information, refer to https://datatracker.ietf.org/doc/html/rfc7232 +export interface R2Conditional { + // Performs the operation if the object’s etag matches the given string. + etagMatches?: string; + // Performs the operation if the object’s etag does not match the given string. + etagDoesNotMatch?: string; + // Performs the operation if the object was uploaded before the given date. + uploadedBefore?: number; + // Performs the operation if the object was uploaded after the given date. + uploadedAfter?: number; +} + +export interface R2Range { + offset?: number; + length?: number; + suffix?: number; +} + +export interface R2GetOptions { + // Specifies that the object should only be returned given satisfaction of + // certain conditions in the R2Conditional. Refer to R2Conditional above. + onlyIf?: R2Conditional; + // Specifies that only a specific length (from an optional offset) or suffix + // of bytes from the object should be returned. Refer to + // https://developers.cloudflare.com/r2/runtime-apis/#ranged-reads. + range?: R2Range; +} + +export interface R2PutOptions { + // Various HTTP headers associated with the object. Refer to + // https://developers.cloudflare.com/r2/runtime-apis/#http-metadata. + httpMetadata: R2HTTPMetadata; + // A map of custom, user-defined metadata that will be stored with the object. + customMetadata: Record; + // A md5 hash to use to check the recieved object’s integrity. + md5?: string; +} + +export type R2ListOptionsInclude = ("httpMetadata" | "customMetadata")[]; + +export interface R2ListOptions { + // The number of results to return. Defaults to 1000, with a maximum of 1000. + limit?: number; + // The prefix to match keys against. Keys will only be returned if they start with given prefix. + prefix?: string; + // An opaque token that indicates where to continue listing objects from. + // A cursor can be retrieved from a previous list operation. + cursor?: string; + // The character to use when grouping keys. + delimiter?: string; + // Can include httpFields and/or customFields. If included, items returned by + // the list will include the specified metadata. Note that there is a limit on the + // total amount of data that a single list operation can return. + // If you request data, you may recieve fewer than limit results in your response + // to accomodate metadata. + // Use the truncated property to determine if the list request has more data to be returned. + include?: R2ListOptionsInclude; +} + +export interface R2Objects { + // An array of objects matching the list request. + objects: R2Object[]; + // If true, indicates there are more results to be retrieved for the current list request. + truncated: boolean; + // A token that can be passed to future list calls to resume listing from that point. + // Only present if truncated is true. + cursor?: string; + // If a delimiter has been specified, contains all prefixes between the specified + // prefix and the next occurence of the delimiter. + // For example, if no prefix is provided and the delimiter is ‘/’, foo/bar/baz + // would return foo as a delimited prefix. If foo/ was passed as a prefix + // with the same structure and delimiter, foo/bar would be returned as a delimited prefix. + delimitedPrefixes: string[]; +} + +const MAX_LIST_KEYS = 1_000; +// https://developers.cloudflare.com/r2/platform/limits/ (5GB - 5MB) + +const validate = new Validator(); export class R2Gateway { - constructor( - private readonly storage: Storage, - private readonly clock: Clock - ) {} + constructor(private readonly storage: Storage) {} + + async head(key: string): Promise { + validate.key(key); + + // Get value, returning null if not found + const stored = await this.storage.head(key); - async get(key: string) { - throw new Error("Not yet implemented!"); + if (stored?.metadata === undefined) throw new NoSuchKey(); + const { metadata } = stored; + + return new R2Object(metadata); } - async put(key: string, value: Uint8Array) { - throw new Error("Not yet implemented!"); + async get( + key: string, + options: R2GetOptions = {} + ): Promise { + const { range = {}, onlyIf } = options; + validate + .key(key) + .getOptions(options) + .condition(await this.head(key), onlyIf); + + let stored: RangeStoredValueMeta | undefined; + + // get data dependent upon whether suffix or range exists + try { + stored = await this.storage.getRange(key, range); + } catch { + throw new InvalidRange(); + } + if (stored?.metadata === undefined) throw new NoSuchKey(); + const { value, metadata } = stored; + // add range should it exist + if ("range" in stored && stored.range !== undefined) { + metadata.range = stored.range; + } + + return new R2ObjectBody(metadata, value); + } + + async put( + key: string, + value: Uint8Array, + options: R2PutOptions + ): Promise { + const { customMetadata, md5, httpMetadata } = options; + + const hash = validate + .key(key) + .putOptions(options) + .size(value) + .md5(value, md5); + + // build metadata + const metadata: R2ObjectMetadata = { + key, + size: value.byteLength, + etag: hash, + version: createVersion(), + httpEtag: `"${hash}"`, + uploaded: Date.now(), + httpMetadata, + customMetadata, + }; + + // Store value with expiration and metadata + await this.storage.put(key, { + value, + metadata, + }); + + return new R2Object(metadata); } async delete(key: string) { - throw new Error("Not yet implemented!"); + validate.key(key); + await this.storage.delete(key); } - async list() { - throw new Error("Not yet implemented!"); + async list(listOptions: R2ListOptions = {}): Promise { + const delimitedPrefixes = new Set(); + + validate.listOptions(listOptions); + + const { prefix = "", include = [], cursor = "" } = listOptions; + let { delimiter, limit = MAX_LIST_KEYS } = listOptions; + if (delimiter === "") delimiter = undefined; + + // if include contains inputs, we reduce the limit to max 100 + if (include.length > 0) limit = Math.min(limit, 100); + + const res = await this.storage.list({ + prefix, + limit, + cursor, + delimiter, + }); + // add delimited prefixes should they exist + for (const dP of res.delimitedPrefixes ?? []) delimitedPrefixes.add(dP); + + const objects = res.keys + // grab metadata + .map((k) => k.metadata) + // filter out objects that exist within the delimiter + .filter( + (metadata): metadata is R2ObjectMetadata => metadata !== undefined + ) + // filter "httpFields" and/or "customFields" if found in "include" + .map((metadata) => { + if (!include.includes("httpMetadata")) metadata.httpMetadata = {}; + if (!include.includes("customMetadata")) metadata.customMetadata = {}; + + return new R2Object(metadata); + }); + + const cursorLength = res.cursor.length > 0; + return { + objects, + truncated: cursorLength, + cursor: cursorLength ? res.cursor : undefined, + delimitedPrefixes: [...delimitedPrefixes], + }; } } diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index 23365b176d1a..db2d08ae5ae9 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -1,5 +1,15 @@ import { z } from "zod"; -import { PersistenceSchema, Plugin } from "../shared"; +import { Service, Worker_Binding } from "../../runtime"; +import { SERVICE_LOOPBACK } from "../core"; +import { + BINDING_SERVICE_LOOPBACK, + encodePersist, + BINDING_TEXT_PLUGIN, + BINDING_TEXT_NAMESPACE, + Plugin, + SCRIPT_PLUGIN_NAMESPACE_PERSIST, + PersistenceSchema, +} from "../shared"; import { R2Gateway } from "./gateway"; import { R2Router } from "./router"; @@ -21,10 +31,37 @@ export const R2_PLUGIN: Plugin< options: R2OptionsSchema, sharedOptions: R2SharedOptionsSchema, getBindings(options) { - return undefined; + const bindings = Object.entries( + options.r2Buckets ?? [] + ).map(([name, id]) => ({ + name, + r2Bucket: { name: `${R2_PLUGIN_NAME}:${id}` }, + })); + + return bindings; }, - getServices(options) { - return undefined; + getServices({ options, sharedOptions }) { + const persistBinding = encodePersist(sharedOptions.r2Persist); + const loopbackBinding: Worker_Binding = { + name: BINDING_SERVICE_LOOPBACK, + service: { name: SERVICE_LOOPBACK }, + }; + const services = Object.entries(options.r2Buckets ?? []).map( + ([_, id]) => ({ + name: `${R2_PLUGIN_NAME}:${id}`, + worker: { + serviceWorkerScript: SCRIPT_PLUGIN_NAMESPACE_PERSIST, + bindings: [ + ...persistBinding, + { name: BINDING_TEXT_PLUGIN, text: R2_PLUGIN_NAME }, + { name: BINDING_TEXT_NAMESPACE, text: id }, + loopbackBinding, + ], + }, + }) + ); + + return services; }, }; diff --git a/packages/miniflare/src/plugins/r2/r2Object.ts b/packages/miniflare/src/plugins/r2/r2Object.ts new file mode 100644 index 000000000000..0851a6c72539 --- /dev/null +++ b/packages/miniflare/src/plugins/r2/r2Object.ts @@ -0,0 +1,141 @@ +import crypto from "crypto"; +import { TextEncoder } from "util"; +import { R2Objects, R2Range } from "./gateway"; + +const encoder = new TextEncoder(); +export interface R2HTTPMetadata { + contentType?: string; + contentLanguage?: string; + contentDisposition?: string; + contentEncoding?: string; + cacheControl?: string; + cacheExpiry?: Date; +} + +export interface R2ObjectMetadata { + // The object’s key. + key: string; + // Random unique string associated with a specific upload of a key. + version: string; + // Size of the object in bytes. + size: number; + // The etag associated with the object upload. + etag: string; + // The object’s etag, in quotes so as to be returned as a header. + httpEtag: string; + // The time the object was uploaded. + uploaded: number; + // Various HTTP headers associated with the object. Refer to HTTP Metadata. + httpMetadata: R2HTTPMetadata; + // A map of custom, user-defined metadata associated with the object. + customMetadata: Record; + // If a GET request was made with a range option, this will be added + range?: R2Range; +} + +// R2ObjectMetadata in the format the Workers Runtime expects to be returned +export interface RawR2ObjectMetadata + extends Omit { + // The object’s name. + name: string; + // Various HTTP headers associated with the object. Refer to HTTP Metadata. + httpFields: R2HTTPMetadata; + // A map of custom, user-defined metadata associated with the object. + customFields: { k: string; v: string }[]; +} + +interface EncodedMetadata { + metadataSize: number; + value: Uint8Array; +} + +export function createVersion(): string { + return crypto.randomBytes(24).toString("base64"); +} + +/** + * R2Object is created when you PUT an object into an R2 bucket. + * R2Object represents the metadata of an object based on the information + * provided by the uploader. Every object that you PUT into an R2 bucket + * will have an R2Object created. + */ +export class R2Object implements R2ObjectMetadata { + // The object’s key. + key: string; + // Random unique string associated with a specific upload of a key. + version: string; + // Size of the object in bytes. + size: number; + // The etag associated with the object upload. + etag: string; + // The object’s etag, in quotes so as to be returned as a header. + httpEtag: string; + // The time the object was uploaded. + uploaded: number; + // Various HTTP headers associated with the object. Refer to + // https://developers.cloudflare.com/r2/runtime-apis/#http-metadata. + httpMetadata: R2HTTPMetadata; + // A map of custom, user-defined metadata associated with the object. + customMetadata: Record; + // If a GET request was made with a range option, this will be added + range?: R2Range; + constructor(metadata: R2ObjectMetadata) { + this.key = metadata.key; + this.version = metadata.version; + this.size = metadata.size; + this.etag = metadata.etag; + this.httpEtag = metadata.httpEtag; + this.uploaded = metadata.uploaded; + this.httpMetadata = metadata.httpMetadata; + this.customMetadata = metadata.customMetadata; + this.range = metadata.range; + } + + // Format for return to the Workers Runtime + rawProperties(): RawR2ObjectMetadata { + return { + ...this, + name: this.key, + httpFields: this.httpMetadata, + customFields: Object.entries(this.customMetadata).map(([k, v]) => ({ + k, + v, + })), + }; + } + + encode(): EncodedMetadata { + const json = JSON.stringify(this.rawProperties()); + const bytes = encoder.encode(json); + return { metadataSize: bytes.length, value: bytes }; + } + + static encodeMultiple(objects: R2Objects): EncodedMetadata { + const json = JSON.stringify({ + ...objects, + objects: objects.objects.map((o) => o.rawProperties()), + }); + const bytes = encoder.encode(json); + return { metadataSize: bytes.length, value: bytes }; + } +} + +export class R2ObjectBody extends R2Object { + body: Uint8Array; + + constructor(metadata: R2ObjectMetadata, body: Uint8Array) { + super(metadata); + this.body = body; + } + + encode(): EncodedMetadata { + const { metadataSize, value: metadata } = super.encode(); + const merged = new Uint8Array(metadataSize + this.body.length); + merged.set(metadata); + merged.set(this.body, metadataSize); + return { + metadataSize: metadataSize, + value: merged, + }; + } +} diff --git a/packages/miniflare/src/plugins/r2/router.ts b/packages/miniflare/src/plugins/r2/router.ts index 01270dd96230..7ed42ec03470 100644 --- a/packages/miniflare/src/plugins/r2/router.ts +++ b/packages/miniflare/src/plugins/r2/router.ts @@ -1,50 +1,192 @@ -import { Response } from "undici"; +import { TextDecoder } from "util"; +import { Request, Response } from "undici"; +import { GET, PUT, RouteHandler, Router, decodePersist } from "../shared"; +import { InternalError, InvalidMetadata, R2Error } from "./errors"; import { - DELETE, - GET, - PUT, - RouteHandler, - Router, - decodePersist, -} from "../shared"; -import { R2Gateway } from "./gateway"; + R2Gateway, + R2GetOptions, + R2ListOptions, + R2PutOptions, +} from "./gateway"; +import { R2HTTPMetadata, R2Object } from "./r2Object"; +export enum CfHeader { + Error = "cf-r2-error", + Request = "cf-r2-request", + MetadataSize = "cf-r2-metadata-size", +} export interface R2Params { bucket: string; - key: string; } -export class R2Router extends Router { - @GET("/:bucket/:key") - get: RouteHandler = async (req, params) => { - // console.log(await req.json()); +const decoder = new TextDecoder(); - const persist = decodePersist(req.headers); - const gateway = this.gatewayFactory.get(params.bucket, persist); - await gateway.get(params.key); - return new Response(); +async function decodeMetadata(req: Request) { + const bytes = await req.arrayBuffer(); + + const metadataSize = Number(req.headers.get(CfHeader.MetadataSize)); + if (Number.isNaN(metadataSize)) { + throw new InvalidMetadata(); + } + + const [metadataBytes, value] = [ + bytes.slice(0, metadataSize), + bytes.slice(metadataSize), + ]; + const metadata = JSON.parse(decoder.decode(metadataBytes)); + return { metadata, value: new Uint8Array(value) }; +} +function decodeHeaderMetadata(req: Request) { + if (req.headers.get(CfHeader.Request) === null) { + throw new InvalidMetadata(); + } + return JSON.parse(req.headers.get(CfHeader.Request) as string); +} + +export interface RawR2GetOptions { + range?: { + offset?: string; + length?: string; + suffix?: string; }; + onlyIf: { + etagMatches?: string; + etagDoesNotMatch?: string; + uploadedBefore?: string; + uploadedAfter?: string; + }; +} +export interface RawR2PutOptions { + // Various HTTP headers associated with the object. Refer to + // https://developers.cloudflare.com/r2/runtime-apis/#http-metadata. + httpFields?: R2HTTPMetadata; + // A map of custom, user-defined metadata that will be stored with the object. + customFields?: { k: string; v: string }[]; + // A md5 hash to use to check the recieved object’s integrity. + md5?: string; +} - @PUT("/:bucket/:key") - put: RouteHandler = async (req, params) => { - const persist = decodePersist(req.headers); - const gateway = this.gatewayFactory.get(params.bucket, persist); - await gateway.put(params.key, new Uint8Array(await req.arrayBuffer())); - return new Response(); +export interface RawR2ListOptions { + // The number of results to return. Defaults to 1000, with a maximum of 1000. + limit?: number; + // The prefix to match keys against. Keys will only be returned if they start with given prefix. + prefix?: string; + // An opaque token that indicates where to continue listing objects from. + // A cursor can be retrieved from a previous list operation. + cursor?: string; + // The character to use when grouping keys. + delimiter?: string; + // Can include httpFields and/or customFields. If included, items returned by + // the list will include the specified metadata. Note that there is a limit on the + // total amount of data that a single list operation can return. + // If you request data, you may recieve fewer than limit results in your response + // to accomodate metadata. + // Use the truncated property to determine if the list request has more data to be returned. + include?: (0 | 1)[]; +} +function parseGetOptions({ + range = {}, + onlyIf = {}, +}: RawR2GetOptions): R2GetOptions { + return { + range: { + offset: range?.offset ? Number(range?.offset) : undefined, + length: range?.length ? Number(range?.length) : undefined, + suffix: range?.suffix ? Number(range?.suffix) : undefined, + }, + onlyIf: { + etagMatches: onlyIf.etagMatches, + etagDoesNotMatch: onlyIf.etagDoesNotMatch, + uploadedAfter: onlyIf?.uploadedAfter + ? Number(onlyIf?.uploadedAfter) + : undefined, + uploadedBefore: onlyIf?.uploadedBefore + ? Number(onlyIf?.uploadedBefore) + : undefined, + }, + }; +} + +function parsePutOptions(options: RawR2PutOptions): R2PutOptions { + return { + ...options, + httpMetadata: options.httpFields ?? {}, + customMetadata: options.customFields + ? Object.fromEntries(options.customFields.map(({ k, v }) => [k, v])) + : {}, }; +} + +function parseListOptions(options: RawR2ListOptions): R2ListOptions { + return { + ...options, + include: options.include + ?.filter((i) => i === 1 || i === 0) + .map((i) => (i === 0 ? "httpMetadata" : "customMetadata")), + }; +} + +export class R2Router extends Router { + @GET("/:bucket") + get: RouteHandler = async (req, params) => { + const { method, object, ...options } = decodeHeaderMetadata(req); - @DELETE("/:bucket/:key") - delete: RouteHandler = async (req, params) => { const persist = decodePersist(req.headers); const gateway = this.gatewayFactory.get(params.bucket, persist); - await gateway.delete(params.key); - return new Response(); + try { + let val; + if (method === "head") { + val = await gateway.head(object); + } else if (method === "get") { + val = await gateway.get(object, parseGetOptions(options)); + } else if (method === "list") { + val = await gateway.list(parseListOptions(options)); + } + if (!val) { + throw new InternalError(); + } + + if (val instanceof R2Object) { + val = val.encode(); + } else { + val = R2Object.encodeMultiple(val); + } + + return new Response(val.value, { + headers: { + [CfHeader.MetadataSize]: `${val.metadataSize}`, + "Content-Type": "application/json", + }, + }); + } catch (e) { + if (e instanceof R2Error) { + return e.toResponse(); + } + throw e; + } }; - @GET("/:bucket/") - list: RouteHandler> = async (req, params) => { + @PUT("/:bucket") + put: RouteHandler = async (req, params) => { + const { metadata, value } = await decodeMetadata(req); const persist = decodePersist(req.headers); const gateway = this.gatewayFactory.get(params.bucket, persist); - await gateway.list(); - return new Response(); + + try { + if (metadata.method === "delete") { + await gateway.delete(metadata.object); + return new Response(); + } else if (metadata.method === "put") { + return Response.json( + await gateway.put(metadata.object, value, parsePutOptions(metadata)) + ); + } + // Unknown method: should never be reached + throw new InternalError(); + } catch (e) { + if (e instanceof R2Error) { + return e.toResponse(); + } + throw e; + } }; } diff --git a/packages/miniflare/src/plugins/r2/validator.ts b/packages/miniflare/src/plugins/r2/validator.ts new file mode 100644 index 000000000000..fd3078411cdf --- /dev/null +++ b/packages/miniflare/src/plugins/r2/validator.ts @@ -0,0 +1,268 @@ +import crypto from "crypto"; +import { + BadDigest, + EntityTooLarge, + InternalError, + InvalidDigest, + InvalidMaxKeys, + InvalidObjectName, + PreconditionFailed, +} from "./errors"; +import { + R2Conditional, + R2GetOptions, + R2ListOptions, + R2PutOptions, +} from "./gateway"; + +import { R2HTTPMetadata, R2Object, R2ObjectMetadata } from "./r2Object"; + +const MAX_LIST_KEYS = 1_000; +const MAX_KEY_SIZE = 1024; + +const UNPAIRED_SURROGATE_PAIR_REGEX = + /^(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])$/; +const MAX_VALUE_SIZE = 5 * 1_000 * 1_000 * 1_000 - 5 * 1_000 * 1_000; + +// false -> the condition testing "failed" +function testR2Conditional( + conditional?: R2Conditional, + metadata?: R2ObjectMetadata +): boolean { + const { etagMatches, etagDoesNotMatch, uploadedBefore, uploadedAfter } = + conditional ?? {}; + + // If the object doesn't exist + if (metadata === undefined) { + // the etagDoesNotMatch and uploadedBefore automatically pass + // etagMatches and uploadedAfter automatically fail if they exist + return etagMatches === undefined && uploadedAfter === undefined; + } + + const { etag, uploaded } = metadata; + + // ifMatch check + const ifMatch = etagMatches ? etagMatches === etag : null; + if (ifMatch === false) return false; + + // ifNoMatch check + const ifNoneMatch = etagDoesNotMatch ? etagDoesNotMatch !== etag : null; + + if (ifNoneMatch === false) return false; + + // ifUnmodifiedSince check + if ( + ifMatch !== true && // if "ifMatch" is true, we ignore date checking + uploadedBefore !== undefined && + uploaded > uploadedBefore + ) { + return false; + } + + // ifModifiedSince check + if ( + ifNoneMatch !== true && // if "ifNoneMatch" is true, we ignore date checking + uploadedAfter !== undefined && + uploaded < uploadedAfter + ) { + return false; + } + + return true; +} +export class Validator { + md5(value: Uint8Array, md5?: string): string { + const md5Hash = crypto.createHash("md5").update(value).digest("base64"); + if (md5 !== undefined && md5 !== md5Hash) { + throw new BadDigest(); + } + return md5Hash; + } + condition(meta: R2Object, onlyIf?: R2Conditional): Validator { + // test conditional should it exist + if (!testR2Conditional(onlyIf, meta) || meta?.size === 0) { + throw new PreconditionFailed().attach(meta); + } + return this; + } + size(value: Uint8Array): Validator { + if (value.byteLength > MAX_VALUE_SIZE) { + throw new EntityTooLarge(); + } + return this; + } + key(key: string): Validator { + // Check key isn't too long and exists outside regex + const keyLength = Buffer.byteLength(key); + if (UNPAIRED_SURROGATE_PAIR_REGEX.test(key)) { + throw new InvalidObjectName(); + } + if (keyLength >= MAX_KEY_SIZE) { + throw new InvalidObjectName(); + } + return this; + } + + onlyIf(onlyIf: R2Conditional): Validator { + if (typeof onlyIf !== "object") { + throw new InternalError().context( + "onlyIf must be an object, a Headers instance, or undefined." + ); + } + + // Check onlyIf variables + const { etagMatches, etagDoesNotMatch, uploadedBefore, uploadedAfter } = + onlyIf; + if ( + etagMatches !== undefined && + !(typeof etagMatches === "string" || Array.isArray(etagMatches)) + ) { + throw new InternalError().context("etagMatches must be a string."); + } + if ( + etagDoesNotMatch !== undefined && + !(typeof etagDoesNotMatch === "string" || Array.isArray(etagDoesNotMatch)) + ) { + throw new InternalError().context("etagDoesNotMatch must be a string."); + } + if (uploadedBefore !== undefined && !!Number.isNaN(uploadedBefore)) { + throw new InternalError().context("uploadedBefore must be a number."); + } + if (uploadedAfter !== undefined && !!Number.isNaN(uploadedBefore)) { + throw new InternalError().context("uploadedAfter must be a number."); + } + return this; + } + + getOptions(options: R2GetOptions): Validator { + const { onlyIf = {}, range = {} } = options; + + this.onlyIf(onlyIf); + + if (typeof range !== "object") { + throw new InternalError().context( + "range must either be an object or undefined." + ); + } + const { offset, length, suffix } = range; + + if (offset !== undefined) { + if (typeof offset !== "number" || Number.isNaN(offset)) { + throw new InternalError().context( + "offset must either be a number or undefined." + ); + } + if (offset < 0) { + throw new InternalError().context( + "Invalid range. Starting offset must be greater than or equal to 0." + ); + } + } + if ( + (length !== undefined && typeof length !== "number") || + Number.isNaN(length) + ) { + throw new InternalError().context( + "length must either be a number or undefined." + ); + } + if ( + (suffix !== undefined && typeof suffix !== "number") || + Number.isNaN(suffix) + ) { + throw new InternalError().context( + "suffix must either be a number or undefined." + ); + } + return this; + } + + httpMetadata(httpMetadata?: R2HTTPMetadata): Validator { + if (httpMetadata === undefined) return this; + if (typeof httpMetadata !== "object") { + throw new InternalError().context( + "httpMetadata must be an object or undefined." + ); + } + for (const [key, value] of Object.entries(httpMetadata)) { + if (typeof value !== "string" && value !== undefined) { + throw new InvalidObjectName().context( + `${key}'s value must be a string or undefined.` + ); + } + } + return this; + } + + putOptions(options: R2PutOptions): Validator { + const { httpMetadata, customMetadata, md5 } = options; + + this.httpMetadata(httpMetadata); + + if (customMetadata !== undefined) { + if (typeof customMetadata !== "object") { + throw new InternalError().context( + "customMetadata must be an object or undefined." + ); + } + for (const v of Object.values(customMetadata)) { + if (typeof v !== "string") { + throw new InternalError().context( + "customMetadata values must be strings." + ); + } + } + } + + if (md5 !== undefined && typeof md5 !== "string") { + throw new InvalidDigest().context("md5 must be a string or undefined."); + } + return this; + } + + listOptions(options: R2ListOptions): Validator { + const { limit, prefix, cursor, delimiter, include } = options; + + if (limit !== undefined) { + if (typeof limit !== "number") { + throw new InternalError().context( + "limit must be a number or undefined." + ); + } + if (limit < 1 || limit > MAX_LIST_KEYS) { + throw new InvalidMaxKeys(); + } + } + if (prefix !== undefined && typeof prefix !== "string") { + throw new InternalError().context( + "prefix must be a string or undefined." + ); + } + if (cursor !== undefined && typeof cursor !== "string") { + throw new InternalError().context( + "cursor must be a string or undefined." + ); + } + if (delimiter !== undefined && typeof delimiter !== "string") { + throw new InternalError().context( + "delimiter must be a string or undefined." + ); + } + + if (include !== undefined) { + if (!Array.isArray(include)) { + throw new InternalError().context( + "include must be an array or undefined." + ); + } + for (const value of include) { + if (value !== "httpMetadata" && value !== "customMetadata") { + throw new InternalError().context( + "include values must be httpMetadata and/or customMetadata strings." + ); + } + } + } + return this; + } +}