Fix three layers of safeguards to ensure node doesn't become it's own parent

This commit is contained in:
Magnus Åhall 2026-06-15 16:39:56 +02:00
parent 960c9e2625
commit edd3d11b09
3 changed files with 143 additions and 7 deletions

123
sql/00008.sql Normal file
View file

@ -0,0 +1,123 @@
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
LANGUAGE plpgsql
AS $procedure$
DECLARE
node_data jsonb;
node_updated timestamptz;
db_updated timestamptz;
db_uuid uuid;
db_client uuid;
db_history_uuid uuid;
node_uuid uuid;
node_parent_uuid uuid;
node_history_uuid uuid;
BEGIN
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
LOOP
node_uuid = (node_data->>'UUID')::uuid;
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
node_updated = (node_data->>'Updated')::timestamptz;
-- Frontend is using an all-zero UUID to define the root node.
-- Database is using NULL.
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
node_parent_uuid = NULL;
ELSE
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
END IF;
-- Safeguard against being your own parent.
IF node_uuid = node_parent_uuid THEN
RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT';
END IF;
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
-- across clients. A history entry could potentially be sent again, but should be
-- safe to ignore as every change to a node should have a new history UUID.
--
-- The current node is also stored as history.
INSERT INTO node_history(
user_id, "uuid", "history_uuid", parents, created, updated,
"name", "content", "content_encrypted",
client
)
VALUES(
p_user_id, -- combined key
node_uuid, -- combined key
node_history_uuid, -- combined key
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
COALESCE((node_data->>'Created')::timestamptz, NOW()),
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
'', /* content_encrypted */
p_client_uuid
)
ON CONFLICT ("user_id", "uuid", "history_uuid")
DO NOTHING;
-- Retrieve the current modified timestamp for this node from the database.
SELECT
uuid, updated, client
INTO
db_uuid, db_updated, db_client
FROM public."node"
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
-- Is the node not in database? It needs to be created.
IF db_uuid IS NULL THEN
RAISE NOTICE '01 New node %', node_uuid;
INSERT INTO public."node" (
user_id, "uuid", parent_uuid, created, updated,
"name", "content", "content_encrypted",
client
)
VALUES(
p_user_id,
node_uuid,
node_parent_uuid,
COALESCE((node_data->>'Created')::timestamptz, NOW()),
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
'', /* content_encrypted */
p_client_uuid
);
CONTINUE;
END IF;
-- Update the public node as well if it was older than incoming node.
IF node_updated > db_updated THEN
UPDATE public."node"
SET
updated = (node_data->>'Updated')::timestamptz,
updated_seq = nextval('node_updates'),
parent_uuid = node_parent_uuid,
name = (node_data->>'Name')::varchar,
content = (node_data->>'Content')::text,
client = p_client_uuid
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
END IF;
END LOOP;
END
$procedure$
;

View file

@ -424,6 +424,9 @@ export class Node {
return this._parent
}//}}}
moveToParent(newParentUUID) {// {{{
if (this.UUID === newParentUUID)
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true

View file

@ -598,15 +598,25 @@ export class N2TreeNode extends CustomHTMLElement {
e.preventDefault()
}// }}}
async dragDrop(e) {// {{{
e.stopPropagation()
const sourceNode = _app.dragIcon.getSource()
await _app.moveNode(sourceNode.node, this.node.UUID)
try {
e.stopPropagation()
const sourceNode = _app.dragIcon.getSource()
_app.sidebar.setNodeExpanded(this, true)
await this.render(true, true)
await sourceNode.render(true, true)
// Abort if user drops the node back on itself.
if (sourceNode.node.UUID === this.node.UUID)
return
this.dragLeave(e)
await _app.moveNode(sourceNode.node, this.node.UUID)
_app.sidebar.setNodeExpanded(this, true)
await this.render(true, true)
await sourceNode.render(true, true)
} catch (e) {
console.error(e)
alert(e)
} finally {
this.dragLeave(e)
}
}// }}}
dragEnter(e) {// {{{
const targetNode = e.target.closest('n2-treenode')