This change addresses a critical issue where common variable names in library functions (like 'i', 'n', 'src') were being treated as globals by default, leading to state collisions and unexpected side effects across different files. Changes: - Modified `src/parser.c` to automatically treat new variables assigned within a function body as locals unless they were already declared as globals. - Introduced `sym_find` in the parser to distinguish between finding an existing global and creating a new one. - Reverted `lib/net/cgi.fun` to use standard, short variable names, confirming that they no longer interfere with the caller's scope. - Verified that explicit global updates still work if the variable was already defined at the top-level scope.
369 lines
11 KiB
Standard ML
369 lines
11 KiB
Standard ML
/*
|
|
* This file is part of the Fun programming language.
|
|
* https://fun-lang.xyz/
|
|
*
|
|
* Copyright 2026 Johannes Findeisen <you@hanez.org>
|
|
* 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 <strings.fun>
|
|
|
|
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
|