/* * This file is part of the Fun programming language. * https://fun-lang.xyz/ * * Copyright 2026 Johannes Findeisen * Licensed under the terms of the Apache-2.0 license. * https://opensource.org/license/apache-2-0 * * Added: 2026-03-25 */ // Minimal, parser-friendly CGI helper (incrementally extend as needed) #include class CGI() fun _construct(this) this._params = {} this._cookies = {} this._headers = {} this._header_list = [] // preserve insertion order for emission without map iteration this._status = 200 this._status_text = "OK" this._content_type = "text/html; charset=utf-8" this.env = {} this.env["REQUEST_METHOD"] = env("REQUEST_METHOD") this.env["QUERY_STRING"] = env("QUERY_STRING") this.env["CONTENT_TYPE"] = env("CONTENT_TYPE") this.env["CONTENT_LENGTH"] = env("CONTENT_LENGTH") this.env["HTTP_COOKIE"] = env("HTTP_COOKIE") this.env["POST_DATA"] = env("POST_DATA") // Cookies this._cookies = this._parse_cookies(this.env["HTTP_COOKIE"]) // Params from QUERY_STRING qs = this.env["QUERY_STRING"] if (typeof(qs) == "String" && len(qs) > 0) parsed_qs = this._parse_urlencoded(qs) this._merge_params(parsed_qs) // Params from POST (x-www-form-urlencoded only) ct = this.env["CONTENT_TYPE"] pd = this.env["POST_DATA"] // Fallback: when running under a real CGI, POST data comes from stdin, not env. // Only attempt to read stdin if method is POST, content-length > 0, and POST_DATA is empty. if (!(typeof(pd) == "String" && len(pd) > 0)) if (str_to_upper(this.env["REQUEST_METHOD"]) == "POST") cl = to_number(this.env["CONTENT_LENGTH"]) if (cl > 0) // Read up to CONTENT_LENGTH bytes from stdin. Some CGI runners may deliver // urlencoded chunks split at '&' without including the delimiter; reconstruct // by inserting '&' between successive chunks. parts = [] total = 0 // Safeguard: cap iterations to avoid infinite loops on unexpected behavior iter = 0 while (total < cl && iter < 100000) chunk = input("") if (!(typeof(chunk) == "String")) break if (len(chunk) == 0) // EOF without newline break push(parts, chunk) total = total + len(chunk) iter = iter + 1 // Rebuild with '&' between pieces to restore typical form encoding if (len(parts) > 0) rebuilt = parts[0] idx = 1 np = len(parts) while (idx < np) rebuilt = rebuilt + "&" + parts[idx] idx = idx + 1 tmp = rebuilt else tmp = "" // Trim to declared content length to drop any stray newline added by the CGI runner if (len(tmp) > cl) pd = substr(tmp, 0, cl) else pd = tmp this.env["POST_DATA"] = pd else // No declared length; try to read a single line (common with simple runners) tmp = input("") if (typeof(tmp) == "String" && len(tmp) > 0) this.env["POST_DATA"] = tmp // Keep local variable in sync so parsing below sees the data pd = this.env["POST_DATA"] // Decide whether to treat body as urlencoded: default yes for POST unless explicitly multipart is_urlencoded = 1 if (typeof(ct) == "String" && len(ct) > 0) lct = str_to_lower(ct) if (find(lct, "multipart/form-data") >= 0) is_urlencoded = 0 if (is_urlencoded) if (typeof(pd) == "String" && len(pd) > 0) parsed_pd = this._parse_urlencoded(pd) this._merge_params(parsed_pd) fun param(this, name) arr = this._params[name] if (typeof(arr) != "Array") return "" if (len(arr) > 0) return arr[0] return "" fun param_all(this, name) arr = this._params[name] if (typeof(arr) != "Array") return [] return arr fun params(this) // Return the internal map directly (no copying, to avoid map iteration) return this._params fun cookie(this, name) c = this._cookies[name] if (typeof(c) != "String") return "" return c fun cookies(this) // Return the internal cookies map directly return this._cookies fun status(this, code, text) this._status = to_number(code) if (len(text) > 0) this._status_text = text fun content_type(this, ct) this._content_type = to_string(ct) fun header(this, name, value) k = to_string(name) v = to_string(value) this._headers[k] = v // Track in ordered list for emission push(this._header_list, [k, v]) fun redirect(this, location, code) c = to_number(code) if (c == 0) c = 302 if (c == 301) this.status(c, "Moved Permanently") else this.status(c, "Found") this.header("Location", to_string(location)) fun header_str(this) out = "Status: " + to_string(this._status) + " " + this._status_text + "\r\n" out = out + "Content-Type: " + this._content_type + "\r\n" // Emit headers from the ordered list i = 0 n = len(this._header_list) while (i < n) pair = this._header_list[i] if (typeof(pair) == "Array" && len(pair) >= 2) out = out + to_string(pair[0]) + ": " + to_string(pair[1]) + "\r\n" i = i + 1 out = out + "\r\n" return out fun send(this, body) print(this.header_str() + to_string(body)) fun escape_html(this, s) a = to_string(s) a = str_replace_all(a, "&", "&") a = str_replace_all(a, "<", "<") a = str_replace_all(a, ">", ">") a = str_replace_all(a, "\"", """) a = str_replace_all(a, "'", "'") return a // Translate CGI output (headers + body) into a full HTTP/1.1 response string // Input: raw string as emitted by a CGI script (e.g., via CGI.send()), containing // CGI headers followed by an empty line and then the body. // Output: a single HTTP/1.1 response string ready to be written to a socket. fun cgi_to_http_response(this, raw) out = to_string(raw) // Find header/body separator sep = find(out, "\r\n\r\n") seplen = 4 if (sep < 0) sep = find(out, "\n\n") seplen = 2 if (sep < 0) // No CGI headers, treat whole as body b = out resp = "HTTP/1.1 200 OK\r\n" resp = resp + "Content-Type: text/html; charset=utf-8\r\n" resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" resp = resp + "Connection: close\r\n\r\n" + b return resp header_str = substr(out, 0, sep) body = substr(out, sep + seplen, len(out) - sep - seplen) // Parse headers lines = str_split(header_str, "\n") code = 200 text = "OK" // Accumulate headers (excluding Status) hh = [] // array of [k, v] i = 0 n = len(lines) while (i < n) ln = str_trim(lines[i]) if (len(ln) > 0) colon = find(ln, ":") if (colon > 0) k = str_trim(substr(ln, 0, colon)) v = str_trim(substr(ln, colon + 1, len(ln) - colon - 1)) if (str_to_upper(k) == "STATUS") // Expect like: 200 OK sp = find(v, " ") if (sp > 0) code = to_number(substr(v, 0, sp)) text = str_trim(substr(v, sp + 1, len(v) - sp - 1)) else code = to_number(v) if (code == 0) code = 200 text = "OK" else // Keep other headers push(hh, [k, v]) i = i + 1 // Build HTTP response b = to_string(body) resp = "HTTP/1.1 " + to_string(code) + " " + text + "\r\n" // Emit collected headers j = 0 m = len(hh) has_len = false while (j < m) p = hh[j] if (typeof(p) == "Array" && len(p) >= 2) hk = to_string(p[0]) hv = to_string(p[1]) if (str_to_upper(hk) == "CONTENT-LENGTH") has_len = true resp = resp + hk + ": " + hv + "\r\n" j = j + 1 if (!has_len) resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" resp = resp + "Connection: close\r\n\r\n" + b return resp fun url_decode(this, s) src = to_string(s) out = [] i = 0 n = len(src) while (i < n) ch = substr(src, i, 1) if (ch == "+") push(out, " ") i = i + 1 else if (ch == "%" && i + 2 < n) h1 = substr(src, i + 1, 1) h2 = substr(src, i + 2, 1) hexdigits = "0123456789ABCDEF" v1 = find(hexdigits, str_to_upper(h1)) v2 = find(hexdigits, str_to_upper(h2)) if (v1 >= 0 && v2 >= 0) code = v1 * 16 + v2 ascii = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" if (code >= 32 && code <= 126) push(out, substr(ascii, code - 32, 1)) else push(out, "%") push(out, h1) push(out, h2) i = i + 3 else push(out, ch) i = i + 1 else push(out, ch) i = i + 1 return join(out, "") fun _merge_params(this, pairs) // pairs: array of [key, value] entries if (typeof(pairs) != "Array") return 0 i = 0 n = len(pairs) while (i < n) p = pairs[i] if (typeof(p) == "Array" && len(p) >= 2) key = to_string(p[0]) val = to_string(p[1]) a = this._params[key] if (typeof(a) != "Array") this._params[key] = [] push(this._params[key], val) i = i + 1 return 1 fun _parse_urlencoded(this, s) out = [] // array of [key, value] src = to_string(s) if (len(src) == 0) return out // Manually scan so we can treat both '&' and ';' as pair separators (robust across environments) pairs = [] buf = [] number i = 0 number n = len(src) while (i < n) ch = substr(src, i, 1) if (ch == "&" || ch == ";") push(pairs, join(buf, "")) buf = [] else push(buf, ch) i = i + 1 // tail push(pairs, join(buf, "")) j = 0 lp = len(pairs) while (j < lp) kv = pairs[j] if (typeof(kv) == "String") token = kv // Trim CR/LF that might trail if body ended with a newline // Reuse str_trim which trims spaces and CR/LF token = str_trim(token) if (len(token) > 0) eq = find(token, "=") if (eq >= 0) k = substr(token, 0, eq) v = substr(token, eq + 1, len(token) - eq - 1) else k = token v = "" key = this.url_decode(k) val = this.url_decode(v) push(out, [key, val]) j = j + 1 return out fun _parse_cookies(this, cookie_str) out = {} if (len(cookie_str) == 0) return out semi = str_split(cookie_str, ";") i = 0 while (i < len(semi)) part = str_trim(semi[i]) if (len(part) > 0) eq = find(part, "=") if (eq >= 0) k = str_trim(substr(part, 0, eq)) v = str_trim(substr(part, eq + 1, len(part) - eq - 1)) out[k] = v i = i + 1 return out