1: <?php
2:
3: /**
4: * Jyxo PHP Library
5: *
6: * LICENSE
7: *
8: * This source file is subject to the new BSD license that is bundled
9: * with this package in the file license.txt.
10: * It is also available through the world-wide-web at this URL:
11: * https://github.com/jyxo/php/blob/master/license.txt
12: */
13:
14: namespace Jyxo\Webdav;
15:
16: /**
17: * Client for work with WebDav. Uses the http PHP extension.
18: *
19: * @category Jyxo
20: * @package Jyxo\Webdav
21: * @copyright Copyright (c) 2005-2011 Jyxo, s.r.o.
22: * @license https://github.com/jyxo/php/blob/master/license.txt
23: * @author Jaroslav HanslĂk
24: */
25: class Client
26: {
27: /**
28: * Servers list.
29: *
30: * @var array
31: */
32: private $servers = array();
33:
34: /**
35: * Connection options.
36: *
37: * @var array
38: */
39: private $options = array(
40: 'connecttimeout' => 1,
41: 'timeout' => 30
42: );
43:
44: /**
45: * Request pool.
46: *
47: * @var \HttpRequestPool
48: */
49: private $pool = null;
50:
51: /**
52: * Constructor.
53: *
54: * @param array $servers
55: */
56: public function __construct(array $servers)
57: {
58: $this->servers = $servers;
59:
60: $this->pool = new \HttpRequestPool();
61: }
62:
63: /**
64: * Sets an option.
65: *
66: * @param string $name Option name
67: * @param mixed $value Option value
68: */
69: public function setOption($name, $value)
70: {
71: $this->options[(string) $name] = $value;
72: }
73:
74: /**
75: * Checks if a file exists.
76: *
77: * @param string $path File path
78: * @return boolean
79: * @throws \Jyxo\Webdav\Exception On error
80: */
81: public function exists($path)
82: {
83: $response = $this->sendRequest($this->getFilePath($path), \HttpRequest::METH_HEAD);
84: return (200 === $response->getResponseCode());
85: }
86:
87: /**
88: * Returns file contents.
89: *
90: * @param string $path File path
91: * @return string
92: * @throws \Jyxo\Webdav\FileNotExistException If the file does not exist
93: * @throws \Jyxo\Webdav\Exception On error
94: */
95: public function get($path)
96: {
97: // Asking random server
98: $path = $this->getFilePath($path);
99: $response = $this->sendRequest($path, \HttpRequest::METH_GET);
100:
101: if (200 !== $response->getResponseCode()) {
102: throw new FileNotExistException(sprintf('File %s does not exist.', $path));
103: }
104:
105: return $response->getBody();
106: }
107:
108: /**
109: * Returns a file property.
110: * If no particular property is set, all properties are returned.
111: *
112: * @param string $path File path
113: * @param string $property Property name
114: * @return mixed
115: * @throws \Jyxo\Webdav\FileNotExistException If the file does not exist
116: * @throws \Jyxo\Webdav\Exception On error
117: */
118: public function getProperty($path, $property = '')
119: {
120: // Asking random server
121: $path = $this->getFilePath($path);
122: $response = $this->sendRequest($path, \HttpRequest::METH_PROPFIND, array('Depth' => '0'));
123:
124: if (207 !== $response->getResponseCode()) {
125: throw new FileNotExistException(sprintf('File %s does not exist.', $path));
126: }
127:
128: // Fetches file properties from the server
129: $properties = $this->getProperties($response);
130:
131: // Returns the requested property value
132: if (isset($properties[$property])) {
133: return $properties[$property];
134: }
135:
136: // Returns all properties
137: return $properties;
138: }
139:
140: /**
141: * Saves data to a remote file.
142: *
143: * @param string $path File path
144: * @param string $data Data
145: * @return boolean
146: * @throws \Jyxo\Webdav\Exception On error
147: */
148: public function put($path, $data)
149: {
150: return $this->processPut($this->getFilePath($path), $data, false);
151: }
152:
153: /**
154: * Saves file contents to a remote file.
155: *
156: * @param string $path File path
157: * @param string $file Local file path
158: * @return boolean
159: * @throws \Jyxo\Webdav\Exception On error
160: */
161: public function putFile($path, $file)
162: {
163: return $this->processPut($this->getFilePath($path), $file, true);
164: }
165:
166: /**
167: * Copies a file.
168: * Does not work on Lighttpd.
169: *
170: * @param string $pathFrom Source file path
171: * @param string $pathTo Target file path
172: * @return boolean
173: * @throws \Jyxo\Webdav\Exception On error
174: */
175: public function copy($pathFrom, $pathTo)
176: {
177: $requestList = $this->getRequestList($this->getFilePath($pathFrom), \HttpRequest::METH_COPY);
178: foreach ($requestList as $server => $request) {
179: $request->addHeaders(array('Destination' => $server . $this->getFilePath($pathTo)));
180: }
181:
182: foreach ($this->sendPool($requestList) as $request) {
183: // 201 means copied
184: if (201 !== $request->getResponseCode()) {
185: return false;
186: }
187: }
188:
189: return true;
190: }
191:
192: /**
193: * Renames a file.
194: * Does not work on Lighttpd.
195: *
196: * @param string $pathFrom Original file name
197: * @param string $pathTo New file name
198: * @return boolean
199: * @throws \Jyxo\Webdav\Exception On error
200: */
201: public function rename($pathFrom, $pathTo)
202: {
203: $requestList = $this->getRequestList($this->getFilePath($pathFrom), \HttpRequest::METH_MOVE);
204: foreach ($requestList as $server => $request) {
205: $request->addHeaders(array('Destination' => $server . $this->getFilePath($pathTo)));
206: }
207:
208: foreach ($this->sendPool($requestList) as $request) {
209: // 201 means renamed
210: if (201 !== $request->getResponseCode()) {
211: return false;
212: }
213: }
214:
215: return true;
216: }
217:
218: /**
219: * Deletes a file.
220: * Contains a check preventing from deleting directories.
221: *
222: * @param string $path Directory path
223: * @return boolean
224: * @throws \Jyxo\Webdav\Exception On error
225: */
226: public function unlink($path)
227: {
228: // We do not delete directories
229: if ($this->isDir($path)) {
230: return false;
231: }
232:
233: foreach ($this->send($this->getFilePath($path), \HttpRequest::METH_DELETE) as $request) {
234: // 204 means deleted
235: if (204 !== $request->getResponseCode()) {
236: return false;
237: }
238: }
239:
240: return true;
241: }
242:
243: /**
244: * Checks if a directory exists.
245: *
246: * @param string $dir Directory path
247: * @return boolean
248: * @throws \Jyxo\Webdav\Exception On error
249: */
250: public function isDir($dir)
251: {
252: // Asking random server
253: $response = $this->sendRequest($this->getDirPath($dir), \HttpRequest::METH_PROPFIND, array('Depth' => '0'));
254:
255: // The directory does not exist
256: if (207 !== $response->getResponseCode()) {
257: return false;
258: }
259:
260: // Fetches properties from the server
261: $properties = $this->getProperties($response);
262:
263: // Checks if it is a directory
264: return isset($properties['getcontenttype']) && ('httpd/unix-directory' === $properties['getcontenttype']);
265: }
266:
267: /**
268: * Creates a directory.
269: *
270: * @param string $dir Directory path
271: * @param boolean $recursive Create directories recursively?
272: * @return boolean
273: * @throws \Jyxo\Webdav\Exception On error
274: */
275: public function mkdir($dir, $recursive = true)
276: {
277: // If creating directories recursively, create the parent directory first
278: $dir = trim($dir, '/');
279: if ($recursive) {
280: $dirs = explode('/', $dir);
281: } else {
282: $dirs = array($dir);
283: }
284:
285: $path = '';
286: foreach ($dirs as $dir) {
287: $path .= rtrim($dir);
288: $path = $this->getDirPath($path);
289:
290: foreach ($this->send($path, \HttpRequest::METH_MKCOL) as $request) {
291: switch ($request->getResponseCode()) {
292: // The directory was created
293: case 201:
294: break;
295: // The directory already exists
296: case 405:
297: break;
298: // The directory could not be created
299: default:
300: return false;
301: }
302: }
303: }
304:
305: // The directory was created
306: return true;
307: }
308:
309: /**
310: * Deletes a directory.
311: *
312: * @param string $dir Directory path
313: * @return boolean
314: * @throws \Jyxo\Webdav\Exception On error
315: */
316: public function rmdir($dir)
317: {
318: foreach ($this->send($this->getDirPath($dir), \HttpRequest::METH_DELETE) as $request) {
319: // 204 means deleted
320: if (204 !== $request->getResponseCode()) {
321: return false;
322: }
323: }
324:
325: return true;
326: }
327:
328: /**
329: * Processes a PUT request.
330: *
331: * @param string $path File path
332: * @param string $data Data
333: * @param boolean $isFile Determines if $data is a file name or actual data
334: * @return boolean
335: * @throws \Jyxo\Webdav\Exception On error
336: */
337: private function processPut($path, $data, $isFile)
338: {
339: $success = true;
340: foreach ($this->sendPut($path, $data, $isFile) as $request) {
341: switch ($request->getResponseCode()) {
342: // Saved
343: case 200:
344: case 201:
345: break;
346: // An existing file was modified
347: case 204:
348: break;
349: // The directory might not exist
350: case 403:
351: case 404:
352: case 409:
353: $success = false;
354: break;
355: // Could not save
356: default:
357: return false;
358: }
359: }
360:
361: // Saved
362: if ($success) {
363: return true;
364: }
365:
366: // Not saved, try creating the directory first
367: if (!$this->mkdir(dirname($path))) {
368: return false;
369: }
370:
371: // Try again
372: foreach ($this->sendPut($path, $data, $isFile) as $request) {
373: // 201 means saved
374: if (201 !== $request->getResponseCode()) {
375: return false;
376: }
377: }
378:
379: return true;
380: }
381:
382: /**
383: * Sends a PUT request.
384: *
385: * @param string $path File path
386: * @param string $data Data
387: * @param boolean $isFile Determines if $data is a file name or actual data
388: * @return \HttpRequestPool
389: * @throws \Jyxo\Webdav\Exception On error
390: */
391: private function sendPut($path, $data, $isFile)
392: {
393: $requestList = $this->getRequestList($path, \HttpRequest::METH_PUT);
394: foreach ($requestList as $request) {
395: if ($isFile) {
396: $request->setPutFile($data);
397: } else {
398: $request->setPutData($data);
399: }
400: }
401:
402: return $this->sendPool($requestList);
403: }
404:
405: /**
406: * Creates a request pool and sends it.
407: *
408: * @param string $path Request path
409: * @param integer $method Request method
410: * @param array $headers Array of headers
411: * @return \HttpRequestPool
412: */
413: private function send($path, $method, array $headers = array())
414: {
415: return $this->sendPool($this->getRequestList($path, $method, $headers));
416: }
417:
418: /**
419: * Sends a request pool.
420: *
421: * @param \ArrayObject $requestList Request list
422: * @return \HttpRequestPool
423: * @throws \Jyxo\Webdav\Exception On error
424: */
425: private function sendPool(\ArrayObject $requestList)
426: {
427: try {
428: // Clean the pool
429: $this->pool->reset();
430:
431: // Attach requests
432: foreach ($requestList as $request) {
433: $this->pool->attach($request);
434: }
435:
436: // Send
437: $this->pool->send();
438:
439: return $this->pool;
440: } catch (\HttpException $e) {
441: // Find the innermost exception
442: $inner = $e;
443: while (null !== $inner->innerException) {
444: $inner = $inner->innerException;
445: }
446: throw new Exception($inner->getMessage(), 0, $inner);
447: }
448: }
449:
450: /**
451: * Sends a request.
452: *
453: * @param string $path Request path
454: * @param integer $method Request method
455: * @param array $headers Array of headers
456: * @return \HttpMessage
457: * @throws \Jyxo\Webdav\Exception On error
458: */
459: private function sendRequest($path, $method, array $headers = array())
460: {
461: try {
462: // Send request to a random server
463: $request = $this->getRequest($this->servers[array_rand($this->servers)] . $path, $method, $headers);
464: return $request->send();
465: } catch (\HttpException $e) {
466: throw new Exception($e->getMessage(), 0, $e);
467: }
468: }
469:
470: /**
471: * Returns a list of requests; one for each server.
472: *
473: * @param string $path Request path
474: * @param integer $method Request method
475: * @param array $headers Array of headers
476: * @return \ArrayObject
477: */
478: private function getRequestList($path, $method, array $headers = array())
479: {
480: $requestList = new \ArrayObject();
481: foreach ($this->servers as $server) {
482: $requestList->offsetSet($server, $this->getRequest($server . $path, $method, $headers));
483: }
484: return $requestList;
485: }
486:
487: /**
488: * Creates a request.
489: *
490: * @param string $url Request URL
491: * @param integer $method Request method
492: * @param array $headers Array of headers
493: * @return \HttpRequest
494: */
495: private function getRequest($url, $method, array $headers = array())
496: {
497: $request = new \HttpRequest($url, $method, $this->options);
498: $request->setHeaders(array('Expect' => ''));
499: $request->addHeaders($headers);
500: return $request;
501: }
502:
503: /**
504: * Creates a file path without the trailing slash.
505: *
506: * @param string $path File path
507: * @return string
508: */
509: private function getFilePath($path)
510: {
511: return '/' . trim($path, '/');
512: }
513:
514: /**
515: * Creates a directory path with the trailing slash.
516: *
517: * @param string $path Directory path
518: * @return string
519: */
520: private function getDirPath($path)
521: {
522: return '/' . trim($path, '/') . '/';
523: }
524:
525: /**
526: * Fetches properties from the response.
527: *
528: * @param \HttpMessage $response Response
529: * @return array
530: */
531: private function getProperties(\HttpMessage $response)
532: {
533: // Process the XML with properties
534: $properties = array();
535: $reader = new \Jyxo\XmlReader();
536: $reader->XML($response->getBody());
537:
538: // Ignore warnings
539: while (@$reader->read()) {
540: if ((\XMLReader::ELEMENT === $reader->nodeType) && ('D:prop' === $reader->name)) {
541: while (@$reader->read()) {
542: // Element must not be empty and has to look something like <lp1:getcontentlength>13744</lp1:getcontentlength>
543: if ((\XMLReader::ELEMENT === $reader->nodeType) && (!$reader->isEmptyElement)) {
544: if (preg_match('~^lp\d+:(.+)$~', $reader->name, $matches)) {
545: // Apache
546: $properties[$matches[1]] = $reader->getTextValue();
547: } elseif (preg_match('~^D:(.+)$~', $reader->name, $matches)) {
548: // Lighttpd
549: $properties[$matches[1]] = $reader->getTextValue();
550: }
551: } elseif ((\XMLReader::END_ELEMENT === $reader->nodeType) && ('D:prop' === $reader->name)) {
552: break;
553: }
554: }
555: }
556: }
557:
558: return $properties;
559: }
560: }
561: