code wiki / wiki / nx_wiki_edit_handler.nx

nx_wiki_edit_handler.nx source

↩ module page · 354 lines · 15575 B

1// nx_wiki_edit_handler.nx -- wiki R1: GET/POST /wiki/<slug>/edit HTTP handlers. 2// 3// The thin HTTP adapter over nx_wiki_page_save (the auth-gated persist core): 4// GET /wiki/<slug>/edit -> 200 an HTML <form> pre-filled with the current 5// markdown (AUTH-GATED: no/invalid X-Nishi-Session 6// -> 401). The textarea body is HTML-escaped. 7// POST /wiki/<slug>/edit -> read the posted body (the new markdown), extract 8// the session token, call nx_wiki_page_save. On OK 9// -> 200 (saved + Refresh to the page); on REFUSED 10// -> 401. Persisted nothing on refuse. 11// 12// SECURITY-CRITICAL: BOTH verbs gate on the SAME reused validator the core uses 13// (hub/nx_modern_auth_flow::nx_modern_auth_validate_session). The GET form is 14// itself behind the gate so the edit UI never renders for an anon visitor; the 15// POST re-checks inside nx_wiki_page_save (defence in depth -- never trust the 16// GET gate alone for the write). 17// 18// COMPOSES (each imported ONCE): 19// wiki/nx_wiki_page_save nx_wiki_page_save + doc-store read helpers 20// nx_http_header_find X-Nishi-Session extraction 21// nx_base64 b64_decode (header carries base64(152B token)) 22// html_escape textarea pre-fill escaping 23// 24// COMPOSED BY: 25// wiki/nx_wiki_routes dispatch /wiki/<slug>/edit (GET + POST) 26// 27// Status: R1. 2026-06-14. license_tier: ORIGINAL 28 29import "nx_syscalls.nx" 30import "wiki/nx_wiki_page_save.nx" 31import "nx_http_header_find.nx" 32import "nx_base64.nx" 33import "html_escape.nx" 34 35// ===== Sealed verdict surface (codes 2920-2929) ================================================= 36const NX_WEH_OK: i64 = 0 37const NX_WEH_BAD_INPUT: i64 = 2920 38const NX_WEH_OVERFLOW: i64 = 2921 39 40// ===== Named constants (M7) ================================================= 41const NX_WEH_HDR_SESSION_NAME: *u8 = "X-Nishi-Session" as *u8 42const NX_WEH_HDR_SESSION_NAME_N: i64 = 15 43const NX_WEH_TOKEN_BYTES: i64 = 152 44const NX_WEH_MAX_SCAN: i64 = 16384 45const NX_WEH_MAX_BODY: i64 = 262144 // 256 KB posted markdown 46const NX_WEH_SUFFIX_EDIT: *u8 = "/edit" as *u8 47const NX_WEH_SUFFIX_EDIT_N: i64 = 5 48const NX_WEH_PREFIX_WIKI: *u8 = "/wiki/" as *u8 49const NX_WEH_PREFIX_WIKI_N: i64 = 6 50 51// ===== append helpers (M5 bounds + M8 propagation) ================================================= 52func _weh_app(out: *u8, off: *i64, cap: i64, src: *u8, n: i64) -> i64 { 53 if off[0] + n > cap { return 0 - NX_WEH_OVERFLOW } 54 var i: i64 = 0 55 while i < n { out[off[0] + i] = src[i]; i = i + 1 } 56 off[0] = off[0] + n 57 return NX_WEH_OK 58} 59func _weh_appz(out: *u8, off: *i64, cap: i64, z: *u8) -> i64 { 60 var n: i64 = 0 61 while n < NX_WEH_MAX_SCAN { 62 if z[n] == (0 as u8) { return _weh_app(out, off, cap, z, n) } 63 n = n + 1 64 } 65 return 0 - NX_WEH_OVERFLOW 66} 67func _weh_appn(out: *u8, off: *i64, cap: i64, v: i64) -> i64 { 68 let t: *u8 = sys_mmap(28) 69 var m: i64 = v 70 var k: i64 = 0 71 if m == 0 { t[0] = 48 as u8; k = 1 } 72 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 73 let rev: *u8 = sys_mmap(28) 74 var i: i64 = 0 75 while i < k { rev[i] = t[k - 1 - i]; i = i + 1 } 76 return _weh_app(out, off, cap, rev, k) 77} 78 79// ===== slug extraction: "/wiki/<slug>/edit" -> slug ptr/len ================== 80// Returns 1 + (out_off/out_n) if the path is a well-formed edit path, else 0. 81func nx_weh_slug_of(url_path: *u8, url_path_n: i64, 82 out_off: *i64, out_n: *i64) -> i64 { 83 // must start with "/wiki/" 84 if url_path_n < NX_WEH_PREFIX_WIKI_N + NX_WEH_SUFFIX_EDIT_N { return 0 } 85 var i: i64 = 0 86 while i < NX_WEH_PREFIX_WIKI_N { 87 if url_path[i] != NX_WEH_PREFIX_WIKI[i] { return 0 } 88 i = i + 1 89 } 90 // must end with "/edit" 91 let tail: i64 = url_path_n - NX_WEH_SUFFIX_EDIT_N 92 var j: i64 = 0 93 while j < NX_WEH_SUFFIX_EDIT_N { 94 if url_path[tail + j] != NX_WEH_SUFFIX_EDIT[j] { return 0 } 95 j = j + 1 96 } 97 let s_off: i64 = NX_WEH_PREFIX_WIKI_N 98 let s_n: i64 = tail - s_off 99 if s_n < 1 { return 0 } 100 out_off[0] = s_off 101 out_n[0] = s_n 102 return 1 103} 104 105// ===== session token extraction from request headers ================================================= 106// Finds X-Nishi-Session, base64-decodes (or accepts a raw 152B value); writes 107// up to 152 bytes into tok_out; returns the decoded length (0 if absent). 108func nx_weh_extract_token(http_req_buf: *u8, http_headers_end: i64, 109 tok_out: *u8) -> i64 { 110 let off_b: *i64 = sys_mmap(8) as *i64 111 let len_b: *i64 = sys_mmap(8) as *i64 112 off_b[0] = 0 113 len_b[0] = 0 114 if nx_http_header_find(http_req_buf, http_headers_end, 115 NX_WEH_HDR_SESSION_NAME, NX_WEH_HDR_SESSION_NAME_N, 116 off_b, len_b) != NXHF_FOUND { 117 return 0 118 } 119 let raw: *u8 = (http_req_buf as i64 + off_b[0]) as *u8 120 let raw_n: i64 = len_b[0] 121 if raw_n == NX_WEH_TOKEN_BYTES { 122 var i: i64 = 0 123 while i < NX_WEH_TOKEN_BYTES { tok_out[i] = raw[i]; i = i + 1 } 124 return NX_WEH_TOKEN_BYTES 125 } 126 let dn: i64 = b64_decode(raw, raw_n, tok_out) 127 return dn 128} 129 130// ===== Content-Length parse (bounded) ================================================= 131func nx_weh_content_length(http_req_buf: *u8, http_headers_end: i64) -> i64 { 132 let off_b: *i64 = sys_mmap(8) as *i64 133 let len_b: *i64 = sys_mmap(8) as *i64 134 off_b[0] = 0 135 len_b[0] = 0 136 if nx_http_header_find(http_req_buf, http_headers_end, 137 "Content-Length" as *u8, 14, off_b, len_b) != NXHF_FOUND { 138 return 0 139 } 140 var v: i64 = 0 141 var i: i64 = 0 142 while i < len_b[0] { 143 let c: i64 = http_req_buf[off_b[0] + i] as i64 144 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } } 145 i = i + 1 146 } 147 if v < 0 { return 0 } 148 if v > NX_WEH_MAX_BODY { return 0 } 149 return v 150} 151 152// ===== response builders ================================================= 153 154func nx_weh_emit_401(resp: *u8, cap: i64, out_n: *i64) -> i64 { 155 let off: *i64 = sys_mmap(8) as *i64 156 off[0] = 0 157 let r: *u8 = "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 196\r\n\r\n<!DOCTYPE html><html><body><h1>Authentication required</h1><p>Editing a wiki page requires a valid admin session. POST your passphrase to <code>/wiki/admin/login</code> first.</p></body></html>" as *u8 158 let rc: i64 = _weh_appz(resp, off, cap, r) 159 if rc != NX_WEH_OK { return rc } 160 out_n[0] = off[0] 161 return NX_WEH_OK 162} 163 164func nx_weh_emit_404(resp: *u8, cap: i64, out_n: *i64) -> i64 { 165 let off: *i64 = sys_mmap(8) as *i64 166 off[0] = 0 167 let r: *u8 = "HTTP/1.1 404 Not Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 61\r\n\r\n<!DOCTYPE html><html><body><h1>404</h1><p>No such page.</p></body></html>" as *u8 168 let rc: i64 = _weh_appz(resp, off, cap, r) 169 if rc != NX_WEH_OK { return rc } 170 out_n[0] = off[0] 171 return NX_WEH_OK 172} 173 174// 200 "saved" page with a meta-refresh back to the rendered page. 175func nx_weh_emit_saved(resp: *u8, cap: i64, out_n: *i64, 176 slug: *u8, slug_n: i64) -> i64 { 177 // body assembled into a scratch buffer so Content-Length is exact. 178 let body: *u8 = sys_mmap(1024) 179 let bo: *i64 = sys_mmap(8) as *i64 180 bo[0] = 0 181 _weh_appz(body, bo, 1024, "<!DOCTYPE html><html><head><meta http-equiv=\"refresh\" content=\"1;url=/wiki/" as *u8) 182 _weh_app(body, bo, 1024, slug, slug_n) 183 _weh_appz(body, bo, 1024, "\"></head><body><h1>Saved.</h1><p>Returning to <a href=\"/wiki/" as *u8) 184 _weh_app(body, bo, 1024, slug, slug_n) 185 _weh_appz(body, bo, 1024, "\">/wiki/" as *u8) 186 _weh_app(body, bo, 1024, slug, slug_n) 187 _weh_appz(body, bo, 1024, "</a> ...</p></body></html>" as *u8) 188 let body_n: i64 = bo[0] 189 190 let off: *i64 = sys_mmap(8) as *i64 191 off[0] = 0 192 _weh_appz(resp, off, cap, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " as *u8) 193 _weh_appn(resp, off, cap, body_n) 194 _weh_appz(resp, off, cap, "\r\n\r\n" as *u8) 195 let rc: i64 = _weh_app(resp, off, cap, body, body_n) 196 if rc != NX_WEH_OK { return rc } 197 out_n[0] = off[0] 198 return NX_WEH_OK 199} 200 201// 200 the EDIT FORM, pre-filled with the current markdown (HTML-escaped). 202// cur_body/cur_n = current markdown (may be empty for a brand-new page). 203func nx_weh_emit_form(resp: *u8, cap: i64, out_n: *i64, 204 slug: *u8, slug_n: i64, 205 cur_body: *u8, cur_n: i64) -> i64 { 206 // Assemble the body in a scratch buffer (size = content + chrome + escape 207 // headroom) so Content-Length is exact. Escaped markdown can up to ~6x for 208 // all-'&'; size generously. 209 let bcap: i64 = cur_n * 6 + 4096 210 let body: *u8 = sys_mmap(bcap) 211 let bo: *i64 = sys_mmap(8) as *i64 212 bo[0] = 0 213 _weh_appz(body, bo, bcap, "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Edit " as *u8) 214 _weh_app(body, bo, bcap, slug, slug_n) 215 _weh_appz(body, bo, bcap, "</title></head><body><h1>Edit /wiki/" as *u8) 216 _weh_app(body, bo, bcap, slug, slug_n) 217 _weh_appz(body, bo, bcap, "</h1><form method=\"POST\" action=\"/wiki/" as *u8) 218 _weh_app(body, bo, bcap, slug, slug_n) 219 _weh_appz(body, bo, bcap, "/edit\"><textarea name=\"content\" rows=\"30\" cols=\"100\">" as *u8) 220 // pre-fill (HTML-escaped) current markdown 221 if cur_n > 0 { 222 let tail: *u8 = (body as i64 + bo[0]) as *u8 223 let avail: i64 = bcap - bo[0] 224 let wrote: i64 = html_escape(tail, avail, cur_body, cur_n) 225 if wrote >= 0 { bo[0] = bo[0] + wrote } 226 } 227 _weh_appz(body, bo, bcap, "</textarea><br><button type=\"submit\">Save</button></form><p>Send your session in the <code>X-Nishi-Session</code> header (the edit endpoint is admin-gated).</p></body></html>" as *u8) 228 let body_n: i64 = bo[0] 229 230 let off: *i64 = sys_mmap(8) as *i64 231 off[0] = 0 232 _weh_appz(resp, off, cap, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " as *u8) 233 _weh_appn(resp, off, cap, body_n) 234 _weh_appz(resp, off, cap, "\r\n\r\n" as *u8) 235 let rc: i64 = _weh_app(resp, off, cap, body, body_n) 236 if rc != NX_WEH_OK { return rc } 237 out_n[0] = off[0] 238 return NX_WEH_OK 239} 240 241// ===== current markdown lookup from the doc store (for the GET pre-fill) ===== 242// Writes the current body bytes for "/wiki/<slug>" into out (cap out_cap); 243// returns length (>=0) or -1 if the page is not in the store. 244func nx_weh_current_body(store: *NxWikiDocStore, slug: *u8, slug_n: i64, 245 out: *u8, out_cap: i64) -> i64 { 246 if (store as i64) == 0 { return 0 - 1 } 247 if store.valid != 1 { return 0 - 1 } 248 let url: *u8 = sys_mmap(NX_WPS_URL_CAP) 249 let url_n: i64 = nx_wps_url(slug, slug_n, url) 250 let rowid: i64 = nx_wiki_doc_store_find_by_url(store, url, url_n) 251 if rowid < 0 { return 0 - 1 } 252 let t_ptr: *i64 = sys_mmap(8) as *i64 253 let t_n: *i64 = sys_mmap(8) as *i64 254 let u_ptr: *i64 = sys_mmap(8) as *i64 255 let u_n: *i64 = sys_mmap(8) as *i64 256 let b_ptr: *i64 = sys_mmap(8) as *i64 257 let b_n: *i64 = sys_mmap(8) as *i64 258 if nx_wiki_doc_store_lookup(store, rowid, t_ptr, t_n, u_ptr, u_n, b_ptr, b_n) != NX_WIB_OK { 259 return 0 - 1 260 } 261 let n: i64 = b_n[0] 262 if n > out_cap { return 0 - 1 } 263 let src: *u8 = b_ptr[0] as *u8 264 var i: i64 = 0 265 while i < n { out[i] = src[i]; i = i + 1 } 266 return n 267} 268 269// ===== TOP-LEVEL handler (dispatched by nx_wiki_routes) ================================================= 270// 271// method_kind: 1 = GET, 2 = POST (per nx_http_io convention). 272func nx_wiki_edit_handle( 273 store: *NxWikiDocStore, 274 ctx: *NxAuthContext, 275 http_req_buf: *u8, http_headers_end: i64, 276 url_path: *u8, url_path_n: i64, 277 method_kind: i64, 278 now_unix_s: i64, 279 resp_buf: *u8, resp_cap: i64, out_resp_n: *i64 280) -> i64 { 281 if (resp_buf as i64) == 0 { return 0 - NX_WEH_BAD_INPUT } 282 if (out_resp_n as i64) == 0 { return 0 - NX_WEH_BAD_INPUT } 283 out_resp_n[0] = 0 284 285 // parse "/wiki/<slug>/edit" 286 let s_off: *i64 = sys_mmap(8) as *i64 287 let s_n: *i64 = sys_mmap(8) as *i64 288 if nx_weh_slug_of(url_path, url_path_n, s_off, s_n) != 1 { 289 return nx_weh_emit_404(resp_buf, resp_cap, out_resp_n) 290 } 291 let slug: *u8 = (url_path as i64 + s_off[0]) as *u8 292 let slug_n: i64 = s_n[0] 293 294 // extract the session token (shared by GET-gate + POST-save) 295 let tok: *u8 = sys_mmap(NX_WEH_TOKEN_BYTES + 8) 296 let tok_n: i64 = nx_weh_extract_token(http_req_buf, http_headers_end, tok) 297 298 if method_kind == 1 { 299 // GET: the form is itself auth-gated -- validate before rendering it. 300 let auth_rc: i64 = nx_modern_auth_validate_session( 301 ctx, tok, tok_n, now_unix_s, (0 as i64) as *u8, 0, (0 as i64) as *i64) 302 if auth_rc != NX_MAUTH_OK { 303 return nx_weh_emit_401(resp_buf, resp_cap, out_resp_n) 304 } 305 // pre-fill from the doc store's current body (empty if a new page) 306 let cur: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16) 307 var cur_n: i64 = nx_weh_current_body(store, slug, slug_n, cur, NX_WPS_MAX_CONTENT_LEN) 308 if cur_n < 0 { cur_n = 0 } 309 return nx_weh_emit_form(resp_buf, resp_cap, out_resp_n, slug, slug_n, cur, cur_n) 310 } 311 312 if method_kind == 2 { 313 // POST: the body IS the new markdown (raw textarea bytes after the 314 // "content=" form prefix if present; V1 accepts the raw body too). 315 let body_n: i64 = nx_weh_content_length(http_req_buf, http_headers_end) 316 let body: *u8 = (http_req_buf as i64 + http_headers_end) as *u8 317 // Strip a leading "content=" form field name if the client sent a 318 // urlencoded form (browsers do); else treat the whole body as content. 319 var c_ptr: *u8 = body 320 var c_n: i64 = body_n 321 if body_n >= 8 { 322 if body[0] == (99 as u8) { // 'c' 323 let pfx: *u8 = "content=" as *u8 324 var m: i64 = 1 325 var k: i64 = 0 326 while k < 8 { if body[k] != pfx[k] { m = 0; k = 8 } else { k = k + 1 } } 327 if m == 1 { c_ptr = (body as i64 + 8) as *u8; c_n = body_n - 8 } 328 } 329 } 330 331 // SAVE (auth re-checked INSIDE nx_wiki_page_save -- the write gate). 332 let rc: i64 = nx_wiki_page_save(store, ctx, slug, slug_n, 333 c_ptr, c_n, tok, tok_n, now_unix_s) 334 if rc == 0 - NX_WPS_REFUSED { 335 return nx_weh_emit_401(resp_buf, resp_cap, out_resp_n) 336 } 337 if rc != NX_WPS_OK { 338 // persist/store error -> honest 500 339 let off: *i64 = sys_mmap(8) as *i64 340 off[0] = 0 341 _weh_appz(resp_buf, off, resp_cap, "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 11\r\n\r\nsave failed" as *u8) 342 out_resp_n[0] = off[0] 343 return NX_WEH_OK 344 } 345 return nx_weh_emit_saved(resp_buf, resp_cap, out_resp_n, slug, slug_n) 346 } 347 348 // other methods -> 405 349 let off: *i64 = sys_mmap(8) as *i64 350 off[0] = 0 351 _weh_appz(resp_buf, off, resp_cap, "HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/plain\r\nContent-Length: 19\r\n\r\nMethod not allowed\n" as *u8) 352 out_resp_n[0] = off[0] 353 return NX_WEH_OK 354}