115 lines
3.7 KiB
JavaScript
115 lines
3.7 KiB
JavaScript
const fs = require("fs/promises");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
|
|
const MAX_DRAFT_BYTES = 100 * 1024 * 1024;
|
|
const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
|
|
function createSharedDraftStore(directory) {
|
|
const root = path.resolve(directory);
|
|
const locks = new Map();
|
|
|
|
function exclusive(id, work) {
|
|
const previous = locks.get(id) || Promise.resolve();
|
|
const current = previous.catch(() => {}).then(work);
|
|
locks.set(id, current);
|
|
current.finally(() => {
|
|
if (locks.get(id) === current) locks.delete(id);
|
|
}).catch(() => {});
|
|
return current;
|
|
}
|
|
|
|
function fileFor(id) {
|
|
if (!ID_PATTERN.test(String(id || ""))) {
|
|
const error = new Error("Invalid project ID.");
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
return path.join(root, id + ".json");
|
|
}
|
|
|
|
async function read(id) {
|
|
let text;
|
|
try {
|
|
text = await fs.readFile(fileFor(id), "utf8");
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
const draft = JSON.parse(text);
|
|
return { draft, revision: crypto.createHash("sha256").update(text).digest("hex") };
|
|
}
|
|
|
|
async function list() {
|
|
await fs.mkdir(root, { recursive: true });
|
|
const names = (await fs.readdir(root)).filter((name) => name.endsWith(".json") && ID_PATTERN.test(name.slice(0, -5)));
|
|
const drafts = {};
|
|
const revisions = {};
|
|
for (const name of names) {
|
|
const id = name.slice(0, -5);
|
|
const item = await read(id);
|
|
if (item && item.draft && item.draft.meta && item.draft.meta.id === id) {
|
|
drafts[id] = item.draft;
|
|
revisions[id] = item.revision;
|
|
}
|
|
}
|
|
return { drafts, revisions };
|
|
}
|
|
|
|
async function save(id, draft, expectedRevision) {
|
|
fileFor(id);
|
|
if (!draft || typeof draft !== "object" || Array.isArray(draft) || !draft.meta || draft.meta.id !== id) {
|
|
const error = new Error("Project ID does not match the saved project.");
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
if (typeof expectedRevision !== "string") {
|
|
const error = new Error("A project revision is required.");
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
const text = JSON.stringify(draft);
|
|
if (Buffer.byteLength(text, "utf8") > MAX_DRAFT_BYTES) {
|
|
const error = new Error("This project exceeds the 100 MB save limit.");
|
|
error.status = 413;
|
|
throw error;
|
|
}
|
|
return exclusive(id, async () => {
|
|
await fs.mkdir(root, { recursive: true });
|
|
const existing = await read(id);
|
|
if ((existing ? existing.revision : "") !== expectedRevision) {
|
|
const error = new Error("This project was changed by someone else. Reload it before saving again.");
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
const target = fileFor(id);
|
|
const temporary = path.join(root, "." + id + "." + crypto.randomUUID() + ".tmp");
|
|
try {
|
|
await fs.writeFile(temporary, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
await fs.rename(temporary, target);
|
|
} finally {
|
|
await fs.rm(temporary, { force: true });
|
|
}
|
|
return crypto.createHash("sha256").update(text).digest("hex");
|
|
});
|
|
}
|
|
|
|
async function remove(id, expectedRevision) {
|
|
return exclusive(id, async () => {
|
|
const existing = await read(id);
|
|
if (!existing) return false;
|
|
if (existing.revision !== expectedRevision) {
|
|
const error = new Error("This project was changed by someone else. Reload it before deleting.");
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
await fs.unlink(fileFor(id));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
return { root, list, read, save, remove };
|
|
}
|
|
|
|
module.exports = { createSharedDraftStore, MAX_DRAFT_BYTES };
|