📜 ⬆️ ⬇️

Let's look at Source-Query.


Source-Query Concept
Source-Query, used to display information about the server, it is also used to manage the server through RCON commands. Source-Query has developed xPaw. The servers use the “Source” protocol, so the “Source-Query” receives data about the server.

Data types
All server requests consist of five basic data types packed together in a data stream. All types have a slightly inverse byte order.
Example
1. Byte 8 bit symbol or unsigned integer
2. Short-16-bit integer
3. Long 32 bit integer
4. Floating 32 bit floating point
5. Long-long- 64 bit integer unsigned
String-byte variable-length field, encoded in "UTF-8", which are terminated by "0x00"

Protocol
')
Since "Source-Query" was created to receive and manage servers "Steam". Steam uses a packet size of 1400 bytes + "IP / UDP" headers If the request or response needs more data packets, then it launches packets with an additional header.

Supported Games
Counter-Strike 1.6
Counter-Strike: Global Offensive
Team Fortress 2
Left 4 Dead 2
Rag Doll Kung Fu
The Ship
Garry's Mod
Nuclear Dawn
Dino D-Day
Arma 3
Call of Duty: Modern Warfare 3
Starbound
Space Engineers
Rust
Quake Live
ARK: Survival Evolved
Minecraft

The list of supported games is updated and expanded. If you want to find out if Source-Query supports the game of your choice, simply add the game to Steam as a favorite, if Steam shows you information about the server, it is supported by Source-Query

Functions
 Connect( $Ip, $Port, $Timeout, $Engine )     Disconnect( )       Ping( )   GetInfo( )       GetPlayers( )       GetRules( )    (CVars)   SetRconPassword( $Password )  RCON ,     RCON ()  RCON ,     RCON () Rcon( $Command )  RCON     RCON    


Requirements

PHP (5.5 )
64-bit PHP "GMP" (sudo apt-get install php5-gmp) "PHP-GMP"
- UDP


Usage example
 <?php require __DIR__ . '/../SourceQuery/bootstrap.php'; use xPaw\SourceQuery\SourceQuery; // For the sake of this example Header( 'Content-Type: text/plain' ); Header( 'X-Content-Type-Options: nosniff' ); // Edit this -> define( 'SQ_SERVER_ADDR', 'localhost' ); define( 'SQ_SERVER_PORT', 27015 ); define( 'SQ_TIMEOUT', 1 ); define( 'SQ_ENGINE', SourceQuery::SOURCE ); // Edit this <- $Query = new SourceQuery( ); try { $Query->Connect( SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE ); print_r( $Query->GetInfo( ) ); print_r( $Query->GetPlayers( ) ); print_r( $Query->GetRules( ) ); } catch( Exception $e ) { echo $e->getMessage( ); } finally { $Query->Disconnect( ); } 

