ConditionalGet.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. <?php
  2. /**
  3. * Class HTTP_ConditionalGet
  4. * @package Minify
  5. * @subpackage HTTP
  6. */
  7. /**
  8. * Implement conditional GET via a timestamp or hash of content
  9. *
  10. * E.g. Content from DB with update time:
  11. * <code>
  12. * list($updateTime, $content) = getDbUpdateAndContent();
  13. * $cg = new HTTP_ConditionalGet(array(
  14. * 'lastModifiedTime' => $updateTime
  15. * ,'isPublic' => true
  16. * ));
  17. * $cg->sendHeaders();
  18. * if ($cg->cacheIsValid) {
  19. * exit();
  20. * }
  21. * echo $content;
  22. * </code>
  23. *
  24. * E.g. Shortcut for the above
  25. * <code>
  26. * HTTP_ConditionalGet::check($updateTime, true); // exits if client has cache
  27. * echo $content;
  28. * </code>
  29. *
  30. * E.g. Content from DB with no update time:
  31. * <code>
  32. * $content = getContentFromDB();
  33. * $cg = new HTTP_ConditionalGet(array(
  34. * 'contentHash' => md5($content)
  35. * ));
  36. * $cg->sendHeaders();
  37. * if ($cg->cacheIsValid) {
  38. * exit();
  39. * }
  40. * echo $content;
  41. * </code>
  42. *
  43. * E.g. Static content with some static includes:
  44. * <code>
  45. * // before content
  46. * $cg = new HTTP_ConditionalGet(array(
  47. * 'lastUpdateTime' => max(
  48. * filemtime(__FILE__)
  49. * ,filemtime('/path/to/header.inc')
  50. * ,filemtime('/path/to/footer.inc')
  51. * )
  52. * ));
  53. * $cg->sendHeaders();
  54. * if ($cg->cacheIsValid) {
  55. * exit();
  56. * }
  57. * </code>
  58. * @package Minify
  59. * @subpackage HTTP
  60. * @author Stephen Clay <steve@mrclay.org>
  61. */
  62. class HTTP_ConditionalGet {
  63. /**
  64. * Does the client have a valid copy of the requested resource?
  65. *
  66. * You'll want to check this after instantiating the object. If true, do
  67. * not send content, just call sendHeaders() if you haven't already.
  68. *
  69. * @var bool
  70. */
  71. public $cacheIsValid = null;
  72. /**
  73. * @param array $spec options
  74. *
  75. * 'isPublic': (bool) if false, the Cache-Control header will contain
  76. * "private", allowing only browser caching. (default false)
  77. *
  78. * 'lastModifiedTime': (int) if given, both ETag AND Last-Modified headers
  79. * will be sent with content. This is recommended.
  80. *
  81. * 'encoding': (string) if set, the header "Vary: Accept-Encoding" will
  82. * always be sent and a truncated version of the encoding will be appended
  83. * to the ETag. E.g. "pub123456;gz". This will also trigger a more lenient
  84. * checking of the client's If-None-Match header, as the encoding portion of
  85. * the ETag will be stripped before comparison.
  86. *
  87. * 'contentHash': (string) if given, only the ETag header can be sent with
  88. * content (only HTTP1.1 clients can conditionally GET). The given string
  89. * should be short with no quote characters and always change when the
  90. * resource changes (recommend md5()). This is not needed/used if
  91. * lastModifiedTime is given.
  92. *
  93. * 'eTag': (string) if given, this will be used as the ETag header rather
  94. * than values based on lastModifiedTime or contentHash. Also the encoding
  95. * string will not be appended to the given value as described above.
  96. *
  97. * 'invalidate': (bool) if true, the client cache will be considered invalid
  98. * without testing. Effectively this disables conditional GET.
  99. * (default false)
  100. *
  101. * 'maxAge': (int) if given, this will set the Cache-Control max-age in
  102. * seconds, and also set the Expires header to the equivalent GMT date.
  103. * After the max-age period has passed, the browser will again send a
  104. * conditional GET to revalidate its cache.
  105. */
  106. public function __construct($spec)
  107. {
  108. $scope = (isset($spec['isPublic']) && $spec['isPublic'])
  109. ? 'public'
  110. : 'private';
  111. $maxAge = 0;
  112. // backwards compatibility (can be removed later)
  113. if (isset($spec['setExpires'])
  114. && is_numeric($spec['setExpires'])
  115. && ! isset($spec['maxAge'])) {
  116. $spec['maxAge'] = $spec['setExpires'] - $_SERVER['REQUEST_TIME'];
  117. }
  118. if (isset($spec['maxAge'])) {
  119. $maxAge = $spec['maxAge'];
  120. $this->_headers['Expires'] = self::gmtDate(
  121. $_SERVER['REQUEST_TIME'] + $spec['maxAge']
  122. );
  123. }
  124. $etagAppend = '';
  125. if (isset($spec['encoding'])) {
  126. $this->_stripEtag = true;
  127. $this->_headers['Vary'] = 'Accept-Encoding';
  128. if ('' !== $spec['encoding']) {
  129. if (0 === strpos($spec['encoding'], 'x-')) {
  130. $spec['encoding'] = substr($spec['encoding'], 2);
  131. }
  132. $etagAppend = ';' . substr($spec['encoding'], 0, 2);
  133. }
  134. }
  135. if (isset($spec['lastModifiedTime'])) {
  136. $this->_setLastModified($spec['lastModifiedTime']);
  137. if (isset($spec['eTag'])) { // Use it
  138. $this->_setEtag($spec['eTag'], $scope);
  139. } else { // base both headers on time
  140. $this->_setEtag($spec['lastModifiedTime'] . $etagAppend, $scope);
  141. }
  142. } elseif (isset($spec['eTag'])) { // Use it
  143. $this->_setEtag($spec['eTag'], $scope);
  144. } elseif (isset($spec['contentHash'])) { // Use the hash as the ETag
  145. $this->_setEtag($spec['contentHash'] . $etagAppend, $scope);
  146. }
  147. $privacy = ($scope === 'private')
  148. ? ', private'
  149. : '';
  150. $this->_headers['Cache-Control'] = "max-age={$maxAge}{$privacy}";
  151. // invalidate cache if disabled, otherwise check
  152. $this->cacheIsValid = (isset($spec['invalidate']) && $spec['invalidate'])
  153. ? false
  154. : $this->_isCacheValid();
  155. }
  156. /**
  157. * Get array of output headers to be sent
  158. *
  159. * In the case of 304 responses, this array will only contain the response
  160. * code header: array('_responseCode' => 'HTTP/1.0 304 Not Modified')
  161. *
  162. * Otherwise something like:
  163. * <code>
  164. * array(
  165. * 'Cache-Control' => 'max-age=0, public'
  166. * ,'ETag' => '"foobar"'
  167. * )
  168. * </code>
  169. *
  170. * @return array
  171. */
  172. public function getHeaders()
  173. {
  174. return $this->_headers;
  175. }
  176. /**
  177. * Set the Content-Length header in bytes
  178. *
  179. * With most PHP configs, as long as you don't flush() output, this method
  180. * is not needed and PHP will buffer all output and set Content-Length for
  181. * you. Otherwise you'll want to call this to let the client know up front.
  182. *
  183. * @param int $bytes
  184. *
  185. * @return int copy of input $bytes
  186. */
  187. public function setContentLength($bytes)
  188. {
  189. return $this->_headers['Content-Length'] = $bytes;
  190. }
  191. /**
  192. * Send headers
  193. *
  194. * @see getHeaders()
  195. *
  196. * Note this doesn't "clear" the headers. Calling sendHeaders() will
  197. * call header() again (but probably have not effect) and getHeaders() will
  198. * still return the headers.
  199. *
  200. * @return null
  201. */
  202. public function sendHeaders()
  203. {
  204. $headers = $this->_headers;
  205. if (array_key_exists('_responseCode', $headers)) {
  206. // FastCGI environments require 3rd arg to header() to be set
  207. list(, $code) = explode(' ', $headers['_responseCode'], 3);
  208. header($headers['_responseCode'], true, $code);
  209. unset($headers['_responseCode']);
  210. }
  211. foreach ($headers as $name => $val) {
  212. header($name . ': ' . $val);
  213. }
  214. }
  215. /**
  216. * Exit if the client's cache is valid for this resource
  217. *
  218. * This is a convenience method for common use of the class
  219. *
  220. * @param int $lastModifiedTime if given, both ETag AND Last-Modified headers
  221. * will be sent with content. This is recommended.
  222. *
  223. * @param bool $isPublic (default false) if true, the Cache-Control header
  224. * will contain "public", allowing proxies to cache the content. Otherwise
  225. * "private" will be sent, allowing only browser caching.
  226. *
  227. * @param array $options (default empty) additional options for constructor
  228. */
  229. public static function check($lastModifiedTime = null, $isPublic = false, $options = array())
  230. {
  231. if (null !== $lastModifiedTime) {
  232. $options['lastModifiedTime'] = (int)$lastModifiedTime;
  233. }
  234. $options['isPublic'] = (bool)$isPublic;
  235. $cg = new HTTP_ConditionalGet($options);
  236. $cg->sendHeaders();
  237. if ($cg->cacheIsValid) {
  238. exit();
  239. }
  240. }
  241. /**
  242. * Get a GMT formatted date for use in HTTP headers
  243. *
  244. * <code>
  245. * header('Expires: ' . HTTP_ConditionalGet::gmtdate($time));
  246. * </code>
  247. *
  248. * @param int $time unix timestamp
  249. *
  250. * @return string
  251. */
  252. public static function gmtDate($time)
  253. {
  254. return gmdate('D, d M Y H:i:s \G\M\T', $time);
  255. }
  256. protected $_headers = array();
  257. protected $_lmTime = null;
  258. protected $_etag = null;
  259. protected $_stripEtag = false;
  260. /**
  261. * @param string $hash
  262. *
  263. * @param string $scope
  264. */
  265. protected function _setEtag($hash, $scope)
  266. {
  267. $this->_etag = '"' . substr($scope, 0, 3) . $hash . '"';
  268. $this->_headers['ETag'] = $this->_etag;
  269. }
  270. /**
  271. * @param int $time
  272. */
  273. protected function _setLastModified($time)
  274. {
  275. $this->_lmTime = (int)$time;
  276. $this->_headers['Last-Modified'] = self::gmtDate($time);
  277. }
  278. /**
  279. * Determine validity of client cache and queue 304 header if valid
  280. *
  281. * @return bool
  282. */
  283. protected function _isCacheValid()
  284. {
  285. if (null === $this->_etag) {
  286. // lmTime is copied to ETag, so this condition implies that the
  287. // server sent neither ETag nor Last-Modified, so the client can't
  288. // possibly has a valid cache.
  289. return false;
  290. }
  291. $isValid = ($this->resourceMatchedEtag() || $this->resourceNotModified());
  292. if ($isValid) {
  293. $this->_headers['_responseCode'] = 'HTTP/1.0 304 Not Modified';
  294. }
  295. return $isValid;
  296. }
  297. /**
  298. * @return bool
  299. */
  300. protected function resourceMatchedEtag()
  301. {
  302. if (!isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
  303. return false;
  304. }
  305. $clientEtagList = get_magic_quotes_gpc()
  306. ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH'])
  307. : $_SERVER['HTTP_IF_NONE_MATCH'];
  308. $clientEtags = explode(',', $clientEtagList);
  309. $compareTo = $this->normalizeEtag($this->_etag);
  310. foreach ($clientEtags as $clientEtag) {
  311. if ($this->normalizeEtag($clientEtag) === $compareTo) {
  312. // respond with the client's matched ETag, even if it's not what
  313. // we would've sent by default
  314. $this->_headers['ETag'] = trim($clientEtag);
  315. return true;
  316. }
  317. }
  318. return false;
  319. }
  320. /**
  321. * @param string $etag
  322. *
  323. * @return string
  324. */
  325. protected function normalizeEtag($etag) {
  326. $etag = trim($etag);
  327. return $this->_stripEtag
  328. ? preg_replace('/;\\w\\w"$/', '"', $etag)
  329. : $etag;
  330. }
  331. /**
  332. * @return bool
  333. */
  334. protected function resourceNotModified()
  335. {
  336. if (!isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
  337. return false;
  338. }
  339. // strip off IE's extra data (semicolon)
  340. list($ifModifiedSince) = explode(';', $_SERVER['HTTP_IF_MODIFIED_SINCE'], 2);
  341. if (strtotime($ifModifiedSince) >= $this->_lmTime) {
  342. // Apache 2.2's behavior. If there was no ETag match, send the
  343. // non-encoded version of the ETag value.
  344. $this->_headers['ETag'] = $this->normalizeEtag($this->_etag);
  345. return true;
  346. }
  347. return false;
  348. }
  349. }