code wiki / _hdl_build / nx_reader_sync.nx
nx_reader_sync.nx source
↩ module page · 250 lines · 12655 B
1// nx_reader_sync.nx -- R1 SYNC SPINE (beat-Kindle ladder #1; census cells SC01/SC37): per-USER cross-device
2// reading position for the library, behind THE WALL. Whispersync-class resume with a sovereign twist:
3// TEXT books -> AUTO-position: the daemon already serves every chapter fetch behind the wall, so recording
4// "last chapter opened" per (user, book) costs ZERO client changes -- the zero-JS text reader
5// stays zero-JS (census SC59 preserved) and still syncs across devices.
6// COMICS/scans -> EXPLICIT position: the zoom viewer (authed JS by design) POSTs {"page":N} and GETs it back
7// on open, so a phone resumes exactly where the desktop left off.
8// Storage: reader/<slug>/pos_<ukey>.json (client JSON stored VERBATIM, atomic tmp+rename, last-write-wins) +
9// reader/<slug>/auto_<ukey>.txt ("<chapfile>\t<epoch>"). <ukey> = sanitized handle + fnv32-hex8 of the RAW handle
10// (deterministic per user, collision-safe after sanitize, path-safe by construction). The user handle comes from
11// nx_sa_validate_handle at the wall -- identity is the AUTH layer's, never a client claim.
12// FAIL-CLOSED: slug via zs_slug_safe -> 403; body cap 2048 -> 413; auto-record NEVER breaks the book serve
13// (any doubt -> silent skip). Lib (no main); nx_reader_sync_gate drives the handlers in-process.
14// license_tier: ORIGINAL
15import "nx_syscalls.nx"
16import "nx_reader_zoomserve.nx" // DRY: zs_qval / zs_slug_safe / zs_fname_safe / zs_slen / zs_apps / zs_dec / zs_exists / zs_mkdir / zs_emit + ZS_ROOT
17const SY_MAGIC_16777619: i64 = 16777619
18const SY_MAGIC_1152: i64 = 1152
19
20const SY_BODY_CAP: i64 = 2048
21const SY_ANN_CAP: i64 = 65536 // annotations = the user's whole list as one verbatim JSON doc (LWW, atomic)
22
23// ukey = up to 24 sanitized handle chars ([a-z0-9_-], others -> '_', uppercase folded) + '_' + fnv1a32 hex8 of
24// the RAW handle bytes. Deterministic, path-safe, distinct for handles that collide after sanitizing.
25func sy_user_key(handle: *u8, hn: i64, out: *u8) -> i64 {
26 var h: i64 = 0x811c9dc5
27 var i: i64 = 0
28 while i < hn { h = ((h ^ ((handle[i] as i64) & 0xff)) * SY_MAGIC_16777619) & 0xffffffff; i = i + 1 }
29 var o: i64 = 0
30 i = 0
31 while i < hn {
32 if o < 24 {
33 var c: i64 = handle[i] as i64
34 if c >= 65 { if c <= 90 { c = c + 32 } }
35 var ok: i64 = 0
36 if c >= 48 { if c <= 57 { ok = 1 } }
37 if c >= 97 { if c <= 122 { ok = 1 } }
38 if c == 95 { ok = 1 }
39 if c == 45 { ok = 1 }
40 if ok == 0 { c = 95 }
41 out[o] = c as u8; o = o + 1
42 }
43 i = i + 1
44 }
45 if o == 0 { out[0] = 97 as u8; o = 1 } // 'a' -- empty handle still yields a key (hash disambiguates)
46 out[o] = 95 as u8; o = o + 1
47 let hx: *u8 = "0123456789abcdef" as *u8
48 var k: i64 = 28
49 while k >= 0 { out[o] = hx[(h >> k) & 0xf]; o = o + 1; k = k - 4 }
50 out[o] = 0 as u8
51 return o
52}
53
54// atomic small-file write: <path>.new -> rename -> path
55func sy_write_atomic(path: *u8, data: *u8, n: i64) -> i64 {
56 let tmp: *u8 = sys_mmap(SY_MAGIC_1152)
57 var o: i64 = zs_apps(tmp, 0, path)
58 o = zs_apps(tmp, o, ".new" as *u8)
59 tmp[o] = 0 as u8
60 let fd: i64 = sys_openat_wr(tmp, 0x1a4)
61 if fd < 0 { return 0 - 1 }
62 sys_write(fd, data, n)
63 sys_close(fd)
64 return sys_renameat(tmp, path)
65}
66
67func sy_read_small(path: *u8, buf: *u8, cap: i64) -> i64 {
68 let fd: i64 = sys_openat_rd(path)
69 if fd < 0 { return 0 - 1 }
70 var tot: i64 = 0; var r: i64 = 1
71 while r > 0 { let dst: *u8 = ((buf as i64) + tot) as *u8; r = sys_read(fd, dst, cap - tot); if r > 0 { tot = tot + r } }
72 sys_close(fd)
73 return tot
74}
75
76// build reader/<slug>/<kind>_<ukey>.<ext> into out; returns len
77func sy_path(slug: *u8, kind: *u8, ukey: *u8, ext: *u8, out: *u8) -> i64 {
78 var o: i64 = zs_apps(out, 0, ZS_ROOT)
79 o = zs_apps(out, o, slug)
80 out[o] = 47 as u8; o = o + 1
81 o = zs_apps(out, o, kind)
82 out[o] = 95 as u8; o = o + 1
83 o = zs_apps(out, o, ukey)
84 o = zs_apps(out, o, ext)
85 out[o] = 0 as u8
86 return o
87}
88
89// GET /api/pos?slug=<slug> -> {"auto":"<chapfile>","auto_ts":N,"pos":<verbatim client JSON | null>}
90func sy_handle_pos_get(cfd: i64, p: *u8, plen: i64, ukey: *u8) -> i64 {
91 let slug: *u8 = sys_mmap(256)
92 zs_qval(p, plen, "slug" as *u8, slug, 256)
93 if zs_slug_safe(slug) == 0 { return zs_emit(cfd, "403 Forbidden" as *u8, "{\"ok\":0,\"err\":\"bad slug\"}" as *u8) }
94
95 let body: *u8 = sys_mmap(SY_BODY_CAP + 512)
96 var o: i64 = zs_apps(body, 0, "{\"auto\":\"" as *u8)
97 let ap: *u8 = sys_mmap(SY_MAGIC_1152)
98 sy_path(slug, "auto" as *u8, ukey, ".txt" as *u8, ap)
99 let ab: *u8 = sys_mmap(512)
100 let an: i64 = sy_read_small(ap, ab, 511)
101 var ats: i64 = 0
102 if an > 0 {
103 var i: i64 = 0
104 while i < an { if ab[i] == (9 as u8) { i = an } else { if ab[i] >= (32 as u8) { if ab[i] != (34 as u8) { if ab[i] != (92 as u8) { body[o] = ab[i]; o = o + 1 } } } i = i + 1 } }
105 // parse the epoch after the tab
106 var j: i64 = 0; var seen: i64 = 0
107 while j < an {
108 if seen == 1 { let c: i64 = ab[j] as i64; if c >= 48 { if c <= 57 { ats = ats * 10 + (c - 48) } } }
109 if ab[j] == (9 as u8) { seen = 1 }
110 j = j + 1
111 }
112 }
113 o = zs_apps(body, o, "\",\"auto_ts\":" as *u8)
114 o = zs_dec(body, o, ats)
115 o = zs_apps(body, o, ",\"pos\":" as *u8)
116 let pp: *u8 = sys_mmap(SY_MAGIC_1152)
117 sy_path(slug, "pos" as *u8, ukey, ".json" as *u8, pp)
118 let pb: *u8 = sys_mmap(SY_BODY_CAP + 16)
119 let pn: i64 = sy_read_small(pp, pb, SY_BODY_CAP)
120 if pn > 0 { var k: i64 = 0; while k < pn { body[o] = pb[k]; o = o + 1; k = k + 1 } } else { o = zs_apps(body, o, "null" as *u8) }
121 o = zs_apps(body, o, "}" as *u8)
122 body[o] = 0 as u8
123 return zs_emit(cfd, "200 OK" as *u8, body)
124}
125
126// POST /api/pos?slug=<slug> (body = the client's position JSON, <= SY_BODY_CAP) -> stored verbatim, LWW.
127func sy_handle_pos_put(cfd: i64, p: *u8, plen: i64, req: *u8, body_off: i64, body_len: i64, ukey: *u8) -> i64 {
128 let slug: *u8 = sys_mmap(256)
129 zs_qval(p, plen, "slug" as *u8, slug, 256)
130 if zs_slug_safe(slug) == 0 { return zs_emit(cfd, "403 Forbidden" as *u8, "{\"ok\":0,\"err\":\"bad slug\"}" as *u8) }
131 if body_len <= 0 { return zs_emit(cfd, "400 Bad Request" as *u8, "{\"ok\":0,\"err\":\"no body\"}" as *u8) }
132 if body_len > SY_BODY_CAP { return zs_emit(cfd, "413 Payload Too Large" as *u8, "{\"ok\":0,\"err\":\"pos too large\"}" as *u8) }
133 // the book dir must already exist (an extracted book) -- sync state never creates book dirs
134 let dir: *u8 = sys_mmap(SY_MAGIC_1152)
135 var dl: i64 = zs_apps(dir, 0, ZS_ROOT)
136 dl = zs_apps(dir, dl, slug)
137 dir[dl] = 0 as u8
138 if zs_exists(dir) == 0 {
139 // dir-existence via open on the dir path fails for directories on some kernels; probe cbx.json/book.json instead
140 let probe: *u8 = sys_mmap(SY_MAGIC_1152)
141 var po: i64 = zs_apps(probe, 0, dir)
142 po = zs_apps(probe, po, "/cbx.json" as *u8)
143 probe[po] = 0 as u8
144 var hav: i64 = zs_exists(probe)
145 if hav == 0 {
146 po = zs_apps(probe, 0, dir)
147 po = zs_apps(probe, po, "/book.json" as *u8)
148 probe[po] = 0 as u8
149 hav = zs_exists(probe)
150 }
151 if hav == 0 { return zs_emit(cfd, "404 Not Found" as *u8, "{\"ok\":0,\"err\":\"no such book\"}" as *u8) }
152 }
153 let pp: *u8 = sys_mmap(SY_MAGIC_1152)
154 sy_path(slug, "pos" as *u8, ukey, ".json" as *u8, pp)
155 let src: *u8 = ((req as i64) + body_off) as *u8
156 if sy_write_atomic(pp, src, body_len) != 0 { return zs_emit(cfd, "500 Internal Server Error" as *u8, "{\"ok\":0,\"err\":\"store failed\"}" as *u8) }
157 return zs_emit(cfd, "200 OK" as *u8, "{\"ok\":1}" as *u8)
158}
159
160// R2 ANNOTATIONS (census SC24/25, beat-Kindle ladder #2): per-user highlights/notes for a book, stored VERBATIM
161// as ONE JSON list (client-owned, LWW whole-list, atomic) at reader/<slug>/ann_<ukey>.json. The client puts a
162// CONTENT anchor inside each annotation (e.g. the page-file stem / a text-snippet hash) -- anchors that survive
163// re-conversion are the exceed angle Kindle's positional anchors cannot match. GET doubles as the machine-
164// readable EXPORT (the stored JSON verbatim). Same guards as /api/pos; bigger cap.
165// GET /api/ann?slug=<slug> -> {"ann":<verbatim client JSON | null>}
166func sy_handle_ann_get(cfd: i64, p: *u8, plen: i64, ukey: *u8) -> i64 {
167 let slug: *u8 = sys_mmap(256)
168 zs_qval(p, plen, "slug" as *u8, slug, 256)
169 if zs_slug_safe(slug) == 0 { return zs_emit(cfd, "403 Forbidden" as *u8, "{\"ok\":0,\"err\":\"bad slug\"}" as *u8) }
170 let body: *u8 = sys_mmap(SY_ANN_CAP + 64)
171 var o: i64 = zs_apps(body, 0, "{\"ann\":" as *u8)
172 let ap: *u8 = sys_mmap(SY_MAGIC_1152)
173 sy_path(slug, "ann" as *u8, ukey, ".json" as *u8, ap)
174 let ab: *u8 = sys_mmap(SY_ANN_CAP + 16)
175 let an: i64 = sy_read_small(ap, ab, SY_ANN_CAP)
176 if an > 0 { var k: i64 = 0; while k < an { body[o] = ab[k]; o = o + 1; k = k + 1 } } else { o = zs_apps(body, o, "null" as *u8) }
177 o = zs_apps(body, o, "}" as *u8)
178 body[o] = 0 as u8
179 return zs_emit(cfd, "200 OK" as *u8, body)
180}
181
182// POST /api/ann?slug=<slug> (body = the user's whole annotation list JSON, <= SY_ANN_CAP) -> stored verbatim, LWW.
183func sy_handle_ann_put(cfd: i64, p: *u8, plen: i64, req: *u8, body_off: i64, body_len: i64, ukey: *u8) -> i64 {
184 let slug: *u8 = sys_mmap(256)
185 zs_qval(p, plen, "slug" as *u8, slug, 256)
186 if zs_slug_safe(slug) == 0 { return zs_emit(cfd, "403 Forbidden" as *u8, "{\"ok\":0,\"err\":\"bad slug\"}" as *u8) }
187 if body_len <= 0 { return zs_emit(cfd, "400 Bad Request" as *u8, "{\"ok\":0,\"err\":\"no body\"}" as *u8) }
188 if body_len > SY_ANN_CAP { return zs_emit(cfd, "413 Payload Too Large" as *u8, "{\"ok\":0,\"err\":\"ann too large\"}" as *u8) }
189 let dir: *u8 = sys_mmap(SY_MAGIC_1152)
190 var dl: i64 = zs_apps(dir, 0, ZS_ROOT)
191 dl = zs_apps(dir, dl, slug)
192 dir[dl] = 0 as u8
193 let probe: *u8 = sys_mmap(SY_MAGIC_1152)
194 var po: i64 = zs_apps(probe, 0, dir)
195 po = zs_apps(probe, po, "/cbx.json" as *u8)
196 probe[po] = 0 as u8
197 var hav: i64 = zs_exists(probe)
198 if hav == 0 {
199 po = zs_apps(probe, 0, dir)
200 po = zs_apps(probe, po, "/book.json" as *u8)
201 probe[po] = 0 as u8
202 hav = zs_exists(probe)
203 }
204 if hav == 0 { return zs_emit(cfd, "404 Not Found" as *u8, "{\"ok\":0,\"err\":\"no such book\"}" as *u8) }
205 let pp: *u8 = sys_mmap(SY_MAGIC_1152)
206 sy_path(slug, "ann" as *u8, ukey, ".json" as *u8, pp)
207 let src: *u8 = ((req as i64) + body_off) as *u8
208 if sy_write_atomic(pp, src, body_len) != 0 { return zs_emit(cfd, "500 Internal Server Error" as *u8, "{\"ok\":0,\"err\":\"store failed\"}" as *u8) }
209 return zs_emit(cfd, "200 OK" as *u8, "{\"ok\":1}" as *u8)
210}
211
212// AUTO-position hook: called (fail-silent) on every authed /api/book fetch. If the path is a chapter fetch
213// (p=reader/<slug>/chap*.txt) record "<chapfile>\t<epoch>" at reader/<slug>/auto_<ukey>.txt (atomic). The
214// zero-JS text reader thus syncs its coarse position across devices with NO client change.
215func sy_record_auto(p: *u8, plen: i64, ukey: *u8, now_s: i64) -> i64 {
216 // extract the p= value (the book-relative path)
217 let rel: *u8 = sys_mmap(512)
218 let rl: i64 = zs_qval(p, plen, "p" as *u8, rel, 512)
219 if rl < 12 { return 0 }
220 // expect "reader/<slug>/<file>"
221 if rel[0] != (114 as u8) { return 0 } // r
222 if rel[6] != (47 as u8) { return 0 } // "reader/"
223 var i: i64 = 7
224 let slug: *u8 = sys_mmap(256)
225 var sl: i64 = 0
226 while i < rl { if rel[i] == (47 as u8) { i = rl } else { if sl < 200 { slug[sl] = rel[i]; sl = sl + 1 } i = i + 1 } }
227 slug[sl] = 0 as u8
228 if zs_slug_safe(slug) == 0 { return 0 }
229 // file = after the slash
230 let file: *u8 = sys_mmap(256)
231 var fl: i64 = 0
232 var j: i64 = 7 + sl + 1
233 while j < rl { if fl < 128 { file[fl] = rel[j]; fl = fl + 1 } j = j + 1 }
234 file[fl] = 0 as u8
235 if zs_fname_safe(file) == 0 { return 0 }
236 // only chapter fetches: "chap" prefix
237 if fl < 5 { return 0 }
238 if file[0] != (99 as u8) { return 0 }
239 if file[1] != (104 as u8) { return 0 }
240 if file[2] != (97 as u8) { return 0 }
241 if file[3] != (112 as u8) { return 0 }
242 let rec: *u8 = sys_mmap(512)
243 var o: i64 = zs_apps(rec, 0, file)
244 rec[o] = 9 as u8; o = o + 1
245 o = zs_dec(rec, o, now_s)
246 let ap: *u8 = sys_mmap(SY_MAGIC_1152)
247 sy_path(slug, "auto" as *u8, ukey, ".txt" as *u8, ap)
248 sy_write_atomic(ap, rec, o)
249 return 1
250}