Sample page
Sample page
 <?php require __DIR__ . '/../SourceQuery/bootstrap.php'; use xPaw\SourceQuery\SourceQuery; // Edit this -> define( 'SQ_SERVER_ADDR', 'localhost' ); define( 'SQ_SERVER_PORT', 27015 ); define( 'SQ_TIMEOUT', 3 ); define( 'SQ_ENGINE', SourceQuery::SOURCE ); // Edit this <- $Timer = MicroTime( true ); $Query = new SourceQuery( ); $Info = Array( ); $Rules = Array( ); $Players = Array( ); try { $Query->Connect( SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE ); //$Query->SetUseOldGetChallengeMethod( true ); // Use this when players/rules retrieval fails on games like Starbound $Info = $Query->GetInfo( ); $Players = $Query->GetPlayers( ); $Rules = $Query->GetRules( ); } catch( Exception $e ) { $Exception = $e; } finally { $Query->Disconnect( ); } $Timer = Number_Format( MicroTime( true ) - $Timer, 4, '.', '' ); ?> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Source Query PHP Library</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"> <style type="text/css"> .table { table-layout: fixed; border-top-color: #428BCA; } .table td { overflow-x: auto; } .table thead th { background-color: #428BCA; border-color: #428BCA !important; color: #FFF; } .info-column { width: 120px; } .frags-column { width: 80px; } </style> </head> <body> <div class="jumbotron"> <div class="container"> <h1>Source Query PHP Library</h1> <p class="lead">This library was created to query game server which use the Source (Steamworks) query protocol.</p> <p> <a class="btn btn-large btn-primary" href="https://xpaw.me">Made by xPaw</a> <a class="btn btn-large btn-primary" href="https://github.com/xPaw/PHP-Source-Query">View on GitHub</a> <a class="btn btn-large btn-danger" href="https://github.com/xPaw/PHP-Source-Query/blob/master/LICENSE">LGPL v2.1</a> </p> </div> </div> <div class="container"> <?php if( isset( $Exception ) ): ?> <div class="panel panel-primary"> <div class="panel-heading"><?php echo Get_Class( $Exception ); ?> at line <?php echo $Exception->getLine( ); ?></div> <p><b><?php echo htmlspecialchars( $Exception->getMessage( ) ); ?></b></p> <p><?php echo nl2br( $e->getTraceAsString(), false ); ?></p> </div> <?php else: ?> <div class="row"> <div class="col-sm-6"> <table class="table table-bordered table-striped"> <thead> <tr> <th class="info-column">Server Info</th> <th><span class="label label-<?php echo $Timer > 1.0 ? 'danger' : 'success'; ?>"><?php echo $Timer; ?>s</span></th> </tr> </thead> <tbody> <?php if( Is_Array( $Info ) ): ?> <?php foreach( $Info as $InfoKey => $InfoValue ): ?> <tr> <td><?php echo htmlspecialchars( $InfoKey ); ?></td> <td><?php if( Is_Array( $InfoValue ) ) { echo "<pre>"; print_r( $InfoValue ); echo "</pre>"; } else { if( $InfoValue === true ) { echo 'true'; } else if( $InfoValue === false ) { echo 'false'; } else { echo htmlspecialchars( $InfoValue ); } } ?></td> </tr> <?php endforeach; ?> <?php else: ?> <tr> <td colspan="2">No information received</td> </tr> <?php endif; ?> </tbody> </table> </div> <div class="col-sm-6"> <table class="table table-bordered table-striped"> <thead> <tr> <th>Player <span class="label label-info"><?php echo count( $Players ); ?></span></th> <th class="frags-column">Frags</th> <th class="frags-column">Time</th> </tr> </thead> <tbody> <?php if( !empty( $Players ) ): ?> <?php foreach( $Players as $Player ): ?> <tr> <td><?php echo htmlspecialchars( $Player[ 'Name' ] ); ?></td> <td><?php echo $Player[ 'Frags' ]; ?></td> <td><?php echo $Player[ 'TimeF' ]; ?></td> </tr> <?php endforeach; ?> <?php else: ?> <tr> <td colspan="3">No players received</td> </tr> <?php endif; ?> </tbody> </table> </div> </div> <div class="row"> <div class="col-sm-12"> <table class="table table-bordered table-striped"> <thead> <tr> <th colspan="2">Rules <span class="label label-info"><?php echo count( $Rules ); ?></span></th> </tr> </thead> <tbody> <?php if( Is_Array( $Rules ) ): ?> <?php foreach( $Rules as $Rule => $Value ): ?> <tr> <td><?php echo htmlspecialchars( $Rule ); ?></td> <td><?php echo htmlspecialchars( $Value ); ?></td> </tr> <?php endforeach; ?> <?php else: ?> <tr> <td colspan="2">No rules received</td> </tr> <?php endif; ?> </tbody> </table> </div> </div> <?php endif; ?> </div> </body> </html> 



Rcon usage example
Rcon example
 <?php require __DIR__ . '/../SourceQuery/bootstrap.php'; use xPaw\SourceQuery\SourceQuery; // For the sake of this example Header( 'Content-Type: text/plain' ); Header( 'X-Content-Type-Options: nosniff' ); // Edit this -> define( 'SQ_SERVER_ADDR', 'localhost' ); define( 'SQ_SERVER_PORT', 27015 ); define( 'SQ_TIMEOUT', 1 ); define( 'SQ_ENGINE', SourceQuery::SOURCE ); // Edit this <- $Query = new SourceQuery( ); try { $Query->Connect( SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE ); $Query->SetRconPassword( 'my_awesome_password' ); var_dump( $Query->Rcon( 'say hello' ) ); } catch( Exception $e ) { echo $e->getMessage( ); } finally { $Query->Disconnect( ); } 


