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
10 if(!defined('DOKU_INC')) die();
11 
12 class 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 
268 class 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