122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
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];
|
|
}
|