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