// Copyright 2022 Jo-Philipp Wich // Licensed to the public under the Apache License 2.0. import { urlencode as _urlencode, urldecode as _urldecode, urlencoded_parser, multipart_parser, header_attribute, ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS } from 'lucihttp'; import { error as fserror, stdin, stdout, mkstemp } from 'fs'; // luci.http module scope export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size // Decode a mime encoded http message body with multipart/form-data // Content-Type. Stores all extracted data associated with its parameter name // in the params table within the given message object. Multiple parameter // values are stored as tables, ordinary ones as strings. // If an optional file callback function is given then it is fed with the // file contents chunk by chunk and only the extracted file name is stored // within the params table. The callback function will be called subsequently // with three arguments: // o Table containing decoded (name, file) and raw (headers) mime header data // o String value containing a chunk of the file data // o Boolean which indicates whether the current chunk is the last one (eof) export function mimedecode_message_body(src, msg, file_cb) { let len = 0, maxlen = +msg.env.CONTENT_LENGTH; let header, field, parser; parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) { if (what == parser.PART_INIT) { field = {}; } else if (what == parser.HEADER_NAME) { header = lc(buffer); } else if (what == parser.HEADER_VALUE && header) { if (lc(header) == 'content-disposition' && header_attribute(buffer, null) == 'form-data') { field.name = header_attribute(buffer, 'name'); field.file = header_attribute(buffer, 'filename'); field[1] = field.file; } field.headers = field.headers || {}; field.headers[header] = buffer; } else if (what == parser.PART_BEGIN) { return !field.file; } else if (what == parser.PART_DATA && field.name && length > 0) { if (field.file) { if (file_cb) { file_cb(field, buffer, false); msg.params[field.name] = msg.params[field.name] || field; } else { if (!field.fd) field.fd = mkstemp(field.name); if (field.fd) { field.fd.write(buffer); msg.params[field.name] = msg.params[field.name] || field; } } } else { field.value = buffer; } } else if (what == parser.PART_END && field.name) { if (field.file && msg.params[field.name]) { if (file_cb) file_cb(field, '', true); else if (field.fd) field.fd.seek(0); } else { let val = msg.params[field.name]; if (type(val) == 'array') push(val, field.value || ''); else if (val != null) msg.params[field.name] = [ val, field.value || '' ]; else msg.params[field.name] = field.value || ''; } field = null; } else if (what == parser.ERROR) { err = buffer; } return true; }, HTTP_MAX_CONTENT); while (true) { let chunk = src(); len += length(chunk); if (maxlen && len > maxlen + 2) die('Message body size exceeds Content-Length'); if (!parser.parse(chunk)) die(err); if (chunk == null) break; } }; // Decode an urlencoded http message body with application/x-www-urlencoded // Content-Type. Stores all extracted data associated with its parameter name // in the params table within the given message object. Multiple parameter // values are stored as tables, ordinary ones as strings. export function urldecode_message_body(src, msg) { let len = 0, maxlen = +msg.env.CONTENT_LENGTH; let err, name, value, parser; parser = urlencoded_parser(function (what, buffer, length) { if (what == parser.TUPLE) { name = null; value = null; } else if (what == parser.NAME) { name = _urldecode(buffer, DECODE_PLUS); } else if (what == parser.VALUE && name) { let val = msg.params[name]; if (type(val) == 'array') push(val, _urldecode(buffer, DECODE_PLUS) || ''); else if (val != null) msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ]; else msg.params[name] = _urldecode(buffer, DECODE_PLUS) || ''; } else if (what == parser.ERROR) { err = buffer; } return true; }, HTTP_MAX_CONTENT); while (true) { let chunk = src(); len += length(chunk); if (maxlen && len > maxlen + 2) die('Message body size exceeds Content-Length'); if (!parser.parse(chunk)) die(err); if (chunk == null) break; } }; // This function will examine the Content-Type within the given message object // to select the appropriate content decoder. // Currently the application/x-www-urlencoded and application/form-data // mime types are supported. If the encountered content encoding can't be // handled then the whole message body will be stored unaltered as 'content' // property within the given message object. export function parse_message_body(src, msg, filecb) { if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') { let ctype = header_attribute(msg.env.CONTENT_TYPE, null); // Is it multipart/mime ? if (ctype == 'multipart/form-data') return mimedecode_message_body(src, msg, filecb); // Is it application/x-www-form-urlencoded ? else if (ctype == 'application/x-www-form-urlencoded') return urldecode_message_body(src, msg); // Unhandled encoding // If a file callback is given then feed it chunk by chunk, else // store whole buffer in message.content let sink; // If we have a file callback then feed it if (type(filecb) == 'function') { let meta = { name: 'raw', encoding: msg.env.CONTENT_TYPE }; sink = (chunk) => { if (chunk != null) return filecb(meta, chunk, false); else return filecb(meta, null, true); }; } // ... else append to .content else { let chunks = [], len = 0; sink = (chunk) => { len += length(chunk); if (len > HTTP_MAX_CONTENT) die('POST data exceeds maximum allowed length'); if (chunk != null) { push(chunks, chunk); } else { msg.content = join('', chunks); msg.content_length = len; } }; } // Pump data... while (true) { let chunk = src(); sink(chunk); if (chunk == null) break; } return true; } return false; }; export function build_querystring(q) { let s = []; for (let k, v in q) { push(s, length(s) ? '&' : '?', _urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k, '=', _urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v ); } return join('', s); }; export function urlencode(value) { if (value == null) return null; value = '' + value; return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value; }; export function urldecode(value, decode_plus) { if (value == null) return null; value = '' + value; return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value; }; // Extract and split urlencoded data pairs, separated bei either "&" or ";" // from given url or string. Returns a table with urldecoded values. // Simple parameters are stored as string values associated with the parameter // name within the table. Parameters with multiple values are stored as array // containing the corresponding values. export function urldecode_params(url, tbl) { let parser, name, value; let params = tbl || {}; parser = urlencoded_parser(function(what, buffer, length) { if (what == parser.TUPLE) { name = null; value = null; } else if (what == parser.NAME) { name = _urldecode(buffer); } else if (what == parser.VALUE && name) { params[name] = _urldecode(buffer) || ''; } return true; }); if (parser) { let m = match(('' + (url || '')), /[^?]*$/); parser.parse(m ? m[0] : ''); parser.parse(null); } return params; }; // Encode each key-value-pair in given table to x-www-urlencoded format, // separated by '&'. Tables are encoded as parameters with multiple values by // repeating the parameter name with each value. export function urlencode_params(tbl) { let enc = []; for (let k, v in tbl) { if (type(v) == 'array') { for (let v2 in v) { if (length(enc)) push(enc, '&'); push(enc, _urlencode(k), '=', _urlencode('' + v2)); } } else { if (length(enc)) push(enc, '&'); push(enc, _urlencode(k), '=', _urlencode('' + v)); } } return join(enc, ''); }; // Default IO routines suitable for CGI invocation let avail_len = +getenv('CONTENT_LENGTH'); const default_source = () => { let rlen = min(avail_len, 4096); if (rlen == 0) { stdin.close(); return null; } let chunk = stdin.read(rlen); if (chunk == null) die(`Input read error: ${fserror()}`); avail_len -= length(chunk); return chunk; }; const default_sink = (...chunks) => { for (let chunk in chunks) stdout.write(chunk); stdout.flush(); }; const Class = { formvalue: function(name, noparse) { if (!noparse && !this.parsed_input) this._parse_input(); if (name != null) return this.message.params[name]; else return this.message.params; }, formvaluetable: function(prefix) { let vals = {}; prefix = (prefix || '') + '.'; if (!this.parsed_input) this._parse_input(); for (let k, v in this.message.params) if (index(k, prefix) == 0) vals[substr(k, length(prefix))] = '' + v; return vals; }, content: function() { if (!this.parsed_input) this._parse_input(); return this.message.content; }, getcookie: function(name) { return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name); }, getenv: function(name) { if (name != null) return this.message.env[name]; else return this.message.env; }, setfilehandler: function(callback) { if (type(callback) == 'resource' && type(callback.call) == 'function') this.filehandler = (...args) => callback.call(...args); else if (type(callback) == 'function') this.filehandler = callback; else die('Invalid callback argument for setfilehandler()'); if (!this.parsed_input) return; // If input has already been parsed then uploads are stored as unlinked // temporary files pointed to by open file handles in the parameter // value table. Loop all params, and invoke the file callback for any // param with an open file handle. for (let name, value in this.message.params) { while (value?.fd) { let data = value.fd.read(1024); let eof = (data == null || data == ''); callback(value, data, eof); if (eof) { value.fd.close(); value.fd = null; } } } }, _parse_input: function() { parse_message_body( this.input, this.message, this.filehandler ); this.parsed_input = true; }, close: function() { this.write_headers(); this.closed = true; }, header: function(key, value) { this.headers ??= {}; this.headers[lc(key)] = value; }, prepare_content: function(mime) { if (!this.headers?.['content-type']) { if (mime == 'application/xhtml+xml') { if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) { mime = 'text/html; charset=UTF-8'; this.header('Vary', 'Accept'); } } this.header('Content-Type', mime); } }, status: function(code, message) { this.status_code = code ?? 200; this.status_message = message ?? 'OK'; }, write_headers: function() { if (this.eoh) return; if (!this.status_code) this.status(); if (!this.headers?.['content-type']) this.header('Content-Type', 'text/html; charset=UTF-8'); if (!this.headers?.['cache-control']) { this.header('Cache-Control', 'no-cache'); this.header('Expires', '0'); } if (!this.headers?.['x-frame-options']) this.header('X-Frame-Options', 'SAMEORIGIN'); if (!this.headers?.['x-xss-protection']) this.header('X-XSS-Protection', '1; mode=block'); if (!this.headers?.['x-content-type-options']) this.header('X-Content-Type-Options', 'nosniff'); this.output('Status: '); this.output(this.status_code); this.output(' '); this.output(this.status_message); this.output('\r\n'); for (let k, v in this.headers) { this.output(k); this.output(': '); this.output(v); this.output('\r\n'); } this.output('\r\n'); this.eoh = true; }, // If the content chunk is nil this function will automatically invoke close. write: function(content) { if (content != null) { this.write_headers(); this.output(content); return true; } else { this.close(); } }, redirect: function(url) { this.status(302, 'Found'); this.header('Location', url ?? '/'); this.close(); }, write_json: function(value) { this.write(sprintf('%.J', value)); }, urlencode, urlencode_params, urldecode, urldecode_params, build_querystring }; export default function(env, sourcein, sinkout) { return proto({ input: sourcein ?? default_source, output: sinkout ?? default_sink, // File handler nil by default to let .content() work file: null, // HTTP-Message table message: { env, headers: {}, params: urldecode_params(env?.QUERY_STRING ?? '') }, parsed_input: false }, Class); };