├── README.md ├── composer.json └── src └── Outlandish └── Sync ├── AbstractSync.php ├── Client.php └── Server.php /README.md: -------------------------------------------------------------------------------- 1 | # Sync directory contents over HTTP using PHP 2 | 3 | Use these classes to recursively sync the contents of two folders on different servers. The source must have 4 | a web server although the directory being synced does not have to be web accessible. The client initiates the 5 | connection and can be either another web server or a command line script. 6 | 7 | 8 | ## Install 9 | 10 | If using Composer, add `"outlandish/sync":"1.*@dev"` to your requirements. 11 | 12 | Otherwise, just download and `require` the classes as normal. 13 | 14 | 15 | ## How it works 16 | 17 | 1. Client collects list of existing files in destination folder (and subfolders), with size and modified dates 18 | 2. Client POSTs list to the server 19 | 3. Server gets list of files in source folder on server and compares this with list of files from client 20 | 4. Server returns list of new or modified files present on server 21 | 5. Client requests contents of each new or modified file and saves it to destination folder 22 | 6. Client sets last modified time of file to match server 23 | 24 | No attempt is made to send diffs; this is not rsync. Symlinks are not explicitly supported. All communication 25 | is via JSON data in the request/response body. 26 | 27 | ## Example 28 | 29 | On the server, e.g. `example.com/remote.php`: 30 | 31 | ```php 32 | require_once 'vendor/autoload.php'; //or include AbstractSync.php and Server.php 33 | 34 | const SECRET = '5ecR3t'; //make this long and complicated 35 | const PATH = '/path/to/source'; //sync all files and folders below this path 36 | 37 | $server = new \Outlandish\Sync\Server(SECRET, PATH); 38 | $server->run(); //process the request 39 | ``` 40 | 41 | On the client(s): 42 | 43 | ```php 44 | require_once 'vendor/autoload.php'; 45 | 46 | const SECRET = '5ecR3t'; //this must match the secret key on the server 47 | const PATH = '/path/to/destination'; //target for files synced from server 48 | 49 | $client = new \Outlandish\Sync\Client(SECRET, PATH); 50 | $client->run('http://example.com/remote.php'); //connect to server and start sync 51 | ``` 52 | 53 | ## FAQ 54 | 55 | ### Why not just use rsync? 56 | 57 | Sometimes you need code to be portable across a range of hosting environments so you can't rely on rsync, scp or 58 | other external dependencies. 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outlandish/sync", 3 | "description":"Sync directory contents over HTTP using PHP", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Tamlyn Rhodes", 8 | "email": "tamlyn@tamlyn.org" 9 | } 10 | ], 11 | "require": { 12 | "php":">=5.3.0", 13 | "ext-curl":"*" 14 | }, 15 | "autoload":{ 16 | "psr-0":{ 17 | "Outlandish":"src/" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Outlandish/Sync/AbstractSync.php: -------------------------------------------------------------------------------- 1 | path = realpath($path) . DIRECTORY_SEPARATOR; 24 | $this->key = $key; 25 | } 26 | 27 | /** 28 | * @param $path string 29 | * @return array 30 | */ 31 | protected function getFileList($path) { 32 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, 33 | \FilesystemIterator::CURRENT_AS_FILEINFO | 34 | \FilesystemIterator::SKIP_DOTS 35 | )); 36 | 37 | $pathPrefixLength = strlen($path); 38 | $files = array(); 39 | foreach ($iterator as $fileInfo) { 40 | $fullPath = str_replace(DIRECTORY_SEPARATOR, '/', substr($fileInfo->getRealPath(), $pathPrefixLength)); 41 | $filePermission = substr(sprintf('%o', fileperms( $fileInfo->getRealPath() )), -4); 42 | $files[$fullPath] = array('size' => $fileInfo->getSize(), 'timestamp' => $fileInfo->getMTime(), 'fileperm' => $filePermission); 43 | } 44 | 45 | return $files; 46 | } 47 | } -------------------------------------------------------------------------------- /src/Outlandish/Sync/Client.php: -------------------------------------------------------------------------------- 1 | curl = curl_init($url); 23 | 24 | //send client file list to server 25 | $localFiles = $this->getFileList($this->path); 26 | $request = array( 27 | 'action' => self::ACTION_FILELIST, 28 | 'data' => $localFiles 29 | ); 30 | $response = $this->post($request); 31 | 32 | if (isset($response['error'])) { 33 | echo $response['error']; 34 | return; 35 | } 36 | 37 | //process modified files 38 | foreach ($response['data'] as $relativePath => $info) { 39 | //fetch file contents 40 | $response = $this->post(array( 41 | 'action' => self::ACTION_FETCH, 42 | 'file' => $relativePath 43 | )); 44 | 45 | //save file 46 | $absolutePath = $this->path . $relativePath; 47 | if (!file_exists(dirname($absolutePath))) { 48 | mkdir(dirname($absolutePath), 0777, true); 49 | } 50 | file_put_contents($absolutePath, $response); 51 | 52 | //update modified time to match server 53 | touch($absolutePath, $info['timestamp']); 54 | 55 | //update permissions to match server 56 | chmod($absolutePath, octdec(intval($info['fileperm']))); 57 | } 58 | } 59 | 60 | /** 61 | * @param $data array 62 | * @return mixed 63 | * @throws \RuntimeException 64 | */ 65 | protected function post($data) { 66 | 67 | $data['key'] = $this->key; 68 | 69 | curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, 1); 70 | curl_setopt($this->curl, CURLOPT_HEADER, 1); 71 | curl_setopt($this->curl, CURLOPT_POST, 1); 72 | curl_setopt($this->curl, CURLOPT_POSTFIELDS, json_encode($data)); 73 | curl_setopt($this->curl, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); 74 | 75 | list($headers, $body) = explode("\r\n\r\n", curl_exec($this->curl), 2); 76 | $code = curl_getinfo($this->curl, CURLINFO_HTTP_CODE); 77 | if ($code != 200) { 78 | throw new \RuntimeException('HTTP error: '.$code); 79 | } 80 | 81 | if (stripos($headers, 'Content-type: application/json') !== false) { 82 | $body = json_decode($body, 1); 83 | } 84 | 85 | return $body; 86 | } 87 | 88 | } -------------------------------------------------------------------------------- /src/Outlandish/Sync/Server.php: -------------------------------------------------------------------------------- 1 | processRequest($rawRequest); 19 | 20 | if (is_string($response)) { 21 | $response = array('error' => $response); 22 | } 23 | 24 | header('Content-type: application/json'); 25 | echo json_encode($response); 26 | } 27 | 28 | /** 29 | * @param $rawRequest string 30 | * @return array|string 31 | */ 32 | protected function processRequest($rawRequest) { 33 | if (empty($rawRequest)) { 34 | return 'No input'; 35 | } 36 | 37 | $request = json_decode($rawRequest, true); 38 | 39 | if (!$request) { 40 | return 'Invalid JSON'; 41 | } elseif (empty($request['key']) || $request['key'] != $this->key) { 42 | return 'Missing or invalid key'; 43 | } elseif (empty($request['action'])) { 44 | return 'Missing action'; 45 | } 46 | 47 | switch ($request['action']) { 48 | case self::ACTION_FILELIST: 49 | 50 | $localFiles = $this->getFileList($this->path); 51 | $remoteFiles = $request['data']; 52 | 53 | //compare local and remote file list to get updated files 54 | $updatedFiles = array(); 55 | foreach ($localFiles as $filePath => $info) { 56 | if (empty($remoteFiles[$filePath]) || $remoteFiles[$filePath] != $info) { 57 | $updatedFiles[$filePath] = $info; 58 | } 59 | } 60 | 61 | return array('data' => $updatedFiles); 62 | 63 | case self::ACTION_FETCH: 64 | 65 | if (strpos($request['file'], '..') !== false) { 66 | return 'Security violation'; 67 | } elseif (!file_exists($this->path.$request['file'])) { 68 | return 'File not found'; 69 | } 70 | 71 | //output file with generic binary mime type 72 | header('Content-type: application/octet-stream'); 73 | $fp = fopen($this->path . $request['file'], 'rb'); 74 | fpassthru($fp); 75 | 76 | exit; 77 | 78 | default : 79 | return 'Unhandled action'; 80 | } 81 | 82 | } 83 | 84 | } --------------------------------------------------------------------------------