websocket graceful shutdown and safety queue
[brisk.git] / web / Obj / transports.phh
1 <?php
2 /*
3  *  sac-a-push - Obj/transports.phh
4  *
5  *  Copyright (C) 2012 Matteo Nastasi
6  *                          mailto: nastasi@alternativeoutput.it
7  *                                  matteo.nastasi@milug.org
8  *                          web: http://www.alternativeoutput.it
9  *
10  * This program is free software; you can redistribute it and/or modify
11  * it under the terms of the GNU General Public License as published by
12  * the Free Software Foundation; either version 2 of the License, or
13  * (at your option) any later version.
14  *
15  * This program is distributed in the hope that it will be useful, but
16  * WITHOUT ANY WARRANTY; without even the implied warranty of
17  * MERCHANTABLILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18  * General Public License for more details. You should have received a
19  * copy of the GNU General Public License along with this program; if
20  * not, write to the Free Software Foundation, Inc, 59 Temple Place -
21  * Suite 330, Boston, MA 02111-1307, USA.
22  *
23  */
24
25 /*
26  *  test: SO x Browser
27  *  Values: Y: works, N: not works, @: continuous download,
28  *          D: continuous download after first reload
29  *
30  *  Stream IFRAME:
31  *
32  * Iframe| IW | FF | Ch | Op | Ko | IE
33  * ------+----+----+----+----+----+----
34  *   Lnx | D  |    | @  |    | @  | x
35  *   Win | x  | D  | @  | @  |    | D
36  *   Mac | x  |    |    |    |    |
37  *
38  *
39  *   WS  | IW | FF | Ch | Op | Ko | IE
40  * ------+----+----+----+----+----+----
41  *   Lnx |    |    |    |    |    |
42  *   Win |    |    |    |    |    |
43  *   Mac |    |    |    |    |    |
44  *
45  *
46  *   XHR | IW | FF | Ch | Op | Ko | IE
47  * ------+----+----+----+----+----+----
48  *   Lnx | Y  |    | ^D |    | Y  | x
49  *   Win | x  | Y  | Y  |    |    | N
50  *   Mac | x  |    |    |    |    |
51  *
52  *
53  * HtmlFl| IW | FF | Ch | Op | Ko | IE
54  * ------+----+----+----+----+----+----
55  *   Lnx | N  |    |    |    | N  |
56  *   Win | x  | N  | N  |    |    | Y* (* seems delay between click and load of a new page)
57  *   Mac | x  |    |    |    |    |
58  *
59  *
60  */
61
62 class Transport_template {
63
64     function Transport_template() {
65     }
66
67     // return string value is appended to the content of the returned page
68     // return FALSE if fails
69     // check with '===' operator to disambiguation between "" and FALSE return value
70     function init($enc, $header, &$header_out, $init_string, $base, $step)
71     {
72     }
73
74     function close()
75     {
76     }
77
78     function postclose_get($sock, $curtime)
79     {
80         return NULL;
81     }
82
83     function chunk($step, $cont)
84     {
85     }
86
87     function is_chunked()
88     {
89     }
90
91     // return string to add to the stream to perform something to the engine
92     static function fini($init_string, $base, $blockerr)
93     {
94         return "";
95     }
96 }
97
98 define("TRANSP_WS_CLOSE_TOUT", 5);
99
100 class Transport_websocket_postclose {
101     function Transport_websocket_postclose($transp_ws, $sock, $curtime) {
102         printf("POSTCLOSE: Creation\n");
103         $this->transp_ws = $transp_ws;
104         $this->sock = $sock;
105         $this->start =  $curtime;
106         // status not required, currently
107         // $this->status = "begin";
108     }
109
110     function read($payload, $curtime) {
111         if ($this->start + TRANSP_WS_CLOSE_TOUT < $curtime) {
112             printf("POSTCLOSE: Closing ws (%d) force close by timeout\n", $this->sock);
113             return 0;
114         }
115         if (mb_strlen($payload, "ASCII") > 1) {
116             $this->transp_ws->unchunk($payload, $this->sock);
117         }
118         if ($this->transp_ws->hasSentClose) {
119             printf("POSTCLOSE: Closing ws gracefully\n");
120             return 0;
121         }
122         else {
123             printf("POSTCLOSE: not yet finished\n");
124             return 1;
125         }
126     }
127 }
128
129
130 class Transport_websocket {
131     protected $magicGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
132
133     function Transport_websocket($secure = FALSE) {
134         $this->type = ($secure == FALSE ? "websocket" : "websocketsec");
135         $this->headerOriginRequired                 = false;
136         $this->headerSecWebSocketProtocolRequired   = false;
137         $this->headerSecWebSocketExtensionsRequired = false;
138
139         $this->sendingContinuous = false;
140
141         $this->handlingPartialPacket = false;
142         $this->partialMessage = "";
143
144         $this->hasSentClose = false;
145     }
146
147     protected function extractHeaders($message) {
148         $header = array('fin'     => $message[0] & chr(128),
149                         'rsv1'    => $message[0] & chr(64),
150                         'rsv2'    => $message[0] & chr(32),
151                         'rsv3'    => $message[0] & chr(16),
152                         'opcode'  => ord($message[0]) & 15,
153                         'hasmask' => $message[1] & chr(128),
154                         'length'  => 0,
155                         'mask'    => "");
156         $header['length'] = (ord($message[1]) >= 128) ? ord($message[1]) - 128 : ord($message[1]);
157
158         if ($header['length'] == 126) {
159             if ($header['hasmask']) {
160                 $header['mask'] = $message[4] . $message[5] . $message[6] . $message[7];
161             }
162             $header['length'] = ord($message[2]) * 256
163                 + ord($message[3]);
164         } elseif ($header['length'] == 127) {
165             if ($header['hasmask']) {
166                 $header['mask'] = $message[10] . $message[11] . $message[12] . $message[13];
167             }
168             $header['length'] = ord($message[2]) * 65536 * 65536 * 65536 * 256
169                 + ord($message[3]) * 65536 * 65536 * 65536
170                 + ord($message[4]) * 65536 * 65536 * 256
171                 + ord($message[5]) * 65536 * 65536
172                 + ord($message[6]) * 65536 * 256
173                 + ord($message[7]) * 65536
174                 + ord($message[8]) * 256
175                 + ord($message[9]);
176         } elseif ($header['hasmask']) {
177             $header['mask'] = $message[2] . $message[3] . $message[4] . $message[5];
178         }
179         //echo $this->strtohex($message);
180         //$this->printHeaders($header);
181         return $header;
182     }
183
184     protected function extractPayload($message,$headers) {
185         $offset = 2;
186         if ($headers['hasmask']) {
187             $offset += 4;
188         }
189         if ($headers['length'] > 65535) {
190             $offset += 8;
191         } elseif ($headers['length'] > 125) {
192             $offset += 2;
193         }
194         return substr($message,$offset);
195     }
196
197     protected function applyMask($headers,$payload) {
198         $effectiveMask = "";
199         if ($headers['hasmask']) {
200             $mask = $headers['mask'];
201         } else {
202             return $payload;
203         }
204
205         while (mb_strlen($effectiveMask, "ASCII") < mb_strlen($payload, "ASCII")) {
206             $effectiveMask .= $mask;
207         }
208         while (mb_strlen($effectiveMask, "ASCII") > mb_strlen($payload, "ASCII")) {
209             $effectiveMask = substr($effectiveMask,0,-1);
210         }
211         return $effectiveMask ^ $payload;
212     }
213
214     protected function checkRSVBits($headers,$user) { // override this method if you are using an extension where the RSV bits are used.
215         if (ord($headers['rsv1']) + ord($headers['rsv2']) + ord($headers['rsv3']) > 0) {
216             //$this->disconnect($user); // todo: fail connection
217             return true;
218         }
219         return false;
220     }
221
222     protected function strtohex($str) {
223         $strout = "";
224         for ($i = 0; $i < mb_strlen($str, "ASCII"); $i++) {
225             $strout .= (ord($str[$i])<16) ? "0" . dechex(ord($str[$i])) : dechex(ord($str[$i]));
226             $strout .= " ";
227             if ($i%32 == 7) {
228                 $strout .= ": ";
229             }
230             if ($i%32 == 15) {
231                 $strout .= ": ";
232             }
233             if ($i%32 == 23) {
234                 $strout .= ": ";
235             }
236             if ($i%32 == 31) {
237                 $strout .= "\n";
238             }
239         }
240         return $strout . "\n";
241     }
242
243     function unchunk($cont, $sock)
244     {
245         // fprintf(STDERR, "CHUNK: [%s]\n", $cont);
246         return $this->deframe($cont, $sock);
247     }
248
249     function chunk($step, $cont)
250     {
251         // fprintf(STDERR, "CHUNK: [%s]\n", $cont);
252         return $this->frame('@BEGIN@'.$cont.'@END@'); // , 'text', TRUE);
253     }
254
255     protected function frame($message, $messageType='text', $messageContinues=false) {
256         switch ($messageType) {
257         case 'continuous':
258             $b1 = 0;
259             break;
260         case 'text':
261             $b1 = ($this->sendingContinuous) ? 0 : 1;
262             break;
263         case 'binary':
264             $b1 = ($this->sendingContinuous) ? 0 : 2;
265             break;
266         case 'close':
267             $b1 = 8;
268             break;
269         case 'ping':
270             $b1 = 9;
271             break;
272         case 'pong':
273             $b1 = 10;
274             break;
275         }
276         if ($messageContinues) {
277             $this->sendingContinuous = true;
278         } else {
279             $b1 += 128;
280             $this->sendingContinuous = false;
281         }
282
283         $length = mb_strlen($message, "ASCII");
284         $lengthField = "";
285         if ($length < 126) {
286             $b2 = $length;
287         } elseif ($length <= 65536) {
288             $b2 = 126;
289             $hexLength = dechex($length);
290             //$this->stdout("Hex Length: $hexLength");
291             if (mb_strlen($hexLength, "ASCII")%2 == 1) {
292                 $hexLength = '0' . $hexLength;
293             }
294             $n = mb_strlen($hexLength, "ASCII") - 2;
295
296             for ($i = $n; $i >= 0; $i=$i-2) {
297                 $lengthField = chr(hexdec(substr($hexLength, $i, 2))) . $lengthField;
298             }
299             while (mb_strlen($lengthField, "ASCII") < 2) {
300                 $lengthField = chr(0) . $lengthField;
301             }
302         } else {
303             $b2 = 127;
304             $hexLength = dechex($length);
305             if (mb_strlen($hexLength, "ASCII")%2 == 1) {
306                 $hexLength = '0' . $hexLength;
307             }
308             $n = mb_strlen($hexLength, "ASCII") - 2;
309
310             for ($i = $n; $i >= 0; $i=$i-2) {
311                 $lengthField = chr(hexdec(substr($hexLength, $i, 2))) . $lengthField;
312             }
313             while (mb_strlen($lengthField, "ASCII") < 8) {
314                 $lengthField = chr(0) . $lengthField;
315             }
316         }
317
318         return chr($b1) . chr($b2) . $lengthField . $message;
319     }
320
321     protected function deframe($message, $socket) {
322         //echo $this->strtohex($message);
323         $headers = $this->extractHeaders($message);
324         $pongReply = false;
325         $willClose = false;
326         switch($headers['opcode']) {
327         case 0:
328         case 1:
329         case 2:
330             break;
331         case 8:
332             // todo: close the connection
333             $this->hasSentClose = true;
334             return "";
335         case 9:
336             $pongReply = true;
337         case 10:
338             break;
339         default:
340             //$this->disconnect($user); // todo: fail connection
341             $willClose = true;
342             break;
343         }
344
345         if ($this->handlingPartialPacket) {
346             $message = $this->partialBuffer . $message;
347             $this->handlingPartialPacket = false;
348             return $this->deframe($message);
349         }
350
351         if ($this->checkRSVBits($headers,$this)) {
352             return false;
353         }
354
355         if ($willClose) {
356             // todo: fail the connection
357             return false;
358         }
359
360         $payload = $this->partialMessage . $this->extractPayload($message,$headers);
361
362         if ($pongReply) {
363             $reply = $this->frame($payload,$this,'pong');
364             // TODO FIXME ALL socket_write management
365             // socket_write($user->socket,$reply,mb_strlen($reply, "ASCII"));
366             @fwrite($socket, $reply, mb_strlen($reply, "ASCII"));
367             return false;
368         }
369         if (extension_loaded('mbstring')) {
370             if ($headers['length'] > mb_strlen($payload, "ASCII")) {
371                 $this->handlingPartialPacket = true;
372                 $this->partialBuffer = $message;
373                 return false;
374             }
375         } else {
376             if ($headers['length'] > mb_strlen($payload, "ASCII")) {
377                 $this->handlingPartialPacket = true;
378                 $this->partialBuffer = $message;
379                 return false;
380             }
381         }
382
383         $payload = $this->applyMask($headers,$payload);
384
385         if ($headers['fin']) {
386             $this->partialMessage = "";
387             return $payload;
388         }
389         $this->partialMessage = $payload;
390         return false;
391     }
392
393
394     protected function checkHost($hostName) {
395         return true; // Override and return false if the host is not one that you would expect.
396         // Ex: You only want to accept hosts from the my-domain.com domain,
397         // but you receive a host from malicious-site.com instead.
398     }
399
400     protected function checkOrigin($origin) {
401         return true; // Override and return false if the origin is not one that you would expect.
402     }
403
404     protected function checkWebsocProtocol($protocol) {
405         return true; // Override and return false if a protocol is not found that you would expect.
406     }
407
408     protected function checkWebsocExtensions($extensions) {
409         return true; // Override and return false if an extension is not found that you would expect.
410     }
411
412     protected function processProtocol($protocol) {
413         return ""; // return either "Sec-WebSocket-Protocol: SelectedProtocolFromClientList\r\n" or return an empty string.
414         // The carriage return/newline combo must appear at the end of a non-empty string, and must not
415         // appear at the beginning of the string nor in an otherwise empty string, or it will be considered part of
416         // the response body, which will trigger an error in the client as it will not be formatted correctly.
417     }
418
419     protected function processExtensions($extensions) {
420         return ""; // return either "Sec-WebSocket-Extensions: SelectedExtensions\r\n" or return an empty string.
421     }
422
423     function init($enc, $headers, &$headers_out, $init_string, $base, $step)
424     {
425         if (0) { // TODO: what is ?
426             if (isset($headers['get'])) {
427                 $this->requestedResource = $headers['get'];
428             } else {
429                 // todo: fail the connection
430                 $headers_out['HTTP-Response'] = "405 Method Not Allowed";
431             }
432         }
433
434         if (!isset($headers['Host']) || !$this->checkHost($headers['Host'])) {
435             // error_log('bad 1');
436             $headers_out['HTTP-Response'] = "400 Bad Request";
437         }
438         if (!isset($headers['Upgrade']) || strtolower($headers['Upgrade']) != 'websocket') {
439             // error_log('bad 2 ' . $headers['Upgrade']);
440             $headers_out['HTTP-Response'] = "400 Bad Request";
441         }
442         if (!isset($headers['Connection']) || strpos(strtolower($headers['Connection']), 'upgrade') === FALSE) {
443             // error_log('bad 3');
444             $headers_out['HTTP-Response'] = "400 Bad Request";
445         }
446         if (!isset($headers['Sec-Websocket-Key'])) {
447             // error_log('bad 4');
448             $headers_out['HTTP-Response'] = "400 Bad Request";
449         } else {
450         }
451
452         if (!isset($headers['Sec-Websocket-Version']) || strtolower($headers['Sec-Websocket-Version']) != 13) {
453             $headers_out['HTTP-Response'] = "426 Upgrade Required";
454             $headers_out['Sec-WebSocketVersion'] = "13";
455         }
456         if ( ($this->headerOriginRequired && !isset($headers['Origin']) )
457              || ($this->headerOriginRequired && !$this->checkOrigin($headers['Origin'])) ) {
458             $headers_out['HTTP-Response'] = "403 Forbidden";
459         }
460         if ( ($this->headerSecWebSocketProtocolRequired && !isset($headers['Sec-Websocket-Protocol']))
461              || ($this->headerSecWebSocketProtocolRequired &&
462                  !$this->checkWebsocProtocol($headers['Sec-Websocket-Protocol']))) {
463             // error_log('bad 5');
464             $headers_out['HTTP-Response'] = "400 Bad Request";
465         }
466         if ( ($this->headerSecWebSocketExtensionsRequired  && !isset($headers['Sec-Websocket-Extensions']))
467              || ($this->headerSecWebSocketExtensionsRequired &&
468                  !$this->checkWebsocExtensions($headers['Sec-Websocket-Extensions'])) ) {
469             // error_log('bad 6');
470             $headers_out['HTTP-Response'] = "400 Bad Request";
471         }
472
473         if (isset($headers_out['HTTP-Response'])) {
474             // TODO: check return management
475             return (FALSE);
476         }
477
478         // TODO: verify both variables
479         // here there is a change of the socket status from start to handshaked
480         // th headers are saved too but without any further access so we skip it
481
482
483
484         $inno = 'x3JJHMbDL1EzLkh9GBhXDw==';
485         $outo = sha1($inno . $this->magicGUID);
486         $rawToken = "";
487         for ($i = 0; $i < 20; $i++) {
488             $rawToken .= chr(hexdec(substr($outo,$i*2, 2)));
489         }
490
491         $outo = base64_encode($rawToken);
492
493         $webSocketKeyHash = sha1($headers['Sec-Websocket-Key'] . $this->magicGUID);
494         $rawToken = "";
495         for ($i = 0; $i < 20; $i++) {
496             $rawToken .= chr(hexdec(substr($webSocketKeyHash,$i*2, 2)));
497         }
498         $handshakeToken = base64_encode($rawToken);
499         $subProtocol = (isset($headers['Sec-Websocket-Protocol'])) ?
500             $this->processProtocol($headers['Sec-Websocket-Protocol']) : "";
501         $extensions = (isset($headers['Sec-Websocket-Extensions'])) ?
502             $this->processExtensions($headers['Sec-Websocket-Extensions']) : "";
503
504         $headers_out['HTTP-Response'] = "101 Switching Protocols";
505         $headers_out['Upgrade']       = 'websocket';
506         $headers_out['Connection']    = 'Upgrade';
507         $headers_out['Sec-WebSocket-Accept'] = "$handshakeToken$subProtocol$extensions";
508
509         return ("");
510     }
511
512     static function close()
513     {
514         return(chr(0x88).chr(0x02).chr(0xe8).chr(0x03));
515     }
516
517     function postclose_get($sock, $curtime)
518     {
519        return new Transport_websocket_postclose($this, $sock, $curtime);
520     }
521
522     static function fini($init_string, $base, $blockerr)
523     {
524         return (sprintf('@BEGIN@ %s window.onbeforeunload = null; window.onunload = null; document.location.assign("%sindex.php"); @END@',  ($blockerr ? 'xstm.stop(); ' : ''), $base).self::close());
525     }
526
527     function is_chunked()
528     {
529         return FALSE;
530     }
531
532 }
533
534 class Transport_xhr {
535
536     function Transport_xhr() {
537         $this->type = 'xhr';
538     }
539
540     function init($enc, $header, &$header_out, $init_string, $base, $step)
541     {
542         $ret = sprintf("@BEGIN@ /* %s */ @END@", $init_string);
543         if ($enc != 'plain')
544             $header_out['Content-Encoding'] = $enc;
545         $header_out['Cache-Control'] = 'no-cache, must-revalidate';     // HTTP/1.1
546         $header_out['Expires']       = 'Mon, 26 Jul 1997 05:00:00 GMT'; // Date in the past
547         $header_out['Content-type']  = 'application/xml; charset="utf-8"';
548
549         return ($ret);
550     }
551
552     function close()
553     {
554         return "";
555     }
556
557     function postclose_get($sock, $curtime)
558     {
559         return NULL;
560     }
561
562     static function fini($init_string, $base, $blockerr)
563     {
564         return (sprintf('@BEGIN@ %s window.onbeforeunload = null; window.onunload = null; document.location.assign("%sindex.php"); @END@',  ($blockerr ? 'xstm.stop(); ' : ''), $base));
565         return ("");
566     }
567
568     function chunk($step, $cont)
569     {
570         // fprintf(STDERR, "CHUNK: [%s]\n", $cont);
571         return ("@BEGIN@".$cont."@END@");
572     }
573
574     function is_chunked()
575     {
576         return TRUE;
577     }
578 }
579
580 class Transport_iframe {
581
582     function Transport_iframe() {
583         $this->type = 'iframe';
584     }
585
586     function init($enc, $header, &$header_out, $init_string, $base, $step)
587     {
588         $ret = "";
589
590         if ($enc != 'plain')
591             $header_out['Content-Encoding'] = $enc;
592         $header_out['Cache-Control'] = 'no-cache, must-revalidate';     // HTTP/1.1
593         $header_out['Expires']       = 'Mon, 26 Jul 1997 05:00:00 GMT'; // Date in the past
594         $header_out['Content-type']  = 'text/html; charset="utf-8"';
595
596         $ret .= sprintf("<html>
597 <head>
598 <script type=\"text/javascript\" src=\"%scommons.js\"></script>
599 <script type=\"text/javascript\" src=\"%sxynt-streaming-ifra.js\"></script>
600 <script type=\"text/javascript\">
601 var xynt_streaming = \"ready\";", $base, $base);
602         if ($step > 0)
603             $ret .= sprintf("last_clean = %d;\n", ($step-1));
604         $ret .= sprintf("
605 window.onload = function () { try { if (xynt_streaming != \"ready\") { xynt_streaming.transp.stopped = true; } } catch(e) { /* console.log(\"catcha\"); */ } };
606 </script>
607 </head>
608 <body>");
609         $ret .= sprintf("<!-- \n%s -->\n", $init_string);
610
611         return ($ret);
612     }
613
614     function close()
615     {
616         return "";
617     }
618
619     function postclose_get($sock, $curtime)
620     {
621         return NULL;
622     }
623
624     static function fini($init_string, $base, $blockerr)
625     {
626         $ret = "";
627         $ret .= sprintf("<html>
628 <head>
629 <script type=\"text/javascript\" src=\"%scommons.js\"></script>
630 <script type=\"text/javascript\" src=\"%sxynt-streaming-ifra.js\"></script>
631 <script type=\"text/javascript\">
632 var xynt_streaming = \"ready\";", $base, $base);
633         $ret .= sprintf("
634 window.onload = function () { try { if (xynt_streaming != \"ready\") { xynt_streaming.reload(); } } catch(e) { /* console.log(\"catcha\"); */ } };
635 </script>
636 </head>
637 <body>");
638         $ret .= sprintf("<!-- \n%s -->\n", $init_string);
639         $ret .= sprintf("<script id='hs%d' type='text/javascript'><!--
640 push(\"%s\");
641 // -->
642 </script>", 0, escpush($blockerr) );
643         return ($ret);
644     }
645
646     function chunk($step, $cont)
647     {
648         // fprintf(STDERR, "CHUNK: [%s]\n", $cont);
649         if ($cont == NULL) {
650             return sprintf("<script id='hs%d' type='text/javascript'><!--
651 push(null);\n// -->\n</script>", $step);
652         }
653         else {
654             return sprintf("<script id='hs%d' type='text/javascript'><!--
655 push(\"%s\");\n// -->\n</script>", $step, escpush($cont) );
656         }
657     }
658
659     function is_chunked()
660     {
661         return TRUE;
662     }
663 }
664
665 class Transport_htmlfile extends Transport_iframe {
666     function Transport_htmlfile() {
667         $this->type = 'htmlfile';
668     }
669
670     function postclose_get($sock, $curtime)
671     {
672         return NULL;
673     }
674 }
675
676 class Transport {
677     function Transport()
678     {
679     }
680
681     static function create($transp)
682     {
683         if ($transp == 'websocket' || $transp == 'websocketsec') {
684             return new Transport_websocket($transp == 'websocketsec');
685         }
686         else if ($transp == 'xhr') {
687             return new Transport_xhr();
688         }
689         else if ($transp == 'htmlfile') {
690             return new Transport_htmlfile();
691         }
692         else  {
693             return new Transport_iframe();
694         }
695     }
696     static function gettype($transp)
697     {
698         if ($transp == 'websocket' || $transp == 'xhr' || $transp == 'htmlfile') {
699             return "Transport_".$transp;
700         }
701         else {
702             return 'Transport_iframe';
703         }
704     }
705 }
706 ?>