code wiki / (root) / nx_4chan.nx

nx_4chan.nx source

↩ module page · 729 lines · 40895 B

1// nx_4chan.nx -- SOVEREIGN 4chan BROWSE / PREVIEW / DOWNLOAD adapter: the first of the media-manager 2// per-source ingesters ported off Python (mm/src/sites/fourchan.py) into the estate. Emits JSON on 3// stdout so the gallery/vault surface consumes it directly; downloads land in a plain directory that 4// `nx_mvault_walk <dir> real 4chan commit` then records into the vault (CID dedup + DSM layout). 5// COMPOSITION, NOT REIMPLEMENTATION: the vault already owns classification/dedup/layout (B1-B10), so 6// this organ deliberately stops at "validated bytes in a directory" and hands off. 7// 8// nx_4chan boards -- board list (+nsfw flag) 9// nx_4chan catalog <board> [max] -- thread previews: subject, comment, counts, THUMB+FULL urls 10// nx_4chan thread <board> <threadno> [max] -- every media item in a thread 11// nx_4chan fetch <board> <threadno> <dir> [max] -- download the thread's media into <dir> 12// nx_4chan selftest -- offline gate: parser teeth incl. neg-controls 13// 14// ★ WHY A BRACE-DEPTH PARSER AND NOT KEY-SCANNING. catalog.json embeds a `last_replies` array whose 15// post objects each carry their OWN "no": field, so scanning for `"no":` reports replies AS THREADS -- 16// and it fails silently, inflating a 15-thread page into 60 "threads". Depth tracking is immune to 17// field order and to nesting. It is also STRING-AWARE: a 4chan comment routinely contains { } and ", 18// and a structural scanner that reads braces inside string data mis-nests the whole document. 19// Conveniently ALL THREE endpoints put their objects of interest at brace-depth 2: 20// catalog.json [ {page {1}, threads:[ {thread {2}, last_replies:[ {reply {3}} ]} ]} ] 21// thread.json { {1} "posts":[ {post {2}} ] } 22// boards.json { {1} "boards":[ {board {2}} ] } 23// so one scanner serves all three and `want_depth` stays a parameter rather than three constants. 24// 25// LEGAL/RATE POSTURE: read-only public JSON API, one polite delay between media fetches. General 26// capability; the operator points it where they like. 27// license_tier: ORIGINAL 28import "nx_syscalls.nx" 29import "nx_x509_trust_store.nx" 30import "nx_trust_store_load_from_certdata.nx" 31import "nx_https_fetch_follow.nx" 32import "nx_json_lib.nx" 33// POLITENESS IS COMPOSED, NOT RE-IMPLEMENTED. This organ hand-rolled fc_sleep_ms(FC_FETCH_DELAY_MS=350) 34// -- three times faster than the ~1 req/s the API asks for, blind to HTTP status so a 429 was 35// indistinguishable from a dropped connection, and PER-PROCESS, so politeness reset to zero on every 36// invocation: running fetch three times tripled the real rate with no memory of it. nx_crawl_pace is the 37// estate's incumbent pacer (per-host interval + Retry-After + exponential backoff, state PERSISTED on 38// disk across processes) and was already wired into nx_bulk_index / nx_url_index / nx_web_crawl_step. 39// Two rulers for one invariant is the duplicate-ruler defect; this deletes ours and adopts theirs. 40import "nx_crawl_pace.nx" 41 42// The two hosts are paced SEPARATELY because they are separately rate-limited: a.4cdn.org serves the 43// JSON API, i.4cdn.org serves media. A single global delay could not express that and had to guess low. 44const FC_API_HOST: *u8 = "a.4cdn.org" 45const FC_API_HOST_LEN: i64 = 10 46const FC_IMG_HOST: *u8 = "i.4cdn.org" 47const FC_IMG_HOST_LEN: i64 = 10 48const FC_MAGIC_1000000: i64 = 1000000 49const FC_MAGIC_1700000000001: i64 = 1700000000001 50 51const FC_MAGIC_4194304: i64 = 4194304 52const FC_MAGIC_2048: i64 = 2048 53const FC_MAGIC_65536: i64 = 65536 54const FC_MAGIC_1024: i64 = 1024 55const FC_MAGIC_512: i64 = 512 56 57const FC_JSON_CAP: i64 = 8388608 // catalog.json for a busy board runs ~1-3MB 58const FC_IMG_CAP: i64 = 33554432 // 32MB: /gif/ webm legitimately exceeds the manga 16MB cap 59const FC_MAX_OBJ: i64 = 4096 // objects enumerated per document (threads/posts/boards) 60const FC_OUT_CAP: i64 = 4194304 // emitted JSON 61const FC_TEXT_CAP: i64 = 8192 // one sub/com field 62const FC_MIN_BYTES: i64 = 512 // below this a "media file" is an error page or a spacer 63const FC_REDIRECTS: i64 = 6 64// Politeness delay between media fetches (ms). 4chan asks ~1 req/s on the API; media is more lenient. 65// Named here rather than buried at the call site (rule 11) so it is tunable without hunting. 66const FC_FETCH_DELAY_MS: i64 = 350 67// Bulk downloads get RATE-CHALLENGED by 4chan/Cloudflare even with the Chrome-JA3 hello -- a single 68// fetch works but a burst trips a per-IP limit. So a failed media fetch is RETRIED with exponential 69// backoff (the rate window resets on wait, not on a faster retry), and only counted failed after all 70// tries. FC_FETCH_RETRIES total attempts per media item. 71const FC_FETCH_RETRIES: i64 = 4 72 73func fc_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 74func fc_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 75func fc_apps(dst: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { dst[o + i] = s[i]; i = i + 1 } return o + i } 76func fc_num(dst: *u8, o: i64, v: i64) -> i64 { 77 let t: *u8 = sys_mmap(32) 78 var m: i64 = v 79 var k: i64 = 0 80 var w: i64 = o 81 if m < 0 { dst[w] = 45 as u8; w = w + 1; m = 0 - m } 82 if m == 0 { t[0] = 48 as u8; k = 1 } 83 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 84 var i: i64 = 0 85 while i < k { dst[w] = t[k - 1 - i]; w = w + 1; i = i + 1 } 86 return w 87} 88func fc_putn(v: i64) -> i64 { let b: *u8 = sys_mmap(32); let e: i64 = fc_num(b, 0, v); sys_write(1, b, e); return 0 } 89func fc_streq(a: *u8, b: *u8) -> i64 { var i: i64 = 0; while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } if b[i] == (0 as u8) { return 1 } return 0 } 90func fc_sleep_ms(ms: i64) -> i64 { 91 let ts: *i64 = sys_mmap(16) as *i64 92 ts[0] = ms / 1000 93 ts[1] = (ms % 1000) * FC_MAGIC_1000000 94 __syscall(35, ts as i64, 0, 0, 0, 0, 0) 95 return 0 96} 97 98// --------------------------------------------------------------------------------------------- 99// STRING-AWARE brace-depth object enumerator. Records every object that OPENS at want_depth. 100// Objects at one depth cannot nest inside each other, so a single `pending` start offset suffices. 101// Returns the count; offs[i]..offs[i]+lens[i] bound object i. 102// ⚠ The escape handling is load-bearing: a comment ending in a backslash before its closing quote 103// would otherwise flip us into "in string" for the rest of the document and yield zero objects. 104func fc_scan_objects(buf: *u8, n: i64, want_depth: i64, offs: *i64, lens: *i64, cap: i64) -> i64 { 105 var depth: i64 = 0 106 var instr: i64 = 0 107 var esc: i64 = 0 108 var cnt: i64 = 0 109 var pending: i64 = 0 - 1 110 var i: i64 = 0 111 while i < n { 112 let c: i64 = buf[i] as i64 113 if instr == 1 { 114 if esc == 1 { esc = 0 } else { 115 if c == 92 { esc = 1 } else { if c == 34 { instr = 0 } } 116 } 117 } else { 118 if c == 34 { instr = 1 } else { 119 if c == 123 { 120 depth = depth + 1 121 if depth == want_depth { if pending < 0 { pending = i } } 122 } else { 123 if c == 125 { 124 if depth == want_depth { 125 if pending >= 0 { 126 if cnt < cap { offs[cnt] = pending; lens[cnt] = (i + 1) - pending; cnt = cnt + 1 } 127 pending = 0 - 1 128 } 129 } 130 depth = depth - 1 131 } 132 } 133 } 134 } 135 i = i + 1 136 } 137 return cnt 138} 139 140// --------------------------------------------------------------------------------------------- 141// Emit `src` (already JSON-de-escaped by jx_copy_str) as a JSON-SAFE, HTML-STRIPPED string body. 142// 4chan comments are HTML: <br>, <a class="quotelink">, <span class="quote">, <wbr>, plus entities. 143// <br> becomes a space; every other tag is DROPPED WITHOUT a space -- <wbr> is an intra-word break 144// hint, so emitting a space there would split words that are not actually separated. 145func fc_emit_text(dst: *u8, o0: i64, src: *u8, maxout: i64) -> i64 { 146 var o: i64 = o0 147 var i: i64 = 0 148 let n: i64 = fc_slen(src) 149 while i < n { 150 if (o - o0) >= maxout { i = n } else { 151 let c: i64 = src[i] as i64 152 if c == 60 { // '<' : a tag 153 var isbr: i64 = 0 154 if i + 2 < n { if (src[i+1] as i64) == 98 { if (src[i+2] as i64) == 114 { isbr = 1 } } } // "br" 155 var j: i64 = i + 1 156 while j < n { if (src[j] as i64) == 62 { break } j = j + 1 } 157 if isbr == 1 { dst[o] = 32 as u8; o = o + 1 } 158 i = j + 1 159 } else { 160 if c == 38 { // '&' : an entity 161 var adv: i64 = 1 162 var em: i64 = 0 163 if i + 3 < n { if (src[i+1] as i64) == 103 { if (src[i+2] as i64) == 116 { if (src[i+3] as i64) == 59 { em = 62; adv = 4 } } } } // &gt; 164 if i + 3 < n { if (src[i+1] as i64) == 108 { if (src[i+2] as i64) == 116 { if (src[i+3] as i64) == 59 { em = 60; adv = 4 } } } } // &lt; 165 if i + 4 < n { if (src[i+1] as i64) == 97 { if (src[i+2] as i64) == 109 { if (src[i+3] as i64) == 112 { if (src[i+4] as i64) == 59 { em = 38; adv = 5 } } } } } // &amp; 166 if i + 5 < n { if (src[i+1] as i64) == 113 { if (src[i+2] as i64) == 117 { if (src[i+3] as i64) == 111 { if (src[i+4] as i64) == 116 { if (src[i+5] as i64) == 59 { em = 39; adv = 6 } } } } } } // &quot;-> ' (never re-inject a quote) 167 if em == 0 { em = 38; adv = 1 } 168 // A decoded '<' or '>' is plain text in JSON, but re-emitting '"' or '\' would tear 169 // the output string, so &quot; is mapped to an apostrophe above rather than a quote. 170 dst[o] = em as u8; o = o + 1 171 i = i + adv 172 } else { 173 if c == 34 { dst[o] = 92 as u8; o = o + 1; dst[o] = 34 as u8; o = o + 1; i = i + 1 } // \" 174 else { if c == 92 { dst[o] = 92 as u8; o = o + 1; dst[o] = 92 as u8; o = o + 1; i = i + 1 } // \\ 175 else { if c < 32 { dst[o] = 32 as u8; o = o + 1; i = i + 1 } 176 else { dst[o] = c as u8; o = o + 1; i = i + 1 } } } 177 } 178 } 179 } 180 } 181 return o 182} 183 184// https://i.4cdn.org/<board>/<tim>[s].<ext> (s = the 4chan thumbnail suffix, always .jpg) 185func fc_media_url(dst: *u8, o0: i64, board: *u8, tim: i64, ext: *u8, thumb: i64) -> i64 { 186 var o: i64 = fc_apps(dst, o0, "https://i.4cdn.org/" as *u8) 187 o = fc_apps(dst, o, board) 188 dst[o] = 47 as u8; o = o + 1 189 o = fc_num(dst, o, tim) 190 if thumb == 1 { o = fc_apps(dst, o, "s.jpg" as *u8) } else { o = fc_apps(dst, o, ext) } 191 return o 192} 193 194// --------------------------------------------------------------------------------------------- 195// A COMPLETE image/video, not merely a plausible one. Images are verified header+trailer so a 196// truncated download can never land as a half-page. ⚠DOCUMENTED IMPRECISION: webm/mp4 completeness 197// is NOT verified -- there is no cheap trailer for them and we do not carry Content-Length here, so 198// video is accepted on magic + size alone. A truncated video CAN land; images cannot. 199func fc_media_complete(b: *u8, n: i64) -> i64 { 200 if n < FC_MIN_BYTES { return 0 } 201 if b[0] == (0xff as u8) { if b[1] == (0xd8 as u8) { // JPEG ffd8..ffd9 202 if b[n-2] == (0xff as u8) { if b[n-1] == (0xd9 as u8) { return 1 } } 203 return 0 } } 204 if b[0] == (0x89 as u8) { if b[1] == (0x50 as u8) { // PNG ..IEND 205 if b[n-8] == (0x49 as u8) { if b[n-7] == (0x45 as u8) { if b[n-6] == (0x4e as u8) { if b[n-5] == (0x44 as u8) { return 1 } } } } 206 return 0 } } 207 if b[0] == (0x47 as u8) { if b[1] == (0x49 as u8) { if b[2] == (0x46 as u8) { // GIF ..3b 208 if b[n-1] == (0x3b as u8) { return 1 } 209 return 0 } } } 210 if b[0] == (0x1a as u8) { if b[1] == (0x45 as u8) { if b[2] == (0xdf as u8) { if b[3] == (0xa3 as u8) { return 1 } } } } // EBML/webm 211 if n > 8 { if b[4] == (0x66 as u8) { if b[5] == (0x74 as u8) { if b[6] == (0x79 as u8) { if b[7] == (0x70 as u8) { return 1 } } } } } // mp4 ftyp 212 return 0 213} 214 215// mkdir -p: create EVERY missing component, not just the leaf. A single mkdir fails on any nested 216// staging path (`incoming/4chan/po_627472`) and the failure is INVISIBLE -- the fetch then reports 217// downloaded=0/failed=N with no reason, because the open() that failed was for a file in a directory 218// that was never created. Idempotent: EEXIST on an existing component is the normal case, not an error. 219func fc_mkdir_p(path: *u8) -> i64 { 220 let tmp: *u8 = sys_mmap(FC_MAGIC_2048) 221 let n: i64 = fc_slen(path) 222 var i: i64 = 0 223 while i < n { 224 tmp[i] = path[i] 225 // i > 0 so a LEADING '/' (absolute path) is not treated as an empty component to create 226 if i > 0 { if (path[i] as i64) == 47 { 227 tmp[i] = 0 as u8 228 __syscall(258, 0 - 100, tmp as i64, 0x1ed, 0, 0, 0) 229 tmp[i] = 47 as u8 230 } } 231 i = i + 1 232 } 233 tmp[n] = 0 as u8 234 __syscall(258, 0 - 100, tmp as i64, 0x1ed, 0, 0, 0) 235 return 0 236} 237 238// Fetch one media URL into <dir>/<name>. Runs in a FORKED CHILD so the TLS mmaps die with it -- 239// a 200-file thread otherwise accumulates every connection's buffers in one address space. 240func fc_fetch_one(url: *u8, store: *TrustStore, dir: *u8, name: *u8) -> i64 { 241 let buf: *u8 = sys_mmap(FC_IMG_CAP) 242 let status: *i64 = sys_mmap(8) as *i64 243 let n: i64 = nx_https_fetch_follow_chrome(url, store, buf, FC_IMG_CAP, FC_REDIRECTS, status) 244 // FEED THE PACER THE REAL STATUS. The old code read status only to return 1, so a 429 and a dropped 245 // connection were the SAME event -- and the retry then re-fired at the same rate that caused the 246 // throttle. pace_after converts a throttle into a longer next interval, persisted per host. 247 // retry_after_s is passed 0 DELIBERATELY: this fetcher hands back the BODY, not the response headers, 248 // so Retry-After is NOT observable here. 0 makes the pacer fall back to its own exponential backoff, 249 // which is correct. Passing a guessed number would be a fabricated measurement wearing a real name. 250 var st0: i64 = 0 251 if n > 0 { st0 = status[0] } 252 pace_after(FC_IMG_HOST, FC_IMG_HOST_LEN, st0, 0) 253 if n <= 0 { return 1 } 254 if status[0] != 200 { return 1 } 255 if fc_media_complete(buf, n) == 0 { return 1 } 256 let path: *u8 = sys_mmap(FC_MAGIC_2048) 257 var o: i64 = fc_apps(path, 0, dir) 258 path[o] = 47 as u8; o = o + 1 259 o = fc_apps(path, o, name) 260 path[o] = 0 as u8 261 let fd: i64 = sys_openat_wr(path, 0x1a4) 262 if fd < 0 { return 1 } 263 sys_write(fd, buf, n) 264 sys_close(fd) 265 return 0 266} 267 268// --------------------------------------------------------------------------------------------- 269// GET a 4chan JSON document. Returns bytes, or a negative code; out_status carries the HTTP status 270// so a 404 board/thread is reported as such rather than as an empty result. 271func fc_get_json(url: *u8, store: *TrustStore, buf: *u8, out_status: *i64) -> i64 { 272 return nx_https_fetch_follow_chrome(url, store, buf, FC_JSON_CAP, FC_REDIRECTS, out_status) 273} 274 275func fc_api_url(dst: *u8, board: *u8, tail: *u8, thread: i64) -> i64 { 276 var o: i64 = fc_apps(dst, 0, "https://a.4cdn.org/" as *u8) 277 if fc_slen(board) > 0 { o = fc_apps(dst, o, board); dst[o] = 47 as u8; o = o + 1 } 278 o = fc_apps(dst, o, tail) 279 if thread > 0 { o = fc_num(dst, o, thread); o = fc_apps(dst, o, ".json" as *u8) } 280 dst[o] = 0 as u8 281 return o 282} 283 284// --------------------------------------------------------------------------------------------- 285func fc_boards(store: *TrustStore) -> i64 { 286 let url: *u8 = sys_mmap(FC_MAGIC_512) 287 fc_api_url(url, "" as *u8, "boards.json" as *u8, 0) 288 let buf: *u8 = sys_mmap(FC_JSON_CAP) 289 let st: *i64 = sys_mmap(8) as *i64 290 let n: i64 = fc_get_json(url, store, buf, st) 291 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 } 292 if st[0] != 200 { fc_puts("{\"error\":\"http \"}\n" as *u8); return 1 } 293 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 294 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 295 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ) 296 let out: *u8 = sys_mmap(FC_OUT_CAP) 297 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"boards\":[" as *u8) 298 let sv: *u8 = sys_mmap(FC_TEXT_CAP) 299 let iv: *i64 = sys_mmap(8) as *i64 300 var i: i64 = 0 301 var emitted: i64 = 0 302 while i < cnt { 303 let s: i64 = offs[i] 304 let e: i64 = offs[i] + lens[i] 305 if jx_get_str(buf, e, s, "board" as *u8, sv, FC_TEXT_CAP) >= 0 { 306 if emitted > 0 { out[o] = 44 as u8; o = o + 1 } 307 o = fc_apps(out, o, "{\"board\":\"" as *u8); o = fc_apps(out, o, sv) 308 o = fc_apps(out, o, "\",\"title\":\"" as *u8) 309 if jx_get_str(buf, e, s, "title" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) } 310 o = fc_apps(out, o, "\",\"nsfw\":" as *u8) 311 // ws_board=1 means WORK-SAFE, so nsfw is its INVERSE. An absent field must not silently 312 // read as "safe": jx_get_int returns -1 for absent, which we map to nsfw=1 (conservative). 313 var nsfw: i64 = 1 314 if jx_get_int(buf, e, s, "ws_board" as *u8, iv) >= 0 { if iv[0] == 1 { nsfw = 0 } } 315 o = fc_num(out, o, nsfw) 316 out[o] = 125 as u8; o = o + 1 317 emitted = emitted + 1 318 } 319 i = i + 1 320 } 321 o = fc_apps(out, o, "],\"count\":" as *u8); o = fc_num(out, o, emitted) 322 o = fc_apps(out, o, "}" as *u8); out[o] = 10 as u8; o = o + 1 323 sys_write(1, out, o) 324 return 0 325} 326 327// Shared emitter for catalog threads and thread posts. `mode` 0 = catalog (thread previews), 328// 1 = thread (media items only). 329func fc_list(store: *TrustStore, board: *u8, thread: i64, maxn: i64, mode: i64) -> i64 { 330 let url: *u8 = sys_mmap(FC_MAGIC_512) 331 if mode == 0 { fc_api_url(url, board, "catalog.json" as *u8, 0) } 332 else { fc_api_url(url, board, "thread/" as *u8, thread) } 333 let buf: *u8 = sys_mmap(FC_JSON_CAP) 334 let st: *i64 = sys_mmap(8) as *i64 335 let n: i64 = fc_get_json(url, store, buf, st) 336 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 } 337 if st[0] == 404 { fc_puts("{\"error\":\"not found or archived\",\"count\":0}\n" as *u8); return 1 } 338 if st[0] != 200 { fc_puts("{\"error\":\"http status\",\"count\":0}\n" as *u8); return 1 } 339 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 340 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 341 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ) 342 let out: *u8 = sys_mmap(FC_OUT_CAP) 343 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"board\":\"" as *u8) 344 o = fc_apps(out, o, board) 345 if mode == 1 { o = fc_apps(out, o, "\",\"thread\":" as *u8); o = fc_num(out, o, thread); o = fc_apps(out, o, ",\"media\":[" as *u8) } 346 else { o = fc_apps(out, o, "\",\"threads\":[" as *u8) } 347 let sv: *u8 = sys_mmap(FC_TEXT_CAP) 348 let ev: *u8 = sys_mmap(64) 349 let iv: *i64 = sys_mmap(8) as *i64 350 var i: i64 = 0 351 var emitted: i64 = 0 352 while i < cnt { 353 if emitted >= maxn { i = cnt } else { 354 let s: i64 = offs[i] 355 let e: i64 = offs[i] + lens[i] 356 var no: i64 = 0 - 1 357 if jx_get_int(buf, e, s, "no" as *u8, iv) >= 0 { no = iv[0] } 358 var tim: i64 = 0 - 1 359 if jx_get_int(buf, e, s, "tim" as *u8, iv) >= 0 { tim = iv[0] } 360 var hasext: i64 = 0 361 if jx_get_str(buf, e, s, "ext" as *u8, ev, 64) >= 0 { hasext = 1 } 362 // mode 1 keeps ONLY posts carrying media; mode 0 keeps every thread (a textual OP is 363 // still a browsable thread) and simply reports empty urls for it. 364 var keep: i64 = 1 365 if mode == 1 { keep = 0; if tim > 0 { if hasext == 1 { keep = 1 } } } 366 if no <= 0 { keep = 0 } 367 if keep == 1 { 368 if emitted > 0 { out[o] = 44 as u8; o = o + 1 } 369 o = fc_apps(out, o, "{\"no\":" as *u8); o = fc_num(out, o, no) 370 if mode == 0 { 371 o = fc_apps(out, o, ",\"sub\":\"" as *u8) 372 if jx_get_str(buf, e, s, "sub" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) } 373 o = fc_apps(out, o, "\",\"com\":\"" as *u8) 374 if jx_get_str(buf, e, s, "com" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) } 375 o = fc_apps(out, o, "\",\"replies\":" as *u8) 376 var rep: i64 = 0 377 if jx_get_int(buf, e, s, "replies" as *u8, iv) >= 0 { rep = iv[0] } 378 o = fc_num(out, o, rep) 379 o = fc_apps(out, o, ",\"images\":" as *u8) 380 var img: i64 = 0 381 if jx_get_int(buf, e, s, "images" as *u8, iv) >= 0 { img = iv[0] } 382 o = fc_num(out, o, img) 383 } else { 384 o = fc_apps(out, o, ",\"filename\":\"" as *u8) 385 if jx_get_str(buf, e, s, "filename" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) } 386 o = fc_apps(out, o, "\",\"w\":" as *u8) 387 var w: i64 = 0 388 if jx_get_int(buf, e, s, "w" as *u8, iv) >= 0 { w = iv[0] } 389 o = fc_num(out, o, w) 390 o = fc_apps(out, o, ",\"h\":" as *u8) 391 var h: i64 = 0 392 if jx_get_int(buf, e, s, "h" as *u8, iv) >= 0 { h = iv[0] } 393 o = fc_num(out, o, h) 394 o = fc_apps(out, o, ",\"bytes\":" as *u8) 395 var fs: i64 = 0 396 if jx_get_int(buf, e, s, "fsize" as *u8, iv) >= 0 { fs = iv[0] } 397 o = fc_num(out, o, fs) 398 o = fc_apps(out, o, ",\"kind\":\"" as *u8) 399 var vid: i64 = 0 400 if hasext == 1 { if fc_streq(ev, ".webm" as *u8) == 1 { vid = 1 } } 401 if hasext == 1 { if fc_streq(ev, ".mp4" as *u8) == 1 { vid = 1 } } 402 if vid == 1 { o = fc_apps(out, o, "video" as *u8) } else { o = fc_apps(out, o, "image" as *u8) } 403 o = fc_apps(out, o, "\"" as *u8) 404 } 405 o = fc_apps(out, o, ",\"thumb\":\"" as *u8) 406 if tim > 0 { if hasext == 1 { o = fc_media_url(out, o, board, tim, ev, 1) } } 407 o = fc_apps(out, o, "\",\"full\":\"" as *u8) 408 if tim > 0 { if hasext == 1 { o = fc_media_url(out, o, board, tim, ev, 0) } } 409 o = fc_apps(out, o, "\"}" as *u8) 410 emitted = emitted + 1 411 } 412 i = i + 1 413 } 414 } 415 o = fc_apps(out, o, "],\"count\":" as *u8); o = fc_num(out, o, emitted) 416 o = fc_apps(out, o, "}" as *u8); out[o] = 10 as u8; o = o + 1 417 sys_write(1, out, o) 418 return 0 419} 420 421// --------------------------------------------------------------------------------------------- 422func fc_fetch(store: *TrustStore, board: *u8, thread: i64, dir: *u8, maxn: i64) -> i64 { 423 let url: *u8 = sys_mmap(FC_MAGIC_512) 424 fc_api_url(url, board, "thread/" as *u8, thread) 425 let buf: *u8 = sys_mmap(FC_JSON_CAP) 426 let st: *i64 = sys_mmap(8) as *i64 427 let n: i64 = fc_get_json(url, store, buf, st) 428 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 } 429 if st[0] != 200 { fc_puts("{\"error\":\"thread not available\"}\n" as *u8); return 1 } 430 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 431 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 432 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ) 433 fc_mkdir_p(dir) // every component, idempotent 434 let ev: *u8 = sys_mmap(64) 435 let iv: *i64 = sys_mmap(8) as *i64 436 let u: *u8 = sys_mmap(FC_MAGIC_1024) 437 let name: *u8 = sys_mmap(128) 438 var got: i64 = 0 439 var failed: i64 = 0 440 var seen: i64 = 0 441 var i: i64 = 0 442 while i < cnt { 443 if got >= maxn { i = cnt } else { 444 let s: i64 = offs[i] 445 let e: i64 = offs[i] + lens[i] 446 var tim: i64 = 0 - 1 447 if jx_get_int(buf, e, s, "tim" as *u8, iv) >= 0 { tim = iv[0] } 448 var hasext: i64 = 0 449 if jx_get_str(buf, e, s, "ext" as *u8, ev, 64) >= 0 { hasext = 1 } 450 if tim > 0 { if hasext == 1 { 451 seen = seen + 1 452 // <tim><ext>: 4chan's tim is unique per board and already a de-dup key, so re-running 453 // the same thread overwrites rather than accumulating copies. Content-level dedup is 454 // the vault's job (CID), not this organ's -- one deduplicator, not two. 455 var no2: i64 = fc_num(name, 0, tim) 456 no2 = fc_apps(name, no2, ev) 457 name[no2] = 0 as u8 458 let ue: i64 = fc_media_url(u, 0, board, tim, ev, 0) 459 u[ue] = 0 as u8 460 let pp: *u8 = sys_mmap(FC_MAGIC_2048) 461 var po: i64 = fc_apps(pp, 0, dir); pp[po] = 47 as u8; po = po + 1 462 po = fc_apps(pp, po, name); pp[po] = 0 as u8 463 let stt: *i64 = sys_mmap(8) as *i64 464 var okf: i64 = 0 465 var tries: i64 = 0 466 while tries < FC_FETCH_RETRIES { 467 // THE INTERVAL IS THE PACER'S JOB NOW. pace_before blocks until this host's next 468 // allowed slot: a >=1s base (PACE_BASE_MS), widened by exponential backoff whenever 469 // pace_after saw a throttle, and PERSISTED on disk so a second `fetch` invocation 470 // cannot reset politeness to zero the way the old per-process sleep did. 471 pace_before(FC_IMG_HOST, FC_IMG_HOST_LEN) 472 let pid: i64 = sys_fork() 473 if pid == 0 { let rc: i64 = fc_fetch_one(u, store, dir, name); sys_exit(rc) } 474 sys_wait4(pid, stt, 0) 475 // Trust the FILE, not the exit status: confirm the child actually left bytes behind. 476 let fd: i64 = sys_openat_rd(pp) 477 if fd >= 0 { sys_close(fd); okf = 1; tries = FC_FETCH_RETRIES } else { tries = tries + 1 } 478 } 479 if okf == 1 { got = got + 1 } else { failed = failed + 1 } 480 } } 481 i = i + 1 482 } 483 } 484 let out: *u8 = sys_mmap(FC_MAGIC_2048) 485 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"board\":\"" as *u8) 486 o = fc_apps(out, o, board) 487 o = fc_apps(out, o, "\",\"thread\":" as *u8); o = fc_num(out, o, thread) 488 o = fc_apps(out, o, ",\"media_seen\":" as *u8); o = fc_num(out, o, seen) 489 o = fc_apps(out, o, ",\"downloaded\":" as *u8); o = fc_num(out, o, got) 490 o = fc_apps(out, o, ",\"failed\":" as *u8); o = fc_num(out, o, failed) 491 o = fc_apps(out, o, ",\"dir\":\"" as *u8); o = fc_apps(out, o, dir) 492 // The vault, not this organ, owns classification/dedup/layout -- name the handoff explicitly so 493 // the next step is discoverable from the output itself rather than from tribal knowledge. 494 o = fc_apps(out, o, "\",\"vault_next\":\"nx_mvault_walk <dir> real 4chan commit\"}" as *u8) 495 out[o] = 10 as u8; o = o + 1 496 sys_write(1, out, o) 497 if got <= 0 { return 1 } 498 return 0 499} 500 501// --------------------------------------------------------------------------------------------- 502// OFFLINE GATE. Every tooth runs on a fixture built at RUNTIME (a detector that scans source finds 503// its own fixture). The neg-controls are the point: two of them encode bugs this organ WOULD have 504// had with the obvious implementation. 505func fc_selftest() -> i64 { 506 var pass: i64 = 0 507 var total: i64 = 0 508 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 509 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64 510 let iv: *i64 = sys_mmap(8) as *i64 511 let sv: *u8 = sys_mmap(FC_TEXT_CAP) 512 513 // A catalog-shaped fixture: ONE thread carrying a last_replies array of TWO replies. The naive 514 // `"no":`-scanner reports 3 threads here; the depth scanner must report exactly 1. 515 let fx: *u8 = sys_mmap(FC_MAGIC_65536) 516 var f: i64 = fc_apps(fx, 0, "[{\"page\":1,\"threads\":[" as *u8) 517 f = fc_apps(fx, f, "{\"no\":111,\"sub\":\"A\",\"com\":\"x\",\"tim\":1700000000001,\"ext\":\".jpg\",\"replies\":7,\"images\":3," as *u8) 518 f = fc_apps(fx, f, "\"last_replies\":[{\"no\":222,\"com\":\"r1\"},{\"no\":333,\"com\":\"r2\"}]}" as *u8) 519 f = fc_apps(fx, f, "]}]" as *u8) 520 521 total = total + 1 522 let c1: i64 = fc_scan_objects(fx, f, 2, offs, lens, FC_MAX_OBJ) 523 fc_puts("T1 depth-scan threads=" as *u8); fc_putn(c1); fc_puts(" (want 1)\n" as *u8) 524 if c1 == 1 { pass = pass + 1 } 525 526 // T2 neg-control: the SAME fixture at depth 3 must find the two replies -- proving T1's answer 527 // comes from depth discrimination and not from the scanner simply finding one object always. 528 total = total + 1 529 let c2: i64 = fc_scan_objects(fx, f, 3, offs, lens, FC_MAX_OBJ) 530 fc_puts("T2 neg-control replies-at-depth3=" as *u8); fc_putn(c2); fc_puts(" (want 2)\n" as *u8) 531 if c2 == 2 { pass = pass + 1 } 532 533 // T3 field extraction inside the thread's bounds 534 total = total + 1 535 fc_scan_objects(fx, f, 2, offs, lens, FC_MAX_OBJ) 536 var t3: i64 = 0 537 if jx_get_int(fx, offs[0] + lens[0], offs[0], "no" as *u8, iv) >= 0 { if iv[0] == 111 { t3 = t3 + 1 } } 538 if jx_get_int(fx, offs[0] + lens[0], offs[0], "tim" as *u8, iv) >= 0 { if iv[0] == FC_MAGIC_1700000000001 { t3 = t3 + 1 } } 539 if jx_get_int(fx, offs[0] + lens[0], offs[0], "replies" as *u8, iv) >= 0 { if iv[0] == 7 { t3 = t3 + 1 } } 540 fc_puts("T3 fields ok=" as *u8); fc_putn(t3); fc_puts("/3\n" as *u8) 541 if t3 == 3 { pass = pass + 1 } 542 543 // T4 THE BOUNDING TOOTH (neg-control for the commonest silent bug): a record whose field is 544 // ABSENT must NOT inherit the NEXT record's value. Unbounded, jx_get_int scans to end-of-buffer 545 // and returns 9 here; bounded to the record it must honestly report absent (-1). 546 total = total + 1 547 let fx2: *u8 = sys_mmap(FC_MAGIC_65536) 548 var g: i64 = fc_apps(fx2, 0, "[{\"p\":1,\"t\":[{\"no\":1},{\"no\":2,\"replies\":9}]}]" as *u8) 549 let c4: i64 = fc_scan_objects(fx2, g, 2, offs, lens, FC_MAX_OBJ) 550 var t4: i64 = 0 551 if c4 == 2 { 552 let bounded: i64 = jx_get_int(fx2, offs[0] + lens[0], offs[0], "replies" as *u8, iv) 553 let leaked: i64 = jx_get_int(fx2, g, offs[0], "replies" as *u8, iv) 554 if bounded < 0 { t4 = 1 } 555 fc_puts("T4 bounded=" as *u8); fc_putn(bounded); fc_puts(" unbounded-leaks=" as *u8); fc_putn(leaked); fc_puts(" (bounded must be -1)\n" as *u8) 556 } 557 if t4 == 1 { pass = pass + 1 } 558 559 // T5 a STRING value must not read as a number -- "absent" and "zero" must stay distinguishable 560 total = total + 1 561 let fx3: *u8 = sys_mmap(FC_MAGIC_1024) 562 let h: i64 = fc_apps(fx3, 0, "{\"a\":{\"n\":\"12\"}}" as *u8) 563 var t5: i64 = 0 564 if jx_get_int(fx3, h, 0, "n" as *u8, iv) < 0 { t5 = 1 } 565 fc_puts("T5 string-value-not-int ok=" as *u8); fc_putn(t5); fc_puts("\n" as *u8) 566 if t5 == 1 { pass = pass + 1 } 567 568 // T6 braces INSIDE a string are data. A structure-blind scanner mis-nests and finds 2 objects. 569 total = total + 1 570 let fx4: *u8 = sys_mmap(FC_MAGIC_1024) 571 let k: i64 = fc_apps(fx4, 0, "[{\"p\":[{\"com\":\"a { b } c \\\" d\",\"no\":5}]}]" as *u8) 572 let c6: i64 = fc_scan_objects(fx4, k, 2, offs, lens, FC_MAX_OBJ) 573 fc_puts("T6 string-aware objects=" as *u8); fc_putn(c6); fc_puts(" (want 1)\n" as *u8) 574 if c6 == 1 { pass = pass + 1 } 575 576 // T7 HTML/entity scrub, and the output must stay JSON-safe 577 total = total + 1 578 let src: *u8 = sys_mmap(FC_MAGIC_1024) 579 fc_apps(src, 0, "hi<br>there<wbr>x &gt;quote &amp; \"q\"" as *u8) 580 src[fc_slen("hi<br>there<wbr>x &gt;quote &amp; \"q\"" as *u8)] = 0 as u8 581 let ob: *u8 = sys_mmap(FC_MAGIC_1024) 582 let oe: i64 = fc_emit_text(ob, 0, src, FC_MAGIC_1024) 583 ob[oe] = 0 as u8 584 var t7: i64 = 1 585 var z: i64 = 0 586 while z < oe { if (ob[z] as i64) == 60 { if z + 1 < oe { if (ob[z+1] as i64) == 98 { t7 = 0 } } } z = z + 1 } // no surviving <b..> tag 587 // a bare (unescaped) double-quote in the output would tear the JSON string 588 z = 0 589 while z < oe { if (ob[z] as i64) == 34 { if z == 0 { t7 = 0 } else { if (ob[z-1] as i64) != 92 { t7 = 0 } } } z = z + 1 } 590 fc_puts("T7 scrub -> [" as *u8); fc_puts(ob); fc_puts("] ok=" as *u8); fc_putn(t7); fc_puts("\n" as *u8) 591 if t7 == 1 { pass = pass + 1 } 592 593 // T8 media-url shape (thumbnail is ALWAYS .jpg even for a .webm post -- a real 4chan rule that a 594 // naive "use ext for both" implementation gets wrong and only notices as broken previews) 595 total = total + 1 596 let ub: *u8 = sys_mmap(FC_MAGIC_512) 597 let ue1: i64 = fc_media_url(ub, 0, "gif" as *u8, FC_MAGIC_1700000000001, ".webm" as *u8, 1) 598 ub[ue1] = 0 as u8 599 var t8: i64 = 0 600 if fc_streq(ub, "https://i.4cdn.org/gif/1700000000001s.jpg" as *u8) == 1 { t8 = 1 } 601 fc_puts("T8 thumb-url [" as *u8); fc_puts(ub); fc_puts("] ok=" as *u8); fc_putn(t8); fc_puts("\n" as *u8) 602 if t8 == 1 { pass = pass + 1 } 603 604 // T9 truncated JPEG must be REJECTED (header valid, trailer missing) and a complete one accepted 605 total = total + 1 606 let jb: *u8 = sys_mmap(FC_MAGIC_1024) 607 var q: i64 = 0 608 while q < FC_MIN_BYTES + 8 { jb[q] = 65 as u8; q = q + 1 } 609 jb[0] = 0xff as u8; jb[1] = 0xd8 as u8 610 let bad: i64 = fc_media_complete(jb, FC_MIN_BYTES + 8) 611 jb[FC_MIN_BYTES + 6] = 0xff as u8; jb[FC_MIN_BYTES + 7] = 0xd9 as u8 612 let good: i64 = fc_media_complete(jb, FC_MIN_BYTES + 8) 613 fc_puts("T9 truncated=" as *u8); fc_putn(bad); fc_puts(" complete=" as *u8); fc_putn(good); fc_puts(" (want 0 then 1)\n" as *u8) 614 if bad == 0 { if good == 1 { pass = pass + 1 } } 615 616 // T10 \uXXXX must DECODE, not leak as text. Caught live: 4chan sends "Anime & Manga" and the 617 // old jx_copy_str emitted "Anime u0026 Manga" -- a corruption visible only to a human reading a 618 // title, which is why no structural test ever saw it. The backslash is CONSTRUCTED as a byte: a 619 // bare one in a literal is ambiguous to the nx lexer. 620 total = total + 1 621 let ux: *u8 = sys_mmap(FC_MAGIC_512) 622 var uo: i64 = fc_apps(ux, 0, "Anime " as *u8) 623 ux[uo] = 92 as u8; uo = uo + 1 624 uo = fc_apps(ux, uo, "u0026 Manga" as *u8) 625 ux[uo] = 34 as u8; uo = uo + 1 // closing quote ends the JSON string 626 let ud: *u8 = sys_mmap(FC_MAGIC_512) 627 jx_copy_str(ux, uo, 0, ud, FC_MAGIC_512) 628 var t10: i64 = 0 629 if fc_streq(ud, "Anime & Manga" as *u8) == 1 { t10 = 1 } 630 var t10bad: i64 = 0 631 if fc_streq(ud, "Anime u0026 Manga" as *u8) == 1 { t10bad = 1 } // the exact old output 632 fc_puts("T10 u-escape [" as *u8); fc_puts(ud); fc_puts("] ok=" as *u8); fc_putn(t10) 633 fc_puts(" old-corruption-present=" as *u8); fc_putn(t10bad); fc_puts("\n" as *u8) 634 if t10 == 1 { if t10bad == 0 { pass = pass + 1 } } 635 636 // T11 mkdir -p must create EVERY component. Scratch lives in /tmp/<gate>/ and is torn down at 637 // SETUP, not teardown: a crashed run never reaches a teardown, and a gate that is not idempotent 638 // reports honestly on its first run and lies on every run after. 639 total = total + 1 640 let rootp: *u8 = sys_mmap(FC_MAGIC_512); let r0: i64 = fc_apps(rootp, 0, "/tmp/nx4c_gate_p" as *u8); rootp[r0] = 0 as u8 641 let d1: *u8 = sys_mmap(FC_MAGIC_512); let r1: i64 = fc_apps(d1, 0, "/tmp/nx4c_gate_p/a" as *u8); d1[r1] = 0 as u8 642 let d2: *u8 = sys_mmap(FC_MAGIC_512); let r2: i64 = fc_apps(d2, 0, "/tmp/nx4c_gate_p/a/b" as *u8); d2[r2] = 0 as u8 643 let fpath: *u8 = sys_mmap(FC_MAGIC_512); let r3: i64 = fc_apps(fpath, 0, "/tmp/nx4c_gate_p/a/b/probe.bin" as *u8); fpath[r3] = 0 as u8 644 __syscall(263, 0 - 100, fpath as i64, 0, 0, 0, 0) // unlink probe 645 __syscall(263, 0 - 100, d2 as i64, 0x200, 0, 0, 0) // rmdir b (AT_REMOVEDIR) 646 __syscall(263, 0 - 100, d1 as i64, 0x200, 0, 0, 0) // rmdir a 647 __syscall(263, 0 - 100, rootp as i64, 0x200, 0, 0, 0) // rmdir root 648 // NEG CONTROL: with the parents gone this open MUST fail. Without it the tooth would pass even if 649 // fc_mkdir_p did nothing at all, because the directory might already exist from a previous run. 650 let pre: i64 = sys_openat_wr(fpath, 0x1a4) 651 if pre >= 0 { sys_close(pre) } 652 fc_mkdir_p(d2) 653 let post: i64 = sys_openat_wr(fpath, 0x1a4) 654 var t11: i64 = 0 655 if pre < 0 { if post >= 0 { t11 = 1 } } 656 if post >= 0 { sys_close(post) } 657 fc_puts("T11 mkdir-p neg-control-pre=" as *u8); fc_putn(pre); fc_puts(" post=" as *u8); fc_putn(post) 658 fc_puts(" ok=" as *u8); fc_putn(t11); fc_puts("\n" as *u8) 659 if t11 == 1 { pass = pass + 1 } 660 661 fc_puts("NX4CHAN-SELFTEST passed " as *u8); fc_putn(pass); fc_puts("/" as *u8); fc_putn(total) 662 if pass == total { fc_puts(" verdict=GREEN\n" as *u8); return 0 } 663 fc_puts(" verdict=RED\n" as *u8) 664 return 1 665} 666 667func fc_atoi(s: *u8) -> i64 { 668 var v: i64 = 0 669 var i: i64 = 0 670 while s[i] != (0 as u8) { let c: i64 = s[i] as i64; if c >= 48 { if c <= 57 { v = (v * 10) + (c - 48) } } i = i + 1 } 671 return v 672} 673 674// Fetch ONE media file (board/<file>, e.g. 1700s.jpg thumb or 1700.png full) into <dir>/<file>. 675// Backs BOTH the same-origin image PROXY (the gallery CSP blocks direct i.4cdn.org loads) and 676// save-to-gallery. url is built from board+file (both charset-whitelisted by the caller for execve). 677func fc_saveone(store: *TrustStore, board: *u8, file: *u8, dir: *u8) -> i64 { 678 let u: *u8 = sys_mmap(FC_MAGIC_512) 679 var uo: i64 = fc_apps(u, 0, "https://i.4cdn.org/" as *u8) 680 uo = fc_apps(u, uo, board); u[uo] = 47 as u8; uo = uo + 1 681 uo = fc_apps(u, uo, file); u[uo] = 0 as u8 682 fc_mkdir_p(dir) 683 let rc: i64 = fc_fetch_one(u, store, dir, file) 684 if rc != 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 } 685 let out: *u8 = sys_mmap(FC_MAGIC_1024) 686 var o: i64 = fc_apps(out, 0, "{\"saved\":\"" as *u8) 687 o = fc_apps(out, o, dir); out[o] = 47 as u8; o = o + 1 688 o = fc_apps(out, o, file); o = fc_apps(out, o, "\"}\n" as *u8) 689 sys_write(1, out, o) 690 return 0 691} 692func main(argc: i64, argv: *i64) -> i64 { 693 if argc < 2 { 694 fc_puts("usage: nx_4chan boards | catalog <board> [max] | thread <board> <no> [max] | fetch <board> <no> <dir> [max] | selftest\n" as *u8) 695 sys_exit(2); return 2 696 } 697 let verb: *u8 = argv[1] as *u8 698 if fc_streq(verb, "selftest" as *u8) == 1 { let r: i64 = fc_selftest(); sys_exit(r); return r } 699 700 let tr: i64 = nx_trust_store_load_from_certdata("data/mozilla_certdata.txt" as *u8, 512, FC_MAGIC_4194304) 701 if tr <= 0 { fc_puts("{\"error\":\"certdata load failed (run from the tree root)\"}\n" as *u8); sys_exit(1); return 1 } 702 let store: *TrustStore = tr as *TrustStore 703 704 if fc_streq(verb, "boards" as *u8) == 1 { let r: i64 = fc_boards(store); sys_exit(r); return r } 705 if fc_streq(verb, "catalog" as *u8) == 1 { 706 if argc < 3 { fc_puts("usage: nx_4chan catalog <board> [max]\n" as *u8); sys_exit(2); return 2 } 707 var mx: i64 = FC_MAX_OBJ 708 if argc > 3 { mx = fc_atoi(argv[3] as *u8) } 709 let r: i64 = fc_list(store, argv[2] as *u8, 0, mx, 0); sys_exit(r); return r 710 } 711 if fc_streq(verb, "thread" as *u8) == 1 { 712 if argc < 4 { fc_puts("usage: nx_4chan thread <board> <no> [max]\n" as *u8); sys_exit(2); return 2 } 713 var mx2: i64 = FC_MAX_OBJ 714 if argc > 4 { mx2 = fc_atoi(argv[4] as *u8) } 715 let r: i64 = fc_list(store, argv[2] as *u8, fc_atoi(argv[3] as *u8), mx2, 1); sys_exit(r); return r 716 } 717 if fc_streq(verb, "fetch" as *u8) == 1 { 718 if argc < 5 { fc_puts("usage: nx_4chan fetch <board> <no> <dir> [max]\n" as *u8); sys_exit(2); return 2 } 719 var mx3: i64 = FC_MAX_OBJ 720 if argc > 5 { mx3 = fc_atoi(argv[5] as *u8) } 721 let r: i64 = fc_fetch(store, argv[2] as *u8, fc_atoi(argv[3] as *u8), argv[4] as *u8, mx3); sys_exit(r); return r 722 } 723 if fc_streq(verb, "saveone" as *u8) == 1 { 724 if argc < 5 { fc_puts("usage: nx_4chan saveone <board> <file> <dir>\n" as *u8); sys_exit(2); return 2 } 725 let r: i64 = fc_saveone(store, argv[2] as *u8, argv[3] as *u8, argv[4] as *u8); sys_exit(r); return r 726 } 727 fc_puts("unknown verb\n" as *u8) 728 sys_exit(2); return 2 729}