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}