Sample page {2}
Sample page {2}
 <?php //   require __DIR__ . '/SourceQuery/SourceQuery.class.php'; // IP $ip = '31.135.208.99'; //   $port = 27203; //  $timeout = 1; //  $Query = new SourceQuery(); //   - .         try { $Query->Connect($ip, $port, $timeout, SourceQuery :: GOLDSOURCE); } catch(Exception $e) { //   ,     ,        ,   exit($e->getMessage()); } //        $info = $Query->GetInfo(); //        $players = $Query->GetPlayers(); //    $Query->Disconnect(); ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>   </title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"> </head> <body> <div class="container"> <div class="panel panel-default"> <div class="panel-heading"> <h3></h3> </div> <div class="panel-body"> <table class="table table-bordered"> <?php if($info):?> <tr> <th> </th> <td><?php echo $info['HostName']?></td> </tr> <tr> <th>  </th> <td><?php echo $ip . ':' . $port?></td> </tr> <tr> <th></th> <td><?php echo $info['Map']?></td> </tr> <tr> <th> </th> <td><?php echo $info['Players']?></td> </tr> <tr> <th> </th> <td><?php echo $info['MaxPlayers']?></td> </tr> <?php else:?> <tr> <th>  .   </th> </tr> <?php endif;?> </table> </div> </div> <div class="panel panel-default"> <div class="panel-heading"> <h3></h3> </div> <div class="panel-body"> <table class="table table-bordered table-condenced"> <?php if($players):?> <thead> <tr> <th></th> <th></th> <th></th> </tr> </thead> <tbody> <?php foreach($players as $player):?> <tr> <td><?php echo htmlspecialchars($player['Name'], ENT_QUOTES)?></td> <td><?php echo intval($player['Frags'])?></td> <td><?php echo $player['TimeF']?></td> </tr> <?php endforeach;?> </tbody> <?php else:?> <tr><td> </td></tr> <?php endif;?> </table> </div> </div> </div> </body> </html> 


Teamspeak

You can also use Source-Query for your own purposes. You can display information about the server, as in the header of the publication, the banner will be updated every 30 seconds. after displaying the server information.

Example for "TeamSpeak"

