579dda3cedcaaa0bd625cace3c685de6fc67a069
[project/luci.git] / modules / luci-base / ucode / http.uc
1 // Copyright 2022 Jo-Philipp Wich <jo@mein.io>
2 // Licensed to the public under the Apache License 2.0.
3
4 import {
5 urlencode as _urlencode,
6 urldecode as _urldecode,
7 urlencoded_parser, multipart_parser, header_attribute,
8 ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS
9 } from 'lucihttp';
10
11 import {
12 error as fserror,
13 stdin, stdout, mkstemp
14 } from 'fs';
15
16 // luci.http module scope
17 export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size
18
19 // Decode a mime encoded http message body with multipart/form-data
20 // Content-Type. Stores all extracted data associated with its parameter name
21 // in the params table within the given message object. Multiple parameter
22 // values are stored as tables, ordinary ones as strings.
23 // If an optional file callback function is given then it is fed with the
24 // file contents chunk by chunk and only the extracted file name is stored
25 // within the params table. The callback function will be called subsequently
26 // with three arguments:
27 // o Table containing decoded (name, file) and raw (headers) mime header data
28 // o String value containing a chunk of the file data
29 // o Boolean which indicates whether the current chunk is the last one (eof)
30 export function mimedecode_message_body(src, msg, file_cb) {
31 let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
32 let err, header, field, parser;
33
34 parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) {
35 if (what == parser.PART_INIT) {
36 field = {};
37 }
38 else if (what == parser.HEADER_NAME) {
39 header = lc(buffer);
40 }
41 else if (what == parser.HEADER_VALUE && header) {
42 if (lc(header) == 'content-disposition' &&
43 header_attribute(buffer, null) == 'form-data') {
44 field.name = header_attribute(buffer, 'name');
45 field.file = header_attribute(buffer, 'filename');
46 field[1] = field.file;
47 }
48
49 field.headers = field.headers || {};
50 field.headers[header] = buffer;
51 }
52 else if (what == parser.PART_BEGIN) {
53 return !field.file;
54 }
55 else if (what == parser.PART_DATA && field.name && length > 0) {
56 if (field.file) {
57 if (file_cb) {
58 file_cb(field, buffer, false);
59
60 msg.params[field.name] = msg.params[field.name] || field;
61 }
62 else {
63 if (!field.fd)
64 field.fd = mkstemp(field.name);
65
66 if (field.fd) {
67 field.fd.write(buffer);
68 msg.params[field.name] = msg.params[field.name] || field;
69 }
70 }
71 }
72 else {
73 field.value = buffer;
74 }
75 }
76 else if (what == parser.PART_END && field.name) {
77 if (field.file && msg.params[field.name]) {
78 if (file_cb)
79 file_cb(field, '', true);
80 else if (field.fd)
81 field.fd.seek(0);
82 }
83 else {
84 let val = msg.params[field.name];
85
86 if (type(val) == 'array')
87 push(val, field.value || '');
88 else if (val != null)
89 msg.params[field.name] = [ val, field.value || '' ];
90 else
91 msg.params[field.name] = field.value || '';
92 }
93
94 field = null;
95 }
96 else if (what == parser.ERROR) {
97 err = buffer;
98 }
99
100 return true;
101 }, HTTP_MAX_CONTENT);
102
103 while (true) {
104 let chunk = src();
105
106 len += length(chunk);
107
108 if (maxlen && len > maxlen + 2)
109 die('Message body size exceeds Content-Length');
110
111 if (!parser.parse(chunk))
112 die(err);
113
114 if (chunk == null)
115 break;
116 }
117 };
118
119 // Decode an urlencoded http message body with application/x-www-urlencoded
120 // Content-Type. Stores all extracted data associated with its parameter name
121 // in the params table within the given message object. Multiple parameter
122 // values are stored as tables, ordinary ones as strings.
123 export function urldecode_message_body(src, msg) {
124 let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
125 let err, name, value, parser;
126
127 parser = urlencoded_parser(function (what, buffer, length) {
128 if (what == parser.TUPLE) {
129 name = null;
130 value = null;
131 }
132 else if (what == parser.NAME) {
133 name = _urldecode(buffer, DECODE_PLUS);
134 }
135 else if (what == parser.VALUE && name) {
136 let val = msg.params[name];
137
138 if (type(val) == 'array')
139 push(val, _urldecode(buffer, DECODE_PLUS) || '');
140 else if (val != null)
141 msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ];
142 else
143 msg.params[name] = _urldecode(buffer, DECODE_PLUS) || '';
144 }
145 else if (what == parser.ERROR) {
146 err = buffer;
147 }
148
149 return true;
150 }, HTTP_MAX_CONTENT);
151
152 while (true) {
153 let chunk = src();
154
155 len += length(chunk);
156
157 if (maxlen && len > maxlen + 2)
158 die('Message body size exceeds Content-Length');
159
160 if (!parser.parse(chunk))
161 die(err);
162
163 if (chunk == null)
164 break;
165 }
166 };
167
168 // This function will examine the Content-Type within the given message object
169 // to select the appropriate content decoder.
170 // Currently the application/x-www-urlencoded and application/form-data
171 // mime types are supported. If the encountered content encoding can't be
172 // handled then the whole message body will be stored unaltered as 'content'
173 // property within the given message object.
174 export function parse_message_body(src, msg, filecb) {
175 if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') {
176 let ctype = header_attribute(msg.env.CONTENT_TYPE, null);
177
178 // Is it multipart/mime ?
179 if (ctype == 'multipart/form-data')
180 return mimedecode_message_body(src, msg, filecb);
181
182 // Is it application/x-www-form-urlencoded ?
183 else if (ctype == 'application/x-www-form-urlencoded')
184 return urldecode_message_body(src, msg);
185
186 // Unhandled encoding
187 // If a file callback is given then feed it chunk by chunk, else
188 // store whole buffer in message.content
189 let sink;
190
191 // If we have a file callback then feed it
192 if (type(filecb) == 'function') {
193 let meta = {
194 name: 'raw',
195 encoding: msg.env.CONTENT_TYPE
196 };
197
198 sink = (chunk) => {
199 if (chunk != null)
200 return filecb(meta, chunk, false);
201 else
202 return filecb(meta, null, true);
203 };
204 }
205
206 // ... else append to .content
207 else {
208 let chunks = [], len = 0;
209
210 sink = (chunk) => {
211 len += length(chunk);
212
213 if (len > HTTP_MAX_CONTENT)
214 die('POST data exceeds maximum allowed length');
215
216 if (chunk != null) {
217 push(chunks, chunk);
218 }
219 else {
220 msg.content = join('', chunks);
221 msg.content_length = len;
222 }
223 };
224 }
225
226 // Pump data...
227 while (true) {
228 let chunk = src();
229
230 sink(chunk);
231
232 if (chunk == null)
233 break;
234 }
235
236 return true;
237 }
238
239 return false;
240 };
241
242 export function build_querystring(q) {
243 let s = [];
244
245 for (let k, v in q) {
246 push(s,
247 length(s) ? '&' : '?',
248 _urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k,
249 '=',
250 _urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v
251 );
252 }
253
254 return join('', s);
255 };
256
257 export function urlencode(value) {
258 if (value == null)
259 return null;
260
261 value = '' + value;
262
263 return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value;
264 };
265
266 export function urldecode(value, decode_plus) {
267 if (value == null)
268 return null;
269
270 value = '' + value;
271
272 return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value;
273 };
274
275 // Extract and split urlencoded data pairs, separated bei either "&" or ";"
276 // from given url or string. Returns a table with urldecoded values.
277 // Simple parameters are stored as string values associated with the parameter
278 // name within the table. Parameters with multiple values are stored as array
279 // containing the corresponding values.
280 export function urldecode_params(url, tbl) {
281 let parser, name, value;
282 let params = tbl || {};
283
284 parser = urlencoded_parser(function(what, buffer, length) {
285 if (what == parser.TUPLE) {
286 name = null;
287 value = null;
288 }
289 else if (what == parser.NAME) {
290 name = _urldecode(buffer);
291 }
292 else if (what == parser.VALUE && name) {
293 params[name] = _urldecode(buffer) || '';
294 }
295
296 return true;
297 });
298
299 if (parser) {
300 let m = match(('' + (url || '')), /[^?]*$/);
301
302 parser.parse(m ? m[0] : '');
303 parser.parse(null);
304 }
305
306 return params;
307 };
308
309 // Encode each key-value-pair in given table to x-www-urlencoded format,
310 // separated by '&'. Tables are encoded as parameters with multiple values by
311 // repeating the parameter name with each value.
312 export function urlencode_params(tbl) {
313 let enc = [];
314
315 for (let k, v in tbl) {
316 if (type(v) == 'array') {
317 for (let v2 in v) {
318 if (length(enc))
319 push(enc, '&');
320
321 push(enc,
322 _urlencode(k),
323 '=',
324 _urlencode('' + v2));
325 }
326 }
327 else {
328 if (length(enc))
329 push(enc, '&');
330
331 push(enc,
332 _urlencode(k),
333 '=',
334 _urlencode('' + v));
335 }
336 }
337
338 return join(enc, '');
339 };
340
341
342 // Default IO routines suitable for CGI invocation
343 let avail_len = +getenv('CONTENT_LENGTH');
344
345 const default_source = () => {
346 let rlen = min(avail_len, 4096);
347
348 if (rlen == 0) {
349 stdin.close();
350
351 return null;
352 }
353
354 let chunk = stdin.read(rlen);
355
356 if (chunk == null)
357 die(`Input read error: ${fserror()}`);
358
359 avail_len -= length(chunk);
360
361 return chunk;
362 };
363
364 const default_sink = (...chunks) => {
365 for (let chunk in chunks)
366 stdout.write(chunk);
367
368 stdout.flush();
369 };
370
371 const Class = {
372 formvalue: function(name, noparse) {
373 if (!noparse && !this.parsed_input)
374 this._parse_input();
375
376 if (name != null)
377 return this.message.params[name];
378 else
379 return this.message.params;
380 },
381
382 formvaluetable: function(prefix) {
383 let vals = {};
384
385 prefix = (prefix || '') + '.';
386
387 if (!this.parsed_input)
388 this._parse_input();
389
390 for (let k, v in this.message.params)
391 if (index(k, prefix) == 0)
392 vals[substr(k, length(prefix))] = '' + v;
393
394 return vals;
395 },
396
397 content: function() {
398 if (!this.parsed_input)
399 this._parse_input();
400
401 return this.message.content;
402 },
403
404 getcookie: function(name) {
405 return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name);
406 },
407
408 getenv: function(name) {
409 if (name != null)
410 return this.message.env[name];
411 else
412 return this.message.env;
413 },
414
415 setfilehandler: function(callback) {
416 if (type(callback) == 'resource' && type(callback.call) == 'function')
417 this.filehandler = (...args) => callback.call(...args);
418 else if (type(callback) == 'function')
419 this.filehandler = callback;
420 else
421 die('Invalid callback argument for setfilehandler()');
422
423 if (!this.parsed_input)
424 return;
425
426 // If input has already been parsed then uploads are stored as unlinked
427 // temporary files pointed to by open file handles in the parameter
428 // value table. Loop all params, and invoke the file callback for any
429 // param with an open file handle.
430 for (let name, value in this.message.params) {
431 while (value?.fd) {
432 let data = value.fd.read(1024);
433 let eof = (data == null || data == '');
434
435 this.filehandler(value, data, eof);
436
437 if (eof) {
438 value.fd.close();
439 value.fd = null;
440 }
441 }
442 }
443 },
444
445 _parse_input: function() {
446 parse_message_body(
447 this.input,
448 this.message,
449 this.filehandler
450 );
451
452 this.parsed_input = true;
453 },
454
455 close: function() {
456 this.write_headers();
457 this.closed = true;
458 },
459
460 header: function(key, value) {
461 this.headers ??= {};
462 this.headers[lc(key)] = value;
463 },
464
465 prepare_content: function(mime) {
466 if (!this.headers?.['content-type']) {
467 if (mime == 'application/xhtml+xml') {
468 if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) {
469 mime = 'text/html; charset=UTF-8';
470 this.header('Vary', 'Accept');
471 }
472 }
473
474 this.header('Content-Type', mime);
475 }
476 },
477
478 status: function(code, message) {
479 this.status_code = code ?? 200;
480 this.status_message = message ?? 'OK';
481 },
482
483 write_headers: function() {
484 if (this.eoh)
485 return;
486
487 if (!this.status_code)
488 this.status();
489
490 if (!this.headers?.['content-type'])
491 this.header('Content-Type', 'text/html; charset=UTF-8');
492
493 if (!this.headers?.['cache-control']) {
494 this.header('Cache-Control', 'no-cache');
495 this.header('Expires', '0');
496 }
497
498 if (!this.headers?.['x-frame-options'])
499 this.header('X-Frame-Options', 'SAMEORIGIN');
500
501 if (!this.headers?.['x-xss-protection'])
502 this.header('X-XSS-Protection', '1; mode=block');
503
504 if (!this.headers?.['x-content-type-options'])
505 this.header('X-Content-Type-Options', 'nosniff');
506
507 this.output('Status: ');
508 this.output(this.status_code);
509 this.output(' ');
510 this.output(this.status_message);
511 this.output('\r\n');
512
513 for (let k, v in this.headers) {
514 this.output(k);
515 this.output(': ');
516 this.output(v);
517 this.output('\r\n');
518 }
519
520 this.output('\r\n');
521
522 this.eoh = true;
523 },
524
525 // If the content chunk is nil this function will automatically invoke close.
526 write: function(content) {
527 if (content != null && !this.closed) {
528 this.write_headers();
529 this.output(content);
530
531 return true;
532 }
533 else {
534 this.close();
535 }
536 },
537
538 redirect: function(url) {
539 this.status(302, 'Found');
540 this.header('Location', url ?? '/');
541 this.close();
542 },
543
544 write_json: function(value) {
545 this.write(sprintf('%.J', value));
546 },
547
548 urlencode,
549 urlencode_params,
550
551 urldecode,
552 urldecode_params,
553
554 build_querystring
555 };
556
557 export default function(env, sourcein, sinkout) {
558 return proto({
559 input: sourcein ?? default_source,
560 output: sinkout ?? default_sink,
561
562 // File handler nil by default to let .content() work
563 file: null,
564
565 // HTTP-Message table
566 message: {
567 env,
568 headers: {},
569 params: urldecode_params(env?.QUERY_STRING ?? '')
570 },
571
572 parsed_input: false
573 }, Class);
574 };