Added work document proposals

This commit is contained in:
Mark Qvist
2026-05-16 02:11:00 +02:00
parent ba8fca6f87
commit 12e45b6483
2 changed files with 249 additions and 54 deletions
+45 -10
View File
@@ -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):
+204 -44
View File
@@ -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