Initial commit
This commit is contained in:
35
server/api/auth/login.post.ts
Normal file
35
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createError, defineEventHandler, setResponseStatus } from "h3";
|
||||
import {
|
||||
authConfigured,
|
||||
issueSession,
|
||||
requireSameOrigin,
|
||||
verifyPasscode,
|
||||
} from "../../utils/auth";
|
||||
import { readJson } from "../../utils/http";
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSameOrigin(event);
|
||||
const body = await readJson<{ passcode?: unknown }>(event, 1024);
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
Object.keys(body).some((key) => key !== "passcode")
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Malformed login request.",
|
||||
});
|
||||
if (!authConfigured())
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: "Authentication is not configured.",
|
||||
});
|
||||
if (!verifyPasscode(body?.passcode)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Incorrect passcode.",
|
||||
});
|
||||
}
|
||||
issueSession(event);
|
||||
setResponseStatus(event, 204);
|
||||
});
|
||||
7
server/api/auth/logout.post.ts
Normal file
7
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineEventHandler, setResponseStatus } from "h3";
|
||||
import { clearAuthCookie, requireSameOrigin } from "../../utils/auth";
|
||||
export default defineEventHandler((event) => {
|
||||
requireSameOrigin(event);
|
||||
clearAuthCookie(event);
|
||||
setResponseStatus(event, 204);
|
||||
});
|
||||
5
server/api/auth/session.get.ts
Normal file
5
server/api/auth/session.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineEventHandler } from "h3";
|
||||
import { isAuthenticated } from "../../utils/auth";
|
||||
export default defineEventHandler((event) => ({
|
||||
authenticated: isAuthenticated(event),
|
||||
}));
|
||||
25
server/api/drops/[id].get.ts
Normal file
25
server/api/drops/[id].get.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestHeader,
|
||||
getRouterParam,
|
||||
} from "h3";
|
||||
import { dropStore, DropStoreError } from "../../utils/drop-store";
|
||||
import { publicHeaders } from "../../utils/http";
|
||||
export default defineEventHandler(async (event) => {
|
||||
publicHeaders(event);
|
||||
const id = String(getRouterParam(event, "id") || "").toUpperCase();
|
||||
const access = getRequestHeader(event, "x-quickdrop-access") || "";
|
||||
try {
|
||||
return await dropStore.descriptor(id, access);
|
||||
} catch (error) {
|
||||
if (error instanceof DropStoreError && error.code === "expired")
|
||||
throw createError({ statusCode: 410, statusMessage: "Drop expired." });
|
||||
if (error instanceof DropStoreError && error.code === "forbidden")
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Invalid access capability.",
|
||||
});
|
||||
throw createError({ statusCode: 404, statusMessage: "Drop not found." });
|
||||
}
|
||||
});
|
||||
72
server/api/drops/[id]/chunks/[index].get.ts
Normal file
72
server/api/drops/[id]/chunks/[index].get.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRouterParam,
|
||||
sendStream,
|
||||
setHeaders,
|
||||
} from "h3";
|
||||
import { dropStore, DropStoreError } from "../../../../utils/drop-store";
|
||||
import { bearerToken, publicHeaders } from "../../../../utils/http";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
publicHeaders(event);
|
||||
const token = bearerToken(event);
|
||||
if (!token)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "A valid lease is required.",
|
||||
});
|
||||
const id = String(getRouterParam(event, "id") || "").toUpperCase();
|
||||
let handle:
|
||||
| Awaited<ReturnType<typeof dropStore.readChunk>>["handle"]
|
||||
| undefined;
|
||||
let stream:
|
||||
| ReturnType<NonNullable<typeof handle>["createReadStream"]>
|
||||
| undefined;
|
||||
let closed = false;
|
||||
const close = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
stream?.destroy();
|
||||
await handle?.close().catch(() => undefined);
|
||||
};
|
||||
const abort = () => {
|
||||
if (!event.node.res.writableEnded && stream && !stream.destroyed)
|
||||
stream.destroy(new Error("Client disconnected."));
|
||||
void close();
|
||||
};
|
||||
try {
|
||||
const chunk = await dropStore.readChunk(
|
||||
id,
|
||||
Number(getRouterParam(event, "index")),
|
||||
token,
|
||||
);
|
||||
handle = chunk.handle;
|
||||
stream = handle.createReadStream();
|
||||
event.node.res.once("close", abort);
|
||||
event.node.res.once("finish", close);
|
||||
setHeaders(event, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": chunk.size,
|
||||
"cache-control": "private, no-store",
|
||||
});
|
||||
return await sendStream(event, stream);
|
||||
} catch (error) {
|
||||
await close();
|
||||
if (error instanceof DropStoreError)
|
||||
throw createError({
|
||||
statusCode:
|
||||
error.code === "expired"
|
||||
? 410
|
||||
: error.code === "not-found"
|
||||
? 404
|
||||
: 403,
|
||||
statusMessage: error.message,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
event.node.res.off("close", abort);
|
||||
event.node.res.off("finish", close);
|
||||
await close();
|
||||
}
|
||||
});
|
||||
30
server/api/drops/[id]/chunks/[index].put.ts
Normal file
30
server/api/drops/[id]/chunks/[index].put.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRouterParam,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import { requireAuth } from "../../../../utils/auth";
|
||||
import { dropStore, DropStoreError } from "../../../../utils/drop-store";
|
||||
import { readBounded, requireContentType } from "../../../../utils/http";
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event);
|
||||
requireContentType(event, "application/octet-stream");
|
||||
const id = String(getRouterParam(event, "id") || "").toUpperCase();
|
||||
const index = Number(getRouterParam(event, "index"));
|
||||
try {
|
||||
const result = await dropStore.putChunk(
|
||||
id,
|
||||
index,
|
||||
await readBounded(event, 8388624),
|
||||
);
|
||||
setResponseStatus(event, result === "created" ? 201 : 204);
|
||||
} catch (error) {
|
||||
if (error instanceof DropStoreError) {
|
||||
const status =
|
||||
error.code === "not-found" ? 404 : error.code === "expired" ? 410 : 409;
|
||||
throw createError({ statusCode: status, statusMessage: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
32
server/api/drops/[id]/complete.post.ts
Normal file
32
server/api/drops/[id]/complete.post.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRouterParam,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import { dropStore, DropStoreError } from "../../../utils/drop-store";
|
||||
import { bearerToken, publicHeaders, readBounded } from "../../../utils/http";
|
||||
export default defineEventHandler(async (event) => {
|
||||
publicHeaders(event);
|
||||
if ((await readBounded(event, 0)).byteLength)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Completion body must be empty.",
|
||||
});
|
||||
const token = bearerToken(event);
|
||||
if (!token)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "A valid lease is required.",
|
||||
});
|
||||
try {
|
||||
await dropStore.complete(
|
||||
String(getRouterParam(event, "id") || "").toUpperCase(),
|
||||
token,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DropStoreError))
|
||||
throw error; /* Completion is deliberately idempotent: an already completed, expired, or unknown lease has the desired state. */
|
||||
}
|
||||
setResponseStatus(event, 204);
|
||||
});
|
||||
37
server/api/drops/[id]/finalize.post.ts
Normal file
37
server/api/drops/[id]/finalize.post.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createError, defineEventHandler, getRouterParam } from "h3";
|
||||
import { requireAuth } from "../../../utils/auth";
|
||||
import { dropStore, DropStoreError } from "../../../utils/drop-store";
|
||||
import { readBounded, requireContentType } from "../../../utils/http";
|
||||
import { validateManifestBodyLength } from "../../../../shared/utils/drop-contract";
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event);
|
||||
requireContentType(event, "application/octet-stream");
|
||||
const body = await readBounded(event, 65552);
|
||||
try {
|
||||
validateManifestBodyLength(body.byteLength);
|
||||
} catch {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid manifest." });
|
||||
}
|
||||
const id = String(getRouterParam(event, "id") || "").toUpperCase();
|
||||
try {
|
||||
const result = await dropStore.finalize(id, body);
|
||||
return {
|
||||
id,
|
||||
expiresAt: new Date(result.expiresAt!).toISOString(),
|
||||
maxReads: result.maxReads,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DropStoreError) {
|
||||
const status =
|
||||
error.code === "not-found"
|
||||
? 404
|
||||
: error.code === "expired"
|
||||
? 410
|
||||
: error.code === "incomplete"
|
||||
? 409
|
||||
: 409;
|
||||
throw createError({ statusCode: status, statusMessage: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
39
server/api/drops/[id]/leases.post.ts
Normal file
39
server/api/drops/[id]/leases.post.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestHeader,
|
||||
getRouterParam,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import { dropStore, DropStoreError } from "../../../utils/drop-store";
|
||||
import { bearerToken, publicHeaders, readBounded } from "../../../utils/http";
|
||||
export default defineEventHandler(async (event) => {
|
||||
publicHeaders(event);
|
||||
if ((await readBounded(event, 0)).byteLength)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Lease claim body must be empty.",
|
||||
});
|
||||
const id = String(getRouterParam(event, "id") || "").toUpperCase();
|
||||
const access = getRequestHeader(event, "x-quickdrop-access") || "";
|
||||
try {
|
||||
const result = await dropStore.claim(id, access, bearerToken(event));
|
||||
setResponseStatus(event, result.reused ? 200 : 201);
|
||||
return {
|
||||
token: result.token,
|
||||
leaseExpiresAt: new Date(result.leaseExpiresAt).toISOString(),
|
||||
expiresAt: new Date(result.expiresAt).toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DropStoreError) {
|
||||
const status =
|
||||
error.code === "exhausted" || error.code === "expired"
|
||||
? 410
|
||||
: error.code === "forbidden"
|
||||
? 403
|
||||
: 404;
|
||||
throw createError({ statusCode: status, statusMessage: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
26
server/api/drops/index.post.ts
Normal file
26
server/api/drops/index.post.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createError, defineEventHandler, setResponseStatus } from "h3";
|
||||
import { requireAuth } from "../../utils/auth";
|
||||
import { dropStore } from "../../utils/drop-store";
|
||||
import { readJson } from "../../utils/http";
|
||||
import { validateReservation } from "../../../shared/utils/drop-contract";
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event);
|
||||
const body = readJson(event, 4096);
|
||||
let request;
|
||||
try {
|
||||
request = validateReservation(await body);
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage:
|
||||
error instanceof Error ? error.message : "Invalid reservation.",
|
||||
});
|
||||
}
|
||||
const result = await dropStore.reserve(request);
|
||||
setResponseStatus(event, 201);
|
||||
return {
|
||||
id: result.id,
|
||||
uploadExpiresAt: new Date(result.uploadExpiresAt).toISOString(),
|
||||
access: result.access,
|
||||
};
|
||||
});
|
||||
16
server/plugins/drop-cleanup.ts
Normal file
16
server/plugins/drop-cleanup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cleanupExpiredDrops } from "../utils/drop-store";
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
cleanupExpiredDrops().catch((error) =>
|
||||
console.error("[quickdrop] Initial expiry cleanup failed.", error),
|
||||
);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
cleanupExpiredDrops().catch((error) =>
|
||||
console.error("[quickdrop] Scheduled expiry cleanup failed.", error),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
timer.unref();
|
||||
|
||||
nitroApp.hooks.hook("close", () => clearInterval(timer));
|
||||
});
|
||||
104
server/utils/auth.ts
Normal file
104
server/utils/auth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { H3Event } from "h3";
|
||||
import {
|
||||
createError,
|
||||
deleteCookie,
|
||||
getCookie,
|
||||
getRequestHeader,
|
||||
setCookie,
|
||||
} from "h3";
|
||||
const COOKIE = "quickdrop_auth";
|
||||
const DAY = 86400000;
|
||||
function config() {
|
||||
const runtime = useRuntimeConfig();
|
||||
const configuredTtl = Number(
|
||||
process.env.AUTH_TOKEN_TTL_DAYS || runtime.authTokenTtlDays || 7,
|
||||
);
|
||||
const ttlDays =
|
||||
Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 7;
|
||||
return {
|
||||
passcode: String(process.env.AUTH_PASSCODE || runtime.authPasscode || ""),
|
||||
secret: String(process.env.AUTH_SECRET || runtime.authSecret || ""),
|
||||
ttl: ttlDays * DAY,
|
||||
};
|
||||
}
|
||||
function mac(value: string, secret: string) {
|
||||
return createHmac("sha256", secret).update(value).digest("base64url");
|
||||
}
|
||||
export function authConfigured() {
|
||||
const c = config();
|
||||
return Boolean(c.passcode && c.secret);
|
||||
}
|
||||
export function issueSession(event: H3Event) {
|
||||
const c = config();
|
||||
if (!c.secret) throw new Error("Authentication is not configured.");
|
||||
const payload = `${Date.now() + c.ttl}.${randomBytes(24).toString("base64url")}`;
|
||||
setCookie(event, COOKIE, `${payload}.${mac(payload, c.secret)}`, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: Math.floor(c.ttl / 1000),
|
||||
});
|
||||
}
|
||||
export function clearAuthCookie(event: H3Event) {
|
||||
deleteCookie(event, COOKIE, { path: "/" });
|
||||
}
|
||||
export function isAuthenticated(event: H3Event): boolean {
|
||||
const c = config();
|
||||
const token = getCookie(event, COOKIE);
|
||||
if (!token || !c.secret) return false;
|
||||
const split = token.lastIndexOf(".");
|
||||
if (split < 1) return false;
|
||||
const payload = token.slice(0, split);
|
||||
const supplied = Buffer.from(token.slice(split + 1));
|
||||
const expected = Buffer.from(mac(payload, c.secret));
|
||||
if (
|
||||
supplied.length !== expected.length ||
|
||||
!timingSafeEqual(supplied, expected)
|
||||
)
|
||||
return false;
|
||||
const expiry = Number(payload.slice(0, payload.indexOf(".")));
|
||||
return Number.isSafeInteger(expiry) && expiry > Date.now();
|
||||
}
|
||||
export function requireAuth(event: H3Event) {
|
||||
if (!isAuthenticated(event))
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Authentication required.",
|
||||
});
|
||||
requireSameOrigin(event);
|
||||
}
|
||||
export function verifyPasscode(value: unknown): boolean {
|
||||
const c = config();
|
||||
if (!c.passcode || typeof value !== "string") return false;
|
||||
const supplied = Buffer.from(value);
|
||||
const expected = Buffer.from(c.passcode);
|
||||
return (
|
||||
supplied.length === expected.length && timingSafeEqual(supplied, expected)
|
||||
);
|
||||
}
|
||||
export function requireSameOrigin(event: H3Event) {
|
||||
const origin = getRequestHeader(event, "origin");
|
||||
const site = getRequestHeader(event, "sec-fetch-site");
|
||||
if (
|
||||
site &&
|
||||
["cross-site", "same-site"].includes(site) &&
|
||||
site === "cross-site"
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Cross-site state change rejected.",
|
||||
});
|
||||
if (origin) {
|
||||
const host = getRequestHeader(event, "host");
|
||||
const proto = getRequestHeader(event, "x-forwarded-proto") || "http";
|
||||
try {
|
||||
if (new URL(origin).host !== host || !["http", "https"].includes(proto))
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw createError({ statusCode: 403, statusMessage: "Origin rejected." });
|
||||
}
|
||||
}
|
||||
}
|
||||
export { COOKIE };
|
||||
580
server/utils/drop-store.ts
Normal file
580
server/utils/drop-store.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
open as openFile,
|
||||
} from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
ROOM_ID_PATTERN,
|
||||
expiresInMs,
|
||||
validateReservation,
|
||||
validateRoomId,
|
||||
} from "../../shared/utils/drop-contract";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
LEASE_IDLE_MS,
|
||||
UPLOAD_RESERVATION_MS,
|
||||
type PublicDescriptor,
|
||||
type ReserveRequest,
|
||||
type StoredDrop,
|
||||
type StoredLease,
|
||||
} from "../../shared/types/drop";
|
||||
const ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
const productionRoot = resolve(
|
||||
process.env.QUICKDROP_STORAGE_DIR || ".data/quickdrop",
|
||||
);
|
||||
export class DropStoreError extends Error {
|
||||
constructor(
|
||||
public code:
|
||||
| "not-found"
|
||||
| "expired"
|
||||
| "conflict"
|
||||
| "incomplete"
|
||||
| "exhausted"
|
||||
| "forbidden",
|
||||
message?: string,
|
||||
) {
|
||||
super(message || code);
|
||||
}
|
||||
}
|
||||
interface LockEntry {
|
||||
tail: Promise<void>;
|
||||
waiters: number;
|
||||
}
|
||||
const locks = new Map<string, LockEntry>();
|
||||
async function withLock<T>(
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
let entry = locks.get(key);
|
||||
if (!entry) {
|
||||
entry = { tail: Promise.resolve(), waiters: 0 };
|
||||
locks.set(key, entry);
|
||||
}
|
||||
entry.waiters++;
|
||||
const previous = entry.tail;
|
||||
let release!: () => void;
|
||||
entry.tail = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
entry.waiters--;
|
||||
if (entry.waiters === 0 && locks.get(key) === entry) locks.delete(key);
|
||||
}
|
||||
}
|
||||
function hash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
function sameHash(left: string, right: string): boolean {
|
||||
const a = Buffer.from(left, "hex");
|
||||
const b = Buffer.from(right, "hex");
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
function bearer(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
function nowValue(clock: (() => number) | { now(): number }): number {
|
||||
return typeof clock === "function" ? clock() : clock.now();
|
||||
}
|
||||
function ensureId(id: string) {
|
||||
try {
|
||||
validateRoomId(id);
|
||||
} catch {
|
||||
throw new DropStoreError("not-found", "Drop not found.");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export interface DropStoreOptions {
|
||||
root?: string;
|
||||
clock?: (() => number) | { now(): number };
|
||||
}
|
||||
const TEMP_FILE_GRACE_MS = 60 * 60 * 1000;
|
||||
const METADATALESS_GRACE_MS = 60 * 60 * 1000;
|
||||
export function pathsFor(id: string, root = productionRoot) {
|
||||
ensureId(id);
|
||||
const directory = resolve(root, id);
|
||||
return {
|
||||
directory,
|
||||
metadata: resolve(directory, "metadata.json"),
|
||||
manifest: resolve(directory, "manifest.bin"),
|
||||
chunks: resolve(directory, "chunks"),
|
||||
};
|
||||
}
|
||||
async function atomicWrite(path: string, data: Uint8Array | string) {
|
||||
const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
||||
await writeFile(temp, data, { mode: 0o600 });
|
||||
try {
|
||||
const handle = await openFile(temp, "r");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch {
|
||||
/* fsync is best effort on unusual filesystems */
|
||||
}
|
||||
await rename(temp, path);
|
||||
}
|
||||
function chunkPath(paths: ReturnType<typeof pathsFor>, index: number) {
|
||||
return resolve(paths.chunks, `${String(index).padStart(10, "0")}.bin`);
|
||||
}
|
||||
|
||||
export class DropStore {
|
||||
readonly root: string;
|
||||
readonly clock: (() => number) | { now(): number };
|
||||
constructor(options: DropStoreOptions = {}) {
|
||||
this.root = resolve(options.root || productionRoot);
|
||||
this.clock = options.clock || (() => Date.now());
|
||||
}
|
||||
async ensure() {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
async reserve(
|
||||
input: ReserveRequest,
|
||||
): Promise<{ id: string; uploadExpiresAt: number; access: string }> {
|
||||
validateReservation(input);
|
||||
await this.ensure();
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const id = Array.from(
|
||||
randomBytes(32),
|
||||
(byte) => ALPHABET[byte % ALPHABET.length],
|
||||
).join("");
|
||||
const paths = pathsFor(id, this.root);
|
||||
try {
|
||||
await mkdir(paths.directory, { recursive: false, mode: 0o700 });
|
||||
await mkdir(paths.chunks, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
||||
throw error;
|
||||
}
|
||||
const createdAt = nowValue(this.clock);
|
||||
const access = bearer();
|
||||
const metadata: StoredDrop = {
|
||||
version: 2,
|
||||
id,
|
||||
state: "uploading",
|
||||
createdAt,
|
||||
uploadExpiresAt: createdAt + UPLOAD_RESERVATION_MS,
|
||||
expiresInMs: expiresInMs(input.expiresIn),
|
||||
expiresAt: null,
|
||||
maxReads: input.maxReads,
|
||||
readsClaimed: 0,
|
||||
chunkSize: input.chunkSize,
|
||||
chunkCount: input.chunkCount,
|
||||
noncePrefix: input.noncePrefix,
|
||||
leases: {},
|
||||
accessHash: hash(access),
|
||||
};
|
||||
try {
|
||||
await atomicWrite(paths.metadata, JSON.stringify(metadata));
|
||||
return { id, uploadExpiresAt: metadata.uploadExpiresAt, access };
|
||||
} catch (error) {
|
||||
await rm(paths.directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Could not allocate a drop id.");
|
||||
}
|
||||
async readMetadata(id: string): Promise<StoredDrop | null> {
|
||||
const paths = pathsFor(id, this.root);
|
||||
try {
|
||||
return JSON.parse(await readFile(paths.metadata, "utf8")) as StoredDrop;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private async metadataLocked(id: string): Promise<StoredDrop> {
|
||||
const metadata = await this.readMetadata(id);
|
||||
if (!metadata || metadata.version !== 2 || metadata.id !== id)
|
||||
throw new DropStoreError("not-found", "Drop not found.");
|
||||
const now = nowValue(this.clock);
|
||||
if (
|
||||
(metadata.state === "uploading" && metadata.uploadExpiresAt <= now) ||
|
||||
(metadata.state === "ready" &&
|
||||
metadata.expiresAt !== null &&
|
||||
metadata.expiresAt <= now)
|
||||
) {
|
||||
await rm(pathsFor(id, this.root).directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
throw new DropStoreError("expired", "Drop expired.");
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
private async save(id: string, metadata: StoredDrop) {
|
||||
await atomicWrite(
|
||||
pathsFor(id, this.root).metadata,
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
}
|
||||
async putChunk(
|
||||
id: string,
|
||||
index: number,
|
||||
body: Uint8Array,
|
||||
): Promise<"created" | "same"> {
|
||||
ensureId(id);
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const metadata = await this.metadataLocked(id);
|
||||
if (metadata.state !== "uploading")
|
||||
throw new DropStoreError("conflict", "Drop is already finalized.");
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
index >= metadata.chunkCount
|
||||
)
|
||||
throw new DropStoreError("conflict", "Invalid chunk index.");
|
||||
const paths = pathsFor(id, this.root);
|
||||
const target = chunkPath(paths, index);
|
||||
const expectedFull = metadata.chunkSize + 16;
|
||||
if (
|
||||
body.byteLength < 16 ||
|
||||
body.byteLength > expectedFull ||
|
||||
(index < metadata.chunkCount - 1 && body.byteLength !== expectedFull)
|
||||
)
|
||||
throw new DropStoreError("conflict", "Invalid chunk length.");
|
||||
try {
|
||||
const existing = await readFile(target);
|
||||
if (
|
||||
existing.byteLength === body.byteLength &&
|
||||
sameHash(
|
||||
hash(existing.toString("base64")),
|
||||
hash(Buffer.from(body).toString("base64")),
|
||||
)
|
||||
)
|
||||
return "same";
|
||||
throw new DropStoreError(
|
||||
"conflict",
|
||||
"Chunk already exists with different contents.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DropStoreError) throw error;
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const temp = resolve(
|
||||
paths.directory,
|
||||
`.chunk-${index}-${randomBytes(5).toString("hex")}`,
|
||||
);
|
||||
await writeFile(temp, body, { mode: 0o600 });
|
||||
try {
|
||||
const handle = await openFile(temp, "r");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch {}
|
||||
await rename(temp, target);
|
||||
return "created";
|
||||
});
|
||||
}
|
||||
async finalize(id: string, manifest: Uint8Array): Promise<StoredDrop> {
|
||||
ensureId(id);
|
||||
if (manifest.byteLength <= 16)
|
||||
throw new DropStoreError("conflict", "Invalid manifest.");
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const metadata = await this.metadataLocked(id);
|
||||
const paths = pathsFor(id, this.root);
|
||||
if (metadata.state === "ready") {
|
||||
const current = await readFile(paths.manifest);
|
||||
if (Buffer.compare(current, Buffer.from(manifest)) !== 0)
|
||||
throw new DropStoreError(
|
||||
"conflict",
|
||||
"Manifest conflicts with finalized drop.",
|
||||
);
|
||||
return metadata;
|
||||
}
|
||||
for (let index = 0; index < metadata.chunkCount; index++) {
|
||||
let info;
|
||||
try {
|
||||
info = await stat(chunkPath(paths, index));
|
||||
} catch {
|
||||
throw new DropStoreError("incomplete", "Some chunks are missing.");
|
||||
}
|
||||
if (
|
||||
info.size < 16 ||
|
||||
(index < metadata.chunkCount - 1 &&
|
||||
info.size !== metadata.chunkSize + 16)
|
||||
)
|
||||
throw new DropStoreError("incomplete", "Chunk geometry is invalid.");
|
||||
}
|
||||
let previous: Buffer | null = null;
|
||||
try {
|
||||
previous = await readFile(paths.manifest);
|
||||
} catch {}
|
||||
if (previous && Buffer.compare(previous, Buffer.from(manifest)) !== 0)
|
||||
throw new DropStoreError(
|
||||
"conflict",
|
||||
"Manifest conflicts with staged data.",
|
||||
);
|
||||
if (!previous) await atomicWrite(paths.manifest, manifest);
|
||||
metadata.state = "ready";
|
||||
metadata.expiresAt = nowValue(this.clock) + metadata.expiresInMs;
|
||||
metadata.manifestBytes = manifest.byteLength;
|
||||
metadata.chunkCipherBytes = [];
|
||||
for (let index = 0; index < metadata.chunkCount; index++)
|
||||
metadata.chunkCipherBytes.push(
|
||||
(await stat(chunkPath(paths, index))).size,
|
||||
);
|
||||
await this.save(id, metadata);
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
async descriptor(id: string, access: string): Promise<PublicDescriptor> {
|
||||
ensureId(id);
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const metadata = await this.metadataLocked(id);
|
||||
if (metadata.state !== "ready")
|
||||
throw new DropStoreError("not-found", "Drop is not ready.");
|
||||
if (!sameHash(hash(access), metadata.accessHash))
|
||||
throw new DropStoreError("forbidden", "Invalid access capability.");
|
||||
const manifest = await readFile(pathsFor(id, this.root).manifest);
|
||||
return {
|
||||
version: 2,
|
||||
id,
|
||||
chunkSize: metadata.chunkSize,
|
||||
chunkCount: metadata.chunkCount,
|
||||
noncePrefix: metadata.noncePrefix,
|
||||
chunkCipherBytes: metadata.chunkCipherBytes || [],
|
||||
manifest: manifest.toString("base64url"),
|
||||
expiresAt: new Date(metadata.expiresAt!).toISOString(),
|
||||
canClaim:
|
||||
metadata.maxReads === null ||
|
||||
metadata.readsClaimed < metadata.maxReads,
|
||||
maxReads: metadata.maxReads,
|
||||
};
|
||||
});
|
||||
}
|
||||
async claim(
|
||||
id: string,
|
||||
access: string,
|
||||
existing?: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
leaseExpiresAt: number;
|
||||
expiresAt: number;
|
||||
reused: boolean;
|
||||
}> {
|
||||
ensureId(id);
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const metadata = await this.metadataLocked(id);
|
||||
if (metadata.state !== "ready")
|
||||
throw new DropStoreError("not-found", "Drop is not ready.");
|
||||
if (!sameHash(hash(access), metadata.accessHash))
|
||||
throw new DropStoreError("forbidden", "Invalid access capability.");
|
||||
const now = nowValue(this.clock);
|
||||
for (const [tokenHash, lease] of Object.entries(metadata.leases)) {
|
||||
if (lease.expiresAt <= now) delete metadata.leases[tokenHash];
|
||||
}
|
||||
if (existing && metadata.leases[hash(existing)]) {
|
||||
const lease = metadata.leases[hash(existing)]!;
|
||||
lease.lastSeenAt = now;
|
||||
lease.expiresAt = Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!);
|
||||
await this.save(id, metadata);
|
||||
return {
|
||||
token: existing,
|
||||
leaseExpiresAt: lease.expiresAt,
|
||||
expiresAt: metadata.expiresAt!,
|
||||
reused: true,
|
||||
};
|
||||
}
|
||||
if (
|
||||
metadata.maxReads !== null &&
|
||||
metadata.readsClaimed >= metadata.maxReads
|
||||
) {
|
||||
await this.save(id, metadata);
|
||||
throw new DropStoreError("exhausted", "No new reads remain.");
|
||||
}
|
||||
const token = bearer();
|
||||
const lease: StoredLease = {
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!),
|
||||
};
|
||||
metadata.leases[hash(token)] = lease;
|
||||
metadata.readsClaimed++;
|
||||
await this.save(id, metadata);
|
||||
return {
|
||||
token,
|
||||
leaseExpiresAt: lease.expiresAt,
|
||||
expiresAt: metadata.expiresAt!,
|
||||
reused: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
async readChunk(
|
||||
id: string,
|
||||
index: number,
|
||||
token: string,
|
||||
): Promise<{ handle: Awaited<ReturnType<typeof openFile>>; size: number }> {
|
||||
ensureId(id);
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const metadata = await this.metadataLocked(id);
|
||||
const tokenHash = hash(token);
|
||||
if (metadata.state !== "ready" || !metadata.leases[tokenHash])
|
||||
throw new DropStoreError("forbidden", "Invalid lease.");
|
||||
const lease = metadata.leases[tokenHash]!;
|
||||
const now = nowValue(this.clock);
|
||||
if (lease.expiresAt <= now) {
|
||||
delete metadata.leases[hash(token)];
|
||||
await this.save(id, metadata);
|
||||
throw new DropStoreError("forbidden", "Lease expired.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
index >= metadata.chunkCount
|
||||
)
|
||||
throw new DropStoreError("not-found", "Chunk not found.");
|
||||
lease.lastSeenAt = now;
|
||||
lease.expiresAt = Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!);
|
||||
await this.save(id, metadata);
|
||||
const handle = await openFile(
|
||||
chunkPath(pathsFor(id, this.root), index),
|
||||
"r",
|
||||
);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
return { handle, size: info.size };
|
||||
} catch (error) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
async complete(id: string, token: string): Promise<void> {
|
||||
ensureId(id);
|
||||
return withLock(`${this.root}\0${id}`, async () => {
|
||||
const existing = await this.readMetadata(id);
|
||||
if (!existing) return;
|
||||
const now = nowValue(this.clock);
|
||||
if (existing.expiresAt !== null && existing.expiresAt <= now) {
|
||||
await rm(pathsFor(id, this.root).directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const metadata = await this.metadataLocked(id);
|
||||
const key = hash(token);
|
||||
if (!metadata.leases[key])
|
||||
throw new DropStoreError("forbidden", "Invalid lease.");
|
||||
delete metadata.leases[key];
|
||||
if (
|
||||
metadata.maxReads !== null &&
|
||||
metadata.readsClaimed >= metadata.maxReads &&
|
||||
Object.keys(metadata.leases).length === 0
|
||||
)
|
||||
await rm(pathsFor(id, this.root).directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
else await this.save(id, metadata);
|
||||
});
|
||||
}
|
||||
async delete(id: string) {
|
||||
ensureId(id);
|
||||
await withLock(`${this.root}\0${id}`, () =>
|
||||
rm(pathsFor(id, this.root).directory, { recursive: true, force: true }),
|
||||
);
|
||||
}
|
||||
async cleanup() {
|
||||
await this.ensure();
|
||||
const entries = await readdir(this.root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !ROOM_ID_PATTERN.test(entry.name)) continue;
|
||||
await withLock(`${this.root}\0${entry.name}`, async () => {
|
||||
try {
|
||||
const dropDirectory = pathsFor(entry.name, this.root).directory;
|
||||
const now = nowValue(this.clock);
|
||||
for (const temporary of await readdir(dropDirectory)) {
|
||||
if (
|
||||
!temporary.startsWith(".chunk-") &&
|
||||
!temporary.includes(".tmp-")
|
||||
)
|
||||
continue;
|
||||
const temporaryPath = resolve(dropDirectory, temporary);
|
||||
const temporaryInfo = await stat(temporaryPath).catch(() => null);
|
||||
if (
|
||||
temporaryInfo &&
|
||||
now - temporaryInfo.mtimeMs >= TEMP_FILE_GRACE_MS
|
||||
)
|
||||
await rm(temporaryPath, { force: true });
|
||||
}
|
||||
const metadata = await this.readMetadata(entry.name);
|
||||
if (!metadata) {
|
||||
const directoryInfo = await stat(dropDirectory);
|
||||
if (now - directoryInfo.mtimeMs >= METADATALESS_GRACE_MS)
|
||||
await rm(dropDirectory, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(metadata.state === "uploading" &&
|
||||
metadata.uploadExpiresAt <= now) ||
|
||||
(metadata.state === "ready" &&
|
||||
metadata.expiresAt !== null &&
|
||||
metadata.expiresAt <= now)
|
||||
) {
|
||||
await rm(dropDirectory, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
let changed = false;
|
||||
for (const [key, lease] of Object.entries(metadata.leases))
|
||||
if (
|
||||
lease.expiresAt <= now ||
|
||||
now - lease.lastSeenAt >= LEASE_IDLE_MS
|
||||
) {
|
||||
delete metadata.leases[key];
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
metadata.maxReads !== null &&
|
||||
metadata.readsClaimed >= metadata.maxReads &&
|
||||
Object.keys(metadata.leases).length === 0
|
||||
) {
|
||||
await rm(dropDirectory, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
if (changed) await this.save(entry.name, metadata);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[quickdrop] ignoring malformed drop ${entry.name}`,
|
||||
error,
|
||||
);
|
||||
await rm(resolve(this.root, entry.name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
export const dropStore = new DropStore();
|
||||
export async function ensureStore() {
|
||||
return dropStore.ensure();
|
||||
}
|
||||
export async function cleanupExpiredDrops() {
|
||||
return dropStore.cleanup();
|
||||
}
|
||||
export async function deleteDrop(id: string) {
|
||||
return dropStore.delete(id);
|
||||
}
|
||||
export async function createRoomId() {
|
||||
return (
|
||||
await dropStore.reserve({
|
||||
version: 2,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
chunkCount: 1,
|
||||
noncePrefix: randomBytes(8).toString("base64url"),
|
||||
expiresIn: "24h",
|
||||
maxReads: 1,
|
||||
})
|
||||
).id;
|
||||
}
|
||||
55
server/utils/http.ts
Normal file
55
server/utils/http.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { H3Event } from "h3";
|
||||
import { createError, getRequestHeader } from "h3";
|
||||
export async function readBounded(
|
||||
event: H3Event,
|
||||
max: number,
|
||||
): Promise<Buffer> {
|
||||
const declared = getRequestHeader(event, "content-length");
|
||||
if (declared && (!/^\d+$/u.test(declared) || Number(declared) > max))
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
statusMessage: "Request body exceeds the limit.",
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const value of event.node.req) {
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
total += chunk.byteLength;
|
||||
if (total > max)
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
statusMessage: "Request body exceeds the limit.",
|
||||
});
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
export async function readJson<T>(event: H3Event, max: number): Promise<T> {
|
||||
const body = await readBounded(event, max);
|
||||
try {
|
||||
return JSON.parse(body.toString("utf8")) as T;
|
||||
} catch {
|
||||
throw createError({ statusCode: 400, statusMessage: "Malformed JSON." });
|
||||
}
|
||||
}
|
||||
export function requireContentType(event: H3Event, expected: string) {
|
||||
const type = (
|
||||
(getRequestHeader(event, "content-type") || "").split(";", 1)[0] || ""
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (type !== expected)
|
||||
throw createError({
|
||||
statusCode: 415,
|
||||
statusMessage: `Content-Type must be ${expected}.`,
|
||||
});
|
||||
}
|
||||
export function bearerToken(event: H3Event): string | undefined {
|
||||
const value = getRequestHeader(event, "authorization") || "";
|
||||
return /^Bearer ([A-Za-z0-9_-]{20,})$/u.exec(value)?.[1];
|
||||
}
|
||||
export function publicHeaders(event: H3Event) {
|
||||
event.node.res.setHeader("cache-control", "no-store");
|
||||
event.node.res.setHeader("referrer-policy", "no-referrer");
|
||||
event.node.res.setHeader("x-content-type-options", "nosniff");
|
||||
}
|
||||
Reference in New Issue
Block a user