Add BIM model comments
This commit is contained in:
+290
@@ -23,9 +23,11 @@ const DATA_DIR = path.join(DATA_ROOT, "projects");
|
||||
const UPLOADS_DIR = path.join(DATA_ROOT, "uploads");
|
||||
const SHARES_DIR = path.join(DATA_ROOT, "shares");
|
||||
const MODELS_DIR = path.join(DATA_ROOT, "models");
|
||||
const COMMENTS_DIR = path.join(DATA_ROOT, "comments");
|
||||
const INDEX_FILE = path.join(DATA_DIR, "index.json");
|
||||
const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json");
|
||||
const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json");
|
||||
const COMMENTS_INDEX_FILE = path.join(COMMENTS_DIR, "index.json");
|
||||
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
|
||||
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
|
||||
const PORT = parseInt(process.env.PORT, 10) || 8080;
|
||||
@@ -98,6 +100,7 @@ const ensureStorage = async () => {
|
||||
await fs.mkdir(UPLOADS_DIR, {recursive: true});
|
||||
await fs.mkdir(SHARES_DIR, {recursive: true});
|
||||
await fs.mkdir(MODELS_DIR, {recursive: true});
|
||||
await fs.mkdir(COMMENTS_DIR, {recursive: true});
|
||||
try {
|
||||
await fs.access(INDEX_FILE);
|
||||
} catch (err) {
|
||||
@@ -113,6 +116,11 @@ const ensureStorage = async () => {
|
||||
} catch (err) {
|
||||
await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8");
|
||||
}
|
||||
try {
|
||||
await fs.access(COMMENTS_INDEX_FILE);
|
||||
} catch (err) {
|
||||
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
|
||||
}
|
||||
};
|
||||
|
||||
const readIndex = async () => {
|
||||
@@ -145,6 +153,27 @@ const writeSharesIndex = async (items) => {
|
||||
await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const readCommentsIndex = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(COMMENTS_INDEX_FILE, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") {
|
||||
await fs.mkdir(COMMENTS_DIR, {recursive: true});
|
||||
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
|
||||
return [];
|
||||
}
|
||||
console.error("[server] failed to read comments index.json", err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeCommentsIndex = async (items) => {
|
||||
await fs.mkdir(COMMENTS_DIR, {recursive: true});
|
||||
await fs.writeFile(COMMENTS_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const createEmptyModelRegistry = () => ({
|
||||
version: 1,
|
||||
assets: [],
|
||||
@@ -839,6 +868,9 @@ const requestNeedsBimSession = (req, url) => {
|
||||
if (url.pathname.startsWith("/api/models")) {
|
||||
return true;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/comments")) {
|
||||
return true;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/projects")) {
|
||||
return true;
|
||||
}
|
||||
@@ -1522,6 +1554,250 @@ const handleGetShare = async (req, res, token) => {
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeCommentText = (value, maxLength = 4000) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().slice(0, maxLength);
|
||||
};
|
||||
|
||||
const normalizeCommentWorldPos = (value) => {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const next = value.map((item) => Number(item));
|
||||
return next.every((item) => Number.isFinite(item)) ? next : null;
|
||||
};
|
||||
|
||||
const sanitizeCommentAttachments = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.slice(0, 8).flatMap((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return [];
|
||||
}
|
||||
const dataUrl = typeof item.dataUrl === "string" ? item.dataUrl : "";
|
||||
if (!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dataUrl)) {
|
||||
return [];
|
||||
}
|
||||
if (dataUrl.length > 4_500_000) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id: stringOrNull(item.id) || `att_${randomBase64Url(8)}`,
|
||||
name: sanitizeFilename(item.name || "image", "image"),
|
||||
type: stringOrNull(item.type) || "image",
|
||||
size: Number.isFinite(Number(item.size)) ? Number(item.size) : null,
|
||||
dataUrl
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const resolveCommentSource = (payload = {}) => {
|
||||
const identity = resolveAssetIdentityFromPayload(payload);
|
||||
const rawSrc = stringOrNull(payload.src) ||
|
||||
stringOrNull(payload.url) ||
|
||||
stringOrNull(payload.sourceSrc) ||
|
||||
stringOrNull(payload.settingsSrc) ||
|
||||
stringOrNull(payload.artifactSrc);
|
||||
const sourceSrc = normalizeUploadSrcValue(rawSrc) || rawSrc || null;
|
||||
if (identity) {
|
||||
return {
|
||||
sourceKey: identity.assetKey,
|
||||
projectId: identity.projectId,
|
||||
assetId: identity.assetId,
|
||||
sourceSrc
|
||||
};
|
||||
}
|
||||
if (!sourceSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceKey: `src:${shortHash(sourceSrc, 24)}`,
|
||||
projectId: safeProjectId(payload.projectId) || null,
|
||||
assetId: safeAssetId(payload.assetId) || null,
|
||||
sourceSrc
|
||||
};
|
||||
};
|
||||
|
||||
const buildCommentMessage = (payload, user, fallbackBody = "") => {
|
||||
const source = typeof payload === "string" ? {body: payload} : (payload || {});
|
||||
const body = sanitizeCommentText(source.body ?? source.text ?? source.message ?? fallbackBody);
|
||||
const attachments = sanitizeCommentAttachments(source.attachments);
|
||||
if (!body && !attachments.length) {
|
||||
return null;
|
||||
}
|
||||
const now = nowIso();
|
||||
return {
|
||||
id: `msg_${randomBase64Url(10)}`,
|
||||
body,
|
||||
attachments,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: summarizeRegistryUser(user)
|
||||
};
|
||||
};
|
||||
|
||||
const publicComment = (comment) => ({
|
||||
id: comment.id,
|
||||
sourceKey: comment.sourceKey,
|
||||
sourceSrc: comment.sourceSrc || null,
|
||||
projectId: comment.projectId || null,
|
||||
assetId: comment.assetId || null,
|
||||
modelId: comment.modelId || null,
|
||||
objectId: comment.objectId || null,
|
||||
title: comment.title || "",
|
||||
body: comment.body || "",
|
||||
worldPos: comment.worldPos || null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
createdBy: comment.createdBy || null,
|
||||
messages: Array.isArray(comment.messages) ? comment.messages : []
|
||||
});
|
||||
|
||||
const handleGetComments = async (req, res, url) => {
|
||||
const user = requireModelRegistryUser(req, res);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const source = resolveCommentSource(Object.fromEntries(url.searchParams.entries()));
|
||||
if (!source?.sourceKey) {
|
||||
sendJSON(res, 200, {ok: true, user: summarizeRegistryUser(user), comments: []});
|
||||
return;
|
||||
}
|
||||
const items = await readCommentsIndex();
|
||||
const comments = items
|
||||
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
|
||||
.map(publicComment)
|
||||
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
user: summarizeRegistryUser(user),
|
||||
source,
|
||||
comments
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateComment = async (req, res) => {
|
||||
const user = requireModelRegistryUser(req, res);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
const source = resolveCommentSource(payload);
|
||||
const worldPos = normalizeCommentWorldPos(payload?.worldPos);
|
||||
const title = sanitizeCommentText(payload?.title, 180);
|
||||
const body = sanitizeCommentText(payload?.body ?? payload?.description, 8000);
|
||||
if (!source?.sourceKey) {
|
||||
sendText(res, 400, "Comment source is required");
|
||||
return;
|
||||
}
|
||||
if (!worldPos) {
|
||||
sendText(res, 400, "Comment worldPos is required");
|
||||
return;
|
||||
}
|
||||
if (!title && !body) {
|
||||
sendText(res, 400, "Comment title or body is required");
|
||||
return;
|
||||
}
|
||||
const now = nowIso();
|
||||
const firstMessage = buildCommentMessage({
|
||||
body,
|
||||
attachments: payload?.attachments
|
||||
}, user);
|
||||
const comment = {
|
||||
id: `cmt_${randomBase64Url(14)}`,
|
||||
sourceKey: source.sourceKey,
|
||||
sourceSrc: source.sourceSrc || null,
|
||||
projectId: source.projectId || null,
|
||||
assetId: source.assetId || null,
|
||||
modelId: stringOrNull(payload?.modelId),
|
||||
objectId: stringOrNull(payload?.objectId),
|
||||
title: title || "Комментарий",
|
||||
body,
|
||||
worldPos,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: summarizeRegistryUser(user),
|
||||
messages: firstMessage ? [firstMessage] : []
|
||||
};
|
||||
const items = await readCommentsIndex();
|
||||
items.unshift(comment);
|
||||
await writeCommentsIndex(items);
|
||||
sendJSON(res, 201, {ok: true, comment: publicComment(comment)});
|
||||
};
|
||||
|
||||
const handlePatchComment = async (req, res, commentId) => {
|
||||
const user = requireModelRegistryUser(req, res);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const safeId = stringOrNull(commentId);
|
||||
if (!safeId) {
|
||||
sendText(res, 400, "Invalid comment id");
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await parseJSONBody(req);
|
||||
} catch (err) {
|
||||
sendText(res, 400, err.message || "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
const items = await readCommentsIndex();
|
||||
const index = items.findIndex((item) => item?.id === safeId && !item.deletedAt);
|
||||
if (index < 0) {
|
||||
sendText(res, 404, "Comment not found");
|
||||
return;
|
||||
}
|
||||
const comment = items[index];
|
||||
const isOwner = comment?.createdBy?.id && comment.createdBy.id === user.id;
|
||||
const now = nowIso();
|
||||
if (payload?.delete === true) {
|
||||
if (!isOwner) {
|
||||
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
|
||||
return;
|
||||
}
|
||||
comment.deletedAt = now;
|
||||
comment.updatedAt = now;
|
||||
await writeCommentsIndex(items);
|
||||
sendJSON(res, 200, {ok: true, deleted: true, id: safeId});
|
||||
return;
|
||||
}
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(payload, "title");
|
||||
const hasBody = Object.prototype.hasOwnProperty.call(payload, "body");
|
||||
const nextTitle = hasTitle ? sanitizeCommentText(payload.title, 180) || comment.title || "Комментарий" : comment.title;
|
||||
const nextBody = hasBody ? sanitizeCommentText(payload.body, 8000) : comment.body;
|
||||
const titleChanged = hasTitle && nextTitle !== comment.title;
|
||||
const bodyChanged = hasBody && nextBody !== comment.body;
|
||||
if ((titleChanged || bodyChanged) && !isOwner) {
|
||||
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
|
||||
return;
|
||||
}
|
||||
if (titleChanged) {
|
||||
comment.title = nextTitle;
|
||||
}
|
||||
if (bodyChanged) {
|
||||
comment.body = nextBody;
|
||||
}
|
||||
const nextMessage = buildCommentMessage(payload?.message || null, user);
|
||||
if (nextMessage) {
|
||||
comment.messages = Array.isArray(comment.messages) ? comment.messages : [];
|
||||
comment.messages.push(nextMessage);
|
||||
}
|
||||
comment.updatedAt = now;
|
||||
items[index] = comment;
|
||||
await writeCommentsIndex(items);
|
||||
sendJSON(res, 200, {ok: true, comment: publicComment(comment)});
|
||||
};
|
||||
|
||||
const requireModelRegistryUser = (req, res) => {
|
||||
const user = getRequestRegistryUser(req);
|
||||
if (!user) {
|
||||
@@ -2709,6 +2985,20 @@ const requestHandler = async (req, res) => {
|
||||
return handleCreateShare(req, res);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/comments")) {
|
||||
if (req.method === "GET" && url.pathname === "/api/comments") {
|
||||
return handleGetComments(req, res, url);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/comments") {
|
||||
return handleCreateComment(req, res);
|
||||
}
|
||||
if (req.method === "PATCH" && /^\/api\/comments\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return handlePatchComment(req, res, url.pathname.split("/")[3]);
|
||||
}
|
||||
sendText(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/models")) {
|
||||
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||
return handleGetModels(req, res);
|
||||
|
||||
Reference in New Issue
Block a user