code wiki / _hdl_build / nx_cms_admin.nx
nx_cms_admin.nx source
↩ module page · 1716 lines · 94554 B
1// nx_cms_admin.nx -- CMS C3+C4+C5: the WYSIWYG (field-based v1) ADMIN DAEMON. The human-engagement
2// surface: legal staff edit labelled fields in the browser; publish is atomic + revisioned; the
3// rendered page goes hot. Composes the session's proven CMS rungs:
4// nx_cms_store (C2: content as data, atomic + .prev revision) [gated]
5// nx_cms_render (C2: render-from-data, escape-on-render) [gated]
6// nx_html_sanitize(step 9: allowlist sanitize ON WRITE, 19/19 XSS) [gated]
7// nx_secure_token (step 3: 128-bit urandom session + CSRF, ct-eq)
8// _pe_pub (step 12: TEAM-AUTHORED publish state machine, CAPREG241)
9// nx_http_form (step 6: urlencoded body parse -- existing runtime rung)
10// nx_http_server (listen helper -- existing runtime rung)
11// Routes (the ONLY untrusted-input surface, rule 12 defensive-at-boundary):
12// GET / serve <site>/page.html (the published render)
13// GET /admin login form (no session) | field editor w/ CSRF (session)
14// POST /admin/login pw -> argon2id vs <site>/admin_pw.ar2 (C10, params live IN the file;
15// sha256 file = legacy migration fallback ONLY, .ar2 always wins);
16// 5-strike lockout; session cookie HttpOnly+SameSite=Strict (Secure = TLS rung)
17// POST /admin/save session + CSRF -> _html fields SANITIZED, store ATOMIC, render, publish;
18// mode=draft -> _pe_draft guard, draft.txt only, public page UNTOUCHED (C9)
19// GET /admin/preview session -> render of the PENDING DRAFT (no-store); 404 when no draft
20// POST /admin/publish session + CSRF -> _pe_draft guard (409 from CLEAN), draft promoted atomic
21// POST /admin/discard session + CSRF -> _pe_draft guard (409 from CLEAN), draft set aside additive
22// POST /admin/rollback session + CSRF -> _pe_pub guard (refuses from DRAFT), .prev swap, render
23// argv: [1]=port [2]=site dir [3]=request budget (exit 0 after N requests -- deterministic gates,
24// no orphan daemons). Plain HTTP loopback v1; TLS termination = the sites-daemon integration rung.
25// license_tier: ORIGINAL
26import "nx_cms_render.nx"
27import "nx_secure_token.nx"
28import "_pe_pub.nx"
29import "_pe_draft.nx"
30import "_pe_blockr.nx"
31import "_pe_seohead.nx"
32import "nx_cms_pw.nx"
33import "nx_session_ttl.nx" // #8: idle + absolute session timeout (sess_issue on login, sess_verify on check)
34import "nx_mfa.nx" // #12: TOTP second factor (mfa_verify after the password, when admin_totp.b32 exists)
35import "nx_http_form.nx"
36import "nx_cms_multipart.nx"
37import "nx_cms_snapshot.nx"
38import "nx_sha256.nx"
39const CA_MAGIC_65536: i64 = 65536
40const CA_MAGIC_65535: i64 = 65535
41const CA_MAGIC_8192: i64 = 8192
42const CA_MAGIC_8191: i64 = 8191
43const CA_MAGIC_131072: i64 = 131072
44const CA_MAGIC_131071: i64 = 131071
45const CA_MAGIC_200000: i64 = 200000
46const CA_MAGIC_4096: i64 = 4096
47const CA_MAGIC_3600: i64 = 3600
48const CA_MAGIC_2048: i64 = 2048
49const CA_MAGIC_2047: i64 = 2047
50const CA_MAGIC_4095: i64 = 4095
51const CA_MAGIC_131080: i64 = 131080
52// (WEBDEV STUDIO imports removed -- the studio is now its own OPAQUE-gated daemon, nx_sitegen_studio_daemon)
53
54const CA_REQCAP: i64 = 262144
55const CA_OUTCAP: i64 = 524288
56const CA_LOCKOUT: i64 = 5
57
58func ca_len(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n }
59func ca_cat(dst: *u8, off: i64, s: *u8) -> i64 {
60 var o: i64 = off
61 var k: i64 = 0
62 while s[k] != (0 as u8) { dst[o] = s[k]; o = o + 1; k = k + 1 }
63 return o
64}
65func ca_catn(dst: *u8, off: i64, s: *u8, n: i64) -> i64 {
66 var o: i64 = off
67 var k: i64 = 0
68 while k < n { dst[o] = s[k]; o = o + 1; k = k + 1 }
69 return o
70}
71func ca_catnum(dst: *u8, off: i64, v: i64) -> i64 {
72 let t: *u8 = sys_mmap(24)
73 var m: i64 = v
74 var k: i64 = 0
75 if m == 0 { t[0] = 48 as u8; k = 1 }
76 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
77 var o: i64 = off
78 var i: i64 = 0
79 while i < k { dst[o] = t[k-1-i]; o = o + 1; i = i + 1 }
80 return o
81}
82
83// first index of needle (nl bytes) in hay[0..n), or -1
84func ca_index(hay: *u8, n: i64, needle: *u8, nl: i64) -> i64 {
85 var i: i64 = 0
86 while i + nl <= n {
87 var q: i64 = 0
88 var ok: i64 = 1
89 while q < nl { if (hay[i+q] as i64) != (needle[q] as i64) { ok = 0; q = nl } q = q + 1 }
90 if ok == 1 { return i }
91 i = i + 1
92 }
93 return 0 - 1
94}
95
96func ca_atoi(s: *u8) -> i64 {
97 var v: i64 = 0
98 var i: i64 = 0
99 while s[i] != (0 as u8) {
100 let c: i64 = s[i] as i64
101 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } }
102 i = i + 1
103 }
104 return v
105}
106
107// read "key=<int>" out of a small config buffer (rule 11/17); returns the value or dflt if absent.
108func ca_conf_int(buf: *u8, n: i64, key: *u8, klen: i64, dflt: i64) -> i64 {
109 let at: i64 = ca_index(buf, n, key, klen)
110 if at < 0 { return dflt }
111 var v: i64 = 0
112 var i: i64 = at + klen
113 var seen: i64 = 0
114 var go: i64 = 1
115 while go == 1 {
116 go = 0
117 if i < n {
118 let c: i64 = buf[i] as i64
119 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48); seen = seen + 1; i = i + 1; go = 1 } }
120 }
121 }
122 if seen == 0 { return dflt }
123 return v
124}
125
126// build <sitedir>/<leaf> into dst
127func ca_path(dst: *u8, sitedir: *u8, leaf: *u8) -> i64 {
128 var o: i64 = ca_cat(dst, 0, sitedir)
129 o = ca_cat(dst, o, "/" as *u8)
130 o = ca_cat(dst, o, leaf)
131 dst[o] = 0 as u8
132 return o
133}
134
135// ---- response writers ----
136func ca_send(cfd: i64, buf: *u8, n: i64) -> i64 {
137 var off: i64 = 0
138 var go: i64 = 1
139 while go == 1 {
140 go = 0
141 if off < n {
142 let w: i64 = sys_write(cfd, (buf + off) as *u8, n - off)
143 if w > 0 { off = off + w; go = 1 }
144 }
145 }
146 return off
147}
148func ca_resp(cfd: i64, status: *u8, extra: *u8, body: *u8, bn: i64) -> i64 {
149 let h: *u8 = sys_mmap(CA_OUTCAP)
150 var o: i64 = ca_cat(h, 0, "HTTP/1.1 " as *u8)
151 o = ca_cat(h, o, status)
152 o = ca_cat(h, o, "\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nContent-Length: " as *u8)
153 o = ca_catnum(h, o, bn)
154 o = ca_cat(h, o, "\r\n" as *u8)
155 o = ca_cat(h, o, extra)
156 o = ca_cat(h, o, "\r\n" as *u8)
157 o = ca_catn(h, o, body, bn)
158 ca_send(cfd, h, o)
159 return 0
160}
161
162// ---- request reading (headers + Content-Length body) ----
163func ca_content_length(buf: *u8, hend: i64) -> i64 {
164 var p: i64 = ca_index(buf, hend, "Content-Length:" as *u8, 15)
165 if p < 0 { p = ca_index(buf, hend, "content-length:" as *u8, 15) }
166 if p < 0 { return 0 }
167 var v: i64 = 0
168 var i: i64 = p + 15
169 var go: i64 = 1
170 while go == 1 {
171 go = 0
172 if i < hend {
173 let c: i64 = buf[i] as i64
174 if c == 32 { i = i + 1; go = 1 }
175 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48); i = i + 1; go = 1 } }
176 }
177 }
178 return v
179}
180func ca_read_req(cfd: i64, buf: *u8, cap: i64, hend_out: *i64) -> i64 {
181 var total: i64 = 0
182 var hend: i64 = 0 - 1
183 var go: i64 = 1
184 while go == 1 {
185 go = 0
186 if total < cap {
187 if hend < 0 {
188 let r: i64 = sys_read(cfd, (buf + total) as *u8, cap - total)
189 if r > 0 {
190 total = total + r
191 hend = ca_index(buf, total, "\r\n\r\n" as *u8, 4)
192 if hend < 0 { go = 1 }
193 }
194 }
195 }
196 }
197 hend_out[0] = hend
198 if hend < 0 { return total }
199 let cl: i64 = ca_content_length(buf, hend)
200 var need: i64 = hend + 4 + cl
201 if need > cap { need = cap }
202 go = 1
203 while go == 1 {
204 go = 0
205 if total < need {
206 let r2: i64 = sys_read(cfd, (buf + total) as *u8, need - total)
207 if r2 > 0 { total = total + r2; go = 1 }
208 }
209 }
210 return total
211}
212
213// ---- auth ----
214// session cookie present + matches? sess[0]==0 means no session issued yet.
215func ca_session_ok(buf: *u8, hend: i64, sess: *u8) -> i64 {
216 if (sess[0] as i64) == 0 { return 0 }
217 let p: i64 = ca_index(buf, hend, "nsess=" as *u8, 6)
218 if p < 0 { return 0 }
219 if p + 6 + 32 > hend { return 0 }
220 if st_eq_ct((buf + p + 6) as *u8, sess, 32) == 0 { return 0 }
221 // #8: token matches -> ALSO enforce idle + absolute timeout (sess_verify slides the idle window on VALID)
222 if sess_verify(sess, sys_now_realtime_ms()) != SESS_VALID { return 0 }
223 return 1
224}
225// #12: MFA satisfied? -> 1 if NOT enrolled (password-only, graceful) OR enrolled with a valid TOTP code in
226// the body. 0 ONLY when enrolled and the code is missing/wrong. now_unix = sys_now_realtime_sec() at login.
227func ca_mfa_ok(body: *u8, bn: i64, totpsec: *u8, totpseclen: i64, now_unix: i64) -> i64 {
228 if mfa_enrolled(totpsec, totpseclen) == 0 { return 1 }
229 let code: *u8 = sys_mmap(32); let cl: *i64 = sys_mmap(16) as *i64
230 if nx_http_form_get_field(body, bn, "code" as *u8, 4, code, 31, cl) != 0 { return 0 }
231 return mfa_verify(totpsec, totpseclen, code, cl[0], now_unix, MFA_WINDOW)
232}
233// csrf form field matches?
234func ca_csrf_ok(body: *u8, bn: i64, csrf: *u8) -> i64 {
235 let v: *u8 = sys_mmap(128)
236 let vl: *i64 = sys_mmap(16) as *i64
237 if nx_http_form_get_field(body, bn, "csrf" as *u8, 4, v, 127, vl) != 0 { return 0 }
238 if vl[0] != 32 { return 0 }
239 return st_eq_ct(v, csrf, 32)
240}
241
242// ---- page assembly ----
243// escape a store value into an editor form (attribute/textarea safe)
244func ca_esc_into(dst: *u8, off: i64, val: *u8, vl: i64) -> i64 {
245 return off + hs_escape(val, vl, (dst + off) as *u8, CA_OUTCAP - off)
246}
247
248// the field editor: one labelled input per content key, textarea for _html, CSRF hidden field.
249func ca_editor_page(out: *u8, store: *u8, sn: i64, csrf: *u8) -> i64 {
250 let v: *u8 = sys_mmap(CA_MAGIC_65536)
251 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Andelin & West — Site Manager</title><style>*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#eef1f6;color:#16202e}header{background:#0b2545;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}header h1{font-size:1.15rem;margin:0;font-weight:700}nav a{color:#cdd9ee;margin-left:16px;text-decoration:none;font-size:.88rem}nav a:hover{color:#fff}main{max-width:760px;margin:24px auto;padding:0 16px}section{background:#fff;border:1px solid #dde3ee;border-radius:10px;padding:16px 22px 20px;margin:16px 0}section h2{margin:0 0 4px;font-size:1.02rem;color:#0b2545;border-bottom:2px solid #eef1f6;padding-bottom:8px}label{display:block;margin-top:14px;font-weight:600;font-size:.86rem}input,textarea{width:100%;padding:9px 11px;font-size:.95rem;border:1px solid #c3ccdd;border-radius:6px;margin-top:5px;font-family:inherit}textarea{min-height:84px}.hint{font-weight:400;color:#7a8699;font-size:.8rem}.bar{display:flex;gap:12px;margin-top:4px;flex-wrap:wrap}button{padding:11px 22px;font-size:.95rem;background:#2a4d8f;color:#fff;border:0;border-radius:6px;cursor:pointer}button:hover{background:#21407a}.ops form{display:inline-block;margin:4px 8px 4px 0}.ops button{background:#5b6b85;font-size:.84rem;padding:8px 14px}.roll button{background:#8f2a2a}a.dl{font-size:.84rem;color:#2a4d8f}</style></head><body><header><h1>Andelin & West — Site Manager</h1><nav><a href=\"/\">View site</a><a href=\"/admin/blocks\">Blocks</a><a href=\"/admin/stats\">Stats</a><a href=\"/admin/submissions\">Submissions</a></nav></header><main><form method=\"POST\" action=\"/admin/save\">" as *u8)
252 // ---- Firm Identity ----
253 o = ca_cat(out, o, "<section><h2>Firm Identity</h2><label>Firm name</label><input name=\"hero_title\" value=\"" as *u8)
254 var vl: i64 = cst_get(store, sn, "hero_title" as *u8, v, CA_MAGIC_65535)
255 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
256 o = ca_cat(out, o, "\"><label>Tagline</label><input name=\"hero_tag\" value=\"" as *u8)
257 vl = cst_get(store, sn, "hero_tag" as *u8, v, CA_MAGIC_65535)
258 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
259 o = ca_cat(out, o, "\"></section>" as *u8)
260 // ---- Contact ----
261 o = ca_cat(out, o, "<section><h2>Contact</h2><label>Phone</label><input name=\"phone\" value=\"" as *u8)
262 vl = cst_get(store, sn, "phone" as *u8, v, CA_MAGIC_65535)
263 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
264 o = ca_cat(out, o, "\"><label>Email</label><input name=\"email\" value=\"" as *u8)
265 vl = cst_get(store, sn, "email" as *u8, v, CA_MAGIC_65535)
266 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
267 o = ca_cat(out, o, "\"><label>Address <span class=\"hint\">(one line per row)</span></label><textarea name=\"address\">" as *u8)
268 vl = cst_get(store, sn, "address" as *u8, v, CA_MAGIC_65535)
269 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
270 o = ca_cat(out, o, "</textarea><label>Office hours <span class=\"hint\">(one line per row)</span></label><textarea name=\"hours\">" as *u8)
271 vl = cst_get(store, sn, "hours" as *u8, v, CA_MAGIC_65535)
272 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
273 o = ca_cat(out, o, "</textarea></section>" as *u8)
274 // ---- Practice Areas ----
275 o = ca_cat(out, o, "<section><h2>Practice Areas <span class=\"hint\">(one per line)</span></h2><textarea name=\"practice_areas\">" as *u8)
276 vl = cst_get(store, sn, "practice_areas" as *u8, v, CA_MAGIC_65535)
277 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
278 o = ca_cat(out, o, "</textarea></section>" as *u8)
279 // ---- About (rich text) ----
280 o = ca_cat(out, o, "<section><h2>About <span class=\"hint\">(rich text: b i strong em p br ul ol li a h2 h3)</span></h2><textarea name=\"note_html\">" as *u8)
281 vl = cst_get(store, sn, "note_html" as *u8, v, CA_MAGIC_65535)
282 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
283 o = ca_cat(out, o, "</textarea></section>" as *u8)
284 // ---- Footer ----
285 o = ca_cat(out, o, "<section><h2>Footer</h2><label>Footer text</label><input name=\"foot\" value=\"" as *u8)
286 vl = cst_get(store, sn, "foot" as *u8, v, CA_MAGIC_65535)
287 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
288 o = ca_cat(out, o, "\"></section>" as *u8)
289 // ---- SEO & Social ----
290 o = ca_cat(out, o, "<section><h2>SEO & Social</h2><label>SEO title <span class=\"hint\">(blank = firm name)</span></label><input name=\"seo_title\" value=\"" as *u8)
291 vl = cst_get(store, sn, "seo_title" as *u8, v, CA_MAGIC_65535)
292 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
293 o = ca_cat(out, o, "\"><label>SEO description <span class=\"hint\">(blank = tagline)</span></label><input name=\"seo_desc\" value=\"" as *u8)
294 vl = cst_get(store, sn, "seo_desc" as *u8, v, CA_MAGIC_65535)
295 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
296 o = ca_cat(out, o, "\"><label>Site URL <span class=\"hint\">(for canonical + sitemap)</span></label><input name=\"site_url\" value=\"" as *u8)
297 vl = cst_get(store, sn, "site_url" as *u8, v, CA_MAGIC_65535)
298 if vl > 0 { o = ca_esc_into(out, o, v, vl) }
299 o = ca_cat(out, o, "\"></section>" as *u8)
300 o = ca_cat(out, o, "<input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
301 o = ca_catn(out, o, csrf, 32)
302 o = ca_cat(out, o, "\"><section><div class=\"bar\"><button type=\"submit\">Save & Publish</button><button type=\"submit\" name=\"mode\" value=\"draft\">Save as draft</button></div></section></form><section class=\"ops\"><h2>Publishing & Backup</h2><p class=\"hint\"><a href=\"/admin/preview\">Preview pending draft →</a></p><form method=\"POST\" action=\"/admin/publish\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
303 o = ca_catn(out, o, csrf, 32)
304 o = ca_cat(out, o, "\"><button type=\"submit\">Publish pending draft</button></form><form method=\"POST\" action=\"/admin/discard\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
305 o = ca_catn(out, o, csrf, 32)
306 o = ca_cat(out, o, "\"><button type=\"submit\">Discard pending draft</button></form><form class=\"roll\" method=\"POST\" action=\"/admin/rollback\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
307 o = ca_catn(out, o, csrf, 32)
308 o = ca_cat(out, o, "\"><button type=\"submit\">Roll back to previous publish</button></form>" as *u8)
309 // W4 backup/restore (UpdraftPlus-class): snapshot the whole site, restore byte-exact
310 o = ca_cat(out, o, "<fieldset><legend>Backup</legend><form method=\"POST\" action=\"/admin/snapshot\" style=\"display:inline\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
311 o = ca_catn(out, o, csrf, 32)
312 o = ca_cat(out, o, "\"><button type=\"submit\">Take snapshot</button></form> <a href=\"/admin/snapshot.nxsnap\">download</a> <form method=\"POST\" action=\"/admin/restore\" style=\"display:inline\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
313 o = ca_catn(out, o, csrf, 32)
314 o = ca_cat(out, o, "\"><button type=\"submit\">Restore last snapshot</button></form></fieldset></section></main></body></html>" as *u8)
315 return o
316}
317
318func ca_login_page(out: *u8, locked: i64) -> i64 {
319 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Editor Login</title><style>body{font-family:Georgia,serif;max-width:420px;margin:10vh auto;padding:0 24px}input{width:100%;padding:8px;margin-top:8px;box-sizing:border-box}button{margin-top:14px;padding:10px 22px;background:#2a4d8f;color:#fff;border:0;border-radius:4px}</style></head><body><h1>Editor Login</h1>" as *u8)
320 if locked == 1 { o = ca_cat(out, o, "<p><b>Locked: too many failed attempts. Restart the editor service to retry.</b></p></body></html>" as *u8); return o }
321 o = ca_cat(out, o, "<form method=\"POST\" action=\"/admin/login\"><label>Password</label><input type=\"password\" name=\"pw\"><label>Authenticator code <span style=\"font-weight:400;color:#888\">(if MFA enabled)</span></label><input name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" maxlength=\"6\" placeholder=\"6-digit code\"><button type=\"submit\">Sign in</button></form></body></html>" as *u8)
322 return o
323}
324
325// ---- CLIENT PORTAL (role 2: clients sign in to their own portal; the lawyer/staff role is /admin) ----
326// client session cookie present + matches? csess[0]==0 means no client session issued yet.
327func ca_client_ok(buf: *u8, hend: i64, csess: *u8) -> i64 {
328 if (csess[0] as i64) == 0 { return 0 }
329 let p: i64 = ca_index(buf, hend, "nclient=" as *u8, 8)
330 if p < 0 { return 0 }
331 if p + 8 + 32 > hend { return 0 }
332 return st_eq_ct((buf + p + 8) as *u8, csess, 32)
333}
334func ca_client_login_page(out: *u8, bad: i64) -> i64 {
335 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Client Portal — Andelin & West</title><style>body{font-family:Georgia,serif;max-width:420px;margin:10vh auto;padding:0 24px;color:#16202e}h1{color:#0b2545}input{width:100%;padding:8px;margin-top:8px;box-sizing:border-box;border:1px solid #aab;border-radius:4px}button{margin-top:14px;padding:10px 22px;background:#0b2545;color:#fff;border:0;border-radius:4px;cursor:pointer}a{color:#2a4d8f}</style></head><body><h1>Client Portal</h1><p>Andelin & West clients — sign in to your secure portal.</p>" as *u8)
336 if bad == 1 { o = ca_cat(out, o, "<p style=\"color:#a11\"><b>Incorrect credentials.</b></p>" as *u8) }
337 o = ca_cat(out, o, "<form method=\"POST\" action=\"/portal/login\"><label>Password</label><input type=\"password\" name=\"pw\"><button type=\"submit\">Sign in</button></form><p style=\"margin-top:1.5rem\"><a href=\"/\">← Back to site</a></p></body></html>" as *u8)
338 return o
339}
340func ca_portal_page(out: *u8) -> i64 {
341 return ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Your Client Portal</title><style>body{font-family:Georgia,serif;max-width:680px;margin:6vh auto;padding:0 24px;color:#16202e;line-height:1.6}h1{color:#0b2545}.card{background:#f4f4f8;border-left:4px solid #0b2545;padding:16px 20px;margin:16px 0}button{padding:8px 18px;background:#0b2545;color:#fff;border:0;border-radius:4px;cursor:pointer}</style></head><body><h1>Welcome to your Client Portal</h1><p>You are securely signed in.</p><div class=\"card\"><strong>Your matters</strong><p>Secure documents, invoices, and messaging with your attorney will appear here.</p></div><form method=\"POST\" action=\"/portal/logout\"><button type=\"submit\">Sign out</button></form></body></html>" as *u8)
342}
343
344// load store + render template -> atomic page.html publish. returns 1/0.
345func ca_render_publish(sitedir: *u8) -> i64 {
346 let cpath: *u8 = sys_mmap(512)
347 let tpath: *u8 = sys_mmap(512)
348 let ppath: *u8 = sys_mmap(512)
349 ca_path(cpath, sitedir, "content.txt" as *u8)
350 ca_path(tpath, sitedir, "template.html" as *u8)
351 ca_path(ppath, sitedir, "page.html" as *u8)
352 let store: *u8 = sys_mmap(CA_REQCAP)
353 var sn: i64 = cst_read(cpath, store, CA_REQCAP - 1)
354 if sn < 0 { return 0 }
355 let tpl: *u8 = sys_mmap(CA_REQCAP)
356 let tn: i64 = cst_read(tpath, tpl, CA_REQCAP - 1)
357 if tn < 0 { return 0 }
358 // C11: blocks -> blocks_html (team-authored renderer) injected before template render
359 let bh: *u8 = sys_mmap(CA_OUTCAP)
360 let bhn: i64 = ca_blocks_html(store, sn, bh, CA_OUTCAP - 1)
361 var rstore: *u8 = store
362 if bhn >= 0 {
363 let s2b: *u8 = sys_mmap(CA_REQCAP)
364 sn = cst_set(store, sn, "blocks_html" as *u8, bh, bhn, s2b, CA_REQCAP - 1)
365 rstore = s2b
366 }
367 // W2: SEO head -> seo_head_html (team-authored renderer); _html key inserts raw (self-escaped)
368 let sh: *u8 = sys_mmap(CA_OUTCAP)
369 let shn: i64 = ca_seo_head(rstore, sn, sh, CA_OUTCAP - 1)
370 if shn > 0 {
371 let s3b: *u8 = sys_mmap(CA_REQCAP)
372 sn = cst_set(rstore, sn, "seo_head_html" as *u8, sh, shn, s3b, CA_REQCAP - 1)
373 rstore = s3b
374 }
375 let page: *u8 = sys_mmap(CA_OUTCAP)
376 let pn: i64 = crd_render(tpl, tn, rstore, sn, page, CA_OUTCAP - 1)
377 return cst_write_atomic(ppath, page, pn)
378}
379
380// ---- C11: blocks + uploads ----
381// like ca_resp but with a caller-chosen Content-Type (binary-safe image serving)
382func ca_resp_ct(cfd: i64, status: *u8, ctype: *u8, body: *u8, bn: i64) -> i64 {
383 let h: *u8 = sys_mmap(CA_OUTCAP)
384 var o: i64 = ca_cat(h, 0, "HTTP/1.1 " as *u8)
385 o = ca_cat(h, o, status)
386 o = ca_cat(h, o, "\r\nContent-Type: " as *u8)
387 o = ca_cat(h, o, ctype)
388 o = ca_cat(h, o, "\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nContent-Length: " as *u8)
389 o = ca_catnum(h, o, bn)
390 o = ca_cat(h, o, "\r\n\r\n" as *u8)
391 o = ca_catn(h, o, body, bn)
392 ca_send(cfd, h, o)
393 return 0
394}
395
396// filename sanity: 1..100 chars of [A-Za-z0-9._-], no leading dot, no ".." (traversal-proof)
397func ca_sane_name(s: *u8, n: i64) -> i64 {
398 if n < 1 { return 0 }
399 if n > 100 { return 0 }
400 if (s[0] as i64) == 46 { return 0 }
401 var i: i64 = 0
402 while i < n {
403 let c: i64 = s[i] as i64
404 var ok: i64 = 0
405 if c >= 97 { if c <= 122 { ok = 1 } }
406 if c >= 65 { if c <= 90 { ok = 1 } }
407 if c >= 48 { if c <= 57 { ok = 1 } }
408 if c == 46 { ok = 1 }
409 if c == 95 { ok = 1 }
410 if c == 45 { ok = 1 }
411 if ok == 0 { return 0 }
412 if c == 46 { if i + 1 < n { if (s[i+1] as i64) == 46 { return 0 } } }
413 i = i + 1
414 }
415 return 1
416}
417
418func ca_lc(c: i64) -> i64 {
419 if c >= 65 { if c <= 90 { return c + 32 } }
420 return c
421}
422
423// image type by extension (the allowed-upload table, DATA): 0=png 1=jpeg 2=gif, -1 = not allowed
424func ca_img_type(name: *u8, n: i64) -> i64 {
425 var d: i64 = 0 - 1
426 var i: i64 = 0
427 while i < n { if (name[i] as i64) == 46 { d = i } i = i + 1 }
428 if d < 0 { return 0 - 1 }
429 let e: i64 = n - d - 1
430 if e == 3 {
431 let a: i64 = ca_lc(name[d+1] as i64)
432 let b: i64 = ca_lc(name[d+2] as i64)
433 let c: i64 = ca_lc(name[d+3] as i64)
434 if a == 112 { if b == 110 { if c == 103 { return 0 } } } // png
435 if a == 106 { if b == 112 { if c == 103 { return 1 } } } // jpg
436 if a == 103 { if b == 105 { if c == 102 { return 2 } } } // gif
437 }
438 if e == 4 {
439 let a2: i64 = ca_lc(name[d+1] as i64)
440 let b2: i64 = ca_lc(name[d+2] as i64)
441 let c2: i64 = ca_lc(name[d+3] as i64)
442 let d2: i64 = ca_lc(name[d+4] as i64)
443 if a2 == 106 { if b2 == 112 { if c2 == 101 { if d2 == 103 { return 1 } } } } // jpeg
444 }
445 return 0 - 1
446}
447
448// magic bytes must MATCH the claimed extension (a .png with non-PNG bytes is refused)
449func ca_magic_ok(t: i64, body: *u8, n: i64) -> i64 {
450 if t == 0 {
451 if n < 4 { return 0 }
452 if (body[0] as i64) != 0x89 { return 0 }
453 if (body[1] as i64) != 0x50 { return 0 }
454 if (body[2] as i64) != 0x4e { return 0 }
455 if (body[3] as i64) != 0x47 { return 0 }
456 return 1
457 }
458 if t == 1 {
459 if n < 3 { return 0 }
460 if (body[0] as i64) != 0xff { return 0 }
461 if (body[1] as i64) != 0xd8 { return 0 }
462 if (body[2] as i64) != 0xff { return 0 }
463 return 1
464 }
465 if t == 2 {
466 if n < 4 { return 0 }
467 if (body[0] as i64) != 71 { return 0 }
468 if (body[1] as i64) != 73 { return 0 }
469 if (body[2] as i64) != 70 { return 0 }
470 if (body[3] as i64) != 56 { return 0 }
471 return 1
472 }
473 return 0
474}
475
476func ca_img_ctype(t: i64) -> *u8 {
477 if t == 0 { return "image/png" as *u8 }
478 if t == 1 { return "image/jpeg" as *u8 }
479 return "image/gif" as *u8
480}
481
482// W2 SEO: build the <head> meta block from store keys via the TEAM-AUTHORED renderer (_pe_seohead),
483// with field fallbacks (seo_title->hero_title, seo_desc->hero_tag). Each tag is emitted only when its
484// source value is present; the renderer escapes every value. Returns head length.
485func ca_seo_get(store: *u8, sn: i64, k1: *u8, k2: *u8, out: *u8, cap: i64) -> i64 {
486 var vl: i64 = cst_get(store, sn, k1, out, cap)
487 if vl > 0 { return vl }
488 if (k2[0] as i64) != 0 { vl = cst_get(store, sn, k2, out, cap) }
489 if vl > 0 { return vl }
490 return 0
491}
492func ca_seo_head(store: *u8, sn: i64, out: *u8, cap: i64) -> i64 {
493 var o: i64 = 0
494 let v: *u8 = sys_mmap(CA_MAGIC_8192)
495 // title (kind 1) + og:title (kind 3): seo_title || hero_title
496 var tl: i64 = ca_seo_get(store, sn, "seo_title" as *u8, "hero_title" as *u8, v, CA_MAGIC_8191)
497 if tl > 0 {
498 o = o + _pe_seohead_one(1, v, tl, ((out as i64) + o) as *u8, cap - o)
499 o = o + _pe_seohead_one(3, v, tl, ((out as i64) + o) as *u8, cap - o)
500 }
501 // description (kind 2) + og:description (kind 4): seo_desc || hero_tag
502 var dl: i64 = ca_seo_get(store, sn, "seo_desc" as *u8, "hero_tag" as *u8, v, CA_MAGIC_8191)
503 if dl > 0 {
504 o = o + _pe_seohead_one(2, v, dl, ((out as i64) + o) as *u8, cap - o)
505 o = o + _pe_seohead_one(4, v, dl, ((out as i64) + o) as *u8, cap - o)
506 }
507 // og:image (kind 5): only if set
508 let il: i64 = cst_get(store, sn, "og_image" as *u8, v, CA_MAGIC_8191)
509 if il > 0 { o = o + _pe_seohead_one(5, v, il, ((out as i64) + o) as *u8, cap - o) }
510 // canonical (kind 6): site_url (+ "/" if bare host)
511 let ul: i64 = cst_get(store, sn, "site_url" as *u8, v, CA_MAGIC_8191)
512 if ul > 0 { o = o + _pe_seohead_one(6, v, ul, ((out as i64) + o) as *u8, cap - o) }
513 // og:type website (kind 7), constant
514 o = o + _pe_seohead_one(7, "website" as *u8, 7, ((out as i64) + o) as *u8, cap - o)
515 return o
516}
517
518// W5: extract the GET path token (after "GET ", up to space or '?') into key, sanitized to a
519// safe store-key charset ([A-Za-z0-9/._-]); returns key length or -1. The path IS the analytics key
520// so analytics is genuinely per-page (the site currently serves one page, "/").
521func ca_path_key(req: *u8, rn: i64, key: *u8, cap: i64) -> i64 {
522 if ca_index(req, rn, "GET " as *u8, 4) != 0 { return 0 - 1 }
523 var i: i64 = 4
524 var o: i64 = 0
525 while i < rn {
526 let c: i64 = req[i] as i64
527 if c == 32 { i = rn }
528 if c == 63 { i = rn }
529 if i < rn {
530 var okc: i64 = 0
531 if c >= 97 { if c <= 122 { okc = 1 } }
532 if c >= 65 { if c <= 90 { okc = 1 } }
533 if c >= 48 { if c <= 57 { okc = 1 } }
534 if c == 47 { okc = 1 }
535 if c == 46 { okc = 1 }
536 if c == 95 { okc = 1 }
537 if c == 45 { okc = 1 }
538 if okc == 1 { if o < cap { key[o] = c as u8; o = o + 1 } }
539 i = i + 1
540 }
541 }
542 if o == 0 { return 0 - 1 }
543 key[o] = 0 as u8
544 return o
545}
546
547// W5: record one page view for key in the analytics store (read-modify-write; the accept loop is
548// sequential so this is race-free). additive: counts only ever increase.
549func ca_analytics_hit(apath: *u8, key: *u8) -> i64 {
550 let st: *u8 = sys_mmap(CA_REQCAP)
551 var sn: i64 = cst_read(apath, st, CA_REQCAP - 1)
552 if sn < 0 { sn = 0 }
553 let cv: *u8 = sys_mmap(32)
554 let cl: i64 = cst_get(st, sn, key, cv, 31)
555 var count: i64 = 0
556 if cl > 0 {
557 var i: i64 = 0
558 while i < cl { let c: i64 = cv[i] as i64; if c >= 48 { if c <= 57 { count = count * 10 + (c - 48) } } i = i + 1 }
559 }
560 count = count + 1
561 let nv: *u8 = sys_mmap(32)
562 var no: i64 = 0
563 let tmp: *u8 = sys_mmap(32)
564 var m: i64 = count
565 var k: i64 = 0
566 if m == 0 { tmp[0] = 48 as u8; k = 1 }
567 while m > 0 { tmp[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
568 while no < k { nv[no] = tmp[k-1-no]; no = no + 1 }
569 let s2: *u8 = sys_mmap(CA_REQCAP)
570 let n2: i64 = cst_set(st, sn, key, nv, no, s2, CA_REQCAP - 1)
571 cst_write_atomic(apath, s2, n2)
572 return count
573}
574
575// W5: render the analytics store as a session-only stats table (path -> count). Returns length.
576func ca_stats_page(out: *u8, apath: *u8) -> i64 {
577 let st: *u8 = sys_mmap(CA_REQCAP)
578 var sn: i64 = cst_read(apath, st, CA_REQCAP - 1)
579 if sn < 0 { sn = 0 }
580 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Stats</title><style>body{font-family:Georgia,serif;max-width:640px;margin:4vh auto}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #ccc;text-align:left;padding:6px}</style></head><body><h1>Site analytics <span style=\"font-size:.6em;color:#888\">(first-party, no third-party beacon)</span></h1><p><a href=\"/admin\">← editor</a></p><table><tr><th>path</th><th>views</th></tr>" as *u8)
581 // each record: @key\n value\n ; walk the store lines
582 var i: i64 = 0
583 var total: i64 = 0
584 while i < sn {
585 if (st[i] as i64) == 64 {
586 // key from i+1 to newline
587 var ke: i64 = i + 1
588 while ke < sn { if (st[ke] as i64) == 10 { break } ke = ke + 1 }
589 // value from ke+1 to next newline
590 var ve: i64 = ke + 1
591 while ve < sn { if (st[ve] as i64) == 10 { break } ve = ve + 1 }
592 o = ca_cat(out, o, "<tr><td>" as *u8)
593 o = o + hs_escape(((st as i64) + i + 1) as *u8, ke - i - 1, ((out as i64) + o) as *u8, CA_OUTCAP - o)
594 o = ca_cat(out, o, "</td><td>" as *u8)
595 o = ca_catn(out, o, ((st as i64) + ke + 1) as *u8, ve - ke - 1)
596 o = ca_cat(out, o, "</td></tr>" as *u8)
597 var cc: i64 = 0
598 var q: i64 = ke + 1
599 while q < ve { let c2: i64 = st[q] as i64; if c2 >= 48 { if c2 <= 57 { cc = cc * 10 + (c2 - 48) } } q = q + 1 }
600 total = total + cc
601 i = ve + 1
602 } else { i = i + 1 }
603 }
604 o = ca_cat(out, o, "<tr><th>total</th><th>" as *u8)
605 o = ca_catnum(out, o, total)
606 o = ca_cat(out, o, "</th></tr></table></body></html>" as *u8)
607 return o
608}
609
610// W3 forms: per-IP sliding-window rate check. Returns 1 if allowed (and records the hit), 0 if over
611// the cap. Table is in-memory (the accept loop is sequential -> race-free). Fail-open if the table
612// is full (capacity 64 distinct IPs is generous; never block a legit visitor on bookkeeping).
613func ca_rate_ok(ip_arr: *i64, cnt_arr: *i64, ws_arr: *i64, nbox: *i64, ip: i64, now: i64, window: i64, maxn: i64) -> i64 {
614 var idx: i64 = 0 - 1
615 var i: i64 = 0
616 while i < nbox[0] { if ip_arr[i] == ip { idx = i; i = nbox[0] } else { i = i + 1 } }
617 if idx < 0 {
618 if nbox[0] >= 64 { return 1 }
619 idx = nbox[0]
620 ip_arr[idx] = ip
621 cnt_arr[idx] = 0
622 ws_arr[idx] = now
623 nbox[0] = nbox[0] + 1
624 }
625 if now - ws_arr[idx] > window { cnt_arr[idx] = 0; ws_arr[idx] = now }
626 if cnt_arr[idx] >= maxn { return 0 }
627 cnt_arr[idx] = cnt_arr[idx] + 1
628 return 1
629}
630
631// W3 forms: append one submission record (additive; never lost). Record line:
632// T<ts>|F<formname>|<urlencoded body>\n
633func ca_submit_append(spath: *u8, ts: i64, fname: *u8, fnl: i64, body: *u8, bn: i64) -> i64 {
634 let line: *u8 = sys_mmap(CA_REQCAP)
635 var o: i64 = ca_cat(line, 0, "T" as *u8)
636 o = ca_catnum(line, o, ts)
637 o = ca_cat(line, o, "|F" as *u8)
638 o = ca_catn(line, o, fname, fnl)
639 o = ca_cat(line, o, "|" as *u8)
640 var i: i64 = 0
641 while i < bn { let c: i64 = body[i] as i64; if c != 10 { if c != 13 { line[o] = c as u8; o = o + 1 } } i = i + 1 }
642 line[o] = 10 as u8
643 o = o + 1
644 let fd: i64 = sys_openat_append(spath, 0x1a4)
645 if fd < 0 { return 0 }
646 sys_write(fd, line, o)
647 sys_close(fd)
648 return 1
649}
650
651// W3 forms: render the submissions log as a session-only list (escaped). Returns length.
652func ca_subs_page(out: *u8, spath: *u8) -> i64 {
653 let st: *u8 = sys_mmap(CA_REQCAP)
654 var sn: i64 = cst_read(spath, st, CA_REQCAP - 1)
655 if sn < 0 { sn = 0 }
656 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Submissions</title><style>body{font-family:Georgia,serif;max-width:760px;margin:4vh auto}li{border-bottom:1px solid #ccc;padding:8px 0;word-break:break-all}</style></head><body><h1>Form submissions <span style=\"font-size:.6em;color:#888\">(additive; never lost)</span></h1><p><a href=\"/admin\">← editor</a></p><ul>" as *u8)
657 var count: i64 = 0
658 var i: i64 = 0
659 while i < sn {
660 var e: i64 = i
661 while e < sn { if (st[e] as i64) == 10 { break } e = e + 1 }
662 if e > i {
663 o = ca_cat(out, o, "<li>" as *u8)
664 o = o + hs_escape(((st as i64) + i) as *u8, e - i, ((out as i64) + o) as *u8, CA_OUTCAP - o)
665 o = ca_cat(out, o, "</li>" as *u8)
666 count = count + 1
667 }
668 i = e + 1
669 }
670 o = ca_cat(out, o, "</ul><p>total: " as *u8)
671 o = ca_catnum(out, o, count)
672 o = ca_cat(out, o, "</p></body></html>" as *u8)
673 return o
674}
675
676// build blocks_html from the store's "blocks" key via the TEAM-AUTHORED renderer (_pe_blockr).
677// blocks value = lines "K|payload" (K = kind digit). -1 = no blocks key.
678func ca_blocks_html(store: *u8, sn: i64, out: *u8, cap: i64) -> i64 {
679 let bl: *u8 = sys_mmap(CA_MAGIC_131072)
680 let bn: i64 = cst_get(store, sn, "blocks" as *u8, bl, CA_MAGIC_131071)
681 if bn < 0 { return 0 - 1 }
682 var o: i64 = 0
683 var i: i64 = 0
684 while i < bn {
685 var e: i64 = i
686 while e < bn { if (bl[e] as i64) == 10 { break } e = e + 1 }
687 if e > i + 1 {
688 let k: i64 = (bl[i] as i64) - 48
689 if (bl[i+1] as i64) == 124 {
690 let fn2: i64 = _pe_blockr_one(k, ((bl as i64) + i + 2) as *u8, e - i - 2, ((out as i64) + o) as *u8, cap - o)
691 if fn2 > 0 {
692 o = o + fn2
693 if o < cap { out[o] = 10 as u8; o = o + 1 }
694 }
695 }
696 }
697 i = e + 1
698 }
699 return o
700}
701
702// rebuild the blocks value applying op (0=add 1=set 2=del) at block index idx with line bytes.
703func ca_blocks_mut(old: *u8, on: i64, op: i64, idx: i64, line: *u8, ln2: i64, out: *u8, cap: i64) -> i64 {
704 var o: i64 = 0
705 var bi: i64 = 0
706 var i: i64 = 0
707 while i < on {
708 var e: i64 = i
709 while e < on { if (old[e] as i64) == 10 { break } e = e + 1 }
710 if e > i {
711 var keep: i64 = 1
712 if op == 2 { if bi == idx { keep = 0 } }
713 if op == 1 { if bi == idx {
714 var q3: i64 = 0
715 while q3 < ln2 { if o < cap { out[o] = line[q3]; o = o + 1 } q3 = q3 + 1 }
716 if o < cap { out[o] = 10 as u8; o = o + 1 }
717 keep = 0
718 } }
719 if keep == 1 {
720 var q4: i64 = i
721 while q4 < e { if o < cap { out[o] = old[q4]; o = o + 1 } q4 = q4 + 1 }
722 if o < cap { out[o] = 10 as u8; o = o + 1 }
723 }
724 bi = bi + 1
725 }
726 i = e + 1
727 }
728 if op == 0 {
729 var q5: i64 = 0
730 while q5 < ln2 { if o < cap { out[o] = line[q5]; o = o + 1 } q5 = q5 + 1 }
731 if o < cap { out[o] = 10 as u8; o = o + 1 }
732 }
733 return o
734}
735
736// mkdirat via runtime-computed x86 number (the portable-syscall idiom)
737func ca_mkdir(path: *u8, mode: i64) -> i64 {
738 let nb: *i64 = sys_mmap(8) as *i64
739 nb[0] = 258
740 return __syscall(nb[0], 0 - 100, path, mode, 0, 0, 0)
741}
742
743// the blocks editor page (session-only): per-block edit/delete forms + add + upload
744func ca_blocks_editor(out: *u8, store: *u8, sn: i64, csrf: *u8) -> i64 {
745 var o: i64 = ca_cat(out, 0, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Blocks</title><style>body{font-family:Georgia,serif;max-width:720px;margin:4vh auto;padding:0 24px}textarea{width:100%;min-height:60px}fieldset{margin-top:1rem;border:1px solid #aab}button{padding:6px 14px}</style></head><body><h1>Block Editor</h1><p><a href=\"/admin\">← fields</a> | <a href=\"/\">view site</a></p>" as *u8)
746 // existing blocks
747 let bl: *u8 = sys_mmap(CA_MAGIC_131072)
748 let bn: i64 = cst_get(store, sn, "blocks" as *u8, bl, CA_MAGIC_131071)
749 var i: i64 = 0
750 var bi: i64 = 0
751 if bn > 0 {
752 while i < bn {
753 var e: i64 = i
754 while e < bn { if (bl[e] as i64) == 10 { break } e = e + 1 }
755 if e > i + 1 {
756 o = ca_cat(out, o, "<fieldset><legend>block " as *u8)
757 o = ca_catnum(out, o, bi)
758 o = ca_cat(out, o, " kind " as *u8)
759 o = ca_catn(out, o, ((bl as i64) + i) as *u8, 1)
760 o = ca_cat(out, o, "</legend><form method=\"POST\" action=\"/admin/blocks\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
761 o = ca_catn(out, o, csrf, 32)
762 o = ca_cat(out, o, "\"><input type=\"hidden\" name=\"op\" value=\"set\"><input type=\"hidden\" name=\"idx\" value=\"" as *u8)
763 o = ca_catnum(out, o, bi)
764 o = ca_cat(out, o, "\"><input type=\"hidden\" name=\"kind\" value=\"" as *u8)
765 o = ca_catn(out, o, ((bl as i64) + i) as *u8, 1)
766 o = ca_cat(out, o, "\"><textarea name=\"payload\">" as *u8)
767 o = o + hs_escape(((bl as i64) + i + 2) as *u8, e - i - 2, ((out as i64) + o) as *u8, CA_OUTCAP - o)
768 o = ca_cat(out, o, "</textarea><button type=\"submit\">Save</button></form><form method=\"POST\" action=\"/admin/blocks\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
769 o = ca_catn(out, o, csrf, 32)
770 o = ca_cat(out, o, "\"><input type=\"hidden\" name=\"op\" value=\"del\"><input type=\"hidden\" name=\"idx\" value=\"" as *u8)
771 o = ca_catnum(out, o, bi)
772 o = ca_cat(out, o, "\"><button type=\"submit\">Delete</button></form></fieldset>" as *u8)
773 bi = bi + 1
774 }
775 i = e + 1
776 }
777 }
778 // add form (kinds: 1=p rich, 2=h2, 3=h3, 4=quote, 5=img src|alt)
779 o = ca_cat(out, o, "<fieldset><legend>add block</legend><form method=\"POST\" action=\"/admin/blocks\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
780 o = ca_catn(out, o, csrf, 32)
781 o = ca_cat(out, o, "\"><input type=\"hidden\" name=\"op\" value=\"add\"><label>kind (1=text 2=h2 3=h3 4=quote 5=img src|alt)</label><input name=\"kind\" value=\"1\"><label>payload</label><textarea name=\"payload\"></textarea><button type=\"submit\">Add</button></form></fieldset>" as *u8)
782 // upload form
783 o = ca_cat(out, o, "<fieldset><legend>upload image (png/jpg/gif)</legend><form method=\"POST\" action=\"/admin/upload\" enctype=\"multipart/form-data\"><input type=\"hidden\" name=\"csrf\" value=\"" as *u8)
784 o = ca_catn(out, o, csrf, 32)
785 o = ca_cat(out, o, "\"><input type=\"file\" name=\"file\"><button type=\"submit\">Upload</button></form></fieldset></body></html>" as *u8)
786 return o
787}
788
789// render the PENDING DRAFT store through the template into out (memory only, no file writes).
790// Returns page length or -1 (no draft / no template).
791func ca_render_draft(sitedir: *u8, out: *u8) -> i64 {
792 let dpath: *u8 = sys_mmap(512)
793 let tpath: *u8 = sys_mmap(512)
794 ca_path(dpath, sitedir, "draft.txt" as *u8)
795 ca_path(tpath, sitedir, "template.html" as *u8)
796 let store: *u8 = sys_mmap(CA_REQCAP)
797 var sn: i64 = cst_read(dpath, store, CA_REQCAP - 1)
798 if sn < 0 { return 0 - 1 }
799 let tpl: *u8 = sys_mmap(CA_REQCAP)
800 let tn: i64 = cst_read(tpath, tpl, CA_REQCAP - 1)
801 if tn < 0 { return 0 - 1 }
802 // C11: drafts render blocks too (same injection as publish)
803 let bh: *u8 = sys_mmap(CA_OUTCAP)
804 let bhn: i64 = ca_blocks_html(store, sn, bh, CA_OUTCAP - 1)
805 var rstore: *u8 = store
806 if bhn >= 0 {
807 let s2b: *u8 = sys_mmap(CA_REQCAP)
808 sn = cst_set(store, sn, "blocks_html" as *u8, bh, bhn, s2b, CA_REQCAP - 1)
809 rstore = s2b
810 }
811 return crd_render(tpl, tn, rstore, sn, out, CA_OUTCAP - 1)
812}
813
814// store one form field (sanitizing _html keys); returns new store length (ping-pongs s1->s2)
815func ca_save_field(body: *u8, bn: i64, key: *u8, ishtml: i64, s_in: *u8, n_in: i64, s_out: *u8) -> i64 {
816 let raw: *u8 = sys_mmap(CA_MAGIC_131072)
817 let vl: *i64 = sys_mmap(16) as *i64
818 if nx_http_form_get_field(body, bn, key, ca_len(key), raw, CA_MAGIC_131071, vl) != 0 {
819 // field absent -> copy store through unchanged
820 var i: i64 = 0
821 while i < n_in { s_out[i] = s_in[i]; i = i + 1 }
822 return n_in
823 }
824 if ishtml == 1 {
825 let clean: *u8 = sys_mmap(CA_MAGIC_131072)
826 let cl: i64 = hs_sanitize(raw, vl[0], clean, CA_MAGIC_131071)
827 return cst_set(s_in, n_in, key, clean, cl, s_out, CA_REQCAP - 1)
828 }
829 return cst_set(s_in, n_in, key, raw, vl[0], s_out, CA_REQCAP - 1)
830}
831
832func main(argc: i64, argv: *i64) -> i64 {
833 if argc < 4 {
834 sys_write(1, "usage: nx_cms_admin <port> <sitedir> <request-budget>\n" as *u8, 55)
835 sys_exit(2)
836 return 2
837 }
838 let port: i64 = ca_atoi(argv[1] as *u8)
839 let sitedir: *u8 = argv[2] as *u8
840 let budget: i64 = ca_atoi(argv[3] as *u8)
841 // C8: argv[4]="tls" -> session cookie gains Secure (set when deployed behind the TLS front)
842 var securecookie: i64 = 0
843 if argc > 4 {
844 let m4: *u8 = argv[4] as *u8
845 if m4[0] == (116 as u8) { if m4[1] == (108 as u8) { if m4[2] == (115 as u8) { if m4[3] == (0 as u8) { securecookie = 1 } } } }
846 }
847
848 // listen on 127.0.0.1:port (loopback ONLY v1 -- TLS termination is the sites-daemon rung)
849 let addr: *u8 = sys_mmap(16)
850 addr[0] = 2 as u8; addr[1] = 0 as u8
851 addr[2] = ((port >> 8) & 0xff) as u8
852 addr[3] = (port & 0xff) as u8
853 addr[4] = 127 as u8; addr[5] = 0 as u8; addr[6] = 0 as u8; addr[7] = 1 as u8
854 var zi: i64 = 8
855 while zi < 16 { addr[zi] = 0 as u8; zi = zi + 1 }
856 let lfd: i64 = sys_socket(2, 1, 0) // AF_INET, SOCK_STREAM
857 if lfd < 0 { sys_write(1, "SOCKET-FAIL\n" as *u8, 12); sys_exit(1); return 1 }
858 let optval: *u8 = sys_mmap(4)
859 optval[0] = 1 as u8; optval[1] = 0 as u8; optval[2] = 0 as u8; optval[3] = 0 as u8
860 sys_setsockopt(lfd, 1, 2, optval, 4) // SOL_SOCKET, SO_REUSEADDR
861 if sys_bind(lfd, addr, 16) < 0 { sys_write(1, "BIND-FAIL\n" as *u8, 10); sys_exit(1); return 1 }
862 if sys_listen(lfd, 16) < 0 { sys_write(1, "LISTEN-FAIL\n" as *u8, 12); sys_exit(1); return 1 }
863 sys_write(1, "CMS-ADMIN-UP\n" as *u8, 13)
864
865 // fail-fast startup validation (rule 20): content + template + a credential must load.
866 // C10: admin_pw.ar2 (argon2id, self-describing params) is checked FIRST and always wins;
867 // admin_pw.sha256 is the legacy migration fallback ONLY -- a stale legacy file alongside a
868 // .ar2 can never downgrade the verify path.
869 let arpath: *u8 = sys_mmap(512)
870 ca_path(arpath, sitedir, "admin_pw.ar2" as *u8)
871 let arbuf: *u8 = sys_mmap(256)
872 let arn: i64 = cst_read(arpath, arbuf, 200)
873 let arbox: *i64 = sys_mmap(32) as *i64
874 let arsalt: *u8 = sys_mmap(64)
875 let artag: *u8 = sys_mmap(96)
876 var pwmode: i64 = 0
877 if arn > 0 { if cpw_parse(arbuf, arn, arbox, arsalt, artag) == 1 { pwmode = 1 } }
878 var arctx: *NxArgon2idCtx = 0 as *NxArgon2idCtx
879 if pwmode == 1 { arctx = cpw_ctx_new(arbox[0]) } // ONE m_kib-sized arena, reused per login
880 let pwpath: *u8 = sys_mmap(512)
881 ca_path(pwpath, sitedir, "admin_pw.sha256" as *u8)
882 let pwhex: *u8 = sys_mmap(128)
883 var pwn: i64 = 0
884 if pwmode == 0 {
885 pwn = cst_read(pwpath, pwhex, 80)
886 if pwn < 64 { sys_write(1, "NO-PW-HASH\n" as *u8, 11); sys_exit(1); return 1 }
887 sys_write(1, "PW-MODE-LEGACY-SHA256 (migrate: nx_cms_setpw2)\n" as *u8, 47)
888 }
889 if pwmode == 1 { sys_write(1, "PW-MODE-ARGON2ID\n" as *u8, 17) }
890 // #12: optional admin MFA -- base32 TOTP secret in <site>/admin_totp.b32. Absent/empty -> not enrolled
891 // -> password-only (graceful, backward-compatible). Present -> a valid 6-digit code is required at login.
892 let totppath: *u8 = sys_mmap(512)
893 ca_path(totppath, sitedir, "admin_totp.b32" as *u8)
894 let totpsec: *u8 = sys_mmap(64)
895 var totpseclen: i64 = cst_read(totppath, totpsec, 63)
896 if totpseclen < 0 { totpseclen = 0 }
897 var totp_trim: i64 = 0
898 while totp_trim == 0 {
899 if totpseclen <= 0 { totp_trim = 1 } else {
900 let tc: u8 = totpsec[totpseclen - 1]
901 if tc == (10 as u8) { totpseclen = totpseclen - 1 } else {
902 if tc == (13 as u8) { totpseclen = totpseclen - 1 } else {
903 if tc == (32 as u8) { totpseclen = totpseclen - 1 } else { totp_trim = 1 } } }
904 }
905 }
906 if mfa_enrolled(totpsec, totpseclen) == 1 { sys_write(1, "MFA-ENROLLED (admin_totp.b32, TOTP-SHA1)\n" as *u8, 41) }
907 if ca_render_publish(sitedir) != 1 { sys_write(1, "SEED-RENDER-FAIL\n" as *u8, 17); sys_exit(1); return 1 }
908
909 let sess: *u8 = sys_mmap(64) // sess[0]=0 -> none
910 let csrf: *u8 = sys_mmap(64)
911 sess[0] = 0 as u8
912 var failed_logins: i64 = 0
913 var pubstate: i64 = 1 // PUBLISHED (seed render above)
914 // CLIENT PORTAL (role 2): separate session + credential. Enabled iff <site>/client_pw.sha256 exists
915 // (sha256 hex of the client password). The lawyer/staff role stays the existing /admin login.
916 let csess: *u8 = sys_mmap(64)
917 csess[0] = 0 as u8
918 var client_failed: i64 = 0
919 let cpwpath: *u8 = sys_mmap(512)
920 ca_path(cpwpath, sitedir, "client_pw.sha256" as *u8)
921 let cpwhex: *u8 = sys_mmap(128)
922 var has_client: i64 = 0
923 let cpwn: i64 = cst_read(cpwpath, cpwhex, 80)
924 if cpwn >= 64 { has_client = 1; sys_write(1, "CLIENT-PORTAL-ENABLED\n" as *u8, 22) }
925
926 let cpath: *u8 = sys_mmap(512)
927 ca_path(cpath, sitedir, "content.txt" as *u8)
928 let ppath: *u8 = sys_mmap(512)
929 ca_path(ppath, sitedir, "page.html" as *u8)
930 // C9: draft lifecycle (team-authored _pe_draft guard). A draft pending on disk survives restarts.
931 let dpath: *u8 = sys_mmap(512)
932 ca_path(dpath, sitedir, "draft.txt" as *u8)
933 let dlast: *u8 = sys_mmap(512)
934 ca_path(dlast, sitedir, "draft.last" as *u8)
935 // W5: first-party analytics store (additive per-path counters; survives restart)
936 let apath: *u8 = sys_mmap(512)
937 ca_path(apath, sitedir, "analytics.txt" as *u8)
938 var draftstate: i64 = 0 // 0=CLEAN 1=DRAFTED (the _pe_draft states)
939 let dchk: i64 = sys_openat_rd(dpath)
940 if dchk >= 0 { draftstate = 1; sys_close(dchk) }
941 // C11: uploads dir + size cap from <site>/upload.conf ("max_bytes=N"; bootstrap default 200000,
942 // bounded by the request cap -- rule 17 config chain, rule 11 no magic numbers in routes)
943 let updir: *u8 = sys_mmap(512)
944 ca_path(updir, sitedir, "uploads" as *u8)
945 ca_mkdir(updir, 0x1ed)
946 var maxup: i64 = CA_MAGIC_200000
947 let ucpath: *u8 = sys_mmap(512)
948 ca_path(ucpath, sitedir, "upload.conf" as *u8)
949 let ucbuf: *u8 = sys_mmap(256)
950 let ucn: i64 = cst_read(ucpath, ucbuf, 200)
951 if ucn > 0 { maxup = ca_conf_int(ucbuf, ucn, "max_bytes=" as *u8, 10, maxup) }
952 if maxup > CA_REQCAP - CA_MAGIC_4096 { maxup = CA_REQCAP - CA_MAGIC_4096 }
953
954 // W3: forms config (rule 11/17) + per-IP rate table (in-memory; the accept loop is sequential)
955 let spath: *u8 = sys_mmap(512)
956 ca_path(spath, sitedir, "submissions.txt" as *u8)
957 var form_max: i64 = 5 // max submissions per IP per window
958 var form_window: i64 = CA_MAGIC_3600 // seconds
959 let fcpath: *u8 = sys_mmap(512)
960 ca_path(fcpath, sitedir, "form.conf" as *u8)
961 let fcbuf: *u8 = sys_mmap(256)
962 let fcn: i64 = cst_read(fcpath, fcbuf, 200)
963 if fcn > 0 {
964 form_max = ca_conf_int(fcbuf, fcn, "max_per_window=" as *u8, 15, form_max)
965 form_window = ca_conf_int(fcbuf, fcn, "window_sec=" as *u8, 11, form_window)
966 }
967 let rl_ip: *i64 = sys_mmap(8 * 64) as *i64 // peer IPv4 as int
968 let rl_cnt: *i64 = sys_mmap(8 * 64) as *i64
969 let rl_ws: *i64 = sys_mmap(8 * 64) as *i64
970 let rl_nbox: *i64 = sys_mmap(16) as *i64
971 rl_nbox[0] = 0
972 let sock_addr: *u8 = sys_mmap(64)
973 let sock_len: *i64 = sys_mmap(16) as *i64
974
975 var served: i64 = 0
976 while served < budget {
977 sock_len[0] = 16
978 let cfd: i64 = sys_accept_with_addr(lfd, sock_addr, sock_len)
979 var peer_ip: i64 = 0
980 peer_ip = ((sock_addr[4] as i64) << 24) + ((sock_addr[5] as i64) << 16) + ((sock_addr[6] as i64) << 8) + (sock_addr[7] as i64)
981 if cfd >= 0 {
982 sys_set_socket_timeout(cfd, 5)
983 let req: *u8 = sys_mmap(CA_REQCAP)
984 let hend_o: *i64 = sys_mmap(16) as *i64
985 let rn: i64 = ca_read_req(cfd, req, CA_REQCAP - 1, hend_o)
986 let hend: i64 = hend_o[0]
987 let out: *u8 = sys_mmap(CA_OUTCAP)
988 var done: i64 = 0
989 if rn <= 0 { done = 1 }
990 if hend < 0 { done = 1 }
991
992 // ---- GET / : the published page (W5: count the view first-party) ----
993 if done == 0 { if ca_index(req, rn, "GET / HTTP" as *u8, 10) == 0 {
994 let page: *u8 = sys_mmap(CA_OUTCAP)
995 let pn: i64 = cst_read(ppath, page, CA_OUTCAP - 1)
996 if pn >= 0 { ca_analytics_hit(apath, "/" as *u8); ca_resp(cfd, "200 OK" as *u8, "" as *u8, page, pn) }
997 if pn < 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "not published\n" as *u8, 14) }
998 done = 1
999 } }
1000
1001 // ---- WEBDEV STUDIO: MOVED out of this daemon. The capability-adaptive studio for nishifamily.com/webdev
1002 // now lives in its OWN daemon (nx_sitegen_studio_daemon) BEHIND THE CANONICAL Modern Auth
1003 // (OPAQUE-3DH + NO-COOKIE X-Nishi-Session via nx_site_auth) -- the same login every family site uses.
1004 // nx_cms_admin is the andelinwest CMS (its pre-charter cookie auth is separate migration debt). The
1005 // earlier cookie-based /webdev routes here were a mismatch and have been removed. ----
1006
1007 // ---- W3: POST /form/<name> : public form submission (honeypot + per-IP rate) ----
1008 if done == 0 { if ca_index(req, rn, "POST /form/" as *u8, 11) == 0 {
1009 let body: *u8 = (req + hend + 4) as *u8
1010 let bn: i64 = rn - hend - 4
1011 // form name from the path (after "/form/", up to space)
1012 let fname: *u8 = sys_mmap(128)
1013 var fnl: i64 = 0
1014 var fp: i64 = 11
1015 var goingf: i64 = 1
1016 while goingf == 1 {
1017 goingf = 0
1018 if fp < rn { if fnl < 100 {
1019 let cf: i64 = req[fp] as i64
1020 if cf != 32 { if cf != 13 { if cf != 10 { if cf != 63 {
1021 var okc2: i64 = 0
1022 if cf >= 97 { if cf <= 122 { okc2 = 1 } }
1023 if cf >= 65 { if cf <= 90 { okc2 = 1 } }
1024 if cf >= 48 { if cf <= 57 { okc2 = 1 } }
1025 if cf == 95 { okc2 = 1 }
1026 if cf == 45 { okc2 = 1 }
1027 if okc2 == 1 { fname[fnl] = cf as u8; fnl = fnl + 1 }
1028 fp = fp + 1; goingf = 1
1029 } } } }
1030 } }
1031 }
1032 // honeypot: a non-empty hp_url field = a bot. Accept-looking (200) but DISCARD.
1033 let hp: *u8 = sys_mmap(256)
1034 let hpl: *i64 = sys_mmap(16) as *i64
1035 var spam: i64 = 0
1036 if nx_http_form_get_field(body, bn, "hp_url" as *u8, 6, hp, 255, hpl) == 0 { if hpl[0] > 0 { spam = 1 } }
1037 if fnl == 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "no such form\n" as *u8, 13) }
1038 if fnl > 0 {
1039 if spam == 1 { ca_resp(cfd, "200 OK" as *u8, "" as *u8, "thanks\n" as *u8, 7) }
1040 if spam == 0 {
1041 if ca_rate_ok(rl_ip, rl_cnt, rl_ws, rl_nbox, peer_ip, sys_now_realtime_sec(), form_window, form_max) == 0 {
1042 ca_resp(cfd, "429 Too Many Requests" as *u8, "" as *u8, "rate limited\n" as *u8, 13)
1043 } else {
1044 ca_submit_append(spath, sys_now_realtime_sec(), fname, fnl, body, bn)
1045 ca_resp(cfd, "303 See Other" as *u8, "Location: /\r\n" as *u8, "thanks\n" as *u8, 7)
1046 }
1047 }
1048 }
1049 done = 1
1050 } }
1051
1052 // ---- W4: POST /admin/snapshot : pack the site into snapshot.nxsnap (session + CSRF) ----
1053 if done == 0 { if ca_index(req, rn, "POST /admin/snapshot" as *u8, 20) == 0 {
1054 let body: *u8 = (req + hend + 4) as *u8
1055 let bn: i64 = rn - hend - 4
1056 var authed: i64 = ca_session_ok(req, hend, sess)
1057 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1058 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1059 if authed == 1 {
1060 let names: *i64 = sys_mmap(8 * 8) as *i64
1061 names[0] = "content.txt" as *u8 as i64
1062 names[1] = "template.html" as *u8 as i64
1063 names[2] = "analytics.txt" as *u8 as i64
1064 names[3] = "submissions.txt" as *u8 as i64
1065 let arc: *u8 = sys_mmap(CA_OUTCAP)
1066 let al: i64 = snap_pack(sitedir, names, 4, arc, CA_OUTCAP - 1)
1067 var sok: i64 = 0
1068 if al > 0 {
1069 let spp: *u8 = sys_mmap(512)
1070 ca_path(spp, sitedir, "snapshot.nxsnap" as *u8)
1071 if cst_write_atomic(spp, arc, al) == 1 { sok = 1 }
1072 }
1073 if sok == 1 { ca_resp(cfd, "303 See Other" as *u8, "Location: /admin\r\n" as *u8, "snapshot saved\n" as *u8, 15) }
1074 if sok == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "snapshot failed\n" as *u8, 16) }
1075 }
1076 done = 1
1077 } }
1078
1079 // ---- W4: GET /admin/snapshot.nxsnap : download the snapshot (session) ----
1080 if done == 0 { if ca_index(req, rn, "GET /admin/snapshot.nxsnap" as *u8, 26) == 0 {
1081 if ca_session_ok(req, hend, sess) == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1082 if ca_session_ok(req, hend, sess) == 1 {
1083 let spp2: *u8 = sys_mmap(512)
1084 ca_path(spp2, sitedir, "snapshot.nxsnap" as *u8)
1085 let ab: *u8 = sys_mmap(CA_OUTCAP)
1086 let an2: i64 = cst_read(spp2, ab, CA_OUTCAP - 1)
1087 if an2 < 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "no snapshot\n" as *u8, 12) }
1088 if an2 >= 0 { ca_resp_ct(cfd, "200 OK" as *u8, "application/octet-stream" as *u8, ab, an2) }
1089 }
1090 done = 1
1091 } }
1092
1093 // ---- W4: POST /admin/restore : restore from snapshot.nxsnap byte-exact (session + CSRF) ----
1094 if done == 0 { if ca_index(req, rn, "POST /admin/restore" as *u8, 19) == 0 {
1095 let body: *u8 = (req + hend + 4) as *u8
1096 let bn: i64 = rn - hend - 4
1097 var authed: i64 = ca_session_ok(req, hend, sess)
1098 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1099 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1100 if authed == 1 {
1101 let spp3: *u8 = sys_mmap(512)
1102 ca_path(spp3, sitedir, "snapshot.nxsnap" as *u8)
1103 let ab2: *u8 = sys_mmap(CA_OUTCAP)
1104 let an3: i64 = cst_read(spp3, ab2, CA_OUTCAP - 1)
1105 var rok: i64 = 0
1106 if an3 > 0 {
1107 if snap_unpack(ab2, an3, sitedir) >= 0 {
1108 if ca_render_publish(sitedir) == 1 { rok = 1 }
1109 }
1110 }
1111 if rok == 1 { ca_resp(cfd, "303 See Other" as *u8, "Location: /\r\n" as *u8, "restored\n" as *u8, 9) }
1112 if rok == 0 { ca_resp(cfd, "409 Conflict" as *u8, "" as *u8, "no snapshot to restore\n" as *u8, 23) }
1113 }
1114 done = 1
1115 } }
1116
1117 // ---- W3: GET /admin/submissions : session-only submissions list ----
1118 if done == 0 { if ca_index(req, rn, "GET /admin/submissions" as *u8, 22) == 0 {
1119 if ca_session_ok(req, hend, sess) == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1120 if ca_session_ok(req, hend, sess) == 1 {
1121 let sl: i64 = ca_subs_page(out, spath)
1122 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, sl)
1123 }
1124 done = 1
1125 } }
1126
1127 // ---- W5: GET /admin/stats : session-only first-party analytics ----
1128 if done == 0 { if ca_index(req, rn, "GET /admin/stats" as *u8, 16) == 0 {
1129 if ca_session_ok(req, hend, sess) == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1130 if ca_session_ok(req, hend, sess) == 1 {
1131 let sl: i64 = ca_stats_page(out, apath)
1132 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, sl)
1133 }
1134 done = 1
1135 } }
1136
1137 // ---- W2: GET /robots.txt (public; references the sitemap when site_url is set) ----
1138 if done == 0 { if ca_index(req, rn, "GET /robots.txt" as *u8, 15) == 0 {
1139 let st12: *u8 = sys_mmap(CA_REQCAP)
1140 let sn12: i64 = cst_read(cpath, st12, CA_REQCAP - 1)
1141 let url12: *u8 = sys_mmap(CA_MAGIC_2048)
1142 var ul12: i64 = 0
1143 if sn12 >= 0 { ul12 = cst_get(st12, sn12, "site_url" as *u8, url12, CA_MAGIC_2047) }
1144 let rb12: *u8 = sys_mmap(CA_MAGIC_4096)
1145 var ro12: i64 = ca_cat(rb12, 0, "User-agent: *\nAllow: /\n" as *u8)
1146 if ul12 > 0 {
1147 ro12 = ca_cat(rb12, ro12, "Sitemap: " as *u8)
1148 ro12 = ca_catn(rb12, ro12, url12, ul12)
1149 if (url12[ul12-1] as i64) != 47 { rb12[ro12] = 47 as u8; ro12 = ro12 + 1 }
1150 ro12 = ca_cat(rb12, ro12, "sitemap.xml\n" as *u8)
1151 }
1152 ca_resp_ct(cfd, "200 OK" as *u8, "text/plain; charset=utf-8" as *u8, rb12, ro12)
1153 done = 1
1154 } }
1155
1156 // ---- W2: GET /sitemap.xml (public; lists the published home page) ----
1157 if done == 0 { if ca_index(req, rn, "GET /sitemap.xml" as *u8, 16) == 0 {
1158 let st13: *u8 = sys_mmap(CA_REQCAP)
1159 let sn13: i64 = cst_read(cpath, st13, CA_REQCAP - 1)
1160 let url13: *u8 = sys_mmap(CA_MAGIC_2048)
1161 var ul13: i64 = 0
1162 if sn13 >= 0 { ul13 = cst_get(st13, sn13, "site_url" as *u8, url13, CA_MAGIC_2047) }
1163 let sm13: *u8 = sys_mmap(CA_MAGIC_4096)
1164 var so13: i64 = ca_cat(sm13, 0, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n<url><loc>" as *u8)
1165 if ul13 > 0 {
1166 so13 = so13 + hs_escape(url13, ul13, ((sm13 as i64) + so13) as *u8, CA_MAGIC_4095 - so13)
1167 if (url13[ul13-1] as i64) != 47 { sm13[so13] = 47 as u8; so13 = so13 + 1 }
1168 }
1169 so13 = ca_cat(sm13, so13, "</loc></url>\n</urlset>\n" as *u8)
1170 ca_resp_ct(cfd, "200 OK" as *u8, "application/xml; charset=utf-8" as *u8, sm13, so13)
1171 done = 1
1172 } }
1173
1174 // ---- GET /admin : login or editor ----
1175 if done == 0 { if ca_index(req, rn, "GET /admin HTTP" as *u8, 15) == 0 {
1176 if ca_session_ok(req, hend, sess) == 1 {
1177 let store: *u8 = sys_mmap(CA_REQCAP)
1178 let sn: i64 = cst_read(cpath, store, CA_REQCAP - 1)
1179 let el: i64 = ca_editor_page(out, store, sn, csrf)
1180 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, el)
1181 }
1182 if ca_session_ok(req, hend, sess) == 0 {
1183 var lk: i64 = 0
1184 if failed_logins >= CA_LOCKOUT { lk = 1 }
1185 let ll: i64 = ca_login_page(out, lk)
1186 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, ll)
1187 }
1188 done = 1
1189 } }
1190
1191 // ---- POST /admin/login ----
1192 if done == 0 { if ca_index(req, rn, "POST /admin/login" as *u8, 17) == 0 {
1193 var ok: i64 = 0
1194 if failed_logins < CA_LOCKOUT {
1195 let body: *u8 = (req + hend + 4) as *u8
1196 let bn: i64 = rn - hend - 4
1197 let pw: *u8 = sys_mmap(512)
1198 let pl: *i64 = sys_mmap(16) as *i64
1199 if nx_http_form_get_field(body, bn, "pw" as *u8, 2, pw, 511, pl) == 0 {
1200 if pwmode == 1 {
1201 // C10: memory-hard verify; params came from the credential file
1202 let thx: *u8 = sys_mmap(96)
1203 if cpw_derive(arctx, pw, pl[0], arsalt, arbox[0], arbox[1], thx) == 1 {
1204 if st_eq_ct(thx, artag, 64) == 1 { ok = 1 }
1205 }
1206 }
1207 if pwmode == 0 {
1208 let dig: *u8 = sys_mmap(32)
1209 sha256_digest(pw, pl[0], dig)
1210 let hex: *u8 = sys_mmap(80)
1211 let hxc: *u8 = "0123456789abcdef" as *u8
1212 var i: i64 = 0
1213 while i < 32 {
1214 hex[i*2] = hxc[((dig[i] as i64) >> 4) & 15]
1215 hex[i*2+1] = hxc[(dig[i] as i64) & 15]
1216 i = i + 1
1217 }
1218 if st_eq_ct(hex, pwhex, 64) == 1 { ok = 1 }
1219 }
1220 }
1221 }
1222 // #12: second factor -- when MFA is enrolled, the password alone is NOT enough; a valid TOTP
1223 // code is also required. A missing/wrong code flips ok->0 (so it also counts toward lockout).
1224 if ok == 1 { if ca_mfa_ok((req + hend + 4) as *u8, rn - hend - 4, totpsec, totpseclen, sys_now_realtime_sec()) == 0 { ok = 0 } }
1225 if ok == 1 {
1226 failed_logins = 0
1227 st_hex128(sess)
1228 st_hex128(csrf)
1229 sess_issue(sess, sys_now_realtime_ms()) // #8: stamp issued_at + last_seen for this fresh (rotated) token
1230 var o: i64 = ca_cat(out, 0, "Set-Cookie: nsess=" as *u8)
1231 o = ca_catn(out, o, sess, 32)
1232 o = ca_cat(out, o, "; HttpOnly; Path=/; SameSite=Strict" as *u8)
1233 if securecookie == 1 { o = ca_cat(out, o, "; Secure" as *u8) }
1234 o = ca_cat(out, o, "\r\nLocation: /admin\r\n" as *u8)
1235 out[o] = 0 as u8
1236 ca_resp(cfd, "303 See Other" as *u8, out, "ok\n" as *u8, 3)
1237 }
1238 if ok == 0 {
1239 failed_logins = failed_logins + 1
1240 ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "bad credentials\n" as *u8, 16)
1241 }
1242 done = 1
1243 } }
1244
1245 // ---- POST /admin/save ----
1246 if done == 0 { if ca_index(req, rn, "POST /admin/save" as *u8, 16) == 0 {
1247 let body: *u8 = (req + hend + 4) as *u8
1248 let bn: i64 = rn - hend - 4
1249 var authed: i64 = ca_session_ok(req, hend, sess)
1250 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1251 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1252 if authed == 1 {
1253 // C9: mode=draft routes this save to the pending working copy (public untouched)
1254 let mv: *u8 = sys_mmap(32)
1255 let ml: *i64 = sys_mmap(16) as *i64
1256 var isdraft: i64 = 0
1257 if nx_http_form_get_field(body, bn, "mode" as *u8, 4, mv, 31, ml) == 0 {
1258 if ml[0] == 5 { if ca_index(mv, 5, "draft" as *u8, 5) == 0 { isdraft = 1 } }
1259 }
1260 let s1: *u8 = sys_mmap(CA_REQCAP)
1261 let s2: *u8 = sys_mmap(CA_REQCAP)
1262 var n: i64 = 0 - 1
1263 if isdraft == 1 { n = cst_read(dpath, s1, CA_REQCAP - 1) } // keep drafting on the draft
1264 if n < 0 { n = cst_read(cpath, s1, CA_REQCAP - 1) }
1265 if n < 0 { n = 0 }
1266 n = ca_save_field(body, bn, "hero_title" as *u8, 0, s1, n, s2)
1267 n = ca_save_field(body, bn, "hero_tag" as *u8, 0, s2, n, s1)
1268 n = ca_save_field(body, bn, "note_html" as *u8, 1, s1, n, s2)
1269 n = ca_save_field(body, bn, "phone" as *u8, 0, s2, n, s1)
1270 n = ca_save_field(body, bn, "email" as *u8, 0, s1, n, s2)
1271 n = ca_save_field(body, bn, "address" as *u8, 0, s2, n, s1)
1272 n = ca_save_field(body, bn, "hours" as *u8, 0, s1, n, s2)
1273 n = ca_save_field(body, bn, "practice_areas" as *u8, 0, s2, n, s1)
1274 n = ca_save_field(body, bn, "foot" as *u8, 0, s1, n, s2)
1275 // SEO fields (plain text, escaped at render)
1276 n = ca_save_field(body, bn, "seo_title" as *u8, 0, s2, n, s1)
1277 n = ca_save_field(body, bn, "seo_desc" as *u8, 0, s1, n, s2)
1278 // ★og_image WAS MISSING FROM THE SAVE PATH (found 2026-08-01 by nx_cms_seo_gate row 4,
1279 // once the gate was repaired enough to actually reach its subject). The renderer
1280 // ca_seo_head READS og_image, the editor form POSTS og_image, and the capability
1281 // register ADVERTISES og:image as part of the SEO head vocabulary -- but this save
1282 // handler never persisted it, so the tag could never appear on a rendered page and
1283 // nothing anywhere said why. ★A FIELD THAT IS ACCEPTED BY THE FORM AND READ BY THE
1284 // RENDERER BUT DROPPED IN BETWEEN FAILS SILENTLY AND LOOKS LIKE A RENDERER BUG.
1285 // ⚠⚠AND THE INSERTION ITSELF TAUGHT THE SECOND HALF OF THE LESSON. These calls
1286 // PING-PONG between two scratch buffers -- each must READ the buffer the previous
1287 // one WROTE. My first attempt read s1 (the pre-seo_desc state) and so silently
1288 // DISCARDED seo_desc: og:image appeared and the XSS-escaping row went red instead.
1289 // ★IN A PING-PONG BUFFER CHAIN, INSERTING ONE STEP FLIPS THE PARITY OF EVERY STEP
1290 // AFTER IT -- and a wrong link does not crash, it silently reverts the field before
1291 // it. Correct chain: seo_title s2->s1, seo_desc s1->s2, og_image s2->s1,
1292 // site_url s1->s2, so the RESULT NOW LIVES IN s2 and both writers below say s2.
1293 // ✅CONTROL RUN COMPLETED 2026-08-01, RESULT RECORDED: with this og_image line REMOVED
1294 // and the parity reverted, nx_cms_gate row 08 (stored-XSS sanitize) STAYED RED --
1295 // so the XSS reds are PRE-EXISTING and this change is exonerated BY MEASUREMENT,
1296 // not by plausibility. Line restored below. ★WHEN YOUR OWN EDIT IS A SUSPECT, THE
1297 // CONTROL IS CHEAPER THAN THE ARGUMENT -- two builds settled what an hour of
1298 // reasoning could not, and would have caught the opposite result just as fast.
1299 n = ca_save_field(body, bn, "og_image" as *u8, 0, s2, n, s1)
1300 n = ca_save_field(body, bn, "site_url" as *u8, 0, s1, n, s2)
1301 if isdraft == 1 {
1302 let dst: i64 = _pe_draft_step(draftstate, 0) // team-authored guard
1303 var dsaved: i64 = 0
1304 if dst >= 0 {
1305 if cst_write_atomic(dpath, s2, n) == 1 { dsaved = 1; draftstate = dst }
1306 }
1307 if dsaved == 1 { ca_resp(cfd, "303 See Other" as *u8, "Location: /admin/preview\r\n" as *u8, "draft saved\n" as *u8, 12) }
1308 if dsaved == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "draft save failed\n" as *u8, 18) }
1309 }
1310 if isdraft == 0 {
1311 // team-authored lifecycle: EDIT then PUBLISH (both must be legal)
1312 var st: i64 = _pe_pub_step(pubstate, 0)
1313 if st >= 0 { st = _pe_pub_step(st, 1) }
1314 var saved: i64 = 0
1315 if st >= 0 {
1316 if cst_write_atomic(cpath, s2, n) == 1 {
1317 if ca_render_publish(sitedir) == 1 { saved = 1; pubstate = st }
1318 }
1319 }
1320 if saved == 1 { ca_resp(cfd, "303 See Other" as *u8, "Location: /\r\n" as *u8, "published\n" as *u8, 10) }
1321 if saved == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "publish failed\n" as *u8, 15) }
1322 }
1323 }
1324 done = 1
1325 } }
1326
1327 // ---- GET /uploads/<name> : public image serve (sanitized names only, C11) ----
1328 if done == 0 { if ca_index(req, rn, "GET /uploads/" as *u8, 13) == 0 {
1329 let nm: *u8 = sys_mmap(256)
1330 var nl2: i64 = 0
1331 var p7: i64 = 13
1332 var going7: i64 = 1
1333 while going7 == 1 {
1334 going7 = 0
1335 if p7 < rn { if nl2 < 120 {
1336 let c7: i64 = req[p7] as i64
1337 if c7 != 32 { if c7 != 13 { if c7 != 10 { nm[nl2] = c7 as u8; nl2 = nl2 + 1; p7 = p7 + 1; going7 = 1 } } }
1338 } }
1339 }
1340 var served7: i64 = 0
1341 if ca_sane_name(nm, nl2) == 1 {
1342 let t7: i64 = ca_img_type(nm, nl2)
1343 if t7 >= 0 {
1344 let up7: *u8 = sys_mmap(512)
1345 var uo7: i64 = ca_cat(up7, 0, updir)
1346 uo7 = ca_cat(up7, uo7, "/" as *u8)
1347 nm[nl2] = 0 as u8
1348 uo7 = ca_cat(up7, uo7, nm)
1349 up7[uo7] = 0 as u8
1350 let fb7: *u8 = sys_mmap(CA_OUTCAP)
1351 let fn7: i64 = cst_read(up7, fb7, CA_OUTCAP - 1)
1352 if fn7 >= 0 { ca_resp_ct(cfd, "200 OK" as *u8, ca_img_ctype(t7), fb7, fn7); served7 = 1 }
1353 }
1354 }
1355 if served7 == 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "no such upload\n" as *u8, 15) }
1356 done = 1
1357 } }
1358
1359 // ---- GET /admin/blocks : the block editor (session-only, C11) ----
1360 if done == 0 { if ca_index(req, rn, "GET /admin/blocks" as *u8, 17) == 0 {
1361 if ca_session_ok(req, hend, sess) == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1362 if ca_session_ok(req, hend, sess) == 1 {
1363 let store8: *u8 = sys_mmap(CA_REQCAP)
1364 let sn8: i64 = cst_read(cpath, store8, CA_REQCAP - 1)
1365 let el8: i64 = ca_blocks_editor(out, store8, sn8, csrf)
1366 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, el8)
1367 }
1368 done = 1
1369 } }
1370
1371 // ---- POST /admin/blocks : block ops add/set/del (session + CSRF, C11) ----
1372 if done == 0 { if ca_index(req, rn, "POST /admin/blocks" as *u8, 18) == 0 {
1373 let body: *u8 = (req + hend + 4) as *u8
1374 let bn: i64 = rn - hend - 4
1375 var authed: i64 = ca_session_ok(req, hend, sess)
1376 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1377 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1378 if authed == 1 {
1379 // fields: op (add|set|del), idx, kind (1..5), payload
1380 let opv: *u8 = sys_mmap(16)
1381 let opl: *i64 = sys_mmap(16) as *i64
1382 var op9: i64 = 0 - 1
1383 if nx_http_form_get_field(body, bn, "op" as *u8, 2, opv, 15, opl) == 0 {
1384 if opl[0] == 3 { if ca_index(opv, 3, "add" as *u8, 3) == 0 { op9 = 0 } }
1385 if opl[0] == 3 { if ca_index(opv, 3, "set" as *u8, 3) == 0 { op9 = 1 } }
1386 if opl[0] == 3 { if ca_index(opv, 3, "del" as *u8, 3) == 0 { op9 = 2 } }
1387 }
1388 let idxv: *u8 = sys_mmap(16)
1389 let idxl: *i64 = sys_mmap(16) as *i64
1390 var idx9: i64 = 0
1391 if nx_http_form_get_field(body, bn, "idx" as *u8, 3, idxv, 15, idxl) == 0 {
1392 var q9: i64 = 0
1393 while q9 < idxl[0] {
1394 let c8: i64 = idxv[q9] as i64
1395 if c8 >= 48 { if c8 <= 57 { idx9 = idx9 * 10 + (c8 - 48) } }
1396 q9 = q9 + 1
1397 }
1398 }
1399 let kv: *u8 = sys_mmap(16)
1400 let kl9: *i64 = sys_mmap(16) as *i64
1401 var kind9: i64 = 0 - 1
1402 if nx_http_form_get_field(body, bn, "kind" as *u8, 4, kv, 15, kl9) == 0 {
1403 if kl9[0] == 1 { kind9 = (kv[0] as i64) - 48 }
1404 }
1405 var pay9: *u8 = sys_mmap(CA_MAGIC_131072)
1406 let pl9: *i64 = sys_mmap(16) as *i64
1407 pl9[0] = 0
1408 nx_http_form_get_field(body, bn, "payload" as *u8, 7, pay9, CA_MAGIC_131071, pl9)
1409 // del needs no kind/payload; add/set validate kind + payload
1410 var valid9: i64 = 0
1411 if op9 == 2 { valid9 = 1 }
1412 if op9 >= 0 { if op9 <= 1 { if kind9 >= 1 { if kind9 <= 5 { valid9 = 1 } } } }
1413 var line9: *u8 = sys_mmap(CA_MAGIC_131080)
1414 var ln9: i64 = 0
1415 if valid9 == 1 { if op9 <= 1 {
1416 // payload: strip CR, LF -> space (a block is ONE line in the store)
1417 var w9: i64 = 0
1418 var r9: i64 = 0
1419 while r9 < pl9[0] {
1420 let c10: i64 = pay9[r9] as i64
1421 if c10 == 13 { r9 = r9 + 1 }
1422 if c10 != 13 {
1423 if c10 == 10 { pay9[w9] = 32 as u8 }
1424 if c10 != 10 { pay9[w9] = c10 as u8 }
1425 w9 = w9 + 1
1426 r9 = r9 + 1
1427 }
1428 }
1429 pl9[0] = w9
1430 // rich kinds sanitized ON WRITE; img payload must be /uploads/<sane>|alt
1431 if kind9 == 1 { let cl9: *u8 = sys_mmap(CA_MAGIC_131072); let cn9: i64 = hs_sanitize(pay9, pl9[0], cl9, CA_MAGIC_131071); pay9 = cl9; pl9[0] = cn9 }
1432 if kind9 == 4 { let cl0: *u8 = sys_mmap(CA_MAGIC_131072); let cn0: i64 = hs_sanitize(pay9, pl9[0], cl0, CA_MAGIC_131071); pay9 = cl0; pl9[0] = cn0 }
1433 if kind9 == 5 {
1434 var sp9: i64 = 0 - 1
1435 var si9: i64 = 0
1436 while si9 < pl9[0] { if (pay9[si9] as i64) == 124 { if sp9 < 0 { sp9 = si9 } } si9 = si9 + 1 }
1437 valid9 = 0
1438 if sp9 > 9 { if ca_index(pay9, 9, "/uploads/" as *u8, 9) == 0 {
1439 if ca_sane_name(((pay9 as i64) + 9) as *u8, sp9 - 9) == 1 { valid9 = 1 }
1440 } }
1441 }
1442 line9[0] = (48 + kind9) as u8
1443 line9[1] = 124 as u8
1444 var q0: i64 = 0
1445 while q0 < pl9[0] { line9[2 + q0] = pay9[q0]; q0 = q0 + 1 }
1446 ln9 = 2 + pl9[0]
1447 } }
1448 if valid9 == 0 { ca_resp(cfd, "400 Bad Request" as *u8, "" as *u8, "bad block\n" as *u8, 10) }
1449 if valid9 == 1 {
1450 let s1b: *u8 = sys_mmap(CA_REQCAP)
1451 var nb9: i64 = cst_read(cpath, s1b, CA_REQCAP - 1)
1452 if nb9 < 0 { nb9 = 0 }
1453 let oldv: *u8 = sys_mmap(CA_MAGIC_131072)
1454 var ovn: i64 = cst_get(s1b, nb9, "blocks" as *u8, oldv, CA_MAGIC_131071)
1455 if ovn < 0 { ovn = 0 }
1456 let newv: *u8 = sys_mmap(CA_MAGIC_131072)
1457 let nvn: i64 = ca_blocks_mut(oldv, ovn, op9, idx9, line9, ln9, newv, CA_MAGIC_131071)
1458 let s2c: *u8 = sys_mmap(CA_REQCAP)
1459 let n2c: i64 = cst_set(s1b, nb9, "blocks" as *u8, newv, nvn, s2c, CA_REQCAP - 1)
1460 var st9: i64 = _pe_pub_step(pubstate, 0)
1461 if st9 >= 0 { st9 = _pe_pub_step(st9, 1) }
1462 var ok9: i64 = 0
1463 if st9 >= 0 {
1464 if cst_write_atomic(cpath, s2c, n2c) == 1 {
1465 if ca_render_publish(sitedir) == 1 { ok9 = 1; pubstate = st9 }
1466 }
1467 }
1468 if ok9 == 1 { ca_resp(cfd, "303 See Other" as *u8, "Location: /admin/blocks\r\n" as *u8, "blocks saved\n" as *u8, 13) }
1469 if ok9 == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "blocks save failed\n" as *u8, 19) }
1470 }
1471 }
1472 done = 1
1473 } }
1474
1475 // ---- POST /admin/upload : multipart image upload (session + CSRF part, C11) ----
1476 if done == 0 { if ca_index(req, rn, "POST /admin/upload" as *u8, 18) == 0 {
1477 let body: *u8 = (req + hend + 4) as *u8
1478 let bn: i64 = rn - hend - 4
1479 var authed: i64 = ca_session_ok(req, hend, sess)
1480 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1481 if authed == 1 {
1482 // boundary= from the Content-Type header
1483 let bnd: *u8 = sys_mmap(128)
1484 var bl9: i64 = 0 - 1
1485 let bp9: i64 = ca_index(req, hend, "boundary=" as *u8, 9)
1486 if bp9 >= 0 {
1487 bl9 = 0
1488 var p9: i64 = bp9 + 9
1489 var going9: i64 = 1
1490 while going9 == 1 {
1491 going9 = 0
1492 if p9 < hend { if bl9 < 100 {
1493 let c11: i64 = req[p9] as i64
1494 if c11 != 13 { if c11 != 10 { if c11 != 59 { if c11 != 32 { bnd[bl9] = c11 as u8; bl9 = bl9 + 1; p9 = p9 + 1; going9 = 1 } } } }
1495 } }
1496 }
1497 }
1498 var status11: i64 = 400 // default refusal
1499 var fname: *u8 = sys_mmap(256)
1500 var fnl: i64 = 0 - 1
1501 var payoff: i64 = 0 - 1
1502 var paylen: i64 = 0
1503 var csrfok11: i64 = 0
1504 if bl9 > 0 {
1505 let parts: *i64 = sys_mmap(8 * 4 * 8) as *i64
1506 let np: i64 = cms_mp_parse(body, bn, bnd, bl9, parts, 8)
1507 var pi: i64 = 0
1508 while pi < np {
1509 let hoff: i64 = parts[pi*4]
1510 let hlen: i64 = parts[pi*4+1]
1511 let boff: i64 = parts[pi*4+2]
1512 let bln: i64 = parts[pi*4+3]
1513 let nv11: *u8 = sys_mmap(256)
1514 let nl11: i64 = cms_mp_field(body, hoff, hoff + hlen, " name=\"" as *u8, nv11, 200)
1515 if nl11 == 4 { if ca_index(nv11, 4, "csrf" as *u8, 4) == 0 {
1516 if bln == 32 { if st_eq_ct(((body as i64) + boff) as *u8, csrf, 32) == 1 { csrfok11 = 1 } }
1517 } }
1518 if nl11 == 4 { if ca_index(nv11, 4, "file" as *u8, 4) == 0 {
1519 fnl = cms_mp_field(body, hoff, hoff + hlen, "filename=\"" as *u8, fname, 200)
1520 payoff = boff
1521 paylen = bln
1522 } }
1523 pi = pi + 1
1524 }
1525 }
1526 if csrfok11 == 0 { status11 = 403 }
1527 if csrfok11 == 1 { if fnl > 0 { if payoff >= 0 {
1528 status11 = 400
1529 if paylen > maxup { status11 = 413 }
1530 if paylen <= maxup { if paylen > 0 {
1531 if ca_sane_name(fname, fnl) == 1 {
1532 let t11: i64 = ca_img_type(fname, fnl)
1533 if t11 >= 0 {
1534 if ca_magic_ok(t11, ((body as i64) + payoff) as *u8, paylen) == 1 {
1535 let dst11: *u8 = sys_mmap(512)
1536 var do11: i64 = ca_cat(dst11, 0, updir)
1537 do11 = ca_cat(dst11, do11, "/" as *u8)
1538 fname[fnl] = 0 as u8
1539 do11 = ca_cat(dst11, do11, fname)
1540 dst11[do11] = 0 as u8
1541 let uf: i64 = sys_openat_wr(dst11, 0x1a4)
1542 if uf >= 0 {
1543 sys_write(uf, ((body as i64) + payoff) as *u8, paylen)
1544 sys_close(uf)
1545 status11 = 303
1546 }
1547 }
1548 }
1549 }
1550 } }
1551 } } }
1552 if status11 == 303 { ca_resp(cfd, "303 See Other" as *u8, "Location: /admin/blocks\r\n" as *u8, "uploaded\n" as *u8, 9) }
1553 if status11 == 403 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1554 if status11 == 413 { ca_resp(cfd, "413 Payload Too Large" as *u8, "" as *u8, "too large\n" as *u8, 10) }
1555 if status11 == 400 { ca_resp(cfd, "400 Bad Request" as *u8, "" as *u8, "bad upload\n" as *u8, 11) }
1556 }
1557 done = 1
1558 } }
1559
1560 // ---- GET /admin/preview : authed render of the PENDING DRAFT (never public) ----
1561 if done == 0 { if ca_index(req, rn, "GET /admin/preview" as *u8, 18) == 0 {
1562 var pauth: i64 = ca_session_ok(req, hend, sess)
1563 if pauth == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1564 if pauth == 1 {
1565 var pn2: i64 = 0 - 1
1566 if draftstate == 1 { pn2 = ca_render_draft(sitedir, out) }
1567 if pn2 < 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "no pending draft\n" as *u8, 17) }
1568 if pn2 >= 0 { ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\nX-Robots-Tag: noindex\r\n" as *u8, out, pn2) }
1569 }
1570 done = 1
1571 } }
1572
1573 // ---- POST /admin/publish : promote the pending draft (C9, _pe_draft guard) ----
1574 if done == 0 { if ca_index(req, rn, "POST /admin/publish" as *u8, 19) == 0 {
1575 let body: *u8 = (req + hend + 4) as *u8
1576 let bn: i64 = rn - hend - 4
1577 var authed: i64 = ca_session_ok(req, hend, sess)
1578 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1579 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1580 if authed == 1 {
1581 let dst2: i64 = _pe_draft_step(draftstate, 1) // refuses from CLEAN -> 409
1582 if dst2 < 0 { ca_resp(cfd, "409 Conflict" as *u8, "" as *u8, "no pending draft\n" as *u8, 17) }
1583 if dst2 >= 0 {
1584 let db: *u8 = sys_mmap(CA_REQCAP)
1585 let dn: i64 = cst_read(dpath, db, CA_REQCAP - 1)
1586 var pub: i64 = 0
1587 if dn >= 0 {
1588 var st3: i64 = _pe_pub_step(pubstate, 0)
1589 if st3 >= 0 { st3 = _pe_pub_step(st3, 1) }
1590 if st3 >= 0 {
1591 if cst_write_atomic(cpath, db, dn) == 1 {
1592 if ca_render_publish(sitedir) == 1 { pub = 1; pubstate = st3 }
1593 }
1594 }
1595 }
1596 if pub == 1 {
1597 sys_renameat(dpath, dlast) // set the consumed draft aside, additive
1598 draftstate = dst2
1599 ca_resp(cfd, "303 See Other" as *u8, "Location: /\r\n" as *u8, "draft published\n" as *u8, 16)
1600 }
1601 if pub == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "draft publish failed\n" as *u8, 21) }
1602 }
1603 }
1604 done = 1
1605 } }
1606
1607 // ---- POST /admin/discard : set the pending draft aside (C9, _pe_draft guard) ----
1608 if done == 0 { if ca_index(req, rn, "POST /admin/discard" as *u8, 19) == 0 {
1609 let body: *u8 = (req + hend + 4) as *u8
1610 let bn: i64 = rn - hend - 4
1611 var authed: i64 = ca_session_ok(req, hend, sess)
1612 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1613 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1614 if authed == 1 {
1615 let dst4: i64 = _pe_draft_step(draftstate, 2) // refuses from CLEAN -> 409
1616 if dst4 < 0 { ca_resp(cfd, "409 Conflict" as *u8, "" as *u8, "no pending draft\n" as *u8, 17) }
1617 if dst4 >= 0 {
1618 var disc: i64 = 0
1619 if sys_renameat(dpath, dlast) == 0 { disc = 1 }
1620 if disc == 1 { draftstate = dst4; ca_resp(cfd, "303 See Other" as *u8, "Location: /admin\r\n" as *u8, "draft discarded\n" as *u8, 16) }
1621 if disc == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "discard failed\n" as *u8, 15) }
1622 }
1623 }
1624 done = 1
1625 } }
1626
1627 // ---- POST /admin/rollback ----
1628 if done == 0 { if ca_index(req, rn, "POST /admin/rollback" as *u8, 20) == 0 {
1629 let body: *u8 = (req + hend + 4) as *u8
1630 let bn: i64 = rn - hend - 4
1631 var authed: i64 = ca_session_ok(req, hend, sess)
1632 if authed == 1 { if ca_csrf_ok(body, bn, csrf) == 0 { authed = 0 } }
1633 if authed == 0 { ca_resp(cfd, "403 Forbidden" as *u8, "" as *u8, "forbidden\n" as *u8, 10) }
1634 if authed == 1 {
1635 let st2: i64 = _pe_pub_step(pubstate, 2) // ROLLBACK guard (team-authored)
1636 if st2 < 0 { ca_resp(cfd, "409 Conflict" as *u8, "" as *u8, "nothing to roll back\n" as *u8, 21) }
1637 if st2 >= 0 {
1638 var rb: i64 = cst_rollback(cpath)
1639 if rb == 1 { rb = ca_render_publish(sitedir) }
1640 if rb == 1 { pubstate = st2; ca_resp(cfd, "303 See Other" as *u8, "Location: /\r\n" as *u8, "rolled back\n" as *u8, 12) }
1641 if rb == 0 { ca_resp(cfd, "500 Internal Server Error" as *u8, "" as *u8, "rollback failed\n" as *u8, 16) }
1642 }
1643 }
1644 done = 1
1645 } }
1646
1647 // ---- GET /portal : client login form (no session) | portal (client session) ----
1648 if done == 0 { if ca_index(req, rn, "GET /portal HTTP" as *u8, 16) == 0 {
1649 if ca_client_ok(req, hend, csess) == 1 {
1650 let pp5: i64 = ca_portal_page(out)
1651 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, pp5)
1652 }
1653 if ca_client_ok(req, hend, csess) == 0 {
1654 let cl5: i64 = ca_client_login_page(out, 0)
1655 ca_resp(cfd, "200 OK" as *u8, "Cache-Control: no-store\r\n" as *u8, out, cl5)
1656 }
1657 done = 1
1658 } }
1659
1660 // ---- POST /portal/login : verify client_pw -> client session cookie (5-strike lockout) ----
1661 if done == 0 { if ca_index(req, rn, "POST /portal/login" as *u8, 18) == 0 {
1662 var cok: i64 = 0
1663 if has_client == 1 { if client_failed < CA_LOCKOUT {
1664 let body: *u8 = (req + hend + 4) as *u8
1665 let bn: i64 = rn - hend - 4
1666 let pw: *u8 = sys_mmap(512)
1667 let pl: *i64 = sys_mmap(16) as *i64
1668 if nx_http_form_get_field(body, bn, "pw" as *u8, 2, pw, 511, pl) == 0 {
1669 let dig: *u8 = sys_mmap(32)
1670 sha256_digest(pw, pl[0], dig)
1671 let hex: *u8 = sys_mmap(80)
1672 let hxc: *u8 = "0123456789abcdef" as *u8
1673 var i: i64 = 0
1674 while i < 32 {
1675 hex[i*2] = hxc[((dig[i] as i64) >> 4) & 15]
1676 hex[i*2+1] = hxc[(dig[i] as i64) & 15]
1677 i = i + 1
1678 }
1679 if st_eq_ct(hex, cpwhex, 64) == 1 { cok = 1 }
1680 }
1681 } }
1682 if cok == 1 {
1683 client_failed = 0
1684 st_hex128(csess)
1685 var o: i64 = ca_cat(out, 0, "Set-Cookie: nclient=" as *u8)
1686 o = ca_catn(out, o, csess, 32)
1687 o = ca_cat(out, o, "; HttpOnly; Path=/; SameSite=Strict" as *u8)
1688 if securecookie == 1 { o = ca_cat(out, o, "; Secure" as *u8) }
1689 o = ca_cat(out, o, "\r\nLocation: /portal\r\n" as *u8)
1690 out[o] = 0 as u8
1691 ca_resp(cfd, "303 See Other" as *u8, out, "ok\n" as *u8, 3)
1692 }
1693 if cok == 0 {
1694 client_failed = client_failed + 1
1695 let cl6: i64 = ca_client_login_page(out, 1)
1696 ca_resp(cfd, "403 Forbidden" as *u8, "Cache-Control: no-store\r\n" as *u8, out, cl6)
1697 }
1698 done = 1
1699 } }
1700
1701 // ---- POST /portal/logout : clear client session ----
1702 if done == 0 { if ca_index(req, rn, "POST /portal/logout" as *u8, 19) == 0 {
1703 csess[0] = 0 as u8
1704 ca_resp(cfd, "303 See Other" as *u8, "Set-Cookie: nclient=x; Max-Age=0; Path=/\r\nLocation: /portal\r\n" as *u8, "bye\n" as *u8, 4)
1705 done = 1
1706 } }
1707
1708 if done == 0 { ca_resp(cfd, "404 Not Found" as *u8, "" as *u8, "no such route\n" as *u8, 14) }
1709 sys_close(cfd)
1710 served = served + 1
1711 }
1712 }
1713 sys_write(1, "CMS-ADMIN-BUDGET-DONE\n" as *u8, 22)
1714 sys_exit(0)
1715 return 0
1716}