From 12e45b6483c65e026a8f55442e2c8a39df6845fc Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 16 May 2026 02:11:00 +0200 Subject: [PATCH] Added work document proposals --- RNS/Utilities/rngit/pages.py | 55 ++++++-- RNS/Utilities/rngit/server.py | 248 ++++++++++++++++++++++++++++------ 2 files changed, 249 insertions(+), 54 deletions(-) diff --git a/RNS/Utilities/rngit/pages.py b/RNS/Utilities/rngit/pages.py index 84c61859..2400c62a 100644 --- a/RNS/Utilities/rngit/pages.py +++ b/RNS/Utilities/rngit/pages.py @@ -221,6 +221,13 @@ class NomadNetworkNode(): if not remote_identity: remote_identity = self.null_ident return self.owner.resolve_permission(remote_identity, group_name, repository_name, permission) + def resolve_doc_permission(self, remote_identity, group_name, repository_name, doc_id, permission): + # Since the nomadnet page protocol doesn't *require* authentication, + # we use null_ident in case the remote hasn't identified. + if not remote_identity: remote_identity = self.null_ident + + return self.owner.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, permission) + def register_request_handlers(self): self.destination.register_request_handler(self.PATH_INDEX, response_generator=self.serve_front_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_GROUP, response_generator=self.serve_group_page, allow=RNS.Destination.ALLOW_ALL) @@ -1349,7 +1356,7 @@ class NomadNetworkNode(): group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" scope = data.get("var_scope", "active") if data else "active" - if scope not in ["active", "completed", "all"]: scope = "active" + if scope not in ["active", "completed", "proposed", "all"]: scope = "active" if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" @@ -1372,16 +1379,18 @@ class NomadNetworkNode(): sep = self.icon("sep") active_s = "`_" if scope == "active" else "" cmplt_s = "`_" if scope == "completed" else "" + prpsd_s = "`_" if scope == "proposed" else "" all_s = "`_" if scope == "all" else "" filter_links = [] filter_links.append(active_s+self.m_link("Active", self.PATH_WORK, g=group_name, r=repo_name, scope="active")+active_s) filter_links.append(cmplt_s+self.m_link("Completed", self.PATH_WORK, g=group_name, r=repo_name, scope="completed")+cmplt_s) + filter_links.append(prpsd_s+self.m_link("Proposed", self.PATH_WORK, g=group_name, r=repo_name, scope="proposed")+prpsd_s) filter_links.append(all_s+self.m_link("All", self.PATH_WORK, g=group_name, r=repo_name, scope="all")+all_s) content_parts.append(f" {sep} ".join(filter_links) + "\n\n") # Load work documents work_path = f"{repo['path']}.work" - scopes_to_show = ["active", "completed"] if scope == "all" else [scope] + scopes_to_show = ["active", "completed", "proposed"] if scope == "all" else [scope] for s in scopes_to_show: folder_path = os.path.join(work_path, s) @@ -1393,6 +1402,9 @@ class NomadNetworkNode(): if not os.path.isdir(doc_dir): continue try: doc_id = int(entry) + read_access = self.resolve_doc_permission(remote_identity, group_name, repo_name, doc_id, self.owner.PERM_READ) + if not read_access: continue + root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): continue @@ -1444,7 +1456,7 @@ class NomadNetworkNode(): repo_name = data.get("var_r", "") if data else "" doc_id = data.get("var_id", "") if data else "" scope = data.get("var_scope", "all") if data else "all" - if scope not in ["active", "completed", "all"]: scope = "active" + if scope not in ["active", "completed", "proposed", "all"]: scope = "active" if not group_name or not repo_name or not doc_id: content = self.m_heading("Error", 2) + "\nInvalid request\n" @@ -1460,20 +1472,31 @@ class NomadNetworkNode(): content = self.m_heading("Error", 2) + "\nThe requested repository was not found\n" return self.render_template(content, st=st) - work_path = f"{repo['path']}.work" - active_dir = os.path.join(work_path, "active", str(doc_id)) + read_access = self.resolve_doc_permission(remote_identity, group_name, repo_name, doc_id, self.owner.PERM_READ) + if not read_access: + content = self.m_heading("Error", 2) + "\nThe requested work document was not found\n" + return self.render_template(content, st=st) + + work_path = f"{repo['path']}.work" + active_dir = os.path.join(work_path, "active", str(doc_id)) completed_dir = os.path.join(work_path, "completed", str(doc_id)) + proposed_dir = os.path.join(work_path, "proposed", str(doc_id)) if scope == "active": doc_dir = active_dir elif scope == "completed": doc_dir = completed_dir + elif scope == "proposed": doc_dir = proposed_dir elif scope == "all": if os.path.isdir(active_dir): doc_dir = active_dir scope = "active" - else: + elif os.path.isdir(completed_dir): doc_dir = completed_dir scope = "completed" + else: + doc_dir = proposed_dir + scope = "proposed" + root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): @@ -1694,28 +1717,40 @@ class NomadNetworkNode(): try: doc_id = int(doc_id) except: + if not type(doc_id) == str or not type(doc_id) == bytes: doc_id = f"{doc_id}" RNS.log(f"Could not parse document ID for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING) return None repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: - RNS.log(f"Repository not found or no access for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id[:128]}", RNS.LOG_WARNING) + RNS.log(f"Repository not found or no access for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id}", RNS.LOG_WARNING) return None - work_path = f"{repo['path']}.work" - active_dir = os.path.join(work_path, "active", str(doc_id)) + doc_access = self.resolve_doc_permission(remote_identity, group_name, repo_name, doc_id, self.owner.PERM_READ) + if not doc_access: + RNS.log(f"No access for workdoc download request {group_name[:128]}/{repo_name[:128]}/{doc_id}", RNS.LOG_WARNING) + return None + + work_path = f"{repo['path']}.work" + active_dir = os.path.join(work_path, "active", str(doc_id)) completed_dir = os.path.join(work_path, "completed", str(doc_id)) + proposed_dir = os.path.join(work_path, "proposed", str(doc_id)) if scope == "active": doc_dir = active_dir elif scope == "completed": doc_dir = completed_dir + elif scope == "proposed": doc_dir = proposed_dir elif scope == "all": if os.path.isdir(active_dir): doc_dir = active_dir scope = "active" - else: + elif os.path.isdir(completed_dir): doc_dir = completed_dir scope = "completed" + else: + doc_dir = proposed_dir + scope = "proposed" + root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): diff --git a/RNS/Utilities/rngit/server.py b/RNS/Utilities/rngit/server.py index 373f5512..39572d39 100644 --- a/RNS/Utilities/rngit/server.py +++ b/RNS/Utilities/rngit/server.py @@ -96,6 +96,7 @@ def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, servic if operation == "list": git_client.work_list(remote=task["remote"], scope=scope) elif operation == "view": git_client.work_view(remote=task["remote"], doc_id=doc_id, scope=scope) elif operation == "create": git_client.work_create(remote=task["remote"], title=title) + elif operation == "propose": git_client.work_propose(remote=task["remote"], title=title) elif operation == "edit": git_client.work_edit(remote=task["remote"], title=title, doc_id=doc_id, scope=scope) elif operation == "delete": git_client.work_delete(remote=task["remote"], doc_id=doc_id, scope=scope) elif operation == "update": git_client.work_comment(remote=task["remote"], doc_id=doc_id, scope=scope) @@ -141,7 +142,7 @@ def main(): parser.add_argument("-t", "--title", action="store", default=None, help="document title for create", type=str) parser.add_argument("-d", "--id", action="store", default=None, help="document ID", type=int) parser.add_argument("repository", nargs="?", default=None, help="URL of remote repository", type=str) - parser.add_argument("operation", nargs="?", default=None, help="list, view, create, edit, delete, update, complete, activate or perms", type=str) + parser.add_argument("operation", nargs="?", default=None, help="list, view, create, propose, edit, delete, update, complete, activate or perms", type=str) parser.add_argument('-v', '--verbose', action='count', default=0) parser.add_argument('-q', '--quiet', action='count', default=0) @@ -723,7 +724,7 @@ class ReticulumGitClient(): if len(response) > 1: result = mp.unpackb(response[1:]) else: result = {"active": [], "completed": []} - scopes_to_show = ["active", "completed"] if scope == "all" else [scope] + scopes_to_show = ["active", "completed", "proposed"] if scope == "all" else [scope] for s in scopes_to_show: docs = result.get(s, []) @@ -745,7 +746,7 @@ class ReticulumGitClient(): print() elif scope != "all": print(f"No {s} work documents found.") - if scope == "all" and not result.get("active") and not result.get("completed"): print("No work documents found.") + if scope == "all" and not result.get("active") and not result.get("completed") and not result.get("proposed"): print("No work documents found.") except Exception as e: self.abort(f"Error listing work documents: {e}") finally: @@ -871,6 +872,51 @@ class ReticulumGitClient(): finally: if self.link: self.link.teardown() + def work_propose(self, remote=None, title=None): + if not remote: print(f"No remote specified"); exit(1) + if not title: print(f"No title specified"); exit(1) + self.connect_remote(remote) + + timeout = self.link_timeout + while not self.link_ready and not self.link_failed and timeout > 0: + time.sleep(0.1) + timeout -= 1 + + if not self.link_ready: self.abort("Failed to establish link") + print("\r \r", end="") + + try: + destination_hash, group, repo = self.parse_remote_url(remote) + repo_path = f"{group}/{repo}" + + content = self._edit_work_content(title=title) + if content is None: print("Proposal cancelled"); return + + signature = self.identity.sign(content.encode("utf-8")) + if not signature: self.abort("Could not sign work document") + + request_data = { self.IDX_REPOSITORY: repo_path, "operation": "propose", + "title": title, "content": content, "format": "markdown", + "signature": signature } + + response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30) + if not response or not isinstance(response, bytes): self.abort("No response from remote") + + status_byte = response[0] + if status_byte != 0: + error_msg = response[1:].decode("utf-8", errors="ignore") + self.abort(f"Server error: {error_msg}") + + if len(response) > 1: + result = mp.unpackb(response[1:]) + print(f"Work document created as {result['scope']} #{result['id']}") + + else: print("Work document proposed") + + except Exception as e: self.abort(f"Error creating work document: {e}") + finally: + if self.link: self.link.teardown() + def work_edit(self, remote=None, doc_id=None, title=None, scope="active"): if not remote: print(f"No remote specified"); exit(1) if doc_id is None: print(f"No document ID specified"); exit(1) @@ -1232,6 +1278,7 @@ class ReticulumGitNode(): PERM_STATS = 0x05 PERM_RELEASE = 0x06 PERM_INTERACT = 0x07 + PERM_PROPOSE = 0x08 PERM_ADMIN = 0xFE PERM_R_SMPHR = ["r", "read"] PERM_W_SMPHR = ["w", "write"] @@ -1240,6 +1287,7 @@ class ReticulumGitNode(): PERM_S_SMPHR = ["s", "stats"] PERM_REL_SMPHR = ["rel", "release"] PERM_I_SMPHR = ["i", "interact"] + PERM_P_SMPHR = ["p", "propose"] PERM_ADM_SMPHR = ["adm", "admin"] TGT_NONE = 0x01 @@ -1432,7 +1480,7 @@ class ReticulumGitNode(): perm, target = self.parse_permission(entry) if not perm or not target: continue else: - read = False; write = False; create = False + read = False; write = False; create = False; propose = False stats = False; release = False; interact = False; admin = False if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True @@ -1440,6 +1488,7 @@ class ReticulumGitNode(): if perm == self.PERM_STATS: stats = True if perm == self.PERM_RELEASE: release = True if perm == self.PERM_INTERACT: interact = True + if perm == self.PERM_PROPOSE: propose = True if perm == self.PERM_ADMIN: admin = True if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target) @@ -1448,6 +1497,7 @@ class ReticulumGitNode(): if stats and not target in self.groups[group_name]["stats"]: self.groups[group_name]["stats"].append(target) if release and not target in self.groups[group_name]["release"]: self.groups[group_name]["release"].append(target) if interact and not target in self.groups[group_name]["interact"]: self.groups[group_name]["interact"].append(target) + if propose and not target in self.groups[group_name]["propose"]: self.groups[group_name]["propose"].append(target) if admin and not target in self.groups[group_name]["admin"]: self.groups[group_name]["admin"].append(target) def parse_permission(self, permission_string): @@ -1462,6 +1512,7 @@ class ReticulumGitNode(): elif perm in self.PERM_S_SMPHR: perm = self.PERM_STATS elif perm in self.PERM_REL_SMPHR: perm = self.PERM_RELEASE elif perm in self.PERM_I_SMPHR: perm = self.PERM_INTERACT + elif perm in self.PERM_P_SMPHR: perm = self.PERM_PROPOSE elif perm in self.PERM_ADM_SMPHR: perm = self.PERM_ADMIN else: perm = None @@ -1516,6 +1567,10 @@ class ReticulumGitNode(): repository_permissions = self.groups[group_name]["repositories"][repository_name]["interact"] group_permissions = self.groups[group_name]["interact"] + elif permission == self.PERM_PROPOSE: + repository_permissions = self.groups[group_name]["repositories"][repository_name]["propose"] + group_permissions = self.groups[group_name]["propose"] + elif permission == self.PERM_ADMIN: repository_permissions = self.groups[group_name]["repositories"][repository_name]["admin"] group_permissions = self.groups[group_name]["admin"] @@ -1578,6 +1633,11 @@ class ReticulumGitNode(): group_permissions = self.groups[group_name]["interact"] doc_permissions = doc_allowed_permissions["interact"] + elif permission == self.PERM_PROPOSE: + repository_permissions = self.groups[group_name]["repositories"][repository_name]["propose"] + group_permissions = self.groups[group_name]["propose"] + doc_permissions = doc_allowed_permissions["propose"] + elif permission == self.PERM_ADMIN: repository_permissions = self.groups[group_name]["repositories"][repository_name]["admin"] group_permissions = self.groups[group_name]["admin"] @@ -1589,22 +1649,22 @@ class ReticulumGitNode(): group_admins = self.groups[group_name]["admin"] doc_admins = doc_allowed_permissions["admin"] - if self.TGT_NONE in repository_permissions: return False - elif self.TGT_ALL in repository_permissions: return True - elif remote_hash in repository_permissions: return True - elif remote_hash in repository_admins: return True + if self.TGT_NONE in doc_permissions: return False + elif self.TGT_ALL in doc_permissions: return True + elif remote_hash in doc_permissions: return True + elif remote_hash in doc_admins: return True else: - if len(repository_permissions) > 0: return False - elif self.TGT_NONE in group_permissions: return False - elif self.TGT_ALL in group_permissions: return True - elif remote_hash in group_permissions: return True - elif remote_hash in group_admins: return True + if self.TGT_NONE in repository_permissions: return False + elif self.TGT_ALL in repository_permissions: return True + elif remote_hash in repository_permissions: return True + elif remote_hash in repository_admins: return True else: - if self.TGT_NONE in doc_permissions: return False - elif self.TGT_ALL in doc_permissions: return True - elif remote_hash in doc_permissions: return True - elif remote_hash in doc_admins: return True - else: return False + if len(repository_permissions) > 0: return False + elif self.TGT_NONE in group_permissions: return False + elif self.TGT_ALL in group_permissions: return True + elif remote_hash in group_permissions: return True + elif remote_hash in group_admins: return True + else: return False return False @@ -1615,6 +1675,7 @@ class ReticulumGitNode(): stats_allowed = [] release_allowed = [] interact_allowed = [] + propose_allowed = [] admin_allowed = [] if allowed_input and type(allowed_input) == str: @@ -1624,7 +1685,7 @@ class ReticulumGitNode(): perm, target = self.parse_permission(perm_input) if not perm or not target: continue else: - read = False; write = False; create = False + read = False; write = False; create = False; propose = False stats = False; release = False; interact = False; admin = False if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True @@ -1632,6 +1693,7 @@ class ReticulumGitNode(): if perm == self.PERM_STATS: stats = True if perm == self.PERM_RELEASE: release = True if perm == self.PERM_INTERACT: interact = True + if perm == self.PERM_PROPOSE: propose = True if perm == self.PERM_ADMIN: admin = True if read and not target in read_allowed: read_allowed.append(target) @@ -1640,17 +1702,18 @@ class ReticulumGitNode(): if stats and not target in stats_allowed: stats_allowed.append(target) if release and not target in release_allowed: release_allowed.append(target) if interact and not target in interact_allowed: interact_allowed.append(target) + if propose and not target in propose_allowed: propose_allowed.append(target) if admin and not target in admin_allowed: admin_allowed.append(target) permissions = {"read": read_allowed, "write": write_allowed, "create": create_allowed, "stats": stats_allowed, - "release": release_allowed, "interact": interact_allowed, "admin": admin_allowed } + "release": release_allowed, "interact": interact_allowed, "propose": propose_allowed, "admin": admin_allowed } return permissions def load_repository_group(self, group_name, group_path): # TODO: Implement group.allowed file if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "repositories": {}, "read": [], "write": [], "create": [], - "stats": [], "release": [], "interact": [], "admin": [] } + "stats": [], "release": [], "interact": [], "propose": [], "admin": [] } if group_name in self.groups and self.groups[group_name]["path"] != group_path: RNS.log(f"Repository group path did not match existing entry while loading {group_name}, aborting load", RNS.LOG_ERROR) @@ -1685,7 +1748,7 @@ class ReticulumGitNode(): group["repositories"][repository_name] = {"name": repository_name, "group": group_name, "path": path, "read": p["read"], "write": p["write"], "create": p["create"], "stats": p["stats"], "release": p["release"], "interact": p["interact"], - "admin": p["admin"] } + "propose": p["propose"], "admin": p["admin"] } loaded += 1 ms = "y" if loaded == 1 else "ies" @@ -2452,24 +2515,43 @@ class ReticulumGitNode(): read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ) write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE) interact_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_INTERACT) + propose_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_PROPOSE) + admin_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_ADMIN) access = False if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" + if operation in ["read", "view", "comment", "edit", "delete", "perms"]: + if data.get("doc_id", None): + try: doc_id = int(data.get("doc_id", None)) + except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + read_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_READ) + read_access |= admin_access + + if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found" + if operation in ["comment"]: if data.get("doc_id", None): try: doc_id = int(data.get("doc_id", None)) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" interact_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_INTERACT) + if operation in ["edit"]: + if data.get("doc_id", None): + try: doc_id = int(data.get("doc_id", None)) + except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + interact_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_INTERACT) + write_access |= self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_WRITE) + comment_access = interact_access and (read_access or write_access) manage_access = interact_access and write_access if operation in ["list", "view"] and read_access: access = True elif operation in ["comment"] and comment_access: access = True + elif operation in ["propose"] and propose_access: access = True elif operation in ["create", "edit", "delete"] and manage_access: access = True elif operation in ["complete", "activate"] and manage_access: access = True - elif operation in ["perms"] and manage_access: access = True + elif operation in ["perms"] and admin_access: access = True else: access = False if not access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed" @@ -2482,18 +2564,30 @@ class ReticulumGitNode(): elif operation == "view" and read_access: return self._work_view(work_path, data, remote_identity) elif operation == "comment" and comment_access: return self._work_comment(work_path, data, remote_identity) elif operation == "create" and manage_access: return self._work_create(work_path, data, remote_identity) + elif operation == "propose" and propose_access: return self._work_propose(work_path, data, remote_identity) elif operation == "edit" and manage_access: return self._work_edit(work_path, data, remote_identity) elif operation == "delete" and manage_access: return self._work_delete(work_path, data, remote_identity) elif operation == "complete" and manage_access: return self._work_complete(work_path, data, remote_identity) elif operation == "activate" and manage_access: return self._work_activate(work_path, data, remote_identity) - elif operation == "perms" and manage_access: return self._work_perms(work_path, data, remote_identity) + elif operation == "perms" and admin_access: return self._work_perms(work_path, data, remote_identity) else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" except Exception as e: RNS.log(f"Error while handling work request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR) return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" - def _work_get_next_id(self, base_path): + def _work_get_next_id(self, work_path): + def scope_next_id(base_path): + if not os.path.isdir(base_path): return 1 + try: + entries = [int(d) for d in os.listdir(base_path) if d.isdigit()] + if not entries: return 1 + return max(entries) + 1 + except: return 1 + + return max(scope_next_id(os.path.join(work_path, scope)) for scope in ["active", "completed", "proposed"]) + + def _work_get_next_comment_id(self, base_path): if not os.path.isdir(base_path): return 1 try: entries = [int(d) for d in os.listdir(base_path) if d.isdigit()] @@ -2521,10 +2615,11 @@ class ReticulumGitNode(): return False def _work_list(self, work_path, data, remote_identity): + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) scope = data.get("scope", "active") - result = {"active": [], "completed": []} - for folder_name, key in [("active", "active"), ("completed", "completed")]: + result = {"active": [], "completed": [], "proposed": []} + for folder_name, key in [("active", "active"), ("completed", "completed"), ("proposed", "proposed")]: if scope not in [folder_name, "all"]: continue folder_path = os.path.join(work_path, folder_name) if not os.path.isdir(folder_path): continue @@ -2534,6 +2629,9 @@ class ReticulumGitNode(): if not os.path.isdir(doc_dir): continue try: doc_id = int(entry) + read_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_READ) + if not read_access: continue + root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): continue @@ -2555,7 +2653,7 @@ class ReticulumGitNode(): def _work_view(self, work_path, data, remote_identity): doc_id = data.get("doc_id") scope = data.get("scope", "all") - if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" try: doc_id = int(doc_id) @@ -2563,7 +2661,7 @@ class ReticulumGitNode(): scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s @@ -2628,8 +2726,7 @@ class ReticulumGitNode(): try: active_path = os.path.join(work_path, "active") - completed_path = os.path.join(work_path, "completed") - doc_id = max(self._work_get_next_id(active_path), self._work_get_next_id(completed_path)) + doc_id = self._work_get_next_id(work_path) doc_dir = os.path.join(active_path, str(doc_id)) now = time.time() @@ -2649,6 +2746,57 @@ class ReticulumGitNode(): RNS.log(f"Error creating work document: {e}", RNS.LOG_ERROR) return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" + def _work_propose(self, work_path, data, remote_identity): + title = data.get("title", "").strip() + content = data.get("content", "").strip() + format_type = data.get("format", "markdown") + signature = data.get("signature", None) + signed_data = content.encode("utf-8") + sig_length = RNS.Identity.SIGLENGTH//8 + limit = self.WORK_DOC_LIMIT + + if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided" + if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length" + if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" + if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded" + if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required" + if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required" + + try: + proposed_path = os.path.join(work_path, "proposed") + doc_id = self._work_get_next_id(work_path) + doc_dir = os.path.join(proposed_path, str(doc_id)) + + now = time.time() + document = { "content": content, + "meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown", + "title": title, "created": now, "edited": now, "author": remote_identity.hash, + "signature": signature, "identity": remote_identity.get_public_key() } } + + root_path = os.path.join(doc_dir, "root") + if not self._work_save_document(root_path, document): + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document" + + try: + owner_permissions = f"i:{RNS.hexrep(remote_identity.hash, delimit=False)}\n" + owner_permissions += f"w:{RNS.hexrep(remote_identity.hash, delimit=False)}\n" + + allowed_path = work_path + f"/{doc_id}.allowed" + tmp_path = allowed_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: f.write(owner_permissions) + os.rename(tmp_path, allowed_path) + + except Exception as e: + RNS.log(f"Error setting permissions: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error setting document ownership" + + RNS.log(f"Proposed work document {doc_id} by {RNS.prettyhexrep(remote_identity.hash)}", RNS.LOG_DEBUG) + return b"\x00" + mp.packb({"id": doc_id, "scope": "proposed"}) + + except Exception as e: + RNS.log(f"Error proposing work document: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" + def _work_edit(self, work_path, data, remote_identity): doc_id = data.get("doc_id") scope = data.get("scope", "active") @@ -2663,7 +2811,7 @@ class ReticulumGitNode(): if title: size += len(title) if content: size += len(content) - if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided" if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length" if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" @@ -2676,7 +2824,7 @@ class ReticulumGitNode(): scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s @@ -2711,18 +2859,19 @@ class ReticulumGitNode(): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" def _work_delete(self, work_path, data, remote_identity): + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) doc_id = data.get("doc_id") scope = data.get("scope", "active") - if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" - if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" + if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" try: doc_id = int(doc_id) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s @@ -2737,7 +2886,18 @@ class ReticulumGitNode(): doc = self._work_load_document(root_path) if not doc: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error loading document" - if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author" + is_author = doc.get("meta", {}).get("author") == remote_identity.hash + admin_access = self.resolve_doc_permission(remote_identity, group_name, repository_name, doc_id, self.PERM_ADMIN) + + if not (is_author or admin_access): return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author" + + try: + allowed_path = work_path + f"/{doc_id}.allowed" + os.unlink(allowed_path) + + except Exception as e: + RNS.log(f"Error while deleting permissions file for {work_path}/{doc_id}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" try: shutil.rmtree(doc_dir) @@ -2757,9 +2917,9 @@ class ReticulumGitNode(): limit = self.WORK_DOC_LIMIT size = len(content) - if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" - if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded" - if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" + if not scope in ["active", "completed", "proposed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded" + if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" try: doc_id = int(doc_id) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" @@ -2768,7 +2928,7 @@ class ReticulumGitNode(): scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s @@ -2781,7 +2941,7 @@ class ReticulumGitNode(): if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found" try: - comment_id = self._work_get_next_id(doc_dir) + comment_id = self._work_get_next_comment_id(doc_dir) now = time.time() comment = { "content": content, @@ -2882,7 +3042,7 @@ class ReticulumGitNode(): scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s @@ -2925,7 +3085,7 @@ class ReticulumGitNode(): scope = None doc_dir = None - for s in ["active", "completed"]: + for s in ["active", "completed", "proposed"]: d = os.path.join(work_path, s, str(doc_id)) if os.path.isdir(d): scope = s