Initial commit
This commit is contained in:
121
shared/utils/drop-contract.ts
Normal file
121
shared/utils/drop-contract.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
EXPIRY_VALUES,
|
||||
GCM_TAG_BYTES,
|
||||
MAX_CHUNK_BODY,
|
||||
MAX_MANIFEST_BODY,
|
||||
MAX_MANIFEST_PLAINTEXT,
|
||||
PROTOCOL_VERSION,
|
||||
READ_VALUES,
|
||||
LEASE_IDLE_MS,
|
||||
UPLOAD_RESERVATION_MS,
|
||||
type ExpiryChoice,
|
||||
type ReadChoice,
|
||||
type ReserveRequest,
|
||||
} from "../types/drop";
|
||||
export const ROOM_ID_PATTERN = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{32}$/u;
|
||||
export const B64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
||||
const MAX_CHUNKS = 0xfffffffe;
|
||||
export const EXPIRY_MS: Record<ExpiryChoice, number> = {
|
||||
"1h": 3600000,
|
||||
"2h": 7200000,
|
||||
"6h": 21600000,
|
||||
"12h": 43200000,
|
||||
"24h": 86400000,
|
||||
"7d": 604800000,
|
||||
};
|
||||
export function isExpiry(value: unknown): value is ExpiryChoice {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(EXPIRY_VALUES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
export function isReadLimit(value: unknown): value is ReadChoice {
|
||||
return (
|
||||
value === null ||
|
||||
(typeof value === "number" && READ_VALUES.includes(value as never))
|
||||
);
|
||||
}
|
||||
export function isBase64Url(value: unknown, bytes?: number): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= 512 &&
|
||||
B64URL_PATTERN.test(value) &&
|
||||
(!bytes ||
|
||||
Math.ceil((value.length * 6) / 8) === bytes ||
|
||||
value.length === Math.ceil((bytes * 8) / 6))
|
||||
);
|
||||
}
|
||||
export function decodeBase64Url(value: string): Uint8Array {
|
||||
if (!B64URL_PATTERN.test(value) || value.length % 4 === 1)
|
||||
throw new Error("Invalid base64url");
|
||||
const normalized = value
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||
const binary = atob(normalized);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
}
|
||||
export function validateRoomId(id: unknown): asserts id is string {
|
||||
if (typeof id !== "string" || !ROOM_ID_PATTERN.test(id))
|
||||
throw new Error("Invalid drop id");
|
||||
}
|
||||
export function validateReservation(input: unknown): ReserveRequest {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
throw new Error("Invalid reservation");
|
||||
const value = input as Record<string, unknown>;
|
||||
const allowed = [
|
||||
"version",
|
||||
"chunkSize",
|
||||
"chunkCount",
|
||||
"noncePrefix",
|
||||
"expiresIn",
|
||||
"maxReads",
|
||||
];
|
||||
if (Object.keys(value).some((key) => !allowed.includes(key)))
|
||||
throw new Error("Unknown reservation field");
|
||||
const chunkCount = value.chunkCount;
|
||||
if (
|
||||
value.version !== PROTOCOL_VERSION ||
|
||||
value.chunkSize !== CHUNK_SIZE ||
|
||||
!Number.isSafeInteger(chunkCount) ||
|
||||
(chunkCount as number) < 1 ||
|
||||
(chunkCount as number) > MAX_CHUNKS
|
||||
)
|
||||
throw new Error("Invalid chunk geometry");
|
||||
if (
|
||||
!isExpiry(value.expiresIn) ||
|
||||
!isReadLimit(value.maxReads) ||
|
||||
!isBase64Url(value.noncePrefix, 8)
|
||||
)
|
||||
throw new Error("Invalid reservation policy");
|
||||
if (decodeBase64Url(value.noncePrefix as string).byteLength !== 8)
|
||||
throw new Error("Invalid nonce prefix");
|
||||
return value as unknown as ReserveRequest;
|
||||
}
|
||||
export function validateChunkIndex(index: unknown, count: number): number {
|
||||
const parsed = typeof index === "number" ? index : Number(index);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed >= count)
|
||||
throw new Error("Invalid chunk index");
|
||||
return parsed;
|
||||
}
|
||||
export function validateChunkBodyLength(length: number, final: boolean): void {
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
length < (final ? GCM_TAG_BYTES : MAX_CHUNK_BODY) ||
|
||||
length > MAX_CHUNK_BODY
|
||||
)
|
||||
throw new Error("Invalid chunk body length");
|
||||
}
|
||||
export function validateManifestBodyLength(length: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
length <= GCM_TAG_BYTES ||
|
||||
length > MAX_MANIFEST_BODY
|
||||
)
|
||||
throw new Error("Invalid manifest body length");
|
||||
}
|
||||
export function expiresInMs(value: ExpiryChoice): number {
|
||||
return EXPIRY_MS[value];
|
||||
}
|
||||
Reference in New Issue
Block a user