TS Example for TS
 <?php require_once('libraries/TeamSpeak3/TeamSpeak3.php'); require_once('libraries/SourceQuery/bootstrap.php'); use xPaw\SourceQuery\SourceQuery; $replacer = [ "ts3" => [ "%status%" => "virtualserver_status", "%sid%" => "virtualserver_id", "%sport%" => "virtualserver_port", "%platform%" => "virtualserver_platform", "%servername%" => "virtualserver_name", "%serverversion%" => "virtualserver_version", "%maxclients%" => "virtualserver_maxclients", "%clientsonline%" => "virtualserver_clientsonline", "%channelcount%" => "virtualserver_channelsonline", "%packetloss%" => "virtualserver_total_packetloss_total", "%ping%" => "virtualserver_total_ping" ] ]; $package = [ 'general' => [ 'static' => [ '%timeHi%', '%timeHis%', '%date%' ] ], 'ts3' => [ 'static' => [ '%status%', '%sid%', '%sport%', '%platform%', '%servername%', '%serverversion%', '%packetloss_floored%', '%ping_floored%', '%packetloss_00%', '%maxclients%', '%clientsonline%', '%channelcount%', '%packetloss%', '%ping%', '%realclients%', '%nickname%' ], 'regex' => [ "%groupcount\[([0-9,]{0,})\]%", ], ], ]; function getIp() { if (isset($_SERVER['HTTP_CLIENT_IP'])) return $_SERVER['HTTP_CLIENT_IP']; else if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) return $_SERVER['HTTP_X_FORWARDED_FOR']; else if(isset($_SERVER['HTTP_X_FORWARDED'])) return $_SERVER['HTTP_X_FORWARDED']; else if(isset($_SERVER['HTTP_FORWARDED_FOR'])) return $_SERVER['HTTP_FORWARDED_FOR']; else if(isset($_SERVER['HTTP_FORWARDED'])) return $_SERVER['HTTP_FORWARDED']; else if(isset($_SERVER['REMOTE_ADDR'])) return $_SERVER['REMOTE_ADDR']; else return NULL; } function paintText($image, $fontsize, $xpos, $ypos, $color, $fontfile, $text) { $hex = str_replace("#", "", $color); if(strlen($hex) == 3) { $r = hexdec(substr($hex,0,1).substr($hex,0,1)); $g = hexdec(substr($hex,1,1).substr($hex,1,1)); $b = hexdec(substr($hex,2,1).substr($hex,2,1)); } else { $r = hexdec(substr($hex,0,2)); $g = hexdec(substr($hex,2,2)); $b = hexdec(substr($hex,4,2)); } imagettftext($image,$fontsize,0,$xpos,$ypos,imagecolorallocate($image, $r, $g, $b),$fontfile,$text); return; } function getImage() { global $config; $packetmanager = json_decode(file_get_contents('cache/packages.json'), 1); $image = imagecreatefrompng('cache/cached_img'); if (in_array('ts3', $packetmanager['packages'])) { require_once('cache/clients.php'); if (!empty($nicklist[getIp()])) { $nickname = $nicklist[getIp()]; foreach ($config['textfield'] as $field) { if (strpos($field['text'], '%nickname%') !== FALSE) { paintText($image, $field['fontsize'], $field['xpos'], $field['ypos'], $field['color'], $field['fontfile'], str_replace('%nickname%', $nickname, $field['text'])); } } } } return $image; } try { if (!function_exists('imagettftext')) { throw new Exception ('PHP-GD not installed'); } if (!is_writable('cache/')) { throw new Exception ('    \'cache/\'  '); } if (!file_exists('config.php' )) { throw new Exception ('config.php does not exist'); } else { require_once('config.php' ); } if (!file_exists('cache/packages.json') || filemtime('cache/packages.json') < filemtime('config.php')) { $packages = []; if (count($config['sourcequery']) > 0 and $config['sqenable']) $packages[] = 'sq'; foreach ($config['textfield'] as $txt) { foreach ($package as $key => $pkg) { if (array_key_exists('static', $pkg)) { foreach ($pkg['static'] as $static) { if (strpos($txt['text'], $static) !== false and !in_array($key, $packages)) $packages[] = $key; } } if (array_key_exists('regex', $pkg)) { foreach ($pkg['regex'] as $regex) { preg_match_all('/'.$regex.'/i', $txt['text'], $out); if (count($out[0]) > 0 and !in_array($key, $packages)) { $packages[] = $key; } } } } } $packagefile = fopen('cache/packages.json', 'w+'); fwrite($packagefile, json_encode(['packages' => $packages],1)); fclose($packagefile); } $packetmanager = json_decode(file_get_contents('cache/packages.json'), 1); if ( ( file_exists('cache/cached_img') and filemtime('cache/cached_img') > (time() - $config['syncintervall']) //and strpos('TeamSpeak3', $_SERVER['HTTP_USER_AGENT']) !== FALSE ) || ( file_exists('cache/cache.lock') ) ){ $i = 0; while (file_exists('cache/cache.lock')) { if ($i >= 10) throw new Exception ('Cache Lock exists... Please Remove the File \'cache.lock\' in Folder \'cache\' manually if it still exists after this Error!'); $i++; sleep(1); } header('Content-Type: image/png'); imagepng(getImage()); die(); } fclose(fopen("cache/cache.lock", "w+")); if (!file_exists($config['backgroundimage'])) throw new Exception ('Error! Background Image not found! Check your config! Searched at '.$config['backgroundimage']); $image = imagecreatefrompng($config['backgroundimage']); if (in_array('ts3', $packetmanager['packages'])) { try { $ts3 = TeamSpeak3::factory( "serverquery://". $config['teamspeak']['loginname'] .":". $config['teamspeak']['loginpass'] ."@". $config['teamspeak']['ip'] .":". $config['teamspeak']['queryport'] ."/?server_port=". $config['teamspeak']['serverport'] ."&nickname=Banner%20Generator" ); } catch (Exception $e) { if (!strpos('TeamSpeak3-ImageFetcher', $_SERVER['HTTP_USER_AGENT'])) throw $e; $i = 0; while (file_exists('cache/cache.lock')) { if ($i >= 10) throw new Exception ('Cache Lock exists... Please Remove the File \'cache.lock\' in Folder \'cache\' manually if it still exists after this Error!'); $i++; sleep(1); } header('Content-Type: image/png'); imagepng(getImage()); die(); } $groupcount = []; $serverinfo = $ts3->getInfo(); $clients = $ts3->clientList(['client_type' => 0]); foreach ($clients as $client) { $ts3clients[htmlentities($client->connection_client_ip)] = htmlentities($client->client_nickname); } $clientcache = fopen('cache/clients.php', 'w+'); fwrite($clientcache, '<?php $nicklist = json_decode(\''.str_replace("'", "\'", json_encode($ts3clients, 1)).'\',1);'); fclose($clientcache); } if (in_array('sq', $packetmanager['packages'])) { $sqinfo = []; foreach ($config['sourcequery'] as $server => $conf) { try { $sq = new SourceQuery(); $sq->Connect($conf['ip'], $conf['port'], $conf['timeout'], SourceQuery::SOURCE); $y = 12; foreach ($sq->GetInfo() as $key => $value) { $sqinfo[$server][$key] = $value; if ($conf['debug']) { $color = imagecolorallocate($image, 255, 0, 0); imagettftext($image, 9, 0, 4, $y, $color, $config['sqlistfont'], 'Use "%sqinfo['.$server.']['.$key.']%" in Textfield to Display "'.$value.'"'); $y = $y + 11; } } $sq->Disconnect(); } catch (Exception $e) { $color = imagecolorallocate($image, 255, 0, 0); imagettftext($image, 11, 0, 4, 15, $color, $config['sqlistfont'], 'SourceQuery Error on Server "'.$server.'":'.$e->getMessage() ); } } } foreach ($config['textfield'] as $field) { if (!file_exists($field['fontfile'])) throw new Exception ('Font File not found! Searched at '.$field['fontfile'].PHP_EOL.'You may need to set the absolute path (from root directory /var/www/...)'); if (strpos($field['text'], '%nickname%') !== FALSE) continue; if (in_array('sq', $packetmanager['packages'])) { $field['text'] = preg_replace_callback('/\%sqinfo\[(.*?)\]\[(.*?)\]\%/', function($matches) { global $sqinfo; return $sqinfo[$matches[1]][$matches[2]]; }, $field['text']); } if (in_array('ts3', $packetmanager['packages'])) { foreach ($replacer['ts3'] as $k => $v) { $field['text'] = str_replace($k, $serverinfo[$v], $field['text']); } $field['text'] = preg_replace_callback('/\%groupcount\[([0-9,]{0,})\]\%/', function($match) { global $ts3; $count = 0; $groups = explode(',',$match[1]); foreach ($ts3->clientList(['client_type' => 0]) as $client) { foreach (explode(',',$client->client_servergroups) as $g) { if (in_array($g,$groups)) { $count++; break; } } } return $count; }, $field['text']); foreach ($groupcount as $k => $v) { $field['text'] = str_replace($k, $groupcount[$k], $field['text']); } $field['text'] = str_replace('%realclients%', $serverinfo['virtualserver_clientsonline']-$serverinfo['virtualserver_queryclientsonline'], $field['text']); $field['text'] = str_replace('%ping_floored%', floor(htmlentities($serverinfo['virtualserver_total_ping'])), $field['text']); $field['text'] = str_replace('%packetloss_00%', round(htmlentities($serverinfo['virtualserver_total_packetloss_total']), 2, PHP_ROUND_HALF_DOWN), $field['text']); $field['text'] = str_replace('%packetloss_floored%', floor(htmlentities($serverinfo['virtualserver_total_packetloss_total'])), $field['text']); } $field['text'] = str_replace('%timeHi%', date("H:i"), $field['text']); $field['text'] = str_replace('%timeHis%', date("H:i:s"), $field['text']); $field['text'] = str_replace('%date%', date("dmY"), $field['text']); paintText($image, $field['fontsize'], $field['xpos'], $field['ypos'], $field['color'], $field['fontfile'], $field['text']); } imagepng($image, 'cache/cached_img'); unlink('cache/cache.lock'); header('Content-Type: image/png'); imagepng(getImage()); imagedestroy($image); } catch (Exception $e) { echo $e->getMessage(); if (file_exists('cache/cache.lock')) unlink('cache/cache.lock'); } 

As well as config
 <?php $config = array('textfield' => [], 'sourcequery' => []); /* ** Teamspeak Configurations */ //Teamspeak Connection IP $config['teamspeak']['ip'] = '192.168.0.3'; //Teamspeak Query Port $config['teamspeak']['queryport'] = '10011'; //Teamspeak Connection/Voice Port $config['teamspeak']['serverport'] = '9987'; //Teamspeak Query Login Name $config['teamspeak']['loginname'] = 'serveradmin'; //Teamspeak Query Password $config['teamspeak']['loginpass'] = 'cgixTVmy'; //Background Image to use $config['backgroundimage'] = 'banner-layout.png'; //Refresh Intervall for the Image Generator in seconds $config['syncintervall'] = 60; //Source Query //  SourceQuery $config['sqenable'] = true; //         $config['sqlistfont'] = 'font/arial.ttf'; /* /* $config['sourcequery']['server1'] = [ // 'ip' => '192.168.0.3', // 'port' => '27015', // 'timeout' => 1, // 'debug' => true, ]; */ // Source Query /* $config['textfield'][] = [ 'text' => '', 'xpos' => '', 'ypos' => '', 'fontsize' => '', 'fontfile' => 'font/bank.ttf', 'color' => '', ]; */ $config['textfield'][] = [ 'text' => '%channelcount%', 'xpos' => '595', 'ypos' => '230', 'fontsize' => '22', 'fontfile' => 'font/bank.ttf', 'color' => '#1C86EE', ]; $config['textfield'][] = [ 'text' => '%date%', 'xpos' => '570', 'ypos' => '320', 'fontsize' => '16', 'fontfile' => 'font/bank.ttf', 'color' => '#CD2626', ]; $config['textfield'][] = [ 'text' => '%clientsonline%/%maxclients%', 'xpos' => '570', 'ypos' => '65', 'fontsize' => '22', 'fontfile' => 'font/bank.ttf', 'color' => '#1C86EE', ]; $config['textfield'][] = [ 'text' => '%timeHi%', 'xpos' => '590', 'ypos' => '295', 'fontsize' => '16', 'fontfile' => 'font/bank.ttf', 'color' => '#CD2626', ]; $config['textfield'][] = [ 'text' => '%groupcount[9,203,204,79,102,111]%', 'xpos' => '610', 'ypos' => '150', 'fontsize' => '22', 'fontfile' => 'font/bank.ttf', 'color' => '#CD2626', ]; /*       */ $config['textfield'][] = [ 'text' => ' %nickname% <3', 'xpos' => '100', 'ypos' => '430', 'fontsize' => '24', 'fontfile' => 'font/bank.ttf', 'color' => '#e74c3c', ]; /*   SourceQuery      ARMA III */ /* $config['textfield'][] = [ 'text' => 'SourceQuery Slots:', 'xpos' => '150', 'ypos' => '350', 'fontsize' => '24', 'fontfile' => 'font/bank.ttf', 'color' => '#ecf0f1', ]; $config['textfield'][] = [ 'text' => '%sqinfo[server1][Players]%/%sqinfo[server1][MaxPlayers]%', 'xpos' => '220', 'ypos' => '400', 'fontsize' => '24', 'fontfile' => 'font/bank.ttf', 'color' => '#ecf0f1', ]; */ 



Conclusion
These were examples of the use of Source-Query, but can you think of another purpose for them? Thank you all, I hope you liked it, ask questions.

protocol data

Source: https://habr.com/ru/post/303172/


All Articles