247 lines
8.2 KiB
TypeScript
247 lines
8.2 KiB
TypeScript
import type { StorageDriver } from "./driver.ts";
|
|
import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts";
|
|
import { UploadError, type UploadedFile } from "./upload.ts";
|
|
|
|
export interface ResumableUploadSession {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
chunkSize: number;
|
|
totalChunks: number;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
chunks: Record<number, Uint8Array>;
|
|
digests: Record<number, string>;
|
|
}
|
|
|
|
export interface ResumableSessionStore {
|
|
get(id: string): Promise<ResumableUploadSession | null>;
|
|
put(session: ResumableUploadSession): Promise<void>;
|
|
delete(id: string): Promise<void>;
|
|
list(): Promise<ResumableUploadSession[]>;
|
|
}
|
|
|
|
export function memoryResumableSessionStore(): ResumableSessionStore {
|
|
const sessions = new Map<string, ResumableUploadSession>();
|
|
return {
|
|
async get(id) {
|
|
return sessions.get(id) ?? null;
|
|
},
|
|
async put(session) {
|
|
sessions.set(session.id, session);
|
|
},
|
|
async delete(id) {
|
|
sessions.delete(id);
|
|
},
|
|
async list() {
|
|
return [...sessions.values()];
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface ResumableUploadManagerOptions {
|
|
driver: StorageDriver;
|
|
sessions?: ResumableSessionStore;
|
|
maxBytes?: number;
|
|
chunkSize?: number;
|
|
maxSessions?: number;
|
|
ttlMs?: number;
|
|
accept?: string[];
|
|
prefix?: string;
|
|
publicUrl?: (key: string) => string | null;
|
|
now?: () => number;
|
|
}
|
|
|
|
export interface CreateResumableUpload {
|
|
name: string;
|
|
type?: string;
|
|
size: number;
|
|
chunkSize?: number;
|
|
}
|
|
|
|
export interface ResumableChunkResult {
|
|
receivedChunks: number;
|
|
totalChunks: number;
|
|
complete: boolean;
|
|
file?: UploadedFile;
|
|
}
|
|
|
|
export interface ResumableUploadManager {
|
|
create(input: CreateResumableUpload): Promise<ResumableUploadSession>;
|
|
uploadChunk(
|
|
id: string,
|
|
index: number,
|
|
data: Uint8Array,
|
|
sha256?: string,
|
|
): Promise<ResumableChunkResult>;
|
|
status(
|
|
id: string,
|
|
): Promise<{ received: number[]; totalChunks: number; expiresAt: number } | null>;
|
|
cancel(id: string): Promise<boolean>;
|
|
prune(): Promise<number>;
|
|
}
|
|
|
|
function safePrefix(prefix: string): string {
|
|
const value = prefix.replace(/^\/+|\/+$/g, "");
|
|
if (
|
|
value &&
|
|
(!/^[A-Za-z0-9._/-]+$/.test(value) ||
|
|
value.split("/").some((part) => !part || part === "." || part === ".."))
|
|
) {
|
|
throw new UploadError("unsafe upload prefix", 400);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function digest(data: Uint8Array): Promise<string> {
|
|
return Array.from(
|
|
new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)),
|
|
(byte) => byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
export function createResumableUploadManager(
|
|
options: ResumableUploadManagerOptions,
|
|
): ResumableUploadManager {
|
|
const sessions = options.sessions ?? memoryResumableSessionStore();
|
|
const maxBytes = options.maxBytes ?? 100 * 1024 * 1024;
|
|
const defaultChunkSize = options.chunkSize ?? 1024 * 1024;
|
|
const maxSessions = options.maxSessions ?? 1000;
|
|
const ttlMs = options.ttlMs ?? 24 * 60 * 60_000;
|
|
if (!Number.isInteger(maxBytes) || maxBytes < 1)
|
|
throw new RangeError("resumable maxBytes must be positive");
|
|
if (!Number.isInteger(defaultChunkSize) || defaultChunkSize < 1)
|
|
throw new RangeError("resumable chunkSize must be positive");
|
|
if (!Number.isInteger(maxSessions) || maxSessions < 1)
|
|
throw new RangeError("resumable maxSessions must be positive");
|
|
if (!Number.isFinite(ttlMs) || ttlMs <= 0)
|
|
throw new RangeError("resumable ttlMs must be positive");
|
|
const prefix = safePrefix(options.prefix ?? "resumable");
|
|
const now = options.now ?? Date.now;
|
|
const finalizing = new Map<string, Promise<UploadedFile>>();
|
|
|
|
const manager: ResumableUploadManager = {
|
|
async create(input) {
|
|
await manager.prune();
|
|
if (!Number.isInteger(input.size) || input.size < 1 || input.size > maxBytes)
|
|
throw new UploadError(`invalid upload size (max ${maxBytes} bytes)`, 413);
|
|
const type = input.type || contentTypeOf(input.name);
|
|
if (!accepts(options.accept, { type, name: input.name }))
|
|
throw new UploadError(`file type not allowed: ${type}`, 415);
|
|
if ((await sessions.list()).length >= maxSessions)
|
|
throw new UploadError("too many active resumable uploads", 429);
|
|
const chunkSize = input.chunkSize ?? defaultChunkSize;
|
|
if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > maxBytes)
|
|
throw new UploadError("invalid resumable chunk size", 400);
|
|
const id = crypto.randomUUID();
|
|
const extension = extOf(input.name) || extForType(type);
|
|
const filename = extension ? `${id}.${extension}` : id;
|
|
const timestamp = now();
|
|
const session: ResumableUploadSession = {
|
|
id,
|
|
key: prefix ? `${prefix}/${filename}` : filename,
|
|
name: input.name.split(/[\\/]/).pop()?.slice(0, 255) || "file",
|
|
type,
|
|
size: input.size,
|
|
chunkSize,
|
|
totalChunks: Math.ceil(input.size / chunkSize),
|
|
createdAt: timestamp,
|
|
expiresAt: timestamp + ttlMs,
|
|
chunks: {},
|
|
digests: {},
|
|
};
|
|
await sessions.put(session);
|
|
return session;
|
|
},
|
|
|
|
async uploadChunk(id, index, data, expectedDigest) {
|
|
const session = await sessions.get(id);
|
|
if (!session || session.expiresAt <= now())
|
|
throw new UploadError("upload session not found or expired", 404);
|
|
if (!Number.isInteger(index) || index < 0 || index >= session.totalChunks)
|
|
throw new UploadError("invalid chunk index", 400);
|
|
const expectedSize =
|
|
index === session.totalChunks - 1
|
|
? session.size - session.chunkSize * (session.totalChunks - 1)
|
|
: session.chunkSize;
|
|
if (data.byteLength !== expectedSize)
|
|
throw new UploadError(`invalid chunk size (expected ${expectedSize})`, 400);
|
|
const actualDigest = await digest(data);
|
|
if (expectedDigest && expectedDigest.toLowerCase() !== actualDigest)
|
|
throw new UploadError("chunk checksum mismatch", 422);
|
|
if (session.digests[index] && session.digests[index] !== actualDigest)
|
|
throw new UploadError("chunk already uploaded with different content", 409);
|
|
session.chunks[index] ??= data.slice();
|
|
session.digests[index] = actualDigest;
|
|
await sessions.put(session);
|
|
const receivedChunks = Object.keys(session.chunks).length;
|
|
if (receivedChunks !== session.totalChunks) {
|
|
return { receivedChunks, totalChunks: session.totalChunks, complete: false };
|
|
}
|
|
let completion = finalizing.get(id);
|
|
if (!completion) {
|
|
completion = (async () => {
|
|
const output = new Uint8Array(session.size);
|
|
let offset = 0;
|
|
for (let chunk = 0; chunk < session.totalChunks; chunk++) {
|
|
const value = session.chunks[chunk];
|
|
if (!value) throw new UploadError("upload is missing a chunk", 409);
|
|
output.set(value, offset);
|
|
offset += value.byteLength;
|
|
}
|
|
await options.driver.put(session.key, output, {
|
|
contentType: session.type,
|
|
filename: session.name,
|
|
});
|
|
await sessions.delete(id);
|
|
return {
|
|
key: session.key,
|
|
url: options.publicUrl?.(session.key) ?? null,
|
|
name: session.name,
|
|
type: session.type,
|
|
size: session.size,
|
|
};
|
|
})().finally(() => finalizing.delete(id));
|
|
finalizing.set(id, completion);
|
|
}
|
|
return {
|
|
receivedChunks,
|
|
totalChunks: session.totalChunks,
|
|
complete: true,
|
|
file: await completion,
|
|
};
|
|
},
|
|
|
|
async status(id) {
|
|
const session = await sessions.get(id);
|
|
return session
|
|
? {
|
|
received: Object.keys(session.chunks)
|
|
.map(Number)
|
|
.sort((a, b) => a - b),
|
|
totalChunks: session.totalChunks,
|
|
expiresAt: session.expiresAt,
|
|
}
|
|
: null;
|
|
},
|
|
async cancel(id) {
|
|
if (!(await sessions.get(id))) return false;
|
|
await sessions.delete(id);
|
|
return true;
|
|
},
|
|
async prune() {
|
|
let removed = 0;
|
|
for (const session of await sessions.list()) {
|
|
if (session.expiresAt <= now()) {
|
|
await sessions.delete(session.id);
|
|
removed++;
|
|
}
|
|
}
|
|
return removed;
|
|
},
|
|
};
|
|
return manager;
|
|
}
|