Parent Directory
|
Revision Log
Revision 1.3 - (view) (download)
| 1 : | moodler | 1.3 | <?php // $Id: googleapi.php,v 1.2 2008/11/30 17:37:06 poltawski Exp $ |
| 2 : | poltawski | 1.1 | /** |
| 3 : | * Moodle - Modular Object-Oriented Dynamic Learning Environment | ||
| 4 : | * http://moodle.org | ||
| 5 : | * Copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com | ||
| 6 : | * | ||
| 7 : | * This program is free software: you can redistribute it and/or modify | ||
| 8 : | * it under the terms of the GNU General Public License as published by | ||
| 9 : | * the Free Software Foundation, either version 2 of the License, or | ||
| 10 : | * (at your option) any later version. | ||
| 11 : | * | ||
| 12 : | * This program is distributed in the hope that it will be useful, | ||
| 13 : | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 14 : | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 15 : | * GNU General Public License for more details. | ||
| 16 : | * | ||
| 17 : | * You should have received a copy of the GNU General Public License | ||
| 18 : | * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| 19 : | * | ||
| 20 : | * @package moodle | ||
| 21 : | * @subpackage lib | ||
| 22 : | * @author Dan Poltawski <talktodan@gmail.com> | ||
| 23 : | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL | ||
| 24 : | * | ||
| 25 : | * Simple implementation of some Google API functions for Moodle. | ||
| 26 : | */ | ||
| 27 : | |||
| 28 : | require_once($CFG->libdir.'/filelib.php'); | ||
| 29 : | |||
| 30 : | /** | ||
| 31 : | * Base class for google authenticated http requests | ||
| 32 : | * | ||
| 33 : | * Most Google API Calls required that requests are sent with an | ||
| 34 : | * Authorization header + token. This class extends the curl class | ||
| 35 : | * to aid this | ||
| 36 : | */ | ||
| 37 : | abstract class google_auth_request extends curl{ | ||
| 38 : | protected $token = ''; | ||
| 39 : | |||
| 40 : | // Must be overriden with the authorization header name | ||
| 41 : | public abstract static function get_auth_header_name(); | ||
| 42 : | |||
| 43 : | protected function request($url, $options = array()){ | ||
| 44 : | if($this->token){ | ||
| 45 : | // Adds authorisation head to a request so that it can be authentcated | ||
| 46 : | $this->setHeader('Authorization: '. $this->get_auth_header_name().'"'.$this->token.'"'); | ||
| 47 : | } | ||
| 48 : | |||
| 49 : | $ret = parent::request($url, $options); | ||
| 50 : | // reset headers for next request | ||
| 51 : | $this->header = array(); | ||
| 52 : | return $ret; | ||
| 53 : | } | ||
| 54 : | |||
| 55 : | public function get_sessiontoken(){ | ||
| 56 : | return $this->token; | ||
| 57 : | } | ||
| 58 : | } | ||
| 59 : | |||
| 60 : | /******* | ||
| 61 : | * The following two classes are usd to implement AuthSub google | ||
| 62 : | * authtentication, as documented here: | ||
| 63 : | * http://code.google.com/apis/accounts/docs/AuthSub.html | ||
| 64 : | *******/ | ||
| 65 : | |||
| 66 : | /** | ||
| 67 : | * Used to uprade a google AuthSubRequest one-time token into | ||
| 68 : | * a session token which can be used long term. | ||
| 69 : | */ | ||
| 70 : | class google_authsub_request extends google_auth_request { | ||
| 71 : | const AUTHSESSION_URL = 'https://www.google.com/accounts/AuthSubSessionToken'; | ||
| 72 : | |||
| 73 : | /** | ||
| 74 : | * Constructor. Calls constructor of its parents | ||
| 75 : | * | ||
| 76 : | * @param string $authtoken The token to upgrade to a session token | ||
| 77 : | */ | ||
| 78 : | public function __construct($authtoken){ | ||
| 79 : | parent::__construct(); | ||
| 80 : | $this->token = $authtoken; | ||
| 81 : | } | ||
| 82 : | |||
| 83 : | /** | ||
| 84 : | * Requests a long-term session token from google based on the | ||
| 85 : | * | ||
| 86 : | * @return string Sub-Auth token | ||
| 87 : | */ | ||
| 88 : | public function get_session_token(){ | ||
| 89 : | $content = $this->get(google_authsub_request::AUTHSESSION_URL); | ||
| 90 : | |||
| 91 : | if( preg_match('/token=(.*)/i', $content, $matches) ){ | ||
| 92 : | return $matches[1]; | ||
| 93 : | }else{ | ||
| 94 : | throw new moodle_exception('could not upgrade google authtoken to session token'); | ||
| 95 : | } | ||
| 96 : | } | ||
| 97 : | |||
| 98 : | public static function get_auth_header_name(){ | ||
| 99 : | return 'AuthSub token='; | ||
| 100 : | } | ||
| 101 : | } | ||
| 102 : | |||
| 103 : | /** | ||
| 104 : | * Allows http calls using google subauth authorisation | ||
| 105 : | */ | ||
| 106 : | class google_authsub extends google_auth_request { | ||
| 107 : | const LOGINAUTH_URL = 'https://www.google.com/accounts/AuthSubRequest'; | ||
| 108 : | const VERIFY_TOKEN_URL = 'https://www.google.com/accounts/AuthSubTokenInfo'; | ||
| 109 : | const REVOKE_TOKEN_URL = 'https://www.google.com/accounts/AuthSubRevokeToken'; | ||
| 110 : | |||
| 111 : | /** | ||
| 112 : | * Constructor, allows subauth requests using the response from an initial | ||
| 113 : | * AuthSubRequest or with the subauth long-term token. Note that constructing | ||
| 114 : | * this object without a valid token will cause an exception to be thrown. | ||
| 115 : | * | ||
| 116 : | * @param string $sessiontoken A long-term subauth session token | ||
| 117 : | * @param string $authtoken A one-time auth token wich is used to upgrade to session token | ||
| 118 : | * @param mixed @options Options to pass to the base curl object | ||
| 119 : | */ | ||
| 120 : | public function __construct($sessiontoken = '', $authtoken = '', $options = array()){ | ||
| 121 : | parent::__construct($options); | ||
| 122 : | |||
| 123 : | if( $authtoken ){ | ||
| 124 : | $gauth = new google_authsub_request($authtoken); | ||
| 125 : | $sessiontoken = $gauth->get_session_token(); | ||
| 126 : | } | ||
| 127 : | |||
| 128 : | $this->token = $sessiontoken; | ||
| 129 : | if(! $this->valid_token() ){ | ||
| 130 : | throw new moodle_exception('Invalid subauth token'); | ||
| 131 : | } | ||
| 132 : | } | ||
| 133 : | |||
| 134 : | /** | ||
| 135 : | * Tests if a subauth token used is valid | ||
| 136 : | * | ||
| 137 : | * @return boolean true if token valid | ||
| 138 : | */ | ||
| 139 : | public function valid_token(){ | ||
| 140 : | $this->get(google_authsub::VERIFY_TOKEN_URL); | ||
| 141 : | |||
| 142 : | if($this->info['http_code'] === 200){ | ||
| 143 : | return true; | ||
| 144 : | }else{ | ||
| 145 : | return false; | ||
| 146 : | } | ||
| 147 : | } | ||
| 148 : | |||
| 149 : | /** | ||
| 150 : | * Calls googles api to revoke the subauth token | ||
| 151 : | * | ||
| 152 : | * @return boolean Returns true if token succesfully revoked | ||
| 153 : | */ | ||
| 154 : | public function revoke_session_token(){ | ||
| 155 : | $this->get(google_authsub::REVOKE_TOKEN_URL); | ||
| 156 : | |||
| 157 : | if($this->info['http_code'] === 200){ | ||
| 158 : | $this->token = ''; | ||
| 159 : | return true; | ||
| 160 : | }else{ | ||
| 161 : | return false; | ||
| 162 : | } | ||
| 163 : | } | ||
| 164 : | |||
| 165 : | /** | ||
| 166 : | * Creates a login url for subauth request | ||
| 167 : | * | ||
| 168 : | * @param string $returnaddr The address which the user should be redirected to recieve the token | ||
| 169 : | * @param string $realm The google realm which is access is being requested | ||
| 170 : | * @return string URL to bounce the user to | ||
| 171 : | */ | ||
| 172 : | public static function login_url($returnaddr, $realm){ | ||
| 173 : | $uri = google_authsub::LOGINAUTH_URL.'?next=' | ||
| 174 : | .urlencode($returnaddr) | ||
| 175 : | .'&scope=' | ||
| 176 : | .urlencode($realm) | ||
| 177 : | .'&session=1&secure=0'; | ||
| 178 : | |||
| 179 : | return $uri; | ||
| 180 : | } | ||
| 181 : | |||
| 182 : | public static function get_auth_header_name(){ | ||
| 183 : | return 'AuthSub token='; | ||
| 184 : | } | ||
| 185 : | } | ||
| 186 : | |||
| 187 : | /** | ||
| 188 : | * Class for manipulating google documents through the google data api | ||
| 189 : | * Docs for this can be found here: | ||
| 190 : | * http://code.google.com/apis/documents/docs/2.0/developers_guide_protocol.html | ||
| 191 : | */ | ||
| 192 : | class google_docs { | ||
| 193 : | const REALM = 'http://docs.google.com/feeds/documents'; | ||
| 194 : | const DOCUMENTFEED_URL = 'http://docs.google.com/feeds/documents/private/full'; | ||
| 195 : | const USER_PREF_NAME = 'google_authsub_sesskey'; | ||
| 196 : | |||
| 197 : | private $google_curl = null; | ||
| 198 : | |||
| 199 : | /** | ||
| 200 : | * Constructor. | ||
| 201 : | * | ||
| 202 : | * @param object A google_auth_request object which can be used to do http requests | ||
| 203 : | */ | ||
| 204 : | public function __construct($google_curl){ | ||
| 205 : | if(is_a($google_curl, 'google_auth_request')){ | ||
| 206 : | $this->google_curl = $google_curl; | ||
| 207 : | }else{ | ||
| 208 : | throw new moodle_exception('Google Curl Request object not given'); | ||
| 209 : | } | ||
| 210 : | } | ||
| 211 : | |||
| 212 : | public static function get_sesskey($userid){ | ||
| 213 : | return get_user_preferences(google_docs::USER_PREF_NAME, false, $userid); | ||
| 214 : | } | ||
| 215 : | |||
| 216 : | public static function set_sesskey($value, $userid){ | ||
| 217 : | return set_user_preference(google_docs::USER_PREF_NAME, $value, $userid); | ||
| 218 : | } | ||
| 219 : | |||
| 220 : | public static function delete_sesskey($userid){ | ||
| 221 : | return unset_user_preference(google_docs::USER_PREF_NAME, $userid); | ||
| 222 : | } | ||
| 223 : | |||
| 224 : | /** | ||
| 225 : | * Returns a list of files the user has formated for files api | ||
| 226 : | * | ||
| 227 : | * @param string $search A search string to do full text search on the documents | ||
| 228 : | * @return mixed Array of files formated for fileapoi | ||
| 229 : | */ | ||
| 230 : | #FIXME | ||
| 231 : | public function get_file_list($search = ''){ | ||
| 232 : | $url = google_docs::DOCUMENTFEED_URL; | ||
| 233 : | |||
| 234 : | if($search){ | ||
| 235 : | $url.='?q='.urlencode($search); | ||
| 236 : | } | ||
| 237 : | $content = $this->google_curl->get($url); | ||
| 238 : | |||
| 239 : | $xml = new SimpleXMLElement($content); | ||
| 240 : | |||
| 241 : | $files = array(); | ||
| 242 : | foreach($xml->entry as $gdoc){ | ||
| 243 : | |||
| 244 : | $files[] = array( 'title' => "$gdoc->title", | ||
| 245 : | 'url' => "{$gdoc->content['src']}", | ||
| 246 : | 'source' => "{$gdoc->content['src']}", | ||
| 247 : | 'date' => usertime(strtotime($gdoc->updated)), | ||
| 248 : | ); | ||
| 249 : | } | ||
| 250 : | |||
| 251 : | return $files; | ||
| 252 : | } | ||
| 253 : | |||
| 254 : | /** | ||
| 255 : | * Sends a file object to google documents | ||
| 256 : | * | ||
| 257 : | * @param object $file File object | ||
| 258 : | * @return boolean True on success | ||
| 259 : | */ | ||
| 260 : | public function send_file($file){ | ||
| 261 : | $this->google_curl->setHeader("Content-Length: ". $file->get_filesize()); | ||
| 262 : | $this->google_curl->setHeader("Content-Type: ". $file->get_mimetype()); | ||
| 263 : | $this->google_curl->setHeader("Slug: ". $file->get_filename()); | ||
| 264 : | |||
| 265 : | $this->google_curl->post(google_docs::DOCUMENTFEED_URL, $file->get_content()); | ||
| 266 : | |||
| 267 : | if($this->google_curl->info['http_code'] === 201){ | ||
| 268 : | return true; | ||
| 269 : | }else{ | ||
| 270 : | return false; | ||
| 271 : | } | ||
| 272 : | } | ||
| 273 : | |||
| 274 : | public function download_file($url, $fp){ | ||
| 275 : | return $this->google_curl->download(array( array('url'=>$url, 'file' => $fp) )); | ||
| 276 : | } | ||
| 277 : | } | ||
| 278 : | |||
| 279 : | /** | ||
| 280 : | * Class for manipulating picasa through the google data api | ||
| 281 : | * Docs for this can be found here: | ||
| 282 : | * http://code.google.com/apis/picasaweb/developers_guide_protocol.html | ||
| 283 : | */ | ||
| 284 : | class google_picasa { | ||
| 285 : | const REALM = 'http://picasaweb.google.com/data/'; | ||
| 286 : | const USER_PREF_NAME = 'google_authsub_sesskey_picasa'; | ||
| 287 : | const UPLOAD_LOCATION = 'http://picasaweb.google.com/data/feed/api/user/default/albumid/default'; | ||
| 288 : | poltawski | 1.2 | const ALBUM_PHOTO_LIST = 'http://picasaweb.google.com/data/feed/api/user/default/albumid/'; |
| 289 : | const PHOTO_SEARCH_URL = 'http://picasaweb.google.com/data/feed/api/user/default?kind=photo&q='; | ||
| 290 : | const LIST_ALBUMS_URL = 'http://picasaweb.google.com/data/feed/api/user/default'; | ||
| 291 : | poltawski | 1.1 | |
| 292 : | private $google_curl = null; | ||
| 293 : | |||
| 294 : | /** | ||
| 295 : | * Constructor. | ||
| 296 : | * | ||
| 297 : | * @param object A google_auth_request object which can be used to do http requests | ||
| 298 : | */ | ||
| 299 : | public function __construct($google_curl){ | ||
| 300 : | if(is_a($google_curl, 'google_auth_request')){ | ||
| 301 : | $this->google_curl = $google_curl; | ||
| 302 : | }else{ | ||
| 303 : | throw new moodle_exception('Google Curl Request object not given'); | ||
| 304 : | } | ||
| 305 : | } | ||
| 306 : | |||
| 307 : | public static function get_sesskey($userid){ | ||
| 308 : | return get_user_preferences(google_picasa::USER_PREF_NAME, false, $userid); | ||
| 309 : | } | ||
| 310 : | |||
| 311 : | public static function set_sesskey($value, $userid){ | ||
| 312 : | return set_user_preference(google_picasa::USER_PREF_NAME, $value, $userid); | ||
| 313 : | } | ||
| 314 : | |||
| 315 : | public static function delete_sesskey($userid){ | ||
| 316 : | return unset_user_preference(google_picasa::USER_PREF_NAME, $userid); | ||
| 317 : | } | ||
| 318 : | |||
| 319 : | /** | ||
| 320 : | * Sends a file object to picasaweb | ||
| 321 : | * | ||
| 322 : | * @param object $file File object | ||
| 323 : | * @return boolean True on success | ||
| 324 : | */ | ||
| 325 : | public function send_file($file){ | ||
| 326 : | $this->google_curl->setHeader("Content-Length: ". $file->get_filesize()); | ||
| 327 : | $this->google_curl->setHeader("Content-Type: ". $file->get_mimetype()); | ||
| 328 : | $this->google_curl->setHeader("Slug: ". $file->get_filename()); | ||
| 329 : | |||
| 330 : | $this->google_curl->post(google_picasa::UPLOAD_LOCATION, $file->get_content()); | ||
| 331 : | |||
| 332 : | if($this->google_curl->info['http_code'] === 201){ | ||
| 333 : | return true; | ||
| 334 : | }else{ | ||
| 335 : | return false; | ||
| 336 : | } | ||
| 337 : | } | ||
| 338 : | poltawski | 1.2 | |
| 339 : | /** | ||
| 340 : | * Returns list of photos for file picker. | ||
| 341 : | * If top level then returns list of albums, otherwise | ||
| 342 : | * photos within an album. | ||
| 343 : | * | ||
| 344 : | * @param string $path The path to files (assumed to be albumid) | ||
| 345 : | * @return mixed $files A list of files for the file picker | ||
| 346 : | */ | ||
| 347 : | public function get_file_list($path = ''){ | ||
| 348 : | if(!$path){ | ||
| 349 : | return $this->get_albums(); | ||
| 350 : | }else{ | ||
| 351 : | return $this->get_album_photos($path); | ||
| 352 : | } | ||
| 353 : | } | ||
| 354 : | |||
| 355 : | /** | ||
| 356 : | * Returns list of photos in album specified | ||
| 357 : | * | ||
| 358 : | * @param int $albumid Photo album to list photos from | ||
| 359 : | * @return mixed $files A list of files for the file picker | ||
| 360 : | */ | ||
| 361 : | public function get_album_photos($albumid){ | ||
| 362 : | $albumcontent = $this->google_curl->get(google_picasa::ALBUM_PHOTO_LIST.$albumid); | ||
| 363 : | |||
| 364 : | return $this->get_photo_details($albumcontent); | ||
| 365 : | } | ||
| 366 : | |||
| 367 : | /** | ||
| 368 : | * Does text search on the users photos and returns | ||
| 369 : | * matches in format for picasa api | ||
| 370 : | * | ||
| 371 : | * @param string $query Search terms | ||
| 372 : | * @return mixed $files A list of files for the file picker | ||
| 373 : | */ | ||
| 374 : | public function do_photo_search($query){ | ||
| 375 : | $content = $this->google_curl->get(google_picasa::PHOTO_SEARCH_URL.htmlentities($query)); | ||
| 376 : | |||
| 377 : | return $this->get_photo_details($content); | ||
| 378 : | } | ||
| 379 : | |||
| 380 : | /** | ||
| 381 : | * Gets all the users albums and returns them as a list of folders | ||
| 382 : | * for the file picker | ||
| 383 : | * | ||
| 384 : | * @return mixes $files Array in the format get_listing uses for folders | ||
| 385 : | */ | ||
| 386 : | public function get_albums(){ | ||
| 387 : | $content = $this->google_curl->get(google_picasa::LIST_ALBUMS_URL); | ||
| 388 : | $xml = new SimpleXMLElement($content); | ||
| 389 : | |||
| 390 : | $files = array(); | ||
| 391 : | |||
| 392 : | foreach($xml->entry as $album){ | ||
| 393 : | $gphoto = $album->children('http://schemas.google.com/photos/2007'); | ||
| 394 : | |||
| 395 : | $mediainfo = $album->children('http://search.yahoo.com/mrss/'); | ||
| 396 : | //hacky... | ||
| 397 : | $thumbnailinfo = $mediainfo->group->thumbnail[0]->attributes(); | ||
| 398 : | |||
| 399 : | $files[] = array( 'title' => (string) $gphoto->name, | ||
| 400 : | 'date' => userdate($gphoto->timestamp), | ||
| 401 : | 'size' => (int) $gphoto->bytesUsed, | ||
| 402 : | 'path' => (string) $gphoto->id, | ||
| 403 : | 'thumbnail' => (string) $thumbnailinfo['url'], | ||
| 404 : | moodler | 1.3 | 'thumbnail_width' => 160, // 160 is the native maximum dimension |
| 405 : | 'thumbnail_height' => 160, | ||
| 406 : | poltawski | 1.2 | 'children' => array(), |
| 407 : | ); | ||
| 408 : | |||
| 409 : | } | ||
| 410 : | |||
| 411 : | return $files; | ||
| 412 : | } | ||
| 413 : | |||
| 414 : | /** | ||
| 415 : | * Recieves XML from a picasa list of photos and returns | ||
| 416 : | * array in format for file picker. | ||
| 417 : | * | ||
| 418 : | * @param string $rawxml XML from picasa api | ||
| 419 : | * @return mixed $files A list of files for the file picker | ||
| 420 : | */ | ||
| 421 : | public function get_photo_details($rawxml){ | ||
| 422 : | |||
| 423 : | $xml = new SimpleXMLElement($rawxml); | ||
| 424 : | |||
| 425 : | $files = array(); | ||
| 426 : | |||
| 427 : | foreach($xml->entry as $photo){ | ||
| 428 : | $gphoto = $photo->children('http://schemas.google.com/photos/2007'); | ||
| 429 : | |||
| 430 : | $mediainfo = $photo->children('http://search.yahoo.com/mrss/'); | ||
| 431 : | $fullinfo = $mediainfo->group->content->attributes(); | ||
| 432 : | //hacky... | ||
| 433 : | $thumbnailinfo = $mediainfo->group->thumbnail[0]->attributes(); | ||
| 434 : | |||
| 435 : | $files[] = array('title' => (string) $mediainfo->group->title, | ||
| 436 : | 'date' => userdate($gphoto->timestamp), | ||
| 437 : | 'size' => (int) $gphoto->size, | ||
| 438 : | 'path' => $gphoto->albumid.'/'.$gphoto->id, | ||
| 439 : | 'thumbnail' => (string) $thumbnailinfo['url'], | ||
| 440 : | moodler | 1.3 | 'thumbnail_width' => 72, // 72 is the native maximum dimension |
| 441 : | 'thumbnail_height' => 72, | ||
| 442 : | poltawski | 1.2 | 'source' => (string) $fullinfo['url'], |
| 443 : | ); | ||
| 444 : | } | ||
| 445 : | |||
| 446 : | return $files; | ||
| 447 : | } | ||
| 448 : | |||
| 449 : | poltawski | 1.1 | } |
| 450 : | |||
| 451 : | /** | ||
| 452 : | * Beginings of an implementation of Clientogin authenticaton for google | ||
| 453 : | * accounts as documented here: | ||
| 454 : | * http://code.google.com/apis/accounts/docs/AuthForInstalledApps.html#ClientLogin | ||
| 455 : | * | ||
| 456 : | * With this authentication we have to accept a username and password and to post | ||
| 457 : | * it to google. Retrieving a token for use afterwards. | ||
| 458 : | */ | ||
| 459 : | class google_authclient extends google_auth_request { | ||
| 460 : | const LOGIN_URL = 'https://www.google.com/accounts/ClientLogin'; | ||
| 461 : | |||
| 462 : | public function __construct($sessiontoken = '', $username = '', $password = '', $options = array() ){ | ||
| 463 : | parent::__construct($options); | ||
| 464 : | |||
| 465 : | if($username and $password){ | ||
| 466 : | $param = array( | ||
| 467 : | 'accountType'=>'GOOGLE', | ||
| 468 : | 'Email'=>$username, | ||
| 469 : | 'Passwd'=>$password, | ||
| 470 : | 'service'=>'writely' | ||
| 471 : | ); | ||
| 472 : | |||
| 473 : | $content = $this->post(google_authclient::LOGIN_URL, $param); | ||
| 474 : | |||
| 475 : | if( preg_match('/auth=(.*)/i', $content, $matches) ){ | ||
| 476 : | $sessiontoken = $matches[1]; | ||
| 477 : | }else{ | ||
| 478 : | throw new moodle_exception('could not upgrade authtoken'); | ||
| 479 : | } | ||
| 480 : | |||
| 481 : | } | ||
| 482 : | |||
| 483 : | if($sessiontoken){ | ||
| 484 : | $this->token = $sessiontoken; | ||
| 485 : | }else{ | ||
| 486 : | throw new moodle_exception('no session token specified'); | ||
| 487 : | } | ||
| 488 : | } | ||
| 489 : | |||
| 490 : | public static function get_auth_header_name(){ | ||
| 491 : | return 'GoogleLogin auth='; | ||
| 492 : | } | ||
| 493 : | } | ||
| 494 : | |||
| 495 : | ?> |
| Moodle CVS Admin | ViewVC Help |
| Powered by ViewVC 1.0.7 |