diff --git a/runtime/el_runtime.c b/runtime/el_runtime.c index 1356ae8..6b81efe 100644 --- a/runtime/el_runtime.c +++ b/runtime/el_runtime.c @@ -77,6 +77,23 @@ static _Thread_local int _tl_arena_active = 0; * Allows serving PNGs and other binary files without strlen truncation. */ static _Thread_local size_t _tl_fs_read_len = 0; +/* Binary body side-channel for http_response(). + * + * http_response() normally JSON-encodes the body via jb_emit_escaped(), which + * stops at the first null byte (C-string semantics). Binary files like PNGs + * contain null bytes as early as byte 8 (IHDR chunk length), causing truncation. + * + * When _tl_fs_read_len > 0 at the time http_response() is called, we skip + * JSON-encoding and instead: + * 1. malloc-copy the raw bytes here + * 2. write the sentinel string "__el_binary__" into the envelope body field + * 3. In http_send_response(), detect the sentinel and use these raw bytes + * + * Thread-local so each worker thread has independent storage. + * Lifecycle: set by http_response(), consumed (and freed) by http_send_response(). */ +static _Thread_local char* _tl_binary_body = NULL; +static _Thread_local size_t _tl_binary_size = 0; + static void el_arena_track(char* p) { if (!_tl_arena_active || !p) return; if (_tl_arena.count >= _tl_arena.cap) { @@ -1536,10 +1553,22 @@ static void http_send_response(int fd, const char* body) { } const char* eff_body = is_envelope ? env_body : body; + int binary_side_channel = 0; + + /* Binary side-channel: if the envelope body is the sentinel "__el_binary__", + * http_response() stored the real bytes in _tl_binary_body/_tl_binary_size. + * Substitute them here so http_send_all() sends the correct binary payload. */ + if (is_envelope && env_body && strcmp(env_body, "__el_binary__") == 0 + && _tl_binary_body && _tl_binary_size > 0) { + eff_body = _tl_binary_body; + binary_side_channel = 1; + } + /* Use the real byte count from fs_read if available (handles binary files * with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for * normal text/JSON responses where _tl_fs_read_len is 0. */ - size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body); + size_t blen = binary_side_channel ? _tl_binary_size + : (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body); _tl_fs_read_len = 0; /* consume — one-shot per response */ int head_only = _tl_http_head_only; @@ -1587,6 +1616,13 @@ static void http_send_response(int fd, const char* body) { if (env_parsed_root) el_release(env_parsed_root); free(env_body); free(hdrs.buf); + + /* Release binary side-channel if it was used (or left over from an error). */ + if (_tl_binary_body) { + free(_tl_binary_body); + _tl_binary_body = NULL; + _tl_binary_size = 0; + } } typedef struct { @@ -1961,6 +1997,14 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { const char* b = EL_CSTR(body); if (!b) b = ""; + /* Capture binary length BEFORE clearing _tl_fs_read_len. + * If the body came from fs_read(), _tl_fs_read_len holds the real byte + * count. jb_emit_escaped() stops at the first NUL byte, so we cannot + * JSON-encode binary data directly. Instead we copy it to a thread-local + * side-channel and write the sentinel "__el_binary__" into the envelope. + * http_send_response() detects the sentinel and uses the side-channel. */ + size_t binary_len = _tl_fs_read_len; + /* Clear the fs_read binary-length hint: the envelope we're about to build * is a fresh JSON string, not the raw file bytes. Without this reset, * http_worker would use the stale _tl_fs_read_len (= original file size) @@ -1968,6 +2012,18 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { * http_send_response and http_parse_envelope. */ _tl_fs_read_len = 0; + if (binary_len > 0) { + /* Binary body path: store raw bytes in thread-local, emit sentinel. */ + free(_tl_binary_body); /* discard any stale binary from a prior error path */ + _tl_binary_body = malloc(binary_len); + if (_tl_binary_body) { + memcpy(_tl_binary_body, b, binary_len); + _tl_binary_size = binary_len; + } else { + _tl_binary_size = 0; /* malloc failed — fall through to empty body */ + } + } + JsonBuf out; jb_init(&out); jb_puts(&out, EL_HTTP_RESPONSE_TAG); /* {"el_http_response":1 */ jb_puts(&out, ",\"status\":"); @@ -1977,7 +2033,12 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { jb_puts(&out, ",\"headers\":"); jb_puts(&out, hj); jb_puts(&out, ",\"body\":"); - jb_emit_escaped(&out, b); + if (binary_len > 0 && _tl_binary_body) { + /* Sentinel: http_send_response() will substitute the real bytes. */ + jb_puts(&out, "\"__el_binary__\""); + } else { + jb_emit_escaped(&out, b); + } jb_putc(&out, '}'); return el_wrap_str(out.buf); } diff --git a/src/checkout.el b/src/checkout.el index c92c1f4..b5a4e5e 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -345,7 +345,31 @@ fn checkout_page(plan: String, pub_key: String) -> String { } fn checkout_style_html() -> String { - let css: String = ".checkout-plan-name { + let css: String = ".checkout-shell { + max-width: 980px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: start; +} +.checkout-summary { + position: sticky; + top: 2rem; +} +.checkout-form-wrap { + min-width: 0; +} +@media (max-width: 860px) { + .checkout-shell { + grid-template-columns: 1fr; + gap: 2.5rem; + } + .checkout-summary { + position: static; + } +} +.checkout-plan-name { font-family: var(--head); font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 600; diff --git a/src/main.el b/src/main.el index 254b1fd..ef491a3 100644 --- a/src/main.el +++ b/src/main.el @@ -698,12 +698,12 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String } // Free tier: $0 PaymentIntent for age verification (18+ requirement). - // Card is verified and saved (setup_future_usage=off_session). No charge. + // Verifies card is valid. No charge, no capture. + // Note: setup_future_usage cannot be used with amount=0. if str_eq(plan, "free") { let free_pi_body: String = "amount=0" + "¤cy=usd" + "&payment_method_types[]=card" - + "&setup_future_usage=off_session" + "&metadata[plan]=free" + "&metadata[purpose]=age_verification" let free_pi_body = if !str_eq(pi_cus_id, "") { free_pi_body + "&customer=" + pi_cus_id } else { free_pi_body }