209 lines
6.4 KiB
TypeScript
209 lines
6.4 KiB
TypeScript
import { accepts, contentTypeOf, extOf } from "./mime.ts";
|
|
|
|
export interface UploadPolicy {
|
|
maxBytes?: number;
|
|
accept?: string[];
|
|
requireChecksum?: boolean;
|
|
filenamePattern?: RegExp;
|
|
}
|
|
|
|
export interface UploadInspection {
|
|
filename: string;
|
|
contentType: string;
|
|
size: number;
|
|
sha256: string;
|
|
extension: string;
|
|
}
|
|
|
|
export class UploadPolicyError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly code: string,
|
|
) {
|
|
super(message);
|
|
this.name = "UploadPolicyError";
|
|
}
|
|
}
|
|
|
|
function safePrefix(prefix: string): string {
|
|
const segments = prefix
|
|
.split(/[\\/]+/)
|
|
.map((segment) => segment.trim())
|
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
|
.map((segment) => segment.replace(/[^A-Za-z0-9._-]+/g, "-"))
|
|
.filter(Boolean);
|
|
return segments.join("/") || "uploads";
|
|
}
|
|
|
|
export function safeObjectKey(filename: string, prefix = "uploads"): string {
|
|
const extension = extOf(filename)
|
|
.replace(/[^A-Za-z0-9.]/g, "")
|
|
.slice(0, 16);
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
return `${safePrefix(prefix)}/${date}/${crypto.randomUUID()}${extension}`;
|
|
}
|
|
|
|
export async function inspectUpload(
|
|
filename: string,
|
|
bytes: Uint8Array,
|
|
declaredType?: string,
|
|
): Promise<UploadInspection> {
|
|
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
|
|
const sha256 = [...new Uint8Array(digest)]
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
return {
|
|
filename,
|
|
contentType: declaredType || contentTypeOf(filename),
|
|
size: bytes.byteLength,
|
|
sha256,
|
|
extension: extOf(filename),
|
|
};
|
|
}
|
|
|
|
export function enforceUploadPolicy(
|
|
inspection: UploadInspection,
|
|
policy: UploadPolicy,
|
|
expectedChecksum?: string,
|
|
): void {
|
|
if (policy.maxBytes !== undefined) {
|
|
if (!Number.isFinite(policy.maxBytes) || policy.maxBytes < 0) {
|
|
throw new TypeError("Upload maxBytes must be a non-negative number");
|
|
}
|
|
if (inspection.size > policy.maxBytes) {
|
|
throw new UploadPolicyError(`File exceeds ${policy.maxBytes} bytes`, "UPLOAD_TOO_LARGE");
|
|
}
|
|
}
|
|
if (
|
|
policy.accept?.length &&
|
|
!accepts(policy.accept, {
|
|
name: inspection.filename,
|
|
type: inspection.contentType,
|
|
})
|
|
) {
|
|
throw new UploadPolicyError("File type is not allowed", "UPLOAD_TYPE_REJECTED");
|
|
}
|
|
if (policy.filenamePattern && !policy.filenamePattern.test(inspection.filename)) {
|
|
throw new UploadPolicyError("Filename is not allowed", "UPLOAD_FILENAME_REJECTED");
|
|
}
|
|
if (policy.requireChecksum && !expectedChecksum) {
|
|
throw new UploadPolicyError("Checksum is required", "UPLOAD_CHECKSUM_REQUIRED");
|
|
}
|
|
if (expectedChecksum && expectedChecksum.toLowerCase() !== inspection.sha256) {
|
|
throw new UploadPolicyError("Checksum does not match", "UPLOAD_CHECKSUM_MISMATCH");
|
|
}
|
|
}
|
|
|
|
function signature(bytes: Uint8Array, values: number[]): boolean {
|
|
return values.every((value, index) => bytes[index] === value);
|
|
}
|
|
|
|
export function sniffContentType(bytes: Uint8Array): string | null {
|
|
if (signature(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
return "image/png";
|
|
}
|
|
if (signature(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
|
if (signature(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif";
|
|
if (signature(bytes, [0x25, 0x50, 0x44, 0x46])) return "application/pdf";
|
|
if (signature(bytes, [0x50, 0x4b, 0x03, 0x04])) return "application/zip";
|
|
if (
|
|
signature(bytes, [0x52, 0x49, 0x46, 0x46]) &&
|
|
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
|
|
) {
|
|
return "image/webp";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface SignedFileToken {
|
|
store: string;
|
|
key: string;
|
|
expiresAt: number;
|
|
disposition?: "inline" | "attachment";
|
|
}
|
|
|
|
function base64url(value: Uint8Array): string {
|
|
let text = "";
|
|
for (const byte of value) text += String.fromCharCode(byte);
|
|
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
function assertSigningSecret(secret: string): void {
|
|
if (secret.length < 16) {
|
|
throw new TypeError("Signed file token secret must be at least 16 characters");
|
|
}
|
|
}
|
|
|
|
function assertSignedFileToken(value: SignedFileToken): void {
|
|
if (!value.store.trim()) throw new TypeError("Signed file token store is required");
|
|
if (!value.key.trim()) throw new TypeError("Signed file token key is required");
|
|
if (!Number.isFinite(value.expiresAt)) {
|
|
throw new TypeError("Signed file token expiresAt must be finite");
|
|
}
|
|
if (
|
|
value.disposition !== undefined &&
|
|
value.disposition !== "inline" &&
|
|
value.disposition !== "attachment"
|
|
) {
|
|
throw new TypeError("Signed file token disposition is invalid");
|
|
}
|
|
}
|
|
|
|
async function sign(value: string, secret: string): Promise<string> {
|
|
assertSigningSecret(secret);
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(secret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
return base64url(
|
|
new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))),
|
|
);
|
|
}
|
|
|
|
function constantTimeEqual(left: string, right: string): boolean {
|
|
const length = Math.max(left.length, right.length);
|
|
let difference = left.length ^ right.length;
|
|
for (let index = 0; index < length; index++) {
|
|
difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
export async function createSignedFileToken(
|
|
input: SignedFileToken,
|
|
secret: string,
|
|
): Promise<string> {
|
|
assertSignedFileToken(input);
|
|
const payload = base64url(new TextEncoder().encode(JSON.stringify(input)));
|
|
return `${payload}.${await sign(payload, secret)}`;
|
|
}
|
|
|
|
export async function verifySignedFileToken(
|
|
token: string,
|
|
secret: string,
|
|
now = Date.now(),
|
|
): Promise<SignedFileToken | null> {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 2) return null;
|
|
const [payload, signature] = parts;
|
|
if (!payload || !signature) return null;
|
|
|
|
const expected = await sign(payload, secret);
|
|
if (!constantTimeEqual(expected, signature)) return null;
|
|
|
|
try {
|
|
const padding = payload.length % 4 ? "=".repeat(4 - (payload.length % 4)) : "";
|
|
const raw = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + padding);
|
|
const value = JSON.parse(
|
|
new TextDecoder().decode(Uint8Array.from(raw, (character) => character.charCodeAt(0))),
|
|
) as SignedFileToken;
|
|
assertSignedFileToken(value);
|
|
return value.expiresAt > now ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|