Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -6,41 +6,98 @@ import { getLibraryFile, libraryFilesRetired } from './index.js';
|
||||
import { assertRegularPath, parseByteRange } from './storage.js';
|
||||
|
||||
export function gameFileError(cause: unknown): Response {
|
||||
const status = typeof cause === 'object' && cause !== null && 'status' in cause && typeof cause.status === 'number' ? cause.status : 400;
|
||||
const receivedBytes = typeof cause === 'object' && cause !== null && 'receivedBytes' in cause && Number.isSafeInteger(cause.receivedBytes) ? cause.receivedBytes : undefined;
|
||||
return json({ error: cause instanceof Error ? cause.message : 'Game file request failed.', ...(receivedBytes === undefined ? {} : { receivedBytes }) }, { status });
|
||||
const status =
|
||||
typeof cause === 'object' &&
|
||||
cause !== null &&
|
||||
'status' in cause &&
|
||||
typeof cause.status === 'number'
|
||||
? cause.status
|
||||
: 400;
|
||||
const receivedBytes =
|
||||
typeof cause === 'object' &&
|
||||
cause !== null &&
|
||||
'receivedBytes' in cause &&
|
||||
Number.isSafeInteger(cause.receivedBytes)
|
||||
? cause.receivedBytes
|
||||
: undefined;
|
||||
return json(
|
||||
{
|
||||
error: cause instanceof Error ? cause.message : 'Game file request failed.',
|
||||
...(receivedBytes === undefined ? {} : { receivedBytes })
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
export async function readGameJson(request: Request, maxBytes = 64 * 1024): Promise<Record<string, unknown>> {
|
||||
export async function readGameJson(
|
||||
request: Request,
|
||||
maxBytes = 64 * 1024
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!request.body) throw new Error('A JSON request body is required.');
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = []; let bytes = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read(); if (next.done) break;
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
bytes += next.value.byteLength;
|
||||
if (bytes > maxBytes) { await reader.cancel(); throw new Error('Request body exceeds its size limit.'); }
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error('Request body exceeds its size limit.');
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON object.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Expected a JSON object.');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function serveGameFile(request: Request, versionId: string, requestedPath: string): Promise<Response> {
|
||||
if (libraryFilesRetired(versionId)) return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
|
||||
export async function serveGameFile(
|
||||
request: Request,
|
||||
versionId: string,
|
||||
requestedPath: string
|
||||
): Promise<Response> {
|
||||
if (libraryFilesRetired(versionId))
|
||||
return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
|
||||
const file = getLibraryFile(versionId, requestedPath);
|
||||
if (!file || file.state !== 'ready') return json({ error: 'File is not in a published manifest.' }, { status: 404 });
|
||||
if (!file || file.state !== 'ready')
|
||||
return json({ error: 'File is not in a published manifest.' }, { status: 404 });
|
||||
const path = await assertRegularPath(file.storageLocation, file.path);
|
||||
if ((await stat(path)).size !== file.size) return json({ error: 'Stored file failed size verification.' }, { status: 503 });
|
||||
if ((await stat(path)).size !== file.size)
|
||||
return json({ error: 'Stored file failed size verification.' }, { status: 503 });
|
||||
const etag = `"${file.sha256}"`;
|
||||
const headers = new Headers({ 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes', ETag: etag, 'Cache-Control': 'private, no-cache', 'X-Content-SHA256': file.sha256 });
|
||||
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers });
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept-Ranges': 'bytes',
|
||||
ETag: etag,
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'X-Content-SHA256': file.sha256
|
||||
});
|
||||
if (request.headers.get('if-none-match') === etag)
|
||||
return new Response(null, { status: 304, headers });
|
||||
let range;
|
||||
try { range = parseByteRange(request.headers.has('if-range') && request.headers.get('if-range') !== etag ? null : request.headers.get('range'), file.size); }
|
||||
catch { headers.set('Content-Range', `bytes */${file.size}`); return new Response(null, { status: 416, headers }); }
|
||||
try {
|
||||
range = parseByteRange(
|
||||
request.headers.has('if-range') && request.headers.get('if-range') !== etag
|
||||
? null
|
||||
: request.headers.get('range'),
|
||||
file.size
|
||||
);
|
||||
} catch {
|
||||
headers.set('Content-Range', `bytes */${file.size}`);
|
||||
return new Response(null, { status: 416, headers });
|
||||
}
|
||||
headers.set('Content-Length', String(range ? range.end - range.start + 1 : file.size));
|
||||
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
|
||||
const body = request.method === 'HEAD' ? null : Readable.toWeb(createReadStream(path, range ?? undefined)) as ReadableStream<Uint8Array>;
|
||||
const body =
|
||||
request.method === 'HEAD'
|
||||
? null
|
||||
: (Readable.toWeb(
|
||||
createReadStream(path, range ?? undefined)
|
||||
) as ReadableStream<Uint8Array>);
|
||||
return new Response(body, { status: range ? 206 : 200, headers });
|
||||
}
|
||||
|
||||
+936
-227
File diff suppressed because it is too large
Load Diff
@@ -50,10 +50,17 @@ export function ensureGameFileSchema(handle: Database.Database): void {
|
||||
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
|
||||
CREATE INDEX IF NOT EXISTS idx_host_library_caches_version ON host_library_caches(version_id);
|
||||
`);
|
||||
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[];
|
||||
if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
|
||||
const cacheColumns = handle.prepare('PRAGMA table_info(host_library_caches)').all() as { name: string }[];
|
||||
if (!cacheColumns.some(column => column.name === 'bytes_verified')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
|
||||
if (!cacheColumns.some(column => column.name === 'transfer_progress')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
|
||||
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!versionColumns.some((column) => column.name === 'last_activity_at'))
|
||||
handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
|
||||
const cacheColumns = handle.prepare('PRAGMA table_info(host_library_caches)').all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cacheColumns.some((column) => column.name === 'bytes_verified'))
|
||||
handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
|
||||
if (!cacheColumns.some((column) => column.name === 'transfer_progress'))
|
||||
handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
|
||||
initialized.add(handle);
|
||||
}
|
||||
|
||||
@@ -8,39 +8,62 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { GameFileEntry } from '../../game-files.js';
|
||||
|
||||
export function validateGamePath(input: unknown): string {
|
||||
if (typeof input !== 'string' || !input || input.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(input) || Buffer.from(input).toString('utf8') !== input
|
||||
|| isAbsolute(input) || input.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) {
|
||||
if (
|
||||
typeof input !== 'string' ||
|
||||
!input ||
|
||||
input.length > 1024 ||
|
||||
// eslint-disable-next-line no-control-regex -- Reject control characters in game file paths.
|
||||
/[\\\x00-\x1f\x7f:]/.test(input) ||
|
||||
Buffer.from(input).toString('utf8') !== input ||
|
||||
isAbsolute(input) ||
|
||||
input
|
||||
.split('/')
|
||||
.some((part) => !part || part === '.' || part === '..' || /[. ]$/.test(part))
|
||||
) {
|
||||
throw new Error('Game file paths must be relative, normalized paths without traversal.');
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function validateManifest(input: unknown): GameFileEntry[] {
|
||||
if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a manifest containing 1–200000 files.');
|
||||
if (!Array.isArray(input) || !input.length || input.length > 200_000)
|
||||
throw new Error('Provide a manifest containing 1–200000 files.');
|
||||
const paths = new Set<string>();
|
||||
let total = 0;
|
||||
const files = input.map(raw => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Invalid manifest entry.');
|
||||
const path = validateGamePath(raw.path);
|
||||
if (!Number.isSafeInteger(raw.size) || raw.size < 0 || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256)) throw new Error(`Invalid size or SHA256 for ${path}.`);
|
||||
const key = path.toLowerCase();
|
||||
if (paths.has(key)) throw new Error(`Duplicate game file path: ${path}.`);
|
||||
paths.add(key); total += raw.size;
|
||||
if (!Number.isSafeInteger(total)) throw new Error('Library size exceeds the supported limit.');
|
||||
return { path, size: raw.size as number, sha256: raw.sha256 as string };
|
||||
}).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
const files = input
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Invalid manifest entry.');
|
||||
const path = validateGamePath(raw.path);
|
||||
if (
|
||||
!Number.isSafeInteger(raw.size) ||
|
||||
raw.size < 0 ||
|
||||
typeof raw.sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(raw.sha256)
|
||||
)
|
||||
throw new Error(`Invalid size or SHA256 for ${path}.`);
|
||||
const key = path.toLowerCase();
|
||||
if (paths.has(key)) throw new Error(`Duplicate game file path: ${path}.`);
|
||||
paths.add(key);
|
||||
total += raw.size;
|
||||
if (!Number.isSafeInteger(total))
|
||||
throw new Error('Library size exceeds the supported limit.');
|
||||
return { path, size: raw.size as number, sha256: raw.sha256 as string };
|
||||
})
|
||||
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
for (const file of files) {
|
||||
const parts = file.path.toLowerCase().split('/');
|
||||
parts.pop();
|
||||
while (parts.length) {
|
||||
if (paths.has(parts.join('/'))) throw new Error('A manifest path cannot be both a file and a directory.');
|
||||
if (paths.has(parts.join('/')))
|
||||
throw new Error('A manifest path cannot be both a file and a directory.');
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export const manifestChecksum = (files: GameFileEntry[]): string => createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
||||
export const manifestChecksum = (files: GameFileEntry[]): string =>
|
||||
createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
||||
|
||||
export function gameFilePath(root: string, path: string): string {
|
||||
const base = resolve(root);
|
||||
@@ -50,21 +73,41 @@ export function gameFilePath(root: string, path: string): string {
|
||||
}
|
||||
|
||||
/** Refuse symlinks at every existing component, including the managed root. */
|
||||
export async function assertRegularPath(root: string, path: string, allowMissing = false): Promise<string> {
|
||||
export async function assertRegularPath(
|
||||
root: string,
|
||||
path: string,
|
||||
allowMissing = false
|
||||
): Promise<string> {
|
||||
const result = gameFilePath(root, path);
|
||||
let current = resolve(root);
|
||||
const components = [current, ...validateGamePath(path).split('/').map(part => current = resolve(current, part))];
|
||||
const components = [
|
||||
current,
|
||||
...validateGamePath(path)
|
||||
.split('/')
|
||||
.map((part) => (current = resolve(current, part)))
|
||||
];
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
let info;
|
||||
try { info = await lstat(components[i]); }
|
||||
catch (cause) { if (allowMissing && (cause as NodeJS.ErrnoException).code === 'ENOENT') continue; throw cause; }
|
||||
if (info.isSymbolicLink() || (i < components.length - 1 ? !info.isDirectory() : !info.isFile())) throw new Error('Game storage contains a non-regular path.');
|
||||
try {
|
||||
info = await lstat(components[i]);
|
||||
} catch (cause) {
|
||||
if (allowMissing && (cause as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||
throw cause;
|
||||
}
|
||||
if (
|
||||
info.isSymbolicLink() ||
|
||||
(i < components.length - 1 ? !info.isDirectory() : !info.isFile())
|
||||
)
|
||||
throw new Error('Game storage contains a non-regular path.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compute a file digest without buffering game archives in memory. */
|
||||
export async function hashGameFile(root: string, file: Pick<GameFileEntry, 'path' | 'size'>): Promise<string> {
|
||||
export async function hashGameFile(
|
||||
root: string,
|
||||
file: Pick<GameFileEntry, 'path' | 'size'>
|
||||
): Promise<string> {
|
||||
const path = await assertRegularPath(root, file.path);
|
||||
const info = await stat(path);
|
||||
if (info.size !== file.size) throw new Error(`Size verification failed for ${file.path}.`);
|
||||
@@ -74,35 +117,61 @@ export async function hashGameFile(root: string, file: Pick<GameFileEntry, 'path
|
||||
}
|
||||
|
||||
export async function verifyGameFile(root: string, file: GameFileEntry): Promise<void> {
|
||||
if (await hashGameFile(root, file) !== file.sha256) throw new Error(`Checksum verification failed for ${file.path}.`);
|
||||
if ((await hashGameFile(root, file)) !== file.sha256)
|
||||
throw new Error(`Checksum verification failed for ${file.path}.`);
|
||||
}
|
||||
|
||||
/** Stream to a private temporary file; publish only after byte count and SHA256 match. */
|
||||
export async function receiveGameFile(root: string, file: GameFileEntry, body: ReadableStream<Uint8Array> | null): Promise<void> {
|
||||
export async function receiveGameFile(
|
||||
root: string,
|
||||
file: GameFileEntry,
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
): Promise<void> {
|
||||
if (!body) throw new Error('A file body is required.');
|
||||
const path = await assertRegularPath(root, file.path, true);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temp = `${path}.upload-${randomUUID()}`;
|
||||
let size = 0;
|
||||
const hash = createHash('sha256');
|
||||
const verifier = new Transform({ transform(chunk, _encoding, done) {
|
||||
size += chunk.length;
|
||||
if (size > file.size) return done(new Error('Upload exceeds the manifest size.'));
|
||||
hash.update(chunk); done(null, chunk);
|
||||
} });
|
||||
const verifier = new Transform({
|
||||
transform(chunk, _encoding, done) {
|
||||
size += chunk.length;
|
||||
if (size > file.size) return done(new Error('Upload exceeds the manifest size.'));
|
||||
hash.update(chunk);
|
||||
done(null, chunk);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(body as never), verifier, createWriteStream(temp, { flags: 'wx', mode: 0o600, flush: true }));
|
||||
if (size !== file.size || hash.digest('hex') !== file.sha256) throw new Error(`Upload checksum or size mismatch for ${file.path}.`);
|
||||
await pipeline(
|
||||
Readable.fromWeb(body as never),
|
||||
verifier,
|
||||
createWriteStream(temp, { flags: 'wx', mode: 0o600, flush: true })
|
||||
);
|
||||
if (size !== file.size || hash.digest('hex') !== file.sha256)
|
||||
throw new Error(`Upload checksum or size mismatch for ${file.path}.`);
|
||||
await rename(temp, path);
|
||||
} finally { await rm(temp, { force: true }); }
|
||||
} finally {
|
||||
await rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseByteRange(header: string | null, size: number): { start: number; end: number } | null {
|
||||
export function parseByteRange(
|
||||
header: string | null,
|
||||
size: number
|
||||
): { start: number; end: number } | null {
|
||||
if (!header) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header);
|
||||
if (!match || (!match[1] && !match[2]) || size === 0) throw new Error('Unsatisfiable range.');
|
||||
let start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
||||
let end = match[1] && match[2] ? Number(match[2]) : size - 1;
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start || (!match[1] && Number(match[2]) === 0)) throw new Error('Unsatisfiable range.');
|
||||
const start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
||||
const end = match[1] && match[2] ? Number(match[2]) : size - 1;
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start < 0 ||
|
||||
start >= size ||
|
||||
end < start ||
|
||||
(!match[1] && Number(match[2]) === 0)
|
||||
)
|
||||
throw new Error('Unsatisfiable range.');
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
|
||||
@@ -32,28 +32,47 @@ export class UploadOffsetError extends Error {
|
||||
|
||||
/** Apply the same path, case, size and file/directory collision rules as published manifests. */
|
||||
export function validateUploadPlan(input: unknown): UploadFilePlan[] {
|
||||
if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a folder containing 1–200000 files.');
|
||||
if (!Array.isArray(input) || !input.length || input.length > 200_000)
|
||||
throw new Error('Provide a folder containing 1–200000 files.');
|
||||
const modified = new Map<string, number>();
|
||||
const manifest = validateManifest(input.map(raw => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Invalid upload file.');
|
||||
const lastModified = raw.lastModified === undefined ? 0 : raw.lastModified;
|
||||
if (!Number.isSafeInteger(lastModified) || lastModified < 0) throw new Error('Invalid file modification time.');
|
||||
modified.set(raw.path, lastModified);
|
||||
return { path: raw.path, size: raw.size, sha256: '0'.repeat(64) };
|
||||
const manifest = validateManifest(
|
||||
input.map((raw) => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Invalid upload file.');
|
||||
const lastModified = raw.lastModified === undefined ? 0 : raw.lastModified;
|
||||
if (!Number.isSafeInteger(lastModified) || lastModified < 0)
|
||||
throw new Error('Invalid file modification time.');
|
||||
modified.set(raw.path, lastModified);
|
||||
return { path: raw.path, size: raw.size, sha256: '0'.repeat(64) };
|
||||
})
|
||||
);
|
||||
return manifest.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
lastModified: modified.get(file.path)!
|
||||
}));
|
||||
return manifest.map(file => ({ path: file.path, size: file.size, lastModified: modified.get(file.path)! }));
|
||||
}
|
||||
|
||||
interface UploadLocks { barrier: Promise<void>; writers: number; files: Map<string, Promise<void>>; active: Set<Promise<void>> }
|
||||
interface UploadLocks {
|
||||
barrier: Promise<void>;
|
||||
writers: number;
|
||||
files: Map<string, Promise<void>>;
|
||||
active: Set<Promise<void>>;
|
||||
}
|
||||
const uploadLocks = new Map<string, UploadLocks>();
|
||||
export function uploadIsActive(root: string): boolean { return uploadLocks.has(resolve(root)); }
|
||||
export function uploadIsActive(root: string): boolean {
|
||||
return uploadLocks.has(resolve(root));
|
||||
}
|
||||
function locksFor(key: string): UploadLocks {
|
||||
let locks = uploadLocks.get(key);
|
||||
if (!locks) { locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() }; uploadLocks.set(key, locks); }
|
||||
if (!locks) {
|
||||
locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() };
|
||||
uploadLocks.set(key, locks);
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
function releaseLocks(key: string, locks: UploadLocks) {
|
||||
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks) uploadLocks.delete(key);
|
||||
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks)
|
||||
uploadLocks.delete(key);
|
||||
}
|
||||
|
||||
/** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */
|
||||
@@ -64,12 +83,15 @@ export async function withUploadLock<T>(root: string, callback: () => Promise<T>
|
||||
// from starting until publication finishes (including failed publication).
|
||||
const previous = Promise.all([locks.barrier, ...locks.active]);
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>(done => { release = done; });
|
||||
const current = new Promise<void>((done) => {
|
||||
release = done;
|
||||
});
|
||||
locks.barrier = current;
|
||||
locks.writers++;
|
||||
await previous;
|
||||
try { return await callback(); }
|
||||
finally {
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
release();
|
||||
locks.writers--;
|
||||
releaseLocks(key, locks);
|
||||
@@ -77,31 +99,47 @@ export async function withUploadLock<T>(root: string, callback: () => Promise<T>
|
||||
}
|
||||
|
||||
/** Different files may transfer together; offsets for the same file remain serial. */
|
||||
export async function withUploadFileLock<T>(root: string, file: string, callback: () => Promise<T>): Promise<T> {
|
||||
const key = resolve(root), locks = locksFor(key);
|
||||
export async function withUploadFileLock<T>(
|
||||
root: string,
|
||||
file: string,
|
||||
callback: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = resolve(root),
|
||||
locks = locksFor(key);
|
||||
const previous = Promise.all([locks.barrier, locks.files.get(file)]);
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>(done => { release = done; });
|
||||
locks.files.set(file, current); locks.active.add(current);
|
||||
const current = new Promise<void>((done) => {
|
||||
release = done;
|
||||
});
|
||||
locks.files.set(file, current);
|
||||
locks.active.add(current);
|
||||
await previous;
|
||||
try { return await callback(); }
|
||||
finally {
|
||||
release(); locks.active.delete(current);
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
release();
|
||||
locks.active.delete(current);
|
||||
if (locks.files.get(file) === current) locks.files.delete(file);
|
||||
releaseLocks(key, locks);
|
||||
}
|
||||
}
|
||||
|
||||
/** File size is the resume checkpoint, including after a controller restart during an append. */
|
||||
export async function getUploadFileProgress(root: string, file: Pick<UploadFilePlan, 'path' | 'size'>): Promise<UploadFileProgress> {
|
||||
export async function getUploadFileProgress(
|
||||
root: string,
|
||||
file: Pick<UploadFilePlan, 'path' | 'size'>
|
||||
): Promise<UploadFileProgress> {
|
||||
const path = await assertRegularPath(root, file.path, true);
|
||||
try {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Game storage contains a non-regular path.');
|
||||
if (info.size > file.size) throw new Error(`Stored upload exceeds the declared size of ${file.path}.`);
|
||||
if (!info.isFile() || info.isSymbolicLink())
|
||||
throw new Error('Game storage contains a non-regular path.');
|
||||
if (info.size > file.size)
|
||||
throw new Error(`Stored upload exceeds the declared size of ${file.path}.`);
|
||||
return { receivedBytes: info.size, complete: info.size === file.size };
|
||||
} catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return { receivedBytes: 0, complete: false };
|
||||
if ((cause as NodeJS.ErrnoException).code === 'ENOENT')
|
||||
return { receivedBytes: 0, complete: false };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -118,10 +156,17 @@ export async function receiveUploadChunk(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
beforeWrite?: () => Promise<void> | void
|
||||
): Promise<UploadFileProgress> {
|
||||
if (!Number.isSafeInteger(chunk.offset) || chunk.offset < 0 || !Number.isSafeInteger(chunk.size)
|
||||
|| chunk.size < 0 || chunk.size > MAX_UPLOAD_CHUNK_BYTES || chunk.offset + chunk.size > file.size
|
||||
|| !Number.isSafeInteger(chunk.offset + chunk.size) || !/^[a-f0-9]{64}$/.test(chunk.sha256)
|
||||
|| (chunk.size === 0 && file.size !== 0)) {
|
||||
if (
|
||||
!Number.isSafeInteger(chunk.offset) ||
|
||||
chunk.offset < 0 ||
|
||||
!Number.isSafeInteger(chunk.size) ||
|
||||
chunk.size < 0 ||
|
||||
chunk.size > MAX_UPLOAD_CHUNK_BYTES ||
|
||||
chunk.offset + chunk.size > file.size ||
|
||||
!Number.isSafeInteger(chunk.offset + chunk.size) ||
|
||||
!/^[a-f0-9]{64}$/.test(chunk.sha256) ||
|
||||
(chunk.size === 0 && file.size !== 0)
|
||||
) {
|
||||
throw new Error('Invalid upload chunk size, offset or SHA256.');
|
||||
}
|
||||
if (!body && chunk.size !== 0) throw new Error('A file chunk body is required.');
|
||||
@@ -129,7 +174,8 @@ export async function receiveUploadChunk(
|
||||
return withUploadFileLock(root, file.path, async () => {
|
||||
await beforeWrite?.();
|
||||
const progress = await getUploadFileProgress(root, file);
|
||||
if (chunk.offset !== progress.receivedBytes) throw new UploadOffsetError(progress.receivedBytes);
|
||||
if (chunk.offset !== progress.receivedBytes)
|
||||
throw new UploadOffsetError(progress.receivedBytes);
|
||||
const destination = await assertRegularPath(root, file.path, true);
|
||||
// Keep transient chunks outside the tree that will become the immutable library.
|
||||
const chunkRoot = `${resolve(root)}.chunks`;
|
||||
@@ -137,19 +183,31 @@ export async function receiveUploadChunk(
|
||||
await mkdir(dirname(temporary), { recursive: true });
|
||||
let received = 0;
|
||||
const hash = createHash('sha256');
|
||||
const verifier = new Transform({ transform(data, _encoding, done) {
|
||||
received += data.length;
|
||||
if (received > chunk.size) return done(new Error('Upload exceeds the declared chunk size.'));
|
||||
hash.update(data); done(null, data);
|
||||
} });
|
||||
const verifier = new Transform({
|
||||
transform(data, _encoding, done) {
|
||||
received += data.length;
|
||||
if (received > chunk.size)
|
||||
return done(new Error('Upload exceeds the declared chunk size.'));
|
||||
hash.update(data);
|
||||
done(null, data);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await pipeline(body ? Readable.fromWeb(body as never) : Readable.from([]), verifier,
|
||||
createWriteStream(temporary, { flags: 'wx', mode: 0o600, flush: true }));
|
||||
if (received !== chunk.size || hash.digest('hex') !== chunk.sha256) throw new Error('Upload chunk checksum or size mismatch.');
|
||||
await pipeline(
|
||||
body ? Readable.fromWeb(body as never) : Readable.from([]),
|
||||
verifier,
|
||||
createWriteStream(temporary, { flags: 'wx', mode: 0o600, flush: true })
|
||||
);
|
||||
if (received !== chunk.size || hash.digest('hex') !== chunk.sha256)
|
||||
throw new Error('Upload chunk checksum or size mismatch.');
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await assertRegularPath(root, file.path, true);
|
||||
const target = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
const target = await open(
|
||||
destination,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
|
||||
0o600
|
||||
);
|
||||
try {
|
||||
const info = await target.stat();
|
||||
if (!info.isFile()) throw new Error('Game storage contains a non-regular path.');
|
||||
@@ -160,8 +218,14 @@ export async function receiveUploadChunk(
|
||||
const bytes = data as Buffer;
|
||||
let written = 0;
|
||||
while (written < bytes.length) {
|
||||
const result = await target.write(bytes, written, bytes.length - written, position);
|
||||
if (result.bytesWritten === 0) throw new Error('Unable to append the upload chunk.');
|
||||
const result = await target.write(
|
||||
bytes,
|
||||
written,
|
||||
bytes.length - written,
|
||||
position
|
||||
);
|
||||
if (result.bytesWritten === 0)
|
||||
throw new Error('Unable to append the upload chunk.');
|
||||
written += result.bytesWritten;
|
||||
position += result.bytesWritten;
|
||||
}
|
||||
@@ -172,15 +236,26 @@ export async function receiveUploadChunk(
|
||||
await target.sync();
|
||||
throw cause;
|
||||
}
|
||||
} finally { await target.close(); }
|
||||
return { receivedBytes: chunk.offset + chunk.size, complete: chunk.offset + chunk.size === file.size };
|
||||
} finally { await rm(temporary, { force: true }); }
|
||||
} finally {
|
||||
await target.close();
|
||||
}
|
||||
return {
|
||||
receivedBytes: chunk.offset + chunk.size,
|
||||
complete: chunk.offset + chunk.size === file.size
|
||||
};
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Caller holds withUploadLock through hashing and publication to prevent concurrent writes. */
|
||||
export async function hashUploadedFiles(root: string, files: ReadonlyArray<Pick<UploadFilePlan, 'path' | 'size'>>): Promise<GameFileEntry[]> {
|
||||
export async function hashUploadedFiles(
|
||||
root: string,
|
||||
files: ReadonlyArray<Pick<UploadFilePlan, 'path' | 'size'>>
|
||||
): Promise<GameFileEntry[]> {
|
||||
const manifest: GameFileEntry[] = [];
|
||||
for (const file of files) manifest.push({ path: file.path, size: file.size, sha256: await hashGameFile(root, file) });
|
||||
for (const file of files)
|
||||
manifest.push({ path: file.path, size: file.size, sha256: await hashGameFile(root, file) });
|
||||
return validateManifest(manifest);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user