1<?php 2/** 3 * DokuWiki Plugin ghissues (Helper Component) 4 * 5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 6 * @author Zach Smith <zsmith12@umd.edu> 7 */ 8 9// must be run within Dokuwiki 10if(!defined('DOKU_INC')) die(); 11 12class helper_plugin_ghissues_apiCacheInterface extends DokuWiki_Plugin { 13 var $_GH_API_limit; 14 15 /** 16 * Return info about supported methods in this Helper Plugin 17 * 18 * @return array of public methods 19 */ 20 public function getMethods() { 21 return array( 22 array( 23 'name' => 'checkIssuesCache', 24 'desc' => 'Takes an api URL, checks local cache for up-to-date, then returns cache path for use in depends["files"]', 25 'params' => array( 26 'apiURL' => 'string', 27 ), 28 'return' => array('cachePath' => 'string') 29 ), 30 array( 31 'name' => 'checkCacheFreshness', 32 'desc' => 'returns true if the cache is up-to-date. Includes API call if required', 33 'params' => array( 34 'apiURL' => 'string', 35 'cache (optional)' => 'class' 36 ), 37 'return' => array('useCache' => 'boolean') 38 ), 39 array( 40 'name' => 'callGithubAPI', 41 'desc' => 'makes the API call given by apiUrl. Saves result in a cache after formatting', 42 'params' => array( 43 'apiURL' => 'string', 44 'cache (optional)' => 'class' 45 ), 46 'return' => array('useCache' => 'boolean') 47 ), 48 array( 49 'name' => 'formatApiResponse', 50 'desc' => 'takes JSON response from API and returns formatted output in xhtml', 51 'params' => array( 52 'rawJSON' => 'string', 53 ), 54 'return' => array('outputXML' => 'string') 55 ), 56 array( 57 'name' => 'getRenderedRequest', 58 'desc' => 'returns rendered output from an API request, using cache if valid', 59 'params' => array( 60 'apiURL' => 'string', 61 ), 62 'return' => array('outputXML' => 'string') 63 ) 64 ); 65 } 66 // Master cache checker. First checks if the cache expired, then checks if the page is older than the (possibly updated) cache 67 public function checkIssuesCache( $apiURL ) { 68 $cache = new cache_ghissues_api($apiURL); 69 $this->checkCacheFreshness($apiURL, $cache); 70 return ($cache->cache); 71 } 72 73 // return true if still fresh. Otherwise you'll confuse the bears 74 public function checkCacheFreshness($apiURL, &$cache=NULL) { 75 //dbglog('ghissues: In checkCacheFreshness'); 76 if ( !isset($cache) ) { 77 $cache = new cache_ghissues_api($apiURL); 78 } 79 80 // Check if we've done a cached API call recently. If you have, the cache is plenty fresh 81 if ( $cache->sniffETag($this->getConf('ghissuerefresh')) ) return true; 82 83 // Old cache, time to check in with GH. 84 return $this->callGithubAPI($apiURL, $cache); 85 } 86 87 // return true if no update since the last time we asked. 88 public function callGithubAPI($apiURL, &$cache=NULL) { 89 //dbglog('ghissues: In callGithubAPI'); 90 if ( !isset($cache) ) { 91 $cache = new cache_ghissues_api($apiURL); 92 } 93 94 //dbglog('ghissues: Make HTTP Client'); 95 $http = new DokuHTTPClient(); 96 //dbglog('ghissues: Made HTTP Client'); 97 98 $oauth_token = $this->getConf('ghissueoauth'); 99 if ( !empty($oauth_token) ) { 100 $http->user = $oauth_token; 101 $http->pass = 'x-oauth-basic'; 102 } 103 104 $http->agent = substr($http->agent,0,-1).' via ghissue plugin from user '.$this->getConf('ghissueuser').')'; 105 $http->headers['Accept'] = 'application/vnd.github.v3.text+json'; 106 $http->keep_alive = FALSE; 107 //dbglog('ghissues: Set Base Headers'); 108 109 $lastETag = $cache->retrieveETag(); 110 //dbglog('ghissues: Cache etag retrieval: '.$lastETag); 111 if ( !empty($lastETag) ) { 112 $http->headers['If-None-Match'] = $lastETag; 113 } 114 //dbglog('ghissues: Start request'); 115 116 $apiResp = $http->get($apiURL); 117 $apiHead = array(); 118 //dbglog('ghissues: madeRequest'); 119 120 $apiHead = $http->resp_headers; 121 //dbglog('ghissues: '.$apiURL); 122 //dbglog('ghissues: '.var_export($http->resp_headers, TRUE)); 123 $this->_GH_API_limit = intval($apiHead['x-ratelimit-remaining']); 124 //dbglog('ghissues: rateLimit='.$this->_GH_API_limit); 125 126 $apiStatus = substr($apiHead['status'],0,3); 127 //dbglog('ghissues: status='.$apiHead['status']); 128 129 if ( $apiStatus == '304' ) { // No modification 130 $cache->storeETag($apiHead['etag']); // Update the last time we checked 131 return true; 132 } else if ( $apiStatus == '200' ) { // Updated content! But will the table change? 133 // Collate results if GitHub paginated them. (Walk the URL ladder) 134 if ( !empty($apiHead['link']) ) { 135 $nextLink = $apiHead['link']; 136 $matches = array(); 137 if(preg_match('/<(.*?)>; rel="next"/', $nextLink, $matches)) { 138 $apiResp = substr($apiResp,0,-1); 139 $apiResp .= $this->_chaseGithubNextLinks($http, $matches[1]); 140 } 141 }; 142 143 // Build the actual table using the response, then make sure it has changed. 144 // Because we don't use all information from the resopnse, it is possible only 145 // things we don't check updated. If that is the case, no need to change the cache 146 $newTable = $this->formatApiResponse($apiResp); 147 148 if ( $newTable != $cache->retrieveCache() ) { 149 if (!$cache->storeCache($newTable)) { 150 //dbglog('ghissues: Unable to save cache file. Hint: disk full; file permissions; safe_mode setting.',-1); 151 return true; // Couldn't save the update, can't reread from new cache 152 } 153 $cache->storeETag($apiHead['etag']); // Update the last time we checked 154 return false; 155 } 156 $cache->storeETag($apiHead['etag']); // Update the last time we checked 157 return true; // All that for a table that looks the same... 158 } else { // Some other HTTP status, we're not handling. Save old table plus error message 159 // Don't update when we checked in case it was a temporary thing (it probably wasn't though) 160 // $cache->storeETag($apiHead['etag']); // Update the last time we checked 161 $errorTable = '<div class=ghissues_plugin_api_err">'; 162 $errorTable .= htmlentities(strftime($conf['dformat'])); 163 $errorTable .= sprintf($this->getLang('badhttpstatus'), htmlentities($apiHead['status'])); 164 $errorTable .= '</div>'."\n".$cache->retrieveCache(); 165 166 if (!$cache->storeCache($errorTable)) { 167 //dbglog('ghissues: Unable to save cache file. Hint: disk full; file permissions; safe_mode setting.',-1); 168 return true; // Couldn't save the update, can't reread from new cache 169 } 170 return false; 171 } 172 // Fallback on true because we don't know what is going on. 173 return true; 174 } 175 176 public function formatApiResponse($rawJSON) { 177 //dbglog('ghissues: In formatApiResponse'); 178 global $conf; 179 180 if ($rawJSON == '[]') { 181 $outputXML = '<div class="ghissues_plugin_issue_line"><div class="ghissues_plugin_issue_title">'; 182 $outputXML .= $this->getLang('noIssues'); 183 $outputXML .= '</div></div>'."\n"; 184 return $outputXML; 185 } 186 187 $json = new JSON(); 188 $response = $json->decode($rawJSON); 189 190 // Assume the top is already there, as that can be built without the API request. 191 $outputXML = '<ul class="ghissues_list">'."\n"; 192 foreach($response as $issueIdx => $issue) { 193 $outputXML .= '<li class="ghissues_plugin_issue_line">'."\n"; 194 $outputXML .= '<div>'."\n"; 195 $outputXML .= $this->external_link($issue->html_url, htmlentities('#'.$issue->number.': '.$issue->title), 'ghissues_plugin_issue_title', '_blank'); 196 $outputXML .= "\n".'<span class="ghissues_plugin_issue_labels">'; 197 foreach($issue->labels as $label) { 198 $outputXML .= '<span style="background-color:#'.$label->color.'"'; 199 $outputXML .= ' class='.$this->_getSpanClassFromBG($label->color).'>'; 200 $outputXML .= htmlentities($label->name); 201 $outputXML .= '</span>'."\n"; 202 } 203 $outputXML .= '</span></div>'."\n"; 204 $outputXML .= '<div class="ghissues_plugin_issue_report">'."\n"; 205 $outputXML .= sprintf($this->getLang('reporter'),htmlentities($issue->user->login)); 206 $outputXML .= htmlentities(strftime($conf['dformat'],strtotime($issue->created_at))); 207 $outpulXML .= '</div>'."\n".'</li>'."\n"; 208 } 209 $outputXML .= '</ul>'."\n"; 210 211 return $outputXML; 212 } 213 214 public function getRenderedRequest($apiURL) { 215 //dbglog('ghissues: In getRenderedRequest'); 216 $outputCache = new cache_ghissues_api($apiURL); 217 218 // Make sure we've got a good copy 219 $this->checkCacheFreshness($apiURL, $outputCache); 220 return $outputCache->retrieveCache(); 221 } 222 223 private function _getSpanClassFromBG($htmlbg) { 224 $colorval = hexdec($htmlbg); 225 226 $red = 0xFF & ($colorval >> 0x10); 227 $green = 0xFF & ($colorval >> 0x08); 228 $blue = 0xFF & $colorval; 229 230 $lum = 1.0 - ( 0.299 * $red + 0.587 * $green + 0.114 * $blue)/255.0; 231 232 if( $lum < 0.5 ) { 233 return '"ghissues_light"'; 234 } else { 235 return '"ghissues_dark"'; 236 } 237 } 238 239 private function _chaseGithubNextLinks(&$http, $apiURL) { 240 //dbglog('ghissues: In _chaseGithubNextLinks'); 241 $http->agent = substr($http->agent,0,-1).' via ghissue plugin from user '.$this->getConf('ghissueuser').')'; 242 $http->headers['Accept'] = 'application/vnd.github.v3.text+json'; 243 unset($http->headers['If-None-Match']); 244 $apiNext = $http->get($apiURL); 245 246 $apiHead = array(); 247 $apiHead = $http->resp_headers; 248 249 $this->_GH_API_limit = intval($apiHead['x-ratelimit-remaining']); 250 251 $apiStatus = substr($apiHead['status'],0,3); 252 // If request somehow failed, do it quietly since the first one didn't 253 if ( $apiStatus != '200' ) return ']'; 254 255 // If we're on the last page, there will be no "next" 256 if ( !empty($apiHead['link']) ) { 257 $nextLink = $apiHead['link']; 258 $matches = array(); 259 if(preg_match('/<(.*?)>; rel="next"/', $nextLink, $matches)) { 260 return ','.substr($apiNext,1,-1).$this->_chaseGithubNextLinks($http, $matches[1]); 261 } 262 }; 263 264 return ','.substr($apiNext,1); 265 } 266} 267 268class cache_ghissues_api extends cache { 269 public $etag = ''; 270 var $_etag_time; 271 272 public function cache_ghissues_api($requestURL) { 273 parent::cache($requestURL,'.ghissues'); 274 $this->etag = substr($this->cache, 0, -9).'.etag'; 275 } 276 277 public function retrieveETag($clean=true) { 278 return io_readFile($this->etag, $clean); 279 } 280 281 public function storeETag($etagValue) { 282 if ( $this->_nocache ) return false; 283 284 return io_saveFile($this->etag, $etagValue); 285 } 286 287 // Sniff to see if it is rotten (expired). <0 means always OK, 0 is never ok. 288 public function sniffETag($expireInterval) { 289 if ( $expireInterval < 0 ) return true; 290 if ( $expireInterval == 0 ) return false; 291 if (!($this->_etag_time = @filemtime($this->etag))) return false; // Check if cache is there 292 if ( (time() - $this->_etag_time) > $expireInterval ) return false; // Past Sell-By 293 return true; 294 } 295} 296// vim:ts=4:sw=4:et: 297