From 5b0d5444d43dc6dab7f98165ec91046ef4aa203e Mon Sep 17 00:00:00 2001 From: taadis Date: Thu, 22 Oct 2026 11:29:19 +0800 Subject: [PATCH] add HTTP query, form and cookie support --- fun/lib/http-server.fun | 196 +++++++++++++++++++++++- fun/test/expected/lib-http-test.fun.txt | 11 +- fun/test/lib-http-test.fun | 90 ++++++++++- 3 files changed, 291 insertions(+), 6 deletions(-) diff --git a/fun/lib/http-server.fun b/fun/lib/http-server.fun index b6f4a1f..9f2fc54 100644 --- a/fun/lib/http-server.fun +++ b/fun/lib/http-server.fun @@ -52,6 +52,13 @@ // * Windows: the winsock backend has no RunOnce/Run and its Start takes // (hwnd, message), so listen()/runOnce()/run() here run on Linux and // macOS today; wiring the Windows message-pump driver is a follow-up. +// +// Request/response API: +// * req.query(k) and req.form() are URL-decoded; req.form() parses +// application/x-www-form-urlencoded bodies on demand. +// * req.cookies / req.cookie(k) read the Cookie header. +// * res.cookie(name, value, opts) / res.clearCookie(name, opts) write +// Set-Cookie; res.addHeader(k, v) appends multi-value headers. use 'lib-winsock.fun'; use 'lib-regex.fun'; @@ -89,20 +96,159 @@ fun _segments(p) if cur <> '' then result.@add(cur); end if; end fun; +fun _hexVal(c) + result = '0123456789abcdef'.subpos(c.lower()); +end fun; + +// %XX and '+' (form/query encoding); invalid escapes stay literal +fun _urlDecode(s) + var out = ''; + var i = 0; + while i < s.length() do + var c = s.substr(i, 1); + if c = '+' then + out &= ' '; + i += 1; + elsif c = '%' and i + 2 < s.length() then + var hi = _hexVal(s.substr(i + 1, 1)); + var lo = _hexVal(s.substr(i + 2, 1)); + if hi >= 0 and lo >= 0 then + out &= (hi * 16 + lo).toChar(); + i += 3; + else + out &= c; + i += 1; + end if; + else + out &= c; + i += 1; + end if; + end do; + result = out; +end fun; + fun _querySet(q) result = new []; for pair in split(/&/, q) do if pair <> '' then var i = pair.subpos('='); if i < 0 then - result[pair] = ''; + result[_urlDecode(pair)] = ''; else - result[pair.substr(0, i)] = pair.substr(i + 1); + result[_urlDecode(pair.substr(0, i))] = _urlDecode(pair.substr(i + 1)); + end if; + end if; + end do; +end fun; + +// Cookie: a=1; b=2 (values may be quoted) +fun _cookieSet(h) + result = new []; + if h = nil or h = '' then return; end if; + for part in split(/;/, h) do + var p = _trim(part); + if p <> '' then + var i = p.subpos('='); + if i > 0 then + var k = _trim(p.substr(0, i)); + var v = _trim(p.substr(i + 1)); + if v.length() >= 2 and v.substr(0, 1) = '"' and v.substr(v.length() - 1, 1) = '"' then + v = v.substr(1, v.length() - 2); + end if; + result[k] = v; end if; end if; end do; end fun; +fun _hasCtl(s) + result = s.subpos(13.toChar()) >= 0 or s.subpos(10.toChar()) >= 0; +end fun; + +fun _checkHeader(k, v) + var ks = '' & k; + var vs = '' & v; + if ks = '' or _hasCtl(ks) or ks.subpos(':') >= 0 then + raise 'http-server: invalid header name or value'; + end if; + if _hasCtl(vs) then + raise 'http-server: invalid header name or value'; + end if; +end fun; + +// names: RFC token-ish; values: no control/separator characters +fun _cookieOk(s, isName) + result = false; + if _hasCtl(s) then return; end if; + if isName and s = '' then return; end if; + if isName then + if s.subpos('=') >= 0 or s.subpos(';') >= 0 or s.subpos(',') >= 0 or + s.subpos(' ') >= 0 or s.subpos(9.toChar()) >= 0 then return; end if; + else + if s.subpos(';') >= 0 or s.subpos(',') >= 0 or s.subpos(' ') >= 0 or + s.subpos(9.toChar()) >= 0 or s.subpos('"') >= 0 then return; end if; + end if; + result = true; +end fun; + +fun _checkAttr(v) + var s = '' & v; + if _hasCtl(s) or s.subpos(';') >= 0 then + raise 'http-server: invalid cookie attribute'; + end if; +end fun; + +fun _cookieLine(name, value, opts, clear) + var n = '' & name; + var v = '' & value; + if not _cookieOk(n, true) or not _cookieOk(v, false) then + raise 'http-server: invalid cookie name or value'; + end if; + var o = opts; + if o = nil then o = new []; end if; + var line = n & '=' & v; + var path = o['path']; + if path <> nil and path <> '' then + _checkAttr(path); + line &= '; Path=' & path; + end if; + var domain = o['domain']; + if domain <> nil and domain <> '' then + _checkAttr(domain); + line &= '; Domain=' & domain; + end if; + if clear then + line &= '; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT'; + else + var ma = o['maxAge']; + if ma <> nil then + _checkAttr(ma); + line &= '; Max-Age=' & ma; + end if; + var ex = o['expires']; + if ex <> nil and ex <> '' then + _checkAttr(ex); + line &= '; Expires=' & ex; + end if; + if o['httpOnly'] then line &= '; HttpOnly'; end if; + if o['secure'] then line &= '; Secure'; end if; + var ss = o['sameSite']; + if ss <> nil and ss <> '' then + var sl = ss.lower(); + if sl = 'lax' then + line &= '; SameSite=Lax'; + elsif sl = 'strict' then + line &= '; SameSite=Strict'; + elsif sl = 'none' then + line &= '; SameSite=None'; + else + raise 'http-server: invalid SameSite value'; + end if; + end if; + end if; + result = line; +end fun; + fun _joinList(a, b) var all = new []; if a <> nil then @@ -195,6 +341,8 @@ class HttpReq() var params = nil; // :name matches (filled by the router) var queryS = nil; // set: query parameters var headS = nil; // set: lowercased header names + var cookies = nil; // set: request cookies + var formS = nil; // set: urlencoded form, parsed on demand fun header(k) result = headS[k.lower()]; @@ -204,6 +352,21 @@ class HttpReq() result = queryS[k]; end fun; + fun cookie(k) + result = cookies[k]; + end fun; + + fun form() + if formS = nil then + formS = new []; + var ct = header('content-type') or ''; + if ct.lower().subpos('application/x-www-form-urlencoded') = 0 then + formS = _querySet(body); + end if; + end if; + result = formS; + end fun; + fun param(k) result = params[k]; end fun; @@ -226,10 +389,33 @@ class HttpRes() end fun; fun header(k, v) + _checkHeader(k, v); hdrs[k] = v; result = this; end fun; + // Append another line for the same header name (Set-Cookie, Vary, ...). + // Values are validated, so the embedded CRLF cannot come from input. + fun addHeader(k, v) + _checkHeader(k, v); + if hdrs[k] = nil then + hdrs[k] = v; + else + hdrs[k] = hdrs[k] & 13.toChar() & 10.toChar() & k & ': ' & v; + end if; + result = this; + end fun; + + fun cookie(name, value, opts) + addHeader('Set-Cookie', _cookieLine(name, value, opts, false)); + result = this; + end fun; + + fun clearCookie(name, opts) + addHeader('Set-Cookie', _cookieLine(name, '', opts, true)); + result = this; + end fun; + fun send(s) if sent then raise 'http-server: response already sent'; @@ -258,8 +444,8 @@ class HttpRes() end fun; fun redirect(url, c) - code = c or 302; - hdrs['Location'] = url; + code = c or 302; + header('Location', url); result = this.send(''); end fun; end class; @@ -453,6 +639,7 @@ class HttpServer(addr) r.headS = new []; r.queryS = new []; r.params = new []; + r.cookies = new []; var lines = split(/\r?\n/, header); if lines.@count() > 0 then @@ -470,6 +657,7 @@ class HttpServer(addr) end if; end if; end do; + r.cookies = _cookieSet(r.headS['cookie']); var qi = req.subpos('?'); if qi >= 0 then diff --git a/fun/test/expected/lib-http-test.fun.txt b/fun/test/expected/lib-http-test.fun.txt index de5d908..025caf6 100644 --- a/fun/test/expected/lib-http-test.fun.txt +++ b/fun/test/expected/lib-http-test.fun.txt @@ -23,6 +23,15 @@ static miss 404: True traversal 404: True boom 500: True boom message: True +query decoded: True +form decoded: True +cookies parsed: True +cookie set sid: True +cookie set theme: True +cookie cleared: True +multi header: True +bad header 500: True +bad header message: True addr ip:port: True addr :port: True -ok \ No newline at end of file +ok diff --git a/fun/test/lib-http-test.fun b/fun/test/lib-http-test.fun index 0b7e56d..133511b 100644 --- a/fun/test/lib-http-test.fun +++ b/fun/test/lib-http-test.fun @@ -62,6 +62,39 @@ fun echoH(req, res) res.text('echo:' & req.body); end fun; +fun queryH(req, res) + res.text('q=' & req.query('q') & ';z=' & req.query('z')); +end fun; + +fun formH(req, res) + var f = req.form(); + res.text('a=' & f['a'] & ';b=' & f['b']); +end fun; + +fun cookiesH(req, res) + res.text(req.cookie('a') & ':' & req.cookie('b')); +end fun; + +fun setCookiesH(req, res) + res.cookie('sid', 'abc', [path: '/', httpOnly: true, sameSite: 'Lax']); + res.cookie('theme', 'dark', [path: '/']); + res.text('cookies'); +end fun; + +fun clearCookieH(req, res) + res.clearCookie('sid', [path: '/']); + res.text('cleared'); +end fun; + +fun multiH(req, res) + res.addHeader('X-Multi', 'one').addHeader('X-Multi', 'two').text('multi'); +end fun; + +fun badHeaderH(req, res) + res.header('X-Bad', 'a' & 13.toChar() & 10.toChar() & 'X-Injected: b'); + res.text('bad'); +end fun; + fun boom(req, res) raise 'kaboom'; end fun; @@ -74,6 +107,13 @@ s.Use(mwA); s.get('/', hello); s.get('/users/:id', userH); s.post('/echo', echoH); +s.get('/query', queryH); +s.post('/form', formH); +s.get('/cookies', cookiesH); +s.get('/setcookies', setCookiesH); +s.get('/clearcookie', clearCookieH); +s.get('/multi', multiH); +s.get('/badheader', badHeaderH); s.get('/boom', boom); var api = s.group('/api'); api.Use(authMw); @@ -197,7 +237,55 @@ var r13 = RecvSome(c13, 4096); CloseSocket(c13); //-------------------------------------------------------------- -// 9. address strings: 'ip:port' and ':port' (all interfaces) +// 9. URL/form decoding, cookies and multi-value headers +//-------------------------------------------------------------- +var c14 = getReq(p, '/query?q=hello%20world&z=a+b'); +pump(s, 5); +var r14 = RecvSome(c14, 4096); +?. 'query decoded: ' & hasRe(r14, %\r\n\r\nq=hello\x20world;z=a\x20b$%); +CloseSocket(c14); + +var fbody = 'a=1&b=hello%20world'; +var c15 = rq(p, 'POST /form HTTP/1.1\r\nHost: t\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: ' & fbody.length() & '\r\n\r\n' & fbody); +pump(s, 5); +var r15 = RecvSome(c15, 4096); +?. 'form decoded: ' & hasRe(r15, %\r\n\r\na=1;b=hello\x20world$%); +CloseSocket(c15); + +var c16 = rq(p, 'GET /cookies HTTP/1.1\r\nHost: t\r\nCookie: a=1; b=two\r\n\r\n'); +pump(s, 5); +var r16 = RecvSome(c16, 4096); +?. 'cookies parsed: ' & hasRe(r16, %\r\n\r\n1:two$%); +CloseSocket(c16); + +var c17 = getReq(p, '/setcookies'); +pump(s, 5); +var r17 = RecvSome(c17, 4096); +?. 'cookie set sid: ' & hasRe(r17, %Set-Cookie:\x20sid=abc;\x20Path=/;\x20HttpOnly;\x20SameSite=Lax%); +?. 'cookie set theme: ' & hasRe(r17, %Set-Cookie:\x20theme=dark;\x20Path=/%); +CloseSocket(c17); + +var c18 = getReq(p, '/clearcookie'); +pump(s, 5); +var r18 = RecvSome(c18, 4096); +?. 'cookie cleared: ' & hasRe(r18, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%); +CloseSocket(c18); + +var c19 = getReq(p, '/multi'); +pump(s, 5); +var r19 = RecvSome(c19, 4096); +?. 'multi header: ' & hasRe(r19, %X-Multi:\x20one\r\nX-Multi:\x20two%); +CloseSocket(c19); + +var c20 = getReq(p, '/badheader'); +pump(s, 5); +var r20 = RecvSome(c20, 4096); +?. 'bad header 500: ' & hasRe(r20, %^HTTP/1\.1\x20500%); +?. 'bad header message: ' & hasRe(r20, %invalid\x20header%); +CloseSocket(c20); + +//-------------------------------------------------------------- +// 10. address strings: 'ip:port' and ':port' (all interfaces) //-------------------------------------------------------------- var s2 = HttpServer('127.0.0.1:0'); ?. 'addr ip:port: ' & (s2.listen() > 0);