From: Alan Knowles Date: Sat, 22 Jan 2011 03:15:31 +0000 (+0800) Subject: import X-Git-Url: http://git.roojs.org/?p=web.mtrack;a=commitdiff_plain;h=1bfa353d79ba68ba8a9fdf6dd0c3f5dc56e09d64 import --- 1bfa353d79ba68ba8a9fdf6dd0c3f5dc56e09d64 diff --git a/.hgignore b/.hgignore new file mode 100644 index 00000000..1b5939e5 --- /dev/null +++ b/.hgignore @@ -0,0 +1,7 @@ +syntax:glob +.DS_Store +.*.swp* +var* +trac-data.tar.bz2 +trac-data +glob:config.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..98b3d447 --- /dev/null +++ b/LICENSE @@ -0,0 +1,120 @@ +The bulk of this work is authored by Wez Furlong and is: +Copyright (C) 2008-2010 Message Systems, Inc. +All rights reserved. + +This software is derived from the "Trac" software product, which is: +Copyright (C) 2003-2008 Edgewall Software +All rights reserved. +The origin of that software is http://trac.edgewall.org + +Portions of this software are taken from the Alexandria PHP +library which is authored by Wez Furlong and is: +Copyright (C) 2007, OmniTI Computer Consulting, Inc. +The origin of that software is http://bitbucket.org/wez/alexandria + +The Lucene search implementation is built using software from +the Zend Framework, which is: +Copyright (C) 2005-2009, Zend Technologies USA, Inc. +The origin of that software is http://framework.zend.com + +This software uses the jQuery javascript/CSS/UI library, which is +Copyright (C) 2009 John Resig +The origin of that software is http://jquery.com + +This software uses the markItUp! Universal MarkUp Engine, which is +Copyright (C) 2007-2009 Jay Salvat +The origin of that software is http://markitup.jaysalvat.com/ + +This software uses the timeago jQuery plugin, which is +Copyright (c) 2008-2010, Ryan McGeary (ryanonjavascript -[at]- mcgeary [*dot*] org) +The origin of that software is http://timeago.yarp.com/ + +This software uses the PHP OpenID library by JanRain, Inc., which is +Copyright (C) 2005-2008 JanRain, Inc. +The origin of that software is http://openidenabled.com/php-openid/ + +This software uses the Hyperlight syntax highlighting library for PHP, which is +Copyright 2008 Konrad Rudolph. +The origin of that software is http://code.google.com/p/hyperlight/ + +Some of the icons used in this software were taken from +http://www.smashingmagazine.com/2009/05/20/flavour-extended-the-ultimate-icon-set-for-web-designers/ +which explicitly states that it may be used for any purpose with no +restrictions. +I'm including a link to the artists web site as a courtesy; thank you! +http://www.addictedtocoffee.de/ + +It is recommended that persons intending to redistribute the third-party +software do so by obtaining it from the origin rather than from the software +distribution containing this license text. + +All of the above software, with the exception of jQuery (and plugins), +markItUp and PHP OpenID, are subject to the modified BSD license, +the text of which is included immediately below: + +------- + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. +3. The name of the copyright holders or contributors may not be used + to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS `AS IS'' +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------- + +jQuery (and related plugins) and markItUp are subject to the MIT license, +included immediately below: + +------- + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +------- + +The PHP OpenID library is subject to the Apache License version 2.0. +The full text of that license can be found at: + + http://www.apache.org/licenses/LICENSE-2.0 Apache + +another copy of the license text can be found in the file + + inc/Auth/COPYING + +which is contained in this source distribution. + diff --git a/README b/README new file mode 100644 index 00000000..e2441f0a --- /dev/null +++ b/README @@ -0,0 +1,8 @@ +mtrack + +This is a tracking, planning and collaboration tool based around agile +principles. + +It integrates with subversion and mercurial repositories and provides a means +for planning projects, tracking issues and recording documentation. + diff --git a/bin/acl-check.php b/bin/acl-check.php new file mode 100644 index 00000000..fb7f929c --- /dev/null +++ b/bin/acl-check.php @@ -0,0 +1,30 @@ +repoid"; +} + +$res = MTrackACL::hasAnyRights($objectid, $argv); + +if ($res) { + exit(0); +} +exit(1); + diff --git a/bin/codeshell b/bin/codeshell new file mode 100755 index 00000000..012581c3 --- /dev/null +++ b/bin/codeshell @@ -0,0 +1,154 @@ +#!/usr/bin/perl +# vim:ts=2:sw=2:et: +# For licensing and copyright terms, see the file named LICENSE +use strict; +use IO::File; + +# We are invoked by sshd as the repo serving user in place of the actual +# command they requested. +# Our purpose is to interpose and ensure that the underlying tool looks +# only at the appropriate location + +# Our parameters are: +# $1: path to the mtrack config.ini file +# $2: the mtrack username +# However, at least on OS/X, we get invoked as "-c '$1 $2'", so we need +# to check for that. + +my ($inifile, $username, $mtrack) = @ARGV; + +if ($inifile eq '-c') { + require 'shellwords.pl'; + @ARGV = &shellwords($username); + shift @ARGV; + ($inifile, $username, $mtrack) = @ARGV; +} +$ENV{MTRACK_CONFIG_FILE} = $inifile; + +# The command requested by the remote user is stored in this envvar. +my $cmd = $ENV{SSH_ORIGINAL_COMMAND}; + +sub validate_reponame { + my ($name) = @_; + + if ($name =~ m/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/) { + if ($name !~ m/\.\./) { + my $base = get_cfg('repos', 'basedir'); + if (! -d "$base/$name") { + print STDERR "Non-existant repo $name\n"; + exit(1); + } + + # Sanity check that we at least have checkout access + my $php = get_tool('php'); + if (system($php, "$mtrack/bin/acl-check.php", $username, '--repo', + $name, 'checkout')) { + print STDERR "$username does not have checkout permission on $name\n"; + exit(1); + } + + return "$base/$name"; + } + } + print STDERR "Invalid repo name $name\n"; + exit(1); +} + +my %CFG; + +sub read_config_file { + my $f = IO::File->new($inifile); + if (!$f) { + print STDERR "Unable to open ini file $inifile: $!\n"; + exit(1); + } + my $sect = undef; + while (<$f>) { + my $line = $_; + $line =~ s/;.*$//; + $line =~ s/\s+$//; + if ($line =~ m/^\[(.*)\]$/) { + $sect = $1; + next; + } + if ($line =~ m/^(\S+)\s*=\s*"(.*)"$/) { + $CFG{$sect}{$1} = $2; + next; + } + if ($line =~ m/^(\S+)\s*=\s*(.*)$/) { + $CFG{$sect}{$1} = $2; + next; + } + } +} + +sub get_cfg { + my ($sect, $name) = @_; + my $val; + + if (not exists $CFG{$sect}) { + return undef; + } + if (not exists $CFG{$sect}{$name}) { + return undef; + } + $val = $CFG{$sect}{$name}; + + while ($val =~ m/\@\{(\S+):(\S+)\}/) { + my ($s, $k) = ($1, $2); + + my $r = ''; + if (exists $CFG{$s} and exists $CFG{$s}{$k}) { + $r = $CFG{$s}{$k}; + } + $val =~ s/\@\{$s:$k\}/$r/g; + } + return $val; +} + +read_config_file(); + +sub get_tool { + my ($name) = @_; + my $tool = get_cfg('tools', $name); + if (-x $tool) { + return $tool; + } + print STDERR "tool $name is not configured\n"; + exit(1); +} + +$ENV{LOGNAME} = $username; +if (0) { + open LOG, ">>/var/tmp/mtrack.ssh.session.log"; + print LOG "$username $cmd\n"; + close LOG; +} + +if ($cmd =~ m/^hg -R (\S+) serve --stdio$/) { + my $name = validate_reponame($1); + + my $hg = get_tool('hg'); + + exec($hg, '-R', $name, 'serve', '--stdio'); +} + +if ($cmd =~ m/^git-(\S+)\s+'(\S+)'$/) { + my ($verb, $name) = ($1, $2); + $name = validate_reponame($name); + my $git = get_tool('git'); + + exec($git, 'shell', '-c', "git-$verb '$name'"); +} + +if ($cmd eq 'svnserve -t') { + my $base = get_cfg('repos', 'basedir'); + if (! -d $base) { + print STDERR "basedir $base does not exist\n"; + exit(1); + } + my $svnserve = get_tool('svnserve'); + exec($svnserve, '-r', $base, '-t', "--tunnel-user=$username"); +} + +print STDERR "Unsupported command:\n$cmd\n"; diff --git a/bin/data-move.php b/bin/data-move.php new file mode 100644 index 00000000..44656c5e --- /dev/null +++ b/bin/data-move.php @@ -0,0 +1,164 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$ddb = new PDO($ddsn); +$ddb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$ddb->exec("set client_encoding='utf-8'"); + +$driver = $ddb->getAttribute(PDO::ATTR_DRIVER_NAME); +$adapter_class = "MTrackDBSchema_$driver"; +$adapter = new $adapter_class; + +$adapter->setDB($ddb); +$vers = $adapter->determineVersion(); +echo "Version: "; +var_dump($vers); + +$ddb->beginTransaction(); + +$schemata = array(); +$latest = null; +foreach (glob(dirname(__FILE__) . '/../schema/*.xml') as $filename) { + $latest = new MTrackDBSchema($filename); + $schemata[$latest->version] = $latest; +} + +echo "Applying schema version $latest->version\n"; +foreach ($latest->tables as $t) { + $adapter->createTable($t); + + $names = array(); + + foreach ($t->fields as $f) { + if ($f->type == 'autoinc') { + // Omit: we want the database to set this for us, otherwise + // sequence numbers won't get populated! + continue; + } + $names[] = $f->name; + } + $pull = 'select ' . join(',', $names) . ' from ' . $t->name; + + $push = 'insert into ' . $t->name . '(' . join(',', $names) . ') values (' . + str_repeat('?,', count($names) - 1) . '?)'; + + $sq = $sdb->query($pull, PDO::FETCH_NUM); + + $dq = $ddb->prepare($push); + + foreach ($sq as $row) { + /* postgres has stronger data validation requirements; + * fixup the data */ + $send = array(); + foreach ($names as $i => $fname) { + $f = $t->fields[$fname]; + switch ($f->type) { + case 'integer': + case 'autoinc': + if ($row[$i] == '') { + if (isset($f->nullable) && $f->nullable == '0') { + $row[$i] = 0; + } else { + $row[$i] = null; + } + } + $dq->bindValue(1+$i, $row[$i]); + break; + case 'real': + if ($row[$i] == '') { + if (isset($f->nullable) && $f->nullable == '0') { + $dq->bindValue(1+$i, 0.0); + } else { + $dq->bindValue(1+$i, null); + } + } else { + /* avoid converting to double here, for sake of precision. + * Also, somehow we have commas in our data... fix that */ + $dq->bindValue(1+$i, str_replace(",", ".", $row[$i])); + } + break; + case 'blob': + if (is_null($row[$i])) { + $dq->bindValue(1+$i, null); + } else { + $stm = fopen('php://memory', 'r+'); + fwrite($stm, $row[$i]); + rewind($stm); + $dq->bindValue(1+$i, $stm, PDO::PARAM_LOB); + } + break; + case 'text': + default: + /* CSV import could have injected non-UTF-8 data */ + if (is_null($row[$i])) { + $dq->bindValue(1+$i, null); + } else { + $enc = mb_detect_encoding($row[$i], 'UTF-8,ISO-8859-1'); + if ($enc != 'UTF-8') { + $dq->bindValue(1+$i, + mb_convert_encoding($row[$i], 'UTF-8', $enc)); + } else { + $dq->bindValue(1+$i, $row[$i]); + } + } + } + } + try { + $dq->execute(); + } catch (Exception $e) { + echo "$push\n"; + var_dump($names); + var_dump($row); + var_dump($send); + foreach ($send as $d) { + echo bin2hex($d) . "\n"; + } + throw $e; + } + } +} +if (isset($latest->post[$driver])) { + $ddb->exec($latest->post[$driver]); +} +$vers = $latest->version; + + + + +$ddb->exec('delete from mtrack_schema'); +$q = $ddb->prepare('insert into mtrack_schema (version) values (?)'); +$q->execute(array($latest->version)); +$ddb->commit(); + + + + diff --git a/bin/git-commit-hook b/bin/git-commit-hook new file mode 100755 index 00000000..e1eefdbd --- /dev/null +++ b/bin/git-commit-hook @@ -0,0 +1,152 @@ +#!/usr/bin/env php +repo = $repo; + + while (($line = fgets(STDIN)) !== false) { + list($old, $new, $ref) = explode(' ', trim($line), 3); + $this->commits[] = $new; + + $fp = run($GIT, 'log', '--no-color', '--name-status', + '--date=rfc', $ref, "$old..$new"); + $props = array(); + $line = fgets($fp); + if (!preg_match("/^commit\s+(\S+)$/", $line)) { + throw new Exception("unexpected output from git log: $line"); + } + while (($line = fgets($fp)) !== false) { + $line = rtrim($line); + if (!strlen($line)) break; + if (preg_match("/^(\S+):\s*(.*)\s*$/", $line, $M)) { + $props[$M[1]] = $M[2]; + } + } + while (($line = fgets($fp)) !== false) { + $line = rtrim($line); + if (strncmp($line, ' ', 4)) { + break; + } + $this->log[] = substr($line, 4); + } + do { + if (preg_match("/^(.+)\s+(\S+)\s*$/", $line, $M)) { + $st = $M[1]; + $file = $M[2]; + $this->files[$file] = $new; + } + } while (($line = fgets($fp)) !== false); + } + } + function enumChangedOrModifiedFileNames() { + return array_keys($this->files); + } + + function getCommitMessage() { + $log = join("\n", $this->log); + $log = preg_replace('/\[([a-fA-F0-9]+)\]/', + "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log); + return $log; + } + + function getFileStream($path) { + global $GIT; + $rev = $this->files[$path]; + + // There may be a better way... + // ls-tree to determine the hash of the file from this change: + $fp = run($GIT, 'ls-tree', '-r', $rev, $path); + $line = fgets($fp); + $fp = null; + list($mode, $type, $hash, $name) = preg_split("/\s+/", $line); + // now we can cat that blob + return run($GIT, 'cat-file', 'blob', $hash); + } + + function getChangesetDescriptor() { + $cs = array(); + foreach ($this->commits as $ref) { + $cs[] = '[changeset:' . $this->repo->getBrowseRootName() . ",$ref]"; + } + return join(", ", $cs); + } +} + +try { + $repo = MTrackRepo::loadByLocation(getcwd()); + $bridge = new GitCommitHookBridge($repo); + $author = MTrackAuth::whoami(); + if ($author == 'anonymous') { + throw new Exception("cannot determine who you are"); + } + $author = mtrack_canon_username($author); + MTrackAuth::su($author); + $checker = new MTrackCommitChecker($repo); + switch ($action) { + case 'pre': + $checker->preCommit($bridge); + break; + default: + $checker->postCommit($bridge); + } + exit(0); +} catch (Exception $e) { + fwrite(STDERR, "\n" . $e->getMessage() . + "\n\n" . + $e->getTraceAsString() . + "\n\n ** Commit failed [$action]\n"); + + exit(1); +} + +function run() +{ + $args = func_get_args(); + $all_args = array(); + foreach ($args as $a) { + if (is_array($a)) { + foreach ($a as $arg) { + $all_args[] = $arg; + } + } else { + $all_args[] = $a; + } + } + + $cmd = ''; + + foreach ($all_args as $i => $arg) { + if ($i > 0) { + $cmd .= ' '; + } + $cmd .= escapeshellarg($arg); + } + +// echo $cmd, "\n"; + return popen($cmd, 'r'); +} diff --git a/bin/hg-commit-hook b/bin/hg-commit-hook new file mode 100755 index 00000000..e2680b72 --- /dev/null +++ b/bin/hg-commit-hook @@ -0,0 +1,193 @@ +#!/usr/bin/env php += 0) { + $HG_PARENT1 = $M[2]; + break; + } + } + } +} else { + $HG_PARENT1 = $_ENV['HG_PARENT1']; +} + + +class HgCommitHookBridge implements IMTrackCommitHookBridge2 { + var $repo; + function __construct($repo) { + $this->repo = $repo; + } + + function getChanges() { + global $HG_NODE; + global $HG; + $cs = array(); + $log = popen("$HG log -r$HG_NODE: --template '{node|short}\n{author|email}\n{date|hgdate}\n{desc|nonempty|tabindent}\n'", 'r'); + $line = fgets($log); + do { + $c = new MTrackCommitHookChangeEvent; + + $node = trim($line); + $c->hash = $node; + $c->rev = "[changeset:" . $this->repo->getBrowseRootName() . ",$node]"; + + $author = trim(fgets($log)); + $c->changeby = mtrack_canon_username($author); + + $date = fgets($log); + if (!preg_match("/^(\d+)\s+\d+$/", $date, $M)) { + throw new Exception("failed to parse date $date"); + } + $c->ctime = MTrackDB::unixtime((int)$M[1]); + + $msg = fgets($log); + do { + $line = fgets($log); + if ($line === false) { + break; + } + if (preg_match("/^[a-fA-F0-9]+$/", $line)) { + break; + } + $msg .= substr($line, 1); + } while (true); + $c->changelog = rtrim($msg); + $cs[] = $c; + } while ($line !== false); + + return $cs; + } + + function enumChangedOrModifiedFileNames() { + global $HG; + global $HG_NODE; + + $files = array(); + $fp = popen("$HG log -r$HG_NODE: --template '{files}\n'", 'r'); + while (($line = fgets($fp)) !== false) { + foreach (preg_split("/\s+/", $line) as $path) { + if (strlen($path)) { + $files[] = $path; + } + } + } + return $files; + } + + function getCommitMessage() { + global $HG; + global $HG_NODE; + $fp = popen("$HG log -r$HG_NODE: --template '{desc}\n\n'", 'r'); + $log = stream_get_contents($fp); + $log = preg_replace('/\[(\d+)\]/', + "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log); + return $log; + } + + function getFileStream($path) { + global $HG; + global $HG_NODE; + return popen("$HG cat $path", 'r'); + } + + function getChangesetDescriptor() { + global $HG_NODE; + global $HG; + $cs = array(); + $nodes = popen("$HG log -r$HG_NODE: --template '{node|short}\n'", 'r'); + while (($line = fgets($nodes)) !== false) { + $n = trim($line); + $cs[] = '[changeset:' . $this->repo->getBrowseRootName() . ",$n]"; + } + return join(", ", $cs); + } +} + +try { + $repo = MTrackRepo::loadByLocation(getcwd()); + $bridge = new HgCommitHookBridge($repo); + /* for pushes, respect OS indication of who this is, unless we don't + * know; we'll use the info from the changeset in that case */ + $author = 'anonymous'; + if (strstr($action, 'group')) { + $author = MTrackAuth::whoami(); + } + if ($author == 'anonymous') { + $author = trim( + shell_exec("$HG log -r$HG_NODE: --template '{author|email}'")); + } + $author = mtrack_canon_username($author); + MTrackAuth::su($author); + $checker = new MTrackCommitChecker($repo); + switch ($action) { + case 'pretxncommit': + case 'pretxnchangegroup': + $checker->preCommit($bridge); + break; + default: + $checker->postCommit($bridge); + } + exit(0); +} catch (Exception $e) { + /* Errors must render to STDERR, or they won't show up in the hg client */ + fwrite(STDERR, "\n" . $e->getMessage() . + "\n\n" . + $e->getTraceAsString() . + "\n\n ** Commit failed [$action]\n"); + + exit(1); +} + +function run() +{ + $args = func_get_args(); + $all_args = array(); + foreach ($args as $a) { + if (is_array($a)) { + foreach ($a as $arg) { + $all_args[] = $arg; + } + } else { + $all_args[] = $a; + } + } + + $cmd = ''; + + foreach ($all_args as $i => $arg) { + if ($i > 0) { + $cmd .= ' '; + } + $cmd .= escapeshellarg($arg); + } + +// echo $cmd, "\n"; + return popen($cmd, 'r'); +} diff --git a/bin/import-trac.php b/bin/import-trac.php new file mode 100644 index 00000000..133ace1d --- /dev/null +++ b/bin/import-trac.php @@ -0,0 +1,908 @@ + 'content', + 'type' => 'classification', + 'estimatedhours' => 'estimated', + 'ec_branches' => 'branches', + 'ec_features' => 'features', +); + +$trac_wiki_names = array( + 'TracAccessibility' => true, + 'TracAdmin' => true, + 'TracBackup' => true, + 'TracBrowser' => true, + 'TracCgi' => true, + 'TracChangeset' => true, + 'TracEnvironment' => true, + 'TracFastCgi' => true, + 'TracGuide' => true, + 'TracImport' => true, + 'TracIni' => true, + 'TracInstall' => true, + 'TracInstallPlatforms' => true, + 'TracInterfaceCustomization' => true, + 'TracLinks' => true, + 'TracLogging' => true, + 'TracModPython' => true, + 'TracMultipleProjects' => true, + 'TracNotification' => true, + 'TracPermissions' => true, + 'TracPlugins' => true, + 'TracQuery' => true, + 'TracReports' => true, + 'TracRevisionLog' => true, + 'TracRoadmap' => true, + 'TracRss' => true, + 'TracSearch' => true, + 'TracStandalone' => true, + 'TracSupport' => true, + 'TracSyntaxColoring' => true, + 'TracTickets' => true, + 'TracTicketsCustomFields' => true, + 'TracTimeline' => true, + 'TracUnicode' => true, + 'TracUpgrade' => true, + 'TracWiki' => true, + 'WikiDeletePage' => true, + 'WikiFormatting' => true, + 'WikiHtml' => true, + 'WikiMacros' => true, + 'WikiNewPage' => true, + 'WikiPageNames' => true, + 'WikiProcessors' => true, + 'WikiRestructuredText' => true, + 'WikiRestructuredTextLinks' => true, + 'CamelCase' => true, + 'InterMapTxt' => true, + 'InterTrac' => true, + 'InterWiki' => true, + 'RecentChanges' => true, + 'SandBox' => true, + 'TitleIndex' => true, +); + +function trac_date($unix) { + return MTrackDB::unixtime($unix); +} + +function trac_get_comp($name, $deleted = true) +{ + global $CS; + global $components_by_name; + + if (!strlen($name)) return null; + + $comp = $components_by_name[$name]; + if ($comp === null) { + /* no longer exists */ + $comp = new MTrackComponent; + $comp->name = $name; + $comp->deleted = $deleted; + $comp->save($CS); + $components_by_name[$comp->name] = $comp; + } + return $comp; +} + +function trac_assoc_comp_and_proj(MTrackComponent $comp, MTrackProject $proj) +{ + static $comp_assoc = array(); + + if (isset($comp_assoc[$proj->shortname][$comp->name])) { + return; + } + + MTrackDB::q('insert into components_by_project (projid, compid) + values (?, ?)', $proj->projid, $comp->compid); + + $comp_assoc[$proj->shortname][$comp->name] = true; +} + +function trac_add_user($username) +{ + static $users = array(); + global $CANON_USERS; + + $username = trim($username); + $username = strtolower($username); + + while (isset($CANON_USERS[$username])) { + $username = strtolower($CANON_USERS[$username]); + } + + if (preg_match('/[ ,]/', $username)) { + // invalid: attempted to set multiple people. + // take the first one + list($username) = preg_split('/[ ,]+/', $username); + + while (isset($CANON_USERS[$username])) { + $username = strtolower($CANON_USERS[$username]); + } + } + + if (preg_match('/^\d+(\.\d+)?$/', $username)) { + // invalid (looks like a version number) + return null; + } + + if ($username == 'somebody' || $username == '') { + return null; + } + + if (isset($users[$username])) { + return $username; + } + + $users[$username] = true; + switch ($username) { + case 'trac': + $active = 0; + break; + default: + $active = 1; + } + try { + MTrackDB::q( + 'insert into userinfo (userid, active) values (?, ?)', + $username, $active); + } catch (Exception $e) { + } + + return $username; +} + +function trac_get_milestone($name, MTrackProject $proj) +{ + global $CS; + global $milestone_by_name; + static $alias = array(); + + $lname = strtolower($name); + if (isset($alias[$proj->shortname][$lname])) { + $name = $alias[$proj->shortname][$lname]; + } else { + $alias[$proj->shortname][$lname] = $name; + } + + $ms = $milestone_by_name[$lname]; + if ($ms === null) { + /* first see if there's a milestone with this name in another project */ + $ms = MTrackMilestone::loadByName($name); + if ($ms) { + $alias[$proj->shortname][$lname] .= " ($proj->shortname)"; + $name = $alias[$proj->shortname][$lname]; + } + + $ms = new MTrackMilestone(); + $ms->name = $name; + $ms->deleted = true; + $ms->description = ''; + $ms->save($CS); + $milestone_by_name[$lname] = $ms; + } + return $ms; +} + +function trac_get_keyword($word) +{ + static $words = array(); + + if (isset($words[$word])) { + return $words[$word]; + } + + $kw = MTrackKeyword::loadByWord($word); + + if (!$kw) { + global $CS; + $kw = new MTrackKeyword; + $kw->keyword = $word; + $kw->save($CS); + } + + $words[$word] = $kw; + + return $kw; +} + +function progress($msg) +{ + static $events = 0; + static $last = 0; + static $clr_eol = null; + static $clr_eod = null; + + if ($clr_eol === null) { + /* el: clr_eol + * ed: clr_eos + */ + $clr_eol = shell_exec("tput el"); + $clr_eod = shell_exec("tput ed"); + } + + $events++; + + $now = time(); + + if ($events % 10 || $now - $last > 2) { + echo "\r$clr_eod$msg"; flush(); + } + $last = $now; +} + +$components_by_name = array(); + +function adjust_links($reason, $ticket_prefix, MTrackProject $project) +{ + return $project->adjust_links($reason, $ticket_prefix); +} + +function import_from_trac(MTrackProject $project, $import_from_db, $ticket_prefix = false) +{ + global $components_by_name; + global $milestone_by_name; + + echo "Importing trac database $import_from_db\n"; flush(); + + $start_import = time(); + + /* reset this list so that we can detect conflicting names + * across projects */ + $milestone_by_name = array(); + + + if (!file_exists("$import_from_db/db/trac.db")) { + echo "No such file $import_from_db/db/trac.db\n"; + exit(1); + } + + $trac = new PDO('sqlite:' . $import_from_db . "/db/trac.db"); + $trac->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + //date_default_timezone_set('UTC'); + + $CS = MTrackChangeset::begin('~import~', "Import trac from $import_from_db"); + + foreach ($trac->query( + "select type, name, value from enum")->fetchAll() + as $row) { + + if ($row['type'] == 'priority') { + try { + $pri = MTrackPriority::loadByName($row['name']); + } catch (Exception $e) { + $pri = new MTrackPriority; + $pri->name = $row['name']; + $pri->value = $row['value']; + $pri->save($CS); + } + } + + if ($row['type'] == 'severity') { + try { + $pri = MTrackSeverity::loadByName($row['name']); + } catch (Exception $e) { + $pri = new MTrackSeverity; + $pri->name = $row['name']; + $pri->value = $row['value']; + $pri->save($CS); + } + } + + if ($row['type'] == 'resolution') { + try { + $pri = MTrackResolution::loadByName($row['name']); + } catch (Exception $e) { + $pri = new MTrackResolution; + $pri->name = $row['name']; + $pri->value = $row['value']; + $pri->save($CS); + } + } + + if ($row['type'] == 'ticket_type') { + try { + $pri = MTrackClassification::loadByName($row['name']); + } catch (Exception $e) { + $pri = new MTrackClassification; + $pri->name = $row['name']; + $pri->value = $row['value']; + $pri->save($CS); + } + } + } + + foreach ($trac->query('select name from component')->fetchAll() as $row) { + $comp = trac_get_comp($row['name'], false); + trac_assoc_comp_and_proj($comp, $project); + } + + foreach ($trac->query("SELECT * from milestone order by name") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + /* first see if there's a milestone with this name in another project */ + $name = $row['name']; + $ms = MTrackMilestone::loadByName($name); + if ($ms) { + $name .= ' (' . $project->shortname . ')'; + } + $ms = new MTrackMilestone(); + $ms->name = $name; + /* for names of the form: sprint.1 sprint.2, tie them back as + * children of "sprint" */ + if (preg_match("/^(.*)\.(\d+)$/", $name, $M)) { + $pms = $milestone_by_name[strtolower($M[1])]; + if ($pms !== null) { + $ms->pmid = $pms->mid; + } + } + $ms->description = $row['description']; + $ms->duedate = trac_date($row['due']); + $ms->completed = trac_date($row['completed']); + $ms->save($CS); + $milestone_by_name[strtolower($row['name'])] = $ms; + } + + $CS->commit(); + $CS = null; + + list($maxtkt) = $trac->query("select max(id) from ticket")->fetchAll(PDO::FETCH_COLUMN, 0); + MTrackConfig::append('trac_import', + "max_ticket:$project->shortname", $maxtkt); + + /* first pass is to reserve ticket ids that match the trac db */ + foreach ($trac->query( + "SELECT * from ticket order by id") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + + $row['reporter'] = trac_add_user($row['reporter']); + progress("issue $row[id] $row[reporter]"); + + $fields = array('summary', 'description', 'resolution', 'status', + 'owner', 'summary', 'component', 'priority', 'severity', + 'changelog', + 'version', 'cc', 'keywords', 'milestone', 'reporter', 'type'); + + foreach ($trac->query( + "select name, value from ticket_custom where ticket='$row[id]'") + ->fetchAll(PDO::FETCH_ASSOC) as $custom) { + if (strlen($custom['value'])) { + $field = $custom['name']; + $row[$field] = $custom['value']; + $fields[] = $field; + } + } + + /* take a peek at the change history on the ticket to see if we can + * determine the original field values */ + foreach ($fields as $field) { + foreach ($trac->query( + "SELECT oldvalue from ticket_change where ticket = '" . + $row['id'] . "' and field='$field' order by time LIMIT 1") + ->fetchAll(PDO::FETCH_ASSOC) as $hist) { + if (!strlen($hist['oldvalue'])) { + $row[$field] = null; + } else { + $row[$field] = $hist['oldvalue']; + } + } + } + + $ctime = (int)$row['time']; + + MTrackAuth::su($row['reporter']); + $CS = MTrackChangeset::begin('ticket:X', $row['summary'], $ctime); + + $issue = new MTrackIssue(); + $issue->summary = $row['summary']; + $issue->description = adjust_links($row['description'], $ticket_prefix, $project); + $issue->priority = $row['priority']; + $issue->classification = $row['type']; + $issue->resolution = $row['resolution']; + $issue->severity = $row['severity']; + $issue->changelog = $row['changelog']; + $issue->cc = $row['cc']; + + $issue->addEffort(0, $row['estimatedhours']); + $issue->addEffort($row['totalhours']); + + if (strlen($row['component'])) { + $comp = trac_get_comp($row['component']); + $issue->assocComponent($comp); + } + if (strlen($row['milestone'])) { + $ms = trac_get_milestone($row['milestone'], $project); + $issue->assocMilestone($ms); + } + + foreach (array('keywords', 'features', 'ec_features', + 'version', + 'branches', 'ec_branches') as $field) { + foreach (preg_split("/\s+/", $row[$field]) as $w) { + if (strlen($w)) { + $kw = trac_get_keyword($w); + $issue->assocKeyword($kw); + } + } + } + + if (strlen($row['owner']) && $row['owner'] != 'somebody') { + $row['owner'] = trac_add_user($row['owner']); + $issue->owner = $row['owner']; + } + + if ($ticket_prefix) { + $issue->nsident = $project->shortname . $row['id']; + } else { + $issue->nsident = $row['id']; + } + + $issue->save($CS); + +# if ($issue->tid != $row['id']) { +# throw new Exception( +# "expected doc to be created with $row[id], got $issue->tid"); +# } + $CS->setObject("ticket:" . $issue->tid); + $CS->commit(); + $CS = null; + $issue = null; + MTrackAuth::drop(); + } + + /* now make a pass through the history to flesh out the comments and + * other changes. + * This can use up a surprising amount of memory, so we stage in + * the work. */ + + echo "\nLooking for changes in $import_from_db\n"; flush(); + + $changes = $trac->query( + "select distinct time, ticket, author from + ticket_change order by ticket asc, time, author") + ->fetchAll(PDO::FETCH_NUM); + + foreach ($changes as $i => $row) { + // we order by field because we always want "estimatedhours" + // to apply before "hours" + $q = $trac->prepare( + "select * from ticket_change + where time = ? and ticket = ? and author = ? + order by field + "); + $q->execute($row); + $batch = $q->fetchAll(PDO::FETCH_ASSOC); + if (empty($batch)) continue; + list($first) = $batch; + global $CS; + + $first['author'] = trac_add_user($first['author']); + MTrackAuth::su($first['author']); + try { + progress("issue $first[ticket] changed by $first[author]"); + + if ($ticket_prefix) { + $issue = MTrackIssue::loadByNSIdent( + $project->shortname . $first['ticket']); + } else { + $issue = MTrackIssue::loadByNSIdent($first['ticket']); + } + + $CS = MTrackChangeset::begin("ticket:" . $issue->tid, + "changed", $first['time']); + + + foreach ($batch as $row) { + switch ($row['field']) { + case 'comment': + $row['newvalue'] = adjust_links($row['newvalue'], $ticket_prefix, $project); + $issue->addComment($row['newvalue']); + $CS->setReason($row['newvalue']); + break; + + case 'owner': + $row['newvalue'] = trac_add_user($row['newvalue']); + if ($row['newvalue'] == 'somebody') { + $issue->owner = null; + } else { + $issue->owner = $row['newvalue']; + } + break; + + case 'status': + if ($row['newvalue'] == 'closed') { + $issue->close(); + } else { + $issue->status = $row['newvalue']; + } + break; + + case 'description': + $issue->description = adjust_links($row['newvalue'], + $ticket_prefix, $project); + break; + + case 'resolution': + case 'summary': + case 'priority': + case 'severity': + case 'changelog': + case 'cc': + $name = $row['field']; + $issue->$name = $row['newvalue']; + break; + + case 'component': + foreach ($issue->getComponents() as $comp) { + $comp = trac_get_comp($comp); + if ($comp) { + $issue->dissocComponent($comp); + } + } + if (strlen($row['newvalue'])) { + $comp = trac_get_comp($row['newvalue']); + $issue->assocComponent($comp); + } + break; + + case 'milestone': + foreach ($issue->getMilestones() as $ms) { + $ms = trac_get_milestone($ms, $project); + if ($ms) { + $issue->dissocMilestone($ms); + } + } + if (strlen($row['newvalue'])) { + $ms = trac_get_milestone($row['newvalue'], $project); + $issue->assocMilestone($ms); + } + break; + + case 'keywords': + case 'features': + case 'ec_features': + case 'ec_branches': + case 'branches': + case 'version': + foreach ($issue->getKeywords() as $w) { + $kw = trac_get_keyword($w); + $issue->dissocKeyword($kw); + } + foreach (preg_split("/\s+/", $row['newvalue']) as $w) { + if (strlen($w)) { + $kw = trac_get_keyword($w); + $issue->assocKeyword($kw); + } + } + break; + + case 'type': + $issue->classification = $row['newvalue']; + break; + + case 'totalhours': + case 'reporter': + /* ignore */ + break; + + case 'hours': + $issue->addEffort($row['newvalue'] + 0); + break; + + case 'estimatedhours': + $issue->addEffort(0, $row['newvalue'] + 0); + break; + + default: + throw new Exception("cant handle field $row[field]"); + } + } + $issue->save($CS); + $issue = null; + $CS->commit(); + $CS = null; + + } catch (Exception $e) { + MTrackAuth::drop(); + throw $e; + } + MTrackAuth::drop(); + } + + /* Find attachments */ + foreach ($trac->query( + "select id, filename, size, time, description, author + from attachment where type = 'ticket'") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + + MTrackAuth::su($row['author']); + try { + $row['author'] = trac_add_user($row['author']); + $row['filename'] = trac_attachment_name($row['filename']); + progress("issue $row[id] attachment $row[filename] $row[author]"); + + if ($ticket_prefix) { + $issue = MTrackIssue::loadByNSIdent( + $project->shortname . $row['id']); + } else { + $issue = MTrackIssue::loadByNSIdent($row['id']); + } + + $CS = MTrackChangeset::begin("ticket:" . $issue->tid, + $row['description'], $row['time']); + + $afile = $import_from_db . "/attachments/ticket/$row[id]/"; + + // trac uses weird url encoding on the filename on disk. + // this weird looking code is because I'm too lazy to reverse + // engineer their encoding + foreach (glob("$afile/*") as $potential) { + if (trac_attachment_name(basename($potential)) == $row['filename']) { + $afile = $potential; + break; + } + } + MTrackAttachment::add("ticket:$issue->tid", + $afile, $row['filename'], $CS); + $CS->commit(); + + } catch (Exception $e) { + MTrackAuth::drop(); + throw $e; + } + MTrackAuth::drop(); + } + + /* Make another pass over the tickets to catch changes made to the + * database by hand that are not journalled in the trac change tables */ + MTrackAuth::su('trac'); + foreach ($trac->query( + "SELECT * from ticket order by id") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + + $fields = array('summary', + 'description', + 'resolution', 'status', + 'owner', 'summary', 'component', 'priority', 'severity', + 'changelog', + 'version', 'cc', 'keywords', 'milestone', 'reporter', 'type'); + + foreach ($trac->query( + "select name, value from ticket_custom where ticket=$row[id]") + ->fetchAll(PDO::FETCH_ASSOC) as $custom) { + if (strlen($custom['value'])) { + $field = $custom['name']; + if ($field == 'description') { + $custom['value'] = adjust_links($custom['value'], $ticket_prefix, $project); + } + + $row[$field] = $custom['value']; + $fields[] = $field; + } + } + + if ($ticket_prefix) { + $issue = MTrackIssue::loadByNSIdent($project->shortname . $row['id']); + } else { + $issue = MTrackIssue::loadByNSIdent($row['id']); + } + $needed = false; + + $row['owner'] = trac_add_user($row['owner']); + $fmap = array( + 'summary', + 'description', + 'priority', + 'status', + 'classification' => 'type', + 'resolution', + 'owner', + 'severity'); + + foreach ($fmap as $sname => $fname) { + if (is_int($sname) || ctype_digit($sname)) { + $sname = $fname; + } + if ($fname == 'description') { + $row[$fname] = adjust_links($row[$fname], $ticket_prefix, $project); + } + if ($issue->$sname != $row[$fname]) { + $needed = true; + $issue->$sname = $row[$fname]; + } + } + + $comp = reset($issue->getComponents()); + if ($comp != $row['component']) { + $needed = true; + $issue->dissocComponent(trac_get_comp($comp)); + if (strlen($row['component'])) { + $comp = trac_get_comp($row['component']); + $issue->assocComponent($comp); + } + } + + $ms = reset($issue->getMilestones()); + if ($ms != $row['milestone']) { + $needed = true; + $issue->dissocMilestone(trac_get_milestone($ms, $project)); + if (strlen($row['milestone'])) { + $ms = trac_get_milestone($row['milestone'], $project); + $issue->assocMilestone($ms); + } + } + + if ($needed) { + progress("$row[id] fixup"); + if ($issue->updated) { + $last_cs = MTrackChangeset::get($issue->updated); + } else { + $last_cs = MTrackChangeset::get($issue->created); + } + $issue->addComment( + "The importer detected manual database changes; " . + "revising ticket to match"); + $CS = MTrackChangeset::begin("ticket:" . $issue->tid, + "fixup", + strtotime($last_cs->when)); + $issue->save($CS); + $CS->commit(); + } + } + MTrackAuth::drop(); + + echo "\nProcessing wiki pages\n"; flush(); + + /* wiki, jungle is posse */ + global $trac_wiki_names; + $wiki = null; + + $wiki_page_remap = array(); + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + if (!strlen($suf)) { + /* Here's a fun problem; trac allows both pages and dirs to exist with the + * same name (because its dirs aren't really dirs, they're just illusions) + * We need to notice those that are pages and that collide with dirs and + * rename them */ + $all_wiki_page_names = array(); + foreach ($trac->query( + "select distinct name from wiki")->fetchAll(PDO::FETCH_COLUMN, 0) + as $name) { + $all_wiki_page_names[$name] = $name; + } + + foreach ($all_wiki_page_names as $name) { + $elements = explode('/', $name); + if (count($elements) > 1) { + $accum = array(); + while (count($elements) > 1) { + $accum[] = array_shift($elements); + $n = join('/', $accum); + if (isset($all_wiki_page_names[$n])) { + // Collision; try adding a suffix of "Page" + if (!isset($all_wiki_page_names[$n . 'Page'])) { + $wiki_page_remap[$n] = $n . 'Page'; + } else { + throw new Exception("wiki collision between $n and $name"); + } + } + } + } + } + echo "The following pages will be renamed\n"; + print_r($wiki_page_remap); + } + + foreach ($trac->query( + "SELECT * from wiki order by time, name, version") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + + if (isset($trac_wiki_names[$row['name']])) { + continue; + } + if (isset($wiki_page_remap[$row['name']])) { + $row['name'] = $wiki_page_remap[$row['name']]; + } + + $author = trac_add_user($row['author']); + try { + MTrackAuth::su($author); + $row['author'] = $author; + } catch (Exception $e) { + echo "Error while assuming $author ($row[author])\n"; + MTrackAuth::drop(); + throw $e; + } + if ($ticket_prefix) { + $row['name'] = $project->shortname . '/' . $row['name']; + } + $CS = MTrackChangeset::begin('wiki:' . $row['name'], + $row['comment'], $row['time']); + if (!is_object($wiki) || $wiki->pagename != $row['name']) { + $wiki = MTrackWikiItem::loadByPageName($row['name']); + } + if (!$wiki) { + $wiki = new MTrackWikiItem($row['name']); + } + progress("$row[name] $row[version]"); + $wiki->content = adjust_links($row['text'], $ticket_prefix, $project); + $wiki->save($CS); + $CS->commit(); + MTrackAuth::drop(); + } + /* Find attachments */ + foreach ($trac->query( + "select id, filename, size, time, description, author + from attachment where type = 'wiki'") + ->fetchAll(PDO::FETCH_ASSOC) as $row) { + + MTrackAuth::su($row['author']); + try { + $row['author'] = trac_add_user($row['author']); + $row['filename'] = trac_attachment_name($row['filename']); + + progress("wiki $row[id] attachment $row[filename] $row[author]"); + + if ($ticket_prefix) { + $name = $project->shortname . '/' . $row['id']; + } else { + $name = $row['id']; + } + + $wiki = MTrackWikiItem::loadByPageName($name); + if (!$wiki) { + MTrackAuth::drop(); + continue; + } + + $CS = MTrackChangeset::begin('wiki:' . $name, + $row['description'], $row['time']); + + $afile = $import_from_db . "/attachments/wiki/$row[id]/"; + + // trac uses weird url encoding on the filename on disk. + // this weird looking code is because I'm too lazy to reverse + // engineer their encoding + foreach (glob("$afile/*") as $potential) { + if (trac_attachment_name(basename($potential)) == $row['filename']) { + $afile = $potential; + break; + } + } + if (!is_file($afile)) { + echo "Looking for attachment $row[filename]\n"; + echo "Didn't find it in $afile\n"; + $g = glob("$afile/*"); + print_r($g); + foreach ($g as $f) { + echo trac_attachment_name($f), "\n"; + } + throw new Exception("fail"); + } + MTrackAttachment::add("wiki:$name", + $afile, $row['filename'], $CS); + $CS->commit(); + + } catch (Exception $e) { + MTrackAuth::drop(); + throw $e; + } + MTrackAuth::drop(); + } + + + $end_import = time(); + $elapsed = $end_import - $start_import; + echo "\nDone with $import_from_db (in $elapsed seconds)\n"; flush(); +} + +function trac_attachment_name($name) +{ + $name = urldecode($name); + $name = str_replace('+', ' ', $name); + return $name; +} diff --git a/bin/init.php b/bin/init.php new file mode 100644 index 00000000..4f9cb789 --- /dev/null +++ b/bin/init.php @@ -0,0 +1,526 @@ + $link[0]\n"; + } + } +} +echo "\n"; + +if (count($tracs)) { + foreach ($tracs as $tname => $pname) { + echo "Import trac $name -> $pname\n"; + } +} + +function usage($msg = '') +{ + echo $msg, << $r) { + $d = $r->getSCMMetaData(); + printf(" %10s %s\n", $t, $d['name']); + } + echo "\n\n\n"; + + exit(1); +} + +if (!is_dir($vardir)) { + mkdir($vardir); + chmod($vardir, 02777); +} +if (!is_dir("$vardir/attach")) { + mkdir("$vardir/attach"); + chmod("$vardir/attach", 02777); +} + +putenv("MTRACK_CONFIG_FILE=" . $config_file_name); +if (!file_exists($config_file_name)) { + /* create a new config file */ + $CFG = file_get_contents("config.ini.sample"); + $CFG = str_replace("@VARDIR@", realpath($vardir), $CFG); + if (count($projects)) { + list($pname) = array_keys($projects); + } else { + $pname = "mtrack demo"; + } + $CFG = str_replace("@PROJECT@", $pname, $CFG); + if ($DSN == null) { + $DSN = "sqlite:@{core:dblocation}"; + } + $CFG = str_replace("@DSN@", "\"$DSN\"", $CFG); + + $tools_to_find = array('diff', 'diff3', 'php', 'svn', 'hg', + 'git', 'svnserve', 'svnlook', 'svnadmin'); + foreach ($SCMS as $S) { + $m = $S->getSCMMetaData(); + if (isset($m['tools'])) { + foreach ($m['tools'] as $t) { + $tools_to_find[] = $t; + } + } + } + + /* find reasonable defaults for tools */ + $tools = array(); + foreach ($tools_to_find as $toolname) { + foreach (explode(PATH_SEPARATOR, getenv('PATH')) as $pdir) { + if (DIRECTORY_SEPARATOR == '\\' && + file_exists($pdir . DIRECTORY_SEPARATOR . $toolname . '.exe')) { + $tools[$toolname] = $pdir . DIRECTORY_SEPARATOR . $toolname . '.exe'; + break; + } else if (file_exists($pdir . DIRECTORY_SEPARATOR . $toolname)) { + $tools[$toolname] = $pdir . DIRECTORY_SEPARATOR . $toolname; + break; + } + } + if (!isset($tools[$toolname])) { + // let the system find it in the path at runtime + $tools[$toolname] = $toolname; + } + } + $toolscfg = ''; + foreach ($tools as $toolname => $toolpath) { + $toolscfg .= "$toolname = \"$toolpath\"\n"; + } + $CFG = str_replace("@TOOLS@", $toolscfg, $CFG); + file_put_contents($config_file_name, $CFG); +} +unset($_GLOBALS['MTRACK_CONFIG_SKIP_BOOT']); +MTrackConfig::$ini = null; +MTrackDB::$db = null; +MTrackTicket_CustomFields::$me = null; +MTrackConfig::boot(); + +include dirname(__FILE__) . '/schema-tool.php'; + +if (file_exists("$vardir/mtrac.db")) { + chmod("$vardir/mtrac.db", 0666); +} + +$db = MTrackDB::get(); + +# if the config has custom fields, or the runtime config from an earlier +# installation does, let's update the schema, if needed. +MTrackTicket_CustomFields::getInstance()->save(); + +MTrackChangeset::$use_txn = false; +$db->beginTransaction(); + +$CANON_USERS = array(); +if ($aliasfile) { + foreach (file($aliasfile) as $line) { + if (preg_match('/^\s*([^=]+)\s*=\s*(.*)\s*$/', $line, $M)) { + if (!strlen($M[1])) { + continue; + } + $CANON_USERS[$M[1]] = $M[2]; + } + } +} + +foreach ($CANON_USERS as $src => $dest) { + MTrackDB::q('insert into useraliases (alias, userid) values (?, ?)', + $src, strlen($dest) ? $dest : null); +} + +if ($authorfile) { + foreach (file($authorfile) as $line) { + $author = explode(',', trim($line)); + if (strlen($author[0])) { + MTrackDB::q('insert into userinfo ( + userid, fullname, email, active, timezone) values + (?, ?, ?, ?, ?)', + $author[0], + $author[1], + $author[2], + ((int)$author[3]) ? 1 : 0, + $author[4]); + } + } +} + +/* set up initial ACL tree structure */ +$rootobjects = array( + 'Reports', 'Browser', 'Wiki', 'Timeline', 'Roadmap', 'Tickets', + 'Enumerations', 'Components', 'Projects', 'User', 'Snippets', +); + +foreach ($rootobjects as $rootobj) { + MTrackACL::addRootObjectAndRoles($rootobj); +} + +# Add forking permissions +$ents = MTrackACL::getACL('Browser', false); +$ents[] = array('BrowserCreator', 'fork', true); +$ents[] = array('BrowserForker', 'fork', true); +$ents[] = array('BrowserForker', 'read', true); +MTrackACL::setACL('Browser', false, $ents); + +$CS = MTrackChangeset::begin('~setup~', 'initial setup'); + +foreach ($projects as $pname) { + $p = new MTrackProject; + $p->shortname = $pname; + $p->name = $pname; + $p->save($CS); + $projects[$pname] = $p; +} + +foreach ($repos as $repo) { + $r = new MTrackRepo; + $r->shortname = $repo[0]; + $r->scmtype = $repo[1]; + $r->repopath = $repo[2]; + + foreach ($links as $link) { + list($pname, $rname, $loc) = $link; + if ($rname == $r->shortname) { + $p = $projects[$pname]; + $r->addLink($p, $loc); + } + } + + $r->save($CS); + $repos[$r->shortname] = $r; +} + +if (!isset($repos['wiki'])) { + // Set up the wiki repo (if they don't already have one named wiki) + + if ($wiki_repo_type === null) { + $wiki_repo_type = MTrackConfig::get('tools', 'hg'); + if (file_exists($wiki_repo_type)) { + $wiki_repo_type = 'hg'; + } else { + $wiki_repo_type = 'svn'; + } + } + + $r = new MTrackRepo; + $r->shortname = 'wiki'; + $r->scmtype = $wiki_repo_type; + $r->repopath = realpath($vardir) . DIRECTORY_SEPARATOR . 'wiki'; + $r->description = 'The mtrack wiki pages are stored here'; + echo " ** Creating repo 'wiki' of type $r->scmtype to hold Wiki content at $r->repopath\n"; + echo " ** (use --repo option to specify an alternate location)\n"; + echo " ** (use --wiki-type option to specify an alternate type)\n"; + $r->save($CS); + $repos['wiki'] = $r; + + $r->reconcileRepoSettings(); +} + + +foreach (glob("defaults/wiki/*") as $filename) { + $name = basename($filename); + echo "wiki: $name\n"; + + $w = MTrackWikiItem::loadByPageName($name); + if ($name == 'WikiStart' && $w !== null) { + /* skip existing WikiStart, as it may have been customized */ + continue; + } + if ($w === null) { + $w = new MTrackWikiItem($name); + } + + $w->content = file_get_contents($filename); + $w->save($CS); +} +touch("$vardir/.initializing"); +MTrackWikiItem::commitNow(); + +foreach (glob("defaults/reports/*") as $filename) { + $name = basename($filename); + echo "report: $name\n"; + + $rep = new MTrackReport; + $rep->summary = $name; + + list($sql, $wiki) = explode("\n\n", file_get_contents($filename), 2); + + $rep->description = $wiki; + $rep->query = $sql; + $rep->save($CS); +} +if (count($tracs) == 0) { + // Default enumerations + foreach (array('defect', 'enhancement', 'task') as $v => $c) { + $cl = new MTrackClassification; + $cl->name = $c; + $cl->value = $v; + $cl->save($CS); + } + foreach (array('fixed', 'invalid', 'wontfix', 'duplicate', 'worksforme') + as $v => $c) { + $cl = new MTrackResolution; + $cl->name = $c; + $cl->value = $v; + $cl->save($CS); + } + foreach (array('blocker', 'critical', 'major', 'normal', 'minor', 'trivial') + as $v => $c) { + $cl = new MTrackSeverity; + $cl->name = $c; + $cl->value = $v; + $cl->save($CS); + } + foreach (array('highest', 'high', 'normal', 'low', 'lowest') + as $v => $c) { + $cl = new MTrackPriority; + $cl->name = $c; + $cl->value = $v; + $cl->save($CS); + } + foreach (array('new', 'open', 'closed', 'reopened') + as $v => $c) { + $cl = new MTrackTicketState; + $cl->name = $c; + $cl->value = $v; + $cl->save($CS); + } +} +$CS->commit(); + +$i = 0; +foreach ($tracs as $tracdb => $pname) { + import_from_trac($projects[$pname], $tracdb, $i++); +} +echo "Committing\n"; flush(); +$db->commit(); +echo "Done\n"; +unlink("$vardir/.initializing"); diff --git a/bin/make-authorized-keys.php b/bin/make-authorized-keys.php new file mode 100644 index 00000000..ef651085 --- /dev/null +++ b/bin/make-authorized-keys.php @@ -0,0 +1,73 @@ +fetchAll(PDO::FETCH_OBJ) as $u) { + $user = escapeshellarg($u->userid); + $lines = preg_split("/\r?\n/", $u->sshkeys); + foreach ($lines as $key) { + $users_with_keys[$u->userid] = $u->userid; + $key = trim($key); + if (!strlen($key)) continue; + fwrite($fp, "command=\"$codeshell $config $user $mtrack\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty $key\n"); + } +} + +fclose($fp); +chmod("$keyfile.new", 0755); +rename("$keyfile.new", $keyfile); + +# Unfortunately, subversion doesn't allow us to hook authorization requests +# over svnserve, so we need to pre-compute access to each svn repo for each +# user that can access it. With very large numbers of svn repos or large +# numbers of users, this will be "expensive". +$fp = null; +$authzname = MTrackConfig::get('core', 'vardir') . '/svn.authz'; + +foreach (MTrackDB::q("select repoid from repos where scmtype = 'svn'") + ->fetchAll(PDO::FETCH_COLUMN, 0) as $repoid) { + $R = MTrackRepo::loadById($repoid); + if (!$fp) { + $fp = fopen("$authzname.new", 'w'); + # deny all + fwrite($fp, "[/]\n* =\n"); + } + fwrite($fp, "[" . $R->getBrowseRootName() . ":/]\n"); + foreach ($users_with_keys as $user) { + MTrackAuth::su($user); + $level = ''; + if (MTrackACL::hasAllRights("repo:$repoid", 'commit')) { + $level = 'rw'; + } elseif (MTrackACL::hasAllRights("repo:$repoid", 'checkout')) { + $level = 'r'; + } + MTrackAuth::drop(); + if (strlen($level)) { + fwrite($fp, "$user = $level\n"); + } + } +} +fclose($fp); +rename("$authzname.new", $authzname); diff --git a/bin/modify.php b/bin/modify.php new file mode 100644 index 00000000..d80f9b21 --- /dev/null +++ b/bin/modify.php @@ -0,0 +1,181 @@ +beginTransaction(); + +$CS = MTrackChangeset::begin('~modify~', 'setup modified'); + +foreach ($projects as $pname) { + $p = MTrackProject::loadByName($pname); + if ($p === null) { + $p = new MTrackProject; + $p->shortname = $pname; + $p->name = $pname; + $p->save($CS); + } + $projects[$pname] = $p; +} + +foreach ($repos as $repo) { + $r = new MTrackRepo; + $r->shortname = $repo[0]; + $r->scmtype = $repo[1]; + $r->repopath = $repo[2]; + + foreach ($links as $link) { + list($pname, $rname, $loc) = $link; + if ($rname == $r->shortname) { + $p = $projects[$pname]; + $r->addLink($p, $loc); + } + } + + $r->save($CS); + $repos[$r->shortname] = $r; +} + +$CS->commit(); + +$i = 0; +foreach ($tracs as $tracdb => $pname) { + import_from_trac($projects[$pname], $tracdb, true); +} +echo "Updating ACL tree\n"; flush(); +MTrackACL::applyBatch(); +echo "Committing\n"; flush(); +$db->commit(); +MTrackSearchDB::optimize(); +echo "Done\n"; + +function usage($msg = '') +{ + require_once 'inc/common.php'; + echo $msg, << $r) { + $d = $r->getSCMMetaData(); + printf(" %10s %s\n", $t, $d['name']); + } + echo "\n\n\n"; + + exit(1); +} diff --git a/bin/schema-tool.php b/bin/schema-tool.php new file mode 100644 index 00000000..4d8bd2b7 --- /dev/null +++ b/bin/schema-tool.php @@ -0,0 +1,131 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$driver = $db->getAttribute(PDO::ATTR_DRIVER_NAME); +$adapter_class = "MTrackDBSchema_$driver"; +$adapter = new $adapter_class; + +$adapter->setDB($db); +$vers = $adapter->determineVersion(); +echo "Version: "; +var_dump($vers); + +$db->beginTransaction(); +MTrackDB::$db = $db; + +$schemata = array(); +$latest = null; +foreach (glob(dirname(__FILE__) . '/../schema/*.xml') as $filename) { + $latest = new MTrackDBSchema($filename); + $schemata[$latest->version] = $latest; +} + +if ($vers === null) { + + if (true) { + // Fresh install + echo "Applying schema version $latest->version\n"; + + foreach ($latest->tables as $t) { + $adapter->createTable($t); + } + if (isset($latest->post[$driver])) { + $db->exec($latest->post[$driver]); + } + + $vers = $latest->version; + + } else { + // while developing, make it go through the whole migration + $initial = $schemata[0]; + echo "Applying schema version $initial->version\n"; + + foreach ($initial->tables as $t) { + $adapter->createTable($t); + } + $vers = 0; + } +} + +while ($vers < $latest->version) { + $current = $schemata[$vers]; + $next = $schemata[$vers+1]; + + echo "Applying migration from schema version $current->version to $next->version\n"; + + $migration = dirname(__FILE__) . "/../schema/$next->version-pre.php"; + if (file_exists($migration)) { + echo "Running migration script schema/$next->version-pre.php\n"; + include $migration; + } + + /* create any new tables */ + foreach ($next->tables as $t) { + if (isset($current->tables[$t->name])) continue; + /* doesn't yet exist, so create it! */ + $adapter->createTable($t); + } + + /* modify existing tables */ + foreach ($current->tables as $t) { + if (!isset($next->tables[$t->name])) continue; + + $nt = $next->tables[$t->name]; + /* compare; have they changed? */ + if (!$t->sameAs($nt)) { + $adapter->alterTable($t, $nt); + } + } + + /* delete dead tables */ + foreach ($current->tables as $t) { + if (isset($next->tables[$t->name])) continue; + $adapter->dropTable($t); + } + + $vers++; + + if (isset($next->post[$driver])) { + $db->exec($next->post[$driver]); + } + + $migration = dirname(__FILE__) . "/../schema/$vers.php"; + if (file_exists($migration)) { + echo "Running migration script schema/$vers.php\n"; + include $migration; + } +} + +$db->exec('delete from mtrack_schema'); +$q = $db->prepare('insert into mtrack_schema (version) values (?)'); +$q->execute(array($latest->version)); +$db->commit(); + + diff --git a/bin/send-notifications.php b/bin/send-notifications.php new file mode 100644 index 00000000..df8f8011 --- /dev/null +++ b/bin/send-notifications.php @@ -0,0 +1,829 @@ +fetchAll() + as $row) { + $last = $row[0]; +} +$LATEST = strtotime($last); +if (getenv('DEBUG_TIME')) { + $dtime = strtotime(getenv('DEBUG_TIME')); + if ($dtime > 0) { + $LATEST = $dtime; + $last = MTrackDB::unixtime($LATEST); + echo "Using $last as last time (specified via DEBUG_TIME var)\n"; + } +} + +class CanonicalLineEndingFilter extends php_user_filter { + function filter($in, $out, &$consumed, $closing) + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = preg_replace("/\r?\n/", "\r\n", $bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + return PSFS_PASS_ON; + } +} +class UnixLineEndingFilter extends php_user_filter { + function filter($in, $out, &$consumed, $closing) + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = preg_replace("/\r?\n/", "\n", $bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + return PSFS_PASS_ON; + } +} +stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter'); +stream_filter_register("mtrackunix", 'UnixLineEndingFilter'); + +$watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email'); +printf("Got %d watchers\n", count($watched)); + +/* For each watcher, compute the changes. + * Group changes by ticket, sending one email per ticket. + * Group tickets into batch updates if the only fields that changed are + * bulk update style (milestone, assignment etc.) + * + * For the wiki repo, group by file so that serial edits within the batch + * period show up as a single email. + */ + +foreach ($watched as $user => $objects) { + $udata = MTrackAuth::getUserData($user); + + foreach ($objects as $object => $items) { + list($otype, $oid) = explode(':', $object, 2); + + $fname = "notify_$otype"; + if (function_exists($fname)) { + call_user_func($fname, $object, $oid, $items, $user, $udata); + } else { + echo "WARN: no notifier for $otype $oid\n"; + } + foreach ($items as $o) { + if ($o instanceof MTrackSCMEvent) { + $t = strtotime($o->ctime); + } else { + $t = strtotime($o->changedate); + } + if ($t > $LATEST) { + $LATEST = $t; + } + } + } +} + +function get_change_audit($items) +{ + $cid_list = array(); + $all_cs = array(); + + foreach ($items as $obj) { + if (!($obj instanceof MTrackSCMEvent)) { + $all_cs[$obj->cid] = $obj; + if (!isset($obj->audit)) { + $obj->audit = array(); + $cid_list[] = $obj->cid; + } + } + } + + if (count($cid_list)) { + $cid_list = join(',', $cid_list); + foreach (MTrackDB::q("select * from change_audit where cid in ($cid_list)") + ->fetchAll(PDO::FETCH_OBJ) as $aud) { + $cid = $aud->cid; + unset($aud->cid); + $all_cs[$cid]->audit[] = $aud; + } + } + + return $all_cs; +} + +function compute_contributor($items) +{ + $contributors = array(); + foreach ($items as $obj) { + if (isset($obj->who)) { + $contributors[$obj->who]++; + } elseif (isset($obj->changeby)) { + $contributors[$obj->changeby]++; + } + } + $count = 0; + $major = null; + foreach ($contributors as $user => $input) { + if ($input > $count) { + $major = $user; + $count = $input; + } + } + unset($contributors[$major]); + + $res = array(); + $res[] = array($major, MTrackAuth::getUserData($major)); + foreach ($contributors as $user => $input) { + $res[] = array($user, MTrackAuth::getUserData($user)); + } + + return $res; +} + +function encode_header($string) +{ + $result = array(); + foreach (preg_split("/\s+/", $string) as $portion) { + if (!preg_match("/[\x80-\xff]/", $portion)) { + $result[] = $portion; + continue; + } + + $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?='; + } + return join(' ', $result); +} + +function make_email($uname, $uinfo) +{ + $email = $uinfo['email']; + $name = $uinfo['fullname']; + if ($name == $email) { + return $email; + } + return encode_header($name) . " <$email>"; +} + +function _sort_mx($A, $B) +{ + $diff = $A->weight - $B->weight; + if ($diff) return $diff; + return strncmp($A->host, $B->host); +} + +function get_weighted_mx($domain) +{ + static $cache = array(); + + if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) { + /* IP literal */ + $mx = new stdclass; + $mx->host = $domain; + $mx->a = array($domain); + $cache[$domain] = array($mx); + return $cache[$domain]; + } + + /* ensure that we don't things as local */ + $domain = rtrim($domain, '.') . '.'; + + if (isset($cache[$domain])) { + return $cache[$domain]; + } + + if (!getmxrr($domain, $hosts, $weight)) { + // Fallback to A + $mx = new stdclass; + $mx->host = $domain; + $mx->a = gethostbynamel($domain); + $cache[$domain] = array($mx); + return $cache[$domain]; + } + $res = array(); + foreach ($hosts as $i => $host) { + $mx = new stdclass; + $mx->host = $host; + $mx->weight = $weight[$i]; + $mx->a = gethostbynamel("$host."); + $res[] = $mx; + } + usort($res, '_sort_mx'); + + $cache[$domain] = $res; + return $cache[$domain]; +} + +$smtp_cache = array(); + +function smtp_cmd($fp, $cmd, $exp = 250) +{ + global $smtp_cache; + global $DEBUG; + + $res = array(); + + if ($DEBUG) { + echo "> $cmd"; + } + fwrite($fp, $cmd); + do { + $line = fgets($fp); + $res[] = $res; + if ($DEBUG) { + echo "< $line"; + } + } while ($line[3] == '-'); + $code = (int)$line; + if ($code != $exp) { + foreach ($smtp_cache as $k => $v) { + if ($v === $fp) { + unset($smtp_cache[$k]); + } + } + throw new Exception("got $code, expected $exp"); + } + return $res; +} + +function smtp_connect($rcpt) +{ + global $DEBUG; + + list($local, $domain) = explode('@', $rcpt); + global $smtp_cache; + if (isset($smtp_cache[$domain])) { + return $smtp_cache[$domain]; + } + + $smarthost = MTrackConfig::get('notify', 'smtp_relay'); + if ($smarthost) { + $domain = $smarthost; + } + $mxs = get_weighted_mx($domain); + + foreach ($mxs as $ent) { + foreach ($ent->a as $addr) { + $fp = stream_socket_client("$addr:25", $e, $s); + if ($fp) { + do { + $banner = fgets($fp); + if ($DEBUG) { + echo "< $banner"; + } + } while ($banner[3] == '-'); + $code = (int)$banner; + if ($code != 220) { + fclose($fp); + continue; + } + smtp_cmd($fp, sprintf("EHLO %s\r\n", php_uname('n'))); + $smtp_cache[$domain] = $fp; + return $fp; + } + } + } + return false; +} + +function send_mail($rcpt, $payload) +{ + global $DEBUG; + global $NO_MAIL; + + $reciplist = escapeshellarg($rcpt); + if ($DEBUG) { + echo "would mail: $reciplist\n\n"; + echo stream_get_contents($payload); + rewind($payload); + } + if ($NO_MAIL) { + echo "Not sending any mail\n"; + return; + } + if (function_exists('getmxrr') && + MTrackConfig::get('notify', 'use_smtp')) { + /* let's do some SMTP */ + echo "Using SMTP\n"; + + $fp = smtp_connect($rcpt); + if ($fp) { + $local = MTrackConfig::get('notify', 'smtp_from'); + if (!$local) { + $local = php_uname('n'); + } + smtp_cmd($fp, "MAIL FROM:<$local>\r\n"); + smtp_cmd($fp, "RCPT TO:<$rcpt>\r\n"); + smtp_cmd($fp, "DATA\r\n", 354); + + while ($line = fgets($payload)) { + // Session transparency + if ($line[0] == '.') { + $line = '.' . $line; + } + // Canonical line endings + $line = preg_replace("/\r?\n/", "\r\n", $line); + if ($DEBUG) { + echo "> $line"; + } + fwrite($fp, $line); + } + smtp_cmd($fp, ".\r\n"); + } + } else { + echo "Using sendmail\n"; + $pipe = popen("/usr/sbin/sendmail $reciplist", 'w'); + stream_filter_append($pipe, 'mtrackunix', STREAM_FILTER_WRITE); + stream_copy_to_stream($payload, $pipe); + pclose($pipe); + } +} + +function notify_repo($object, $tid, $items, $user, $udata) +{ + global $ABSWEB; + + $revlist = array(); + $repo = null; + + $code_by_repo = array(); + foreach ($items as $obj) { + if (!($obj instanceof MTrackSCMEvent)) { + if (!isset($obj->ent)) { + continue; + } + $obj = $obj->ent; + } + + $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj; + $revlist[] = $obj->rev; + if ($repo === null) { + $repo = $obj->repo; + } + } + if (!count($code_by_repo)) { + return; + } + + $reponame = $repo->getBrowseRootName(); + + $from = compute_contributor($items); + + $headers = array( + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset="UTF-8"', + 'Content-Transfer-Encoding' => 'quoted-printable', + ); + + $headers['To'] = make_email($user, $udata); + $headers['From'] = make_email($from[0][0], $from[0][1]); + if (count($from) > 1) { + $rep = array(); + array_shift($from); + foreach ($from as $email) { + $rep[] = make_email($email[0], $email[1]); + } + $headers['Reply-To'] = join(', ', $rep); + } + $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n'); + $headers['Message-ID'] = "<$mid>"; + + /* find related project(s) */ + $projects = array(); + foreach ($items as $obj) { + if (!isset($obj->_related)) continue; + foreach ($obj->_related as $rel) { + if ($rel[0] == 'project') { + $p = get_project($rel[1]); + $projects[$p->projid] = $p->shortname; + } + } + } + if (count($projects)) { + natsort($projects); + $subj = "[" . join($projects) . "] "; + $headers['X-mtrack-project-list'] = join(' ', $projects); + foreach ($projects as $pname) { + $headers["X-mtrack-project-$pname"] = $pname; + $headers['X-mtrack-project'][] = $pname; + } + } else { + $subj = ''; + } + $subj = sprintf("%scommit %s ", $subj, $reponame); + foreach ($revlist as $rev) { + if (strlen($subj) > 72) break; + $subj .= " [$rev]"; + } + $headers['Subject'] = $subj; + + global $ABSWEB; + + $plain = tmpfile(); + stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE); + foreach ($headers as $name => $value) { + if (is_array($value)) { + foreach ($value as $v) { + fprintf($plain, "%s: %s\n", $name, encode_header($v)); + } + } else { + fprintf($plain, "%s: %s\n", $name, encode_header($value)); + } + } + + fprintf($plain, "\n"); + fflush($plain); + add_qp_filter($plain); + + generate_repo_changes($plain, $code_by_repo, true); + + rewind($plain); + + send_mail($udata['email'], $plain); +} + +function add_qp_filter($stream) +{ + stream_filter_append($stream, 'convert.quoted-printable-encode', + STREAM_FILTER_WRITE, array( + 'line-length' => 74, + 'line-break-chars' => "\r\n", + ) + ); +} + +function notify_ticket($object, $tid, $items, $user, $udata) +{ + global $MAX_DIFF; + $T = MTrackIssue::loadById($tid); + if (!is_object($T)) { + echo "Failed to load ticket by id: $tid\n"; + return; + } + + $from = compute_contributor($items); + $audit = get_change_audit($items); + + $comments = array(); + $fields = array(); + $field_changers = array(); + $old_values = array(); + $is_initial = false; + + foreach ($audit as $CS) { + if ($CS->cid == $T->created) { + // We use this to set a Message-ID header + $is_initial = true; + } + foreach ($CS->audit as $aud) { + // fieldname is of the form: "ticket:id:fieldname" + $field = substr($aud->fieldname, strlen($object)+1); + + if ($field == '@comment') { + $comments[] = "Comment by " . + $CS->who . ":\n" . $aud->value; + } elseif ($field != 'spent') { + $field_changers[$field] = $CS->who; + if (!isset($old_values[$field])) { + $old_values[$field] = $aud->oldvalue; + } + } + } + } + + + $headers = array( + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset="UTF-8"', + 'Content-Transfer-Encoding' => 'quoted-printable', + ); + + $headers['To'] = make_email($user, $udata); + $headers['From'] = make_email($from[0][0], $from[0][1]); + if (count($from) > 1) { + $rep = array(); + array_shift($from); + foreach ($from as $email) { + $rep[] = make_email($email[0], $email[1]); + } + $headers['Reply-To'] = join(', ', $rep); + } + $mid = $T->tid . '@' . php_uname('n'); + if ($is_initial) { + $headers['Message-ID'] = "<$mid>"; + } else { + $headers['Message-ID'] = "<$T->updated.$mid>"; + $headers['In-Reply-To'] = "<$mid>"; + $headers['References'] = "<$mid>"; + } + /* find related project(s) */ + $projects = array(); + foreach ($items as $obj) { + if (!isset($obj->_related)) continue; + foreach ($obj->_related as $rel) { + if ($rel[0] == 'project') { + $p = get_project($rel[1]); + $projects[$p->projid] = $p->shortname; + } + } + } + if (count($projects)) { + natsort($projects); + $subj = "[" . join($projects, ' ') . "] "; + + $headers['X-mtrack-project-list'] = join(' ', $projects); + foreach ($projects as $pname) { + $headers["X-mtrack-project-$pname"] = $pname; + $headers['X-mtrack-project'][] = $pname; + } + } else { + $subj = ''; + } + + $headers['Subject'] = sprintf("%s#%s %s (%s %s)", + $subj, $T->nsident, $T->summary, $T->status, $T->classification); + + global $ABSWEB; + + $plain = tmpfile(); + stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE); + foreach ($headers as $name => $value) { + if (is_array($value)) { + foreach ($value as $v) { + fprintf($plain, "%s: %s\n", $name, encode_header($v)); + } + } else { + fprintf($plain, "%s: %s\n", $name, encode_header($value)); + } + } + fprintf($plain, "\n"); + fflush($plain); + add_qp_filter($plain); + + fprintf($plain, "%sticket.php/%s\n\n", $ABSWEB, $T->nsident); + + fprintf($plain, "#%s: %s (%s %s)\n", + $T->nsident, $T->summary, $T->status, $T->classification); + + $owner = strlen($T->owner) ? $T->owner : 'nobody'; + fprintf($plain, "Responsible: %s (%s / %s)\n", + $owner, $T->priority, $T->severity); + + fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones())); + fprintf($plain, "Component: %s\n", join(', ', $T->getComponents())); + + fprintf($plain, "\n"); + + // Display changed fields grouped by the person that last changed them + $who_changed = array(); + foreach ($field_changers as $field => $who) { + $who_changed[$who][] = $field; + } + foreach ($who_changed as $who => $fieldlist) { + fprintf($plain, "Changes by %s:\n", $who); + + foreach ($fieldlist as $field) { + $old = $old_values[$field]; + + if (!strlen($old) && $field == 'nsident') { + continue; + } + + $value = null; + switch ($field) { + case '@components': + $old = array(); + foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) { + if (!strlen($id)) continue; + $c = get_component($id); + $old[$id] = $c->name; + } + $value = $T->getComponents(); + $field = 'Component'; + break; + case '@milestones': + $old = array(); + foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) { + if (!strlen($id)) continue; + $m = get_milestone($id); + $old[$id] = $m->name; + } + $value = array(); + $value = $T->getMilestones(); + $field = 'Milestone'; + break; + case '@keywords': + $old = array(); + $field = 'Keywords'; + $value = $T->getKeywords(); + break; + default: + $old = null; + $value = $T->{$field}; + } + if (is_array($value)) { + $value = join(', ', $value); + } + if (is_array($old)) { + $old = join(', ', $old); + } + if ($value == $old) { + continue; + } + if ($field == 'description') { + $lines = count(explode("\n", $old)); + $diff = mtrack_diff_strings($old, $value); + $diff_add = 0; + $diff_rem = 0; + foreach (explode("\n", $diff) as $line) { + if ($line[0] == '-') { + $diff_rem++; + } else if ($line[0] == '+') { + $diff_add++; + } + } + if (abs($diff_add - $diff_rem) > $lines / 2) { + fprintf($plain, "Description changed to:\n%s\n\n", $value); + } else { + fprintf($plain, "Description changed:\n%s\n\n", $diff); + } + } else { + fprintf($plain, "%s %s -> %s\n", $field, $old, $value); + } + } + } + foreach ($comments as $comment) { + fprintf($plain, "\n%s\n", $comment); + } + + $code_by_repo = array(); + foreach ($items as $obj) { + if (!($obj instanceof MTrackSCMEvent)) { + if (!isset($obj->ent)) { + continue; + } + $obj = $obj->ent; + } + $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj; + } + generate_repo_changes($plain, $code_by_repo); + + fprintf($plain, "\n%sticket.php/%s\n\n", $ABSWEB, $T->nsident); + rewind($plain); + + send_mail($udata['email'], $plain); +} + +function generate_repo_changes($plain, $code_by_repo, $changelog = false) +{ + global $MAX_DIFF; + global $ABSWEB; + + foreach ($code_by_repo as $reponame => $ents) { + fprintf($plain, "\nChanges in %s:\n", $reponame); + + /* Gather up affected files */ + $files = array(); + foreach ($ents as $obj) { + foreach ($obj->files as $file) { + $files[$file->name][$file->status]++; + } + } + ksort($files); + $n = 0; + fprintf($plain, " Affected files:\n"); + foreach ($files as $filename => $status) { + if ($n++ > 20) { + fprintf($plain, " ** More than 20 files were changed\n"); + break; + } + fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename); + } + + $too_big = false; + foreach ($ents as $obj) { + fprintf($plain, "\n[%s] by %s\n", $obj->rev, $obj->changeby); + fprintf($plain, "%schangeset.php/%s/%s\n\n", + $ABSWEB, $reponame, $obj->rev); + + if ($changelog) { + fprintf($plain, "%s\n\n", $obj->changelog); + } + + $email_size = get_stream_size($plain); + if ($email_size >= $MAX_DIFF) { + $too_big = true; + continue; + } + foreach ($obj->files as $file) { + $diff = get_diff($obj, $file); + + $email_size = get_stream_size($plain); + $diff_size = get_stream_size($diff); + + if ($email_size + $diff_size < $MAX_DIFF) { + stream_copy_to_stream($diff, $plain); + fwrite($plain, "\n"); + } else { + $too_big = true; + } + } + + } + if ($too_big) { + fprintf($plain, " * Diff exceeds configured limit\n"); + } + } +} + +function get_stream_size($stm) +{ + $st = fstat($stm); + return $st['size']; +} + +function get_diff(MTrackSCMEvent $ent, $file) +{ + $fname = $file->name; + if (isset($ent->__diff[$fname])) { + $diff = $ent->__diff[$fname]; + rewind($diff); + return $diff; + } + $tmp = tmpfile(); + $diff = $ent->repo->diff($file, $ent->rev); + stream_copy_to_stream($diff, $tmp); + $ent->__diff[$fname] = $tmp; + rewind($tmp); + return $tmp; +} + +function get_project($pid) { + static $projects = array(); + if (isset($projects[$pid])) { + return $projects[$pid]; + } + $projects[$pid] = MTrackProject::loadById($pid); + return $projects[$pid]; +} + +function get_component($cid) { + static $comps = array(); + if (isset($comps[$cid])) { + return $comps[$cid]; + } + $comps[$cid] = MTrackComponent::loadById($cid); + return $comps[$cid]; +} + +function get_milestone($mid) { + static $comps = array(); + if (isset($comps[$mid])) { + return $comps[$mid]; + } + $comps[$mid] = MTrackMilestone::loadById($mid); + return $comps[$mid]; +} + +if (!$DEBUG) { + // Now we are done, update the last run time + $db->beginTransaction(); + $db->exec("delete from last_notification"); + $t = MTrackDB::unixtime($LATEST); + echo "updating last run to $t $LATEST\n"; + $db->exec("insert into last_notification (last_run) values ('$t')"); + $db->commit(); +} + +mtrack_cache_maintain(); + diff --git a/bin/setup b/bin/setup new file mode 100755 index 00000000..102b779f --- /dev/null +++ b/bin/setup @@ -0,0 +1,17 @@ +#!/bin/sh + +if test -z "$PHP" ; then + PHP=`which php` +fi + +if test ! -x "$PHP" ; then + echo "Could not find PHP; please install PHP 5.2 or later" + exit 1 +fi + +if test ! -f bin/setup ; then + echo "You must run me from the top-level mtrack dir" + exit 1 +fi + +exec $PHP bin/init.php $* diff --git a/bin/setup.bat b/bin/setup.bat new file mode 100644 index 00000000..c7deffa0 --- /dev/null +++ b/bin/setup.bat @@ -0,0 +1,26 @@ +@ECHO OFF +REM This is the windows equivalent of the setup bash script in this directory + +REM Please change this variable to point to your php.exe location, please do not place quotation marks around the path +SET PHP_BIN=C:\Program Files\PHP\php.exe +SET PROJ_NAM= +SET REPO_NAME= +SET REPO_PATH= +SET REPO_TYPE=svn + +REM Do not edit beyond this point + +IF EXIST %CD%/init.php GOTO CDONEUP +IF EXIST %CD%/init.php GOTO CDONEUP +IF EXIST %CD%/bin/init.php GOTO RUN + +ECHO "Error: cannot find bin\init.php, you can double click on setup.bat to run me properly" + +:CDONEUP +CD ../ + +:RUN + +CALL "%PHP_BIN%" "%CD%\bin\init.php" --repo %REPO_NAME% %REPO_TYPE% %REPO_PATH% + --link %PROJ_NAME% %REPO_NAME% /%* +PAUSE \ No newline at end of file diff --git a/bin/solr-schema.xml b/bin/solr-schema.xml new file mode 100644 index 00000000..1fdb55e3 --- /dev/null +++ b/bin/solr-schema.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + all + + + + + + diff --git a/bin/svn-commit-hook b/bin/svn-commit-hook new file mode 100755 index 00000000..6e756c24 --- /dev/null +++ b/bin/svn-commit-hook @@ -0,0 +1,91 @@ +#!/usr/bin/env php +repo = $repo; + $this->svnlook = MTrackConfig::get('tools', 'svnlook'); + $this->svnrepo = $svnrepo; + $this->svntxn = $svntxn; + } + + function enumChangedOrModifiedFileNames() { + $files = array(); + $fp = popen("$this->svnlook changed $this->svntxn $this->svnrepo", 'r'); + while (($line = fgets($fp)) !== false) { + if (preg_match("/^(\w)\s+(.*)$/", trim($line), $M)) { + $action = $M[1]; + $path = $M[2]; + if ($action == 'A' || $action == 'U' || $action == 'UU') { + $files[] = $path; + } + } + } + return $files; + } + + function getCommitMessage() { + $fp = popen("$this->svnlook log $this->svntxn $this->svnrepo", 'r'); + $log = stream_get_contents($fp); + $log = preg_replace('/\[(\d+)\]/', + "[changeset:" . $this->repo->getBrowseRootName() . ",\$1]", $log); + return $log; + } + + function getFileStream($path) { + return popen( + "$this->svnlook cat $this->svntxn $this->svnrepo $path", 'r'); + } + + function getChangesetDescriptor() { + $rev = trim(str_replace('-r ', '', $this->svntxn)); + return '[changeset:' . $this->repo->getBrowseRootName() . ",$rev]"; + } +} + +try { + $repo = MTrackRepo::loadByLocation($svnrepo); + $bridge = new SvnCommitHookBridge($repo, $svnrepo, $svntxn); + $author = trim(shell_exec("$bridge->svnlook author $svntxn $svnrepo")); + $author = mtrack_canon_username($author); + MTrackAuth::su($author); + $checker = new MTrackCommitChecker($repo); + if ($action == 'pre') { + $checker->preCommit($bridge); + } else { + $checker->postCommit($bridge); + } + exit(0); +} catch (Exception $e) { + fwrite(STDERR, "\n" . $e->getMessage() . "\n\n ** Commit failed [$action]\n"); + exit(1); +} + diff --git a/bin/update-search-index.php b/bin/update-search-index.php new file mode 100644 index 00000000..e9d686a9 --- /dev/null +++ b/bin/update-search-index.php @@ -0,0 +1,159 @@ +fetchAll() + as $row) { + $last = $row[0]; + $ALL = false; +} +$LATEST = strtotime($last); +$FIRST = $LATEST; +$ITEMS = 0; +$DONE = array(); + +function index_and_measure($object) +{ + global $DONE; + if (isset($DONE[$object])) { + return true; + } + $DONE[$object] = true; + + echo "Examine: $object\n"; + log_flush(); + $start = time(); + $res = MTrackSearchDB::index_object($object); + $elapsed = time() - $start; + printf("Indexed $object in %f seconds\n", $elapsed); + log_flush(); + return $res; +} + +function index_items($lower) +{ + global $LATEST; + global $ITEMS; + global $start_time; + global $DONE; + global $FIRST; + + /* do the work here */ + + foreach (MTrackDB::q('select object, max(changedate) from changes where changedate > ? group by object order by max(changedate)', $lower)->fetchAll(PDO::FETCH_NUM) + as $row) { + + if ($LATEST > ($FIRST + 3) && time() - $start_time > 280) { + // Step back 1 second on the next run, otherwise we may miss out + // a couple of items from the current second + $LATEST--; + break; + } + + list($object, $when) = $row; + + if (true) { + $ITEMS++; + $res = index_and_measure($object); + } else { + $res = true; + } + if ($res === false) { + echo "Don't know how to index $object\n"; + } else { + echo "Processed $object $when > $lower\n"; + } + $t = strtotime($when); + if ($t > $LATEST) { + $LATEST = $t; + } + } +} + +if ($ALL) { + // walk all the wiki pages, in case someone checked in against the + // wiki repo outside of the app + $repo = null; + $root = MTrackWikiItem::getRepoAndRoot($repo); + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + function walk_wiki($repo, $dir, $suf) + { + global $DONE; + + $items = $repo->readdir($dir); + foreach ($items as $file) { + if ($file->is_dir) { + walk_wiki($repo, $file->name, $suf); + } else { + if (!strlen($suf) || substr($file->name, -strlen($suf)) == $suf) { + //echo "Going to index wiki:$file->name\n"; + $object = "wiki:$file->name"; + index_and_measure($object); + } else { + //echo "NO: wiki:$file->name\n"; + } + } + } + } + walk_wiki($repo, $root, $suf); +} + +index_items($last); + +$db = MTrackDB::get(); +$db->beginTransaction(); +$db->exec("delete from search_engine_state"); +$insert = $db->prepare("insert into search_engine_state (last_run) values (?)"); +$insert->execute(array(MTrackDB::unixtime($LATEST))); +$db->commit(); + +if ($ITEMS > 0) { + MTrackSearchDB::commit(); +} + +$end_time = time(); +$elapsed = $end_time - $start_time; +echo "$ITEMS items processed (in $elapsed seconds)\n"; + diff --git a/config.ini.sample b/config.ini.sample new file mode 100644 index 00000000..fdc88755 --- /dev/null +++ b/config.ini.sample @@ -0,0 +1,118 @@ +; vim:ts=2:sw=2:et:ft=dosini: +; This file is parsed subject to the following rules: +; +; Unquoted tokens on the right hand side of an equals sign that correspond to +; constants defined in PHP are replaced by the value of that constant. +; +; Values for the form ${name} are substituted with the value of the +; corresponding PHP configuration directive, or if none is found, the +; corresponding environmental variable value. +; +; Values of the form @{section:myname} are substituted with the value of the +; option defined in this configuration file (for example, the "myname" value +; in the "section" section). + +; Core configuration options +[core] +vardir = @VARDIR@ +dblocation = "@{core:vardir}/mtrac.db" +dsn = @DSN@ +searchdb = "@{core:vardir}/search.db" +projectname = @PROJECT@ +timezone = EST +; mimetype_detect = fileinfo or mimemagic or file - if empty will attempt to detect which one to use or bails +; projectlogo = image url to show in nav bar +; default.repo = name of default repo when constructing changeset links +; weburl = URL (including trailing slash) of canonical home of this instance. used when sending notification email. +; default_email_domain = domain name to use for notification mail when the user is not present in the userinfo table +; includes = comma separated list of files to include; supports plugins +;debug.footer = 1 +; Use .wiki to indicate a wiki filename as distinct from a wiki dir +wikifilenamesuffix=.wiki +; Fresh installs start in "admin party" mode, which means that any user +; accessing the system from the loopback (127.0.0.1) is treated as admin. +admin_party = true +; Which search engine to use. Lucene works out of the box, but Solr has +; better performance and higher quality results. +; MTrackSearchEngineLucene or MTrackSearchEngineSolr +search_engine = MTrackSearchEngineLucene + +[repos] +; If true, permit the creation and forking of per-user repositories +allow_user_repo_creation = true +; Where per-user repositories should be created (both git and hg) +basedir = "@{core:vardir}/repos" + +; The SSH user@host which can be used to access repos. You must have configured +; SSH access as described in the mtrack SSH installation documentation +;serverurl = "code@example.com" + +; The URL over which users can clone, push, pull via HG. +; You need to configure this URL yourself using your choice of Mercurial server. +; This is ignored if you have set serverurl. +;hg.serverurl = "http://example.com/hg" +; +; The URL over which users can clone, push, pull via GIT. +; You need to configure this URL yourself using your choice of Git server. +; This is ignored if you have set serverurl. +;git.serverurl = "http://example.com/git" + +[solr] +; How to find your Solr instance if you're using the Apache Solr search +; engine implementation (search_engine = MTrackSearchEngineLucene) +url = "http://localhost:8983/solr" + +[ticket] +default.classification = defect +default.severity = normal +default.priority = normal + +[notify] +; Should we use SMTP directly? +; Requires PHP with getmxrr functionality (not Windows on PHP < 5.3) +use_smtp = false +; If using SMTP, identifies a smart host via which mail will be routed. +; Otherwise, we'll try to lookup the MX records via DNS. +smtp_relay = "127.0.0.1" +; If using SMTP, sets the envelope from +smtp_from = "noreply@example.com" + +; Defines some basic, reasonable, permission sets for 3 classes of user. +; These are used in addition to whatever is selected by auth plugins +[user_class_roles] +anonymous = ReportViewer,BrowserViewer,WikiViewer,TimelineViewer,RoadmapViewer,TicketViewer +authenticated = ReportViewer,BrowserViewer,WikiCreator,TimelineViewer,RoadmapViewer,TicketCreator,UserViewer,SnippetCreator,BrowserForker +admin = ReportCreator,BrowserCreator,WikiCreator,TimelineViewer,RoadmapCreator,TicketCreator,EnumerationCreator,ComponentCreator,ProjectCreator,UserCreator,SnippetCreator,BrowserForker + +; Explicitly place certain users in certain user classes. This is used mainly +; to provide a means to indicate that particular users are classed as admins. +; The auth module group assignments are the recommended way to go for more +; powerful/flexible group/role assignment +[user_classes] + +; use the tools section to override the location of certain +; tools, in case you have multiples or in case they live outside +; of the standard locations +[tools] +@TOOLS@ +; hg = /usr/local/bin/hg +; svn = /opt/msys/3rdParty/bin/svn +; svnlook = /opt/msys/3rdParty/bin/svnlook +; php = /opt/msys/3rdParty/bin/php +; diff3 = /opt/msys/3rdParty/bin/diff3 +; diff = /opt/msys/3rdParty/bin/diff + +[nav:mainnav] +; If you want to turn off the wiki navigation link (does not disable the wiki, +; just hides the link), uncomment the following line (the empty right hand +; side deletes the link keyed by the left hand side) +; /wiki.php = +; If you want to add in other links, you can do so by adding the link on the +; left hand side of the equals and the label on the right hand side + +[plugins] +; MTrackAuth_HTTP = /Users/wez/Sites/svn.htgroup, /Users/wez/Sites/svn.htpasswd +; MTrackCommitCheck_NoEmptyLogMessage = +; MTrackCommitCheck_RequiresTimeReference = +; MTrackCaptcha_Recaptcha = public, private, userclasses +; MTrackAuth_OpenID = diff --git a/defaults/help/ConfigIni b/defaults/help/ConfigIni new file mode 100644 index 00000000..ed0e7902 --- /dev/null +++ b/defaults/help/ConfigIni @@ -0,0 +1,181 @@ += config.ini = + +The configuration file defines an mtrack instance. mtrack will look for this +file by first inspecting the {{{$MTRACK_CONFIG_FILE}}} environmental variable. +If it is not set it will default to looking for {{{config.ini}}} in the mtrack +source directory. + +{{{config.ini}}} is parsed using the following rule: + + * {{{[name]}}} indicates that the following values belong to the ''name'' section. You may switch section multiple times in the file if you wish. + * Lines beginning with a semicolon {{{;}}} character are comments and are ignored by the parser + * values are specified by lines of the form {{{name = value}}}. The value belongs to the previously indicated section. + * Unquoted tokens on the right hand size of an equals sign are replaced by the value of a matching PHP constant + * Values of the form {{{${name}}}} are substituted with the value of the corresponding PHP configuration directive, or if none is found, the corresponding environmental variable value + * Values of the form {{{@{section:myname}}}} are substituted with the value of the option defined in this configuration file. For example, the ''myname'' value in the ''section'' section) + +== [core] == #core + +The following options are defined for the {{{core}}} section. + + vardir:: + The location of the {{{var}}} directory, which holds all of the mtrack + runtime state + + dblocation:: + Where the mtrack sqlite database can be found. This is usually defined + to be {{{"@{core:vardir}/mtrac.db"}}} which means that it lives in the + {{{var}}} directory. + + searchdb:: + Where the mtrack full-text search database can be found. This is usually + defined to be {{{"@{core:vardir}/search.db"}}} which means that it lives in + the {{{var}}} directory. + + projectname:: + The name of the mtrack instance. This is displayed in the top left of + the navigation area if the ''projectlogo'' is not defined. + + timezone:: + The default timezone to use when rendering dates. + + projectlogo:: + Specifies an URL that will be used in an image tag displayed in the top + left of the navigation area. + + weburl:: + Specifies the canonical URL (including trailing slash) for this mtrack + instance. This is used when generating links in notification email, + but will also be used when generating links in the web application. + + default.repo:: + Specifies the shortname of the repo to use when generating changeset + links that don't otherwise specify one. You only need this when you + have multiple repos. mtrack will default to the first repo. + + default_email_domain:: + Domain name to use when inferring the email address for users that do + not have an email address configured in the userinfo table. + + includes:: + Comma separated list of files to be included. The intended use is for + loading plugins without modifying the mtrack code. + +== [ticket] == #ticket + + default.classification:: + When creating a new ticket, specifies which classification to pre-select + + default.severity:: + When creating a new ticket, specifies which severity to pre-select + + default.priority:: + When creating a new ticket, specifies which priority to pre-select + +== [user_class_roles] == #user_class_roles + +This section allows you to define classes of users. Unauthenticated users are +placed in the ''anonymous'' user class. Authenticated users are placed in the +''authenticated'' user class. + +The names in this section define user classes, and their corresponding values +define a list of rights that are granted to users that are in that class. + +The default configuration for this section is reproduced below: + +{{{ +; Defines some basic, reasonable, permission sets for 3 classes of user. +; These are used in addition to whatever is selected by auth plugins +[user_class_roles] +anonymous = ReportViewer,BrowserViewer,WikiViewer,TimelineViewer,RoadmapViewer,TicketViewer +authenticated = ReportViewer,BrowserViewer,WikiCreator,TimelineViewer,RoadmapViewer,TicketCreator +admin = ReportCreator,BrowserCreator,WikiCreator,TimelineViewer,RoadmapCreator,TicketCreator,EnumerationCreator,ComponentCreator,ProjectCreator +}}} + +This give anonymous users read-only access to the major areas of mtrack. +Authenticated users are given write access to the major areas. + +This also defines a class called ''admin'' that has full access to all areas of mtrack. + +== [user_classes] == #user_classes + +This names in this section correspond to user names. The value is the user class that is explicitly assigned to that user. + +For example: + +{{{ +[user_classes] +wez = admin +}}} + +places the ''wez'' user in to the ''admin'' user class. When combined with the +above [help:ConfigIni#user_class_roles user_class_roles] causes ''wez'' to +belong to each of the groups associated with the ''admin'' class and thus have +full access to the system. + +Configuring user_classes is not necessary if you are using an authentication +scheme where you control which groups are assigned to the users. + +== [tools] == #tools + +The tools section controls where mtrack finds the various command line tools +that it may need to run. + +The names in this section are the tool names and the value is the path to the +tool itself. [help:bin/Init bin/init.php] will try to populate these +automatically when it runs, so you will not usually need to make changes here +unless you have an alternate version of a given that is not in a standard +location. + +== ![nav:mainnav] == #nav:mainnav + +If you want to turn off, rename or add navigation links you can do so by +making changes to this section. + +The names in this section correspond to the URL of one of the navigation links +and the value is the displayed text. + +To remove the wiki link from navigation: + +{{{ +[nav:mainnav] +/wiki.php = +}}} + +To rename the wiki link: + +{{{ +[nav:mainnav] +/wiki.php = Awesome Wiki +}}} + +To add a new navigation item: + +{{{ +[nav:mainnav] +http://bitbucket.org/wez/mtrack/ = mtrack home +}}} + +== [plugins] == #plugins + +mtrack has a simple plugin system. After a plugin has been installed, it needs +to be configured by adding an entry to this section of the configuration file. + +The names in this section correspond to the names of the plugin classes. The value is interpreted as a comma separated list of strings that will be passed as arguments to the constructor of that class. + +For example: + +{{{ +[plugins] +MTrackAuth_HTTP = /Users/wez/Sites/svn.htgroup, /Users/wez/Sites/svn.htpasswd +}}} + +this will cause mtrack to run the equivalent of the following php code: + +{{{ +$obj = new MTrackAuth_HTTP( + '/Users/wez/Sites/svn.htgroup', + '/Users/wez/Sites/svn.htpasswd'); +}}} + +For more information about plugins, see [help:Plugins]. diff --git a/defaults/help/Install b/defaults/help/Install new file mode 100644 index 00000000..44bb699f --- /dev/null +++ b/defaults/help/Install @@ -0,0 +1,371 @@ +{{{ +#!comment +This page is formatted using wiki markup. You may find it easier to +run through the Quick Install steps and then navigate to help.php/Install +and continue reading. +}}} + += Installing mtrack = + +== Pre-requisites == + + * A unix style operating system, such as Linux, Solaris, OS/X, FreeBSD etc. + * PHP 5.2, both the standalone CLI executable and Web Server (such as Apache) versions + * PHP must have PDO and pdo_sqlite support + * fileinfo or mime_magic support is recommended + * The {{{diff}}} and {{{diff3}}} command line tools + * Subversion command line tools ({{{svn}}} and {{{svnlook}}}) for Subversion repo support + * Mercurial command line tools ({{{hg}}}) for Mercurial repo support + * Access to the ''cron'' mechanism or equivalent on your system to schedule background tasks + * The {{{sendmail}}} command line tool for change notification emails + +== Quick Install == + +It is recommended that you read this guide in full before installing, but if +you're impatient and want to see it running very quickly, you can follow these +steps. They are intentionally terse; if you want more detail, read this guide +in full! + +Note that if you want to import data from Trac, you will need to start over +with the initialization. + +You should treat the quick install as a way to make a quick assessment about +mtrack before beginning your migration in earnest. + +{{{ +% cd $MTRACK +% php bin/init.php +}}} + + * configure your webserver so that the $MTRACK/web dir is accessible + * turn off magic_quotes_gpc + +You can now visit mtrack as an anonymous user and continue reading this +document by navigating to help.php/Install. + +To do anything interesting, you will need to configure authentication. + +== Background == + +An mtrack installation is defined in terms of the mtrack configuration file +{{{config.ini}}}, which contains system settings, and the application files, +which contain the program logic and that can be shared between between +instances so that multiple mtrack projects don't need to have their own copies +of the application files. + +mtrack uses the environment variable {{{MTRACK_CONFIG_FILE}}} to locate the +{{{config.ini}}}, so sharing the same mtrack codebase across multiple projects +is just a matter of ensuring that the environment is correctly set for each +project. + +== Installation == + +Decide where you would like the mtrack application files to reside on your +filesystem and put them there. mtrack itself does not place any restrictions +on location, although the recommendation is that you do not place it in a path +where any of the parent directories have spaces in their names. + +The {{{web}}} directory of the sources is intended to be the only portion +served via your web server, and it is recommended that you configure your +system such that the other directories are prevented from being served as a +security precaution. + +You must also decide where you want to store the state for your mtrack project. +State includes the mtrack database (which holds tickets, wiki pages and more) +as well as attachment files and a Lucene search index. All of these things are +encapsulated in a {{{var}}} directory. + +The {{{var}}} directory ''must not'' be served via your web server. + +== A note on Wiki == + +mtrack stores wiki pages in a repository. By default, it will create this repo +in the {{{var}}} directory. If you would like to locate the wiki repo +elsewhere, perhaps because you have want to export that and allow wiki edits to +be made via conventional editing tools and checked back in, then you may use +the {{{--repo}}} option to inform mtrack where it can find the existing wiki repository (you need to create it and ensure that it is accessible). + + +== Performing the Installation == + +From this point onwards, we use {{{$MTRACK}}} to denote the root of the mtrack +source files, and {{{$VARDIR}}} to denote the location that you selected to +hold the state for your mtrack project instance. Each instance '''must''' have +its own distinct {{{$VARDIR}}}. + +Each of the steps below cause {{{config.ini}}} to be created in the +{{{$MTRACK}}} directory; you may change this by usin the {{{--config-file}}} +option. + +=== Initializing === + +To initialize a fresh environment that is not related to any source +repositories: + +{{{ +% cd $MTRACK +% php bin/init.php --vardir $VARDIR +}}} + +However, it is quite likely that you have a source repository or two; if so, +you will probably want to configure mtrack to see them. You should also define +a ''project'' and associate it with the repo; this is used later for change +notifications. You should initialize your instance using the following +invocation instead: + +{{{ +% cd $MTRACK +% php bin/init.php --vardir $VARDIR \ + --repo $REPONAME svn /path/to/repo \ + --link $PROJNAME $REPONAME / +}}} + + * $REPONAME will show up as the top level name in the source browser + * $PROJNAME will show up in the subject line of notification email + * The {{{/}}} in the {{{--link}}} line causes all changes in $REPONAME to be recognized as happening within the $PROJNAME project. More advanced rules are possible, such as allowing multiple projects to be contained with the same repo, but are not explained here. + +If you are migrating from Trac, then you will want to associate your +repository and tell mtrack to import your Trac data: + +{{{ +% cd $MTRACK +% php bin/init.php --vardir $VARDIR \ + --trac $PROJNAME /path/to/trac/environment/dir \ + --repo $REPONAME svn /path/to/repo \ + --link $PROJNAME $REPONAME / +}}} + +If your Trac instance contains a lot of data, you might want to use the +{{{--disable-index}}} option to improve the import speed. This turns off +incremental index updates during the import and trades import speed now for +indexing speed in the indexing background job that runs later. + +{{{ +#!comment + +For Windows users - there is a setup.bat file in the /bin directory you may use +Right clik the setup.bat file and choose edit +Change the PHP_BIN location to the absolute path to your PHP directory. +Change the PROJ_NAM, REPO_NAME and REPO_PATH to the desired locations +and values. You may also need to change REPO_TYPE if you are not setting up an +svn environment. +Save the changes, then exit and double click on setup.bat to initialize your environment + +Currently the windows batch file does not support the --trac or --vardir arguments +If you are comfortable in a command line environment, you may open cmd.exe, cd +to the location of setup.bat, and pass the additional arguments you desire. +}}} + +=== Set the ownership on $VARDIR === + +Ensure that the web server process can access the mtrack state: + +{{{ +# chown -R nobody:nobody $VARDIR +}}} + + * '''nobody''' must be changed to match the user account under which the web server process runs + +{{{ +#!comment + * In Windows environments, make sure the user your webserver is running as can write to $VARDIR + This is normally the SYSTEM user for apache2 installations and IUSR_computername for IIS installations + See php.net installation instructions for your version of IIS for more information +}}} + +For a reference on the init script and its parameters, consult [help:bin/Init]. + +=== Tool configuration === + +Once initialized, open {{{config.ini}}} in your text editor and fill out the +{{{[tools]}}} section so that mtrack knows the full path to the {{{svn}}}, +{{{svnlook}}} and {{{php}}} command line utilities. These will be guessed by +the initialization script based on what it can find your {{{$PATH}}}. + +{{{ +#!comment +Windows users can find the appropriate diff tools at +http://gnuwin32.sourceforge.net/packages/diffutils.htm Make sure to use +absolute paths to the appropriate tools and use quotes around the values. Note +that if you have tortoisesvn installed you will not have the command line svn +tools required, you'll need to install the command lines tools as well. + +For example, on a 64 bit system your paths will look similiar to this +{{{ +hg = "C:\Program Files\TortoiseHg\hg.exe" +; svn = /opt/msys/3rdParty/bin/svn +; svnlook = /opt/msys/3rdParty/bin/svnlook +php = "C:\Program Files (x86)\PHP\php.exe" +diff3 = "C:\Program Files (x86)\diff\diff3.exe" +diff = "C:\Program Files (x86)\diff\dif.exe" +}}} +}}} + +=== Cron configuration === + +mtrack defers content indexing and email notifications so that they can be +intelligently handled in batches and not intrude on the web application +performance. + +Configure a cron entry to run these batch processes every 10 minutes, using the following as a template: + +{{{ +0,10,20,30,40,50 * * * * nice su nobody -c "php $MTRACK/bin/update-search-index.php ; php $MTRACK/bin/send-notifications.php" >/dev/null 2>/dev/null +}}} + + * '''nobody''' must be changed to match the user account under which the web server process runs + +You are free to change the interval to anything you like (although the system +minimum is 1 minute); longer intervals allow more ticket changes to be +collapsed into an email at the expense of a larger perceived lag between the +time the event happens and the time the email is sent. + +If you imported a large trac instance, the initial run of +{{{update-search-index.php}}} can take some time to run (and can tax the CPU +while it is running). You need not worry about this; it is normal. Both +{{{update-search-index.php}}} and {{{send-notifications.php}}} are intelligent +enough to only allow 1 instance to run concurrently, so even if there is a +backlog of work for them to process, they won't trip over each other or other +invocations launched from cron. + +=== Subversion commit-hook configuration === + +mtrack works best when integrated with your SCM. There is a pre-commit hook +that can be used to enforce commit policies (such as proper formatting of +commit messages, or proper syntax in changed source files), and a post-commit +hook that can be used to apply commit messages as comments to related tickets. + +Both the pre- and post-commit hooks are implemented by +{{{bin/svn-commit-hook}}}. To enable it, arrange for your pre-commit hook to +invoke it: + +If you do not have an existing hook, then create the following shell script in +the {{{hooks}}} directory of your subversion repository. If you have an +existing hook, then adjust it to invoke the mtrack commit hook in addition to +the other actions it takes: + +{{{ +#!/bin/sh +php $MTRACK/bin/svn-commit-hook pre $1 $2 $MTRACK/config.ini +}}} + +Then make sure it is executable: + +{{{ +# chmod a+rx hooks/pre-commit +}}} + +The post-commit hook is similar: + +{{{ +#!/bin/sh +php $MTRACK/bin/svn-commit-hook post $1 $2 $MTRACK/config.ini +}}} + +Then make sure it is executable: + +{{{ +# chmod a+rx hooks/post-commit +}}} + +=== Mercurial Commit Hook === + +Add this to the .hg/hgrc in the Mercurial repos: + +{{{ +[hooks] +changegroup.mtrack = php $MTRACK/bin/hg-commit-hook changegroup $MTRACK/config.ini +commit.mtrack = php $MTRACK/bin/hg-commit-hook commit $MTRACK/config.ini +pretxncommit.mtrack = php $MTRACK/bin/hg-commit-hook pretxncommit $MTRACK/config.ini +pretxnchangegroup.mtrack = php $MTRACK/bin/hg-commit-hook pretxnchangegroup $MTRACK/config.ini +}}} + +=== Notification Email Configuration === + +mtrack notifies users of changes based on the project associated with the +source code that was changed. During initialization, we used the {{{--link}}} +argument to define a relationship between a location within a repo and a +project. + +To enable email notification, we now need to associate an email address with a +project. This is done via the Administration section; you can edit the email +address associated with the project from there. + +Edit {{{config.ini}}} and set the '''weburl''' to match the URL you are going +to use for the web application. It is important to include the trailing slash +in the URL that you put into the configuration file. This value is used to +construct clickable links in notification emails. + +=== Authentication Considerations === + +mtrack uses plugins to control authentication and authorization. By default, +it will respect the user identity of the command line user, but all web +accesses will be mapped to an ''anonymous'' user account that has read-only +access rights. + +The recommended authentication approach is to configure your web server to +apply HTTP authentication to the mtrack application to secure it. + +mtrack ships with an {{{MTrackAuth_HTTP}}} plugin that will recognize when the +web server has authenticated the user, and if not, will initiate Basic or +Digest authentication itself. + +The default {{{config.ini}}} file leaves the HTTP auth module commented out; +you should uncomment it and inform it where it can find apache style group and +password files. If the password file contains digest authentication +credentials, the filename must be prefixed with {{{digest:}}}. + +{{{ +[plugins] +MTrackAuth_HTTP = /path/to/htgroup, /path/to/htpasswd +; for digest: +;MTrackAuth_HTTP = /path/to/htgroup, digest:/path/to/htpasswd +}}} + + * At this time, mtrack does not ship with a mechanism to allow both unauthenticated and authenticated access (but it could be implemented pretty easily) + +More information on authentication can be found in [help:plugin/AuthHTTP]. + +== Web server Configuration == + + * Configure your web server such that your preferred URL maps to the {{{$MTRACK/web}}} directory + * Ensure that {{{magic_quotes_gpc}}} is set to {{{Off}}} in your PHP configuration. + +A snippet from my httpd.conf: + +{{{ +# mtrack prototype + + AuthType Basic + AuthName "Access for mtrack" + AuthUserFile "/path/to/htpasswd" + AuthGroupFile "/path/to/htgroup" + require group developers + + + Options Indexes FollowSymLinks + AllowOverride None + Order allow,deny + Allow from all + + DirectoryIndex index.php + php_value magic_quotes_gpc Off + +Alias /mtrack/eng /home/wez/mtrack/web +}}} + +You may want to consider something like this for multi-instance hosting, if your "foo" project has its vardir at {{{/data/foo}}} and your "bar" project has its vardir at {{{/data/bar}}}: + +{{{ +Alias /mtrack/foo /home/wez/mtrack/web +SetEnvIf Request_URI "^/mtrack/foo(/|$)" "MTRACK_CONFIG_FILE=/data/foo/config.ini" +Alias /mtrack/bar /home/wez/mtrack/web +SetEnvIf Request_URI "^/mtrack/bar(/|$)" "MTRACK_CONFIG_FILE=/data/bar/config.ini" +}}} + +== Done == + +Your basic configuration is now complete. There are a number of other settings +in {{{config.ini}}} that can be adjusted (See [help:ConfigIni] for details), +but following the steps above should be sufficient to get you up and running. + diff --git a/defaults/help/Introduction b/defaults/help/Introduction new file mode 100644 index 00000000..d04b31be --- /dev/null +++ b/defaults/help/Introduction @@ -0,0 +1,16 @@ += Welcome to mtrack = + +This document serves as an introduction to '''mtrack'''. If you're seeing this text show up when you click on the ''wiki'' navigation button, it is because the default WikiStart page includes this introductory text. + +You are free to edit any of the wiki content without fear of losing the help pages; you can always find and read them via the title index in the wiki section. + +== What is mtrack ? == + +'''mtrack''' is an Open Source project management tool heavily inspired by the popular [http://trac.edgewall.org Trac] tool created by Edgewall Software. '''mtrack''' is implemented in PHP and is geared towards managing issues that span multiple code repositories. + +'''mtrack''' is Copyright 2008-2010 [http://www.messagesystems.com/ Message Systems, Inc.] and is licensed under the terms of the [http://bitbucket.org/wez/mtrack/src/tip/LICENSE Modified BSD License]. + +== Getting Started == + + * [help:Install Installation] + diff --git a/defaults/help/Links b/defaults/help/Links new file mode 100644 index 00000000..03e0dbe8 --- /dev/null +++ b/defaults/help/Links @@ -0,0 +1,291 @@ += Links = + +TracLinks are a fundamental feature of Trac, because they allow easy hyperlinking between the various entities in the system—such as tickets, reports, changesets, Wiki pages, milestones, and source files—from anywhere WikiFormatting is used. + +TracLinks are generally of the form '''type:id''' (where ''id'' represents the +number, name or path of the item) though some frequently used kinds of items +also have short-hand notations. + +== Where to use TracLinks == +You can use TracLinks in: + + * Source code (Subversion) commit messages + * Wiki pages + * Full descriptions for tickets, reports and milestones + +and any other text fields explicitly marked as supporting WikiFormatting. + +Some examples: + * Tickets: '''!#1''' or '''!ticket:1''' + * Ticket comments: '''!comment:1:ticket:2''' + * Reports: '''!{1}''' or '''!report:1''' + * Changesets: '''!r1''', '''![1]''', '''!changeset:1''' or (restricted) '''![1/trunk]''', '''!changeset:1/trunk''' + * Revision log: '''!r1:3''', '''![1:3]''' or '''!log:@1:3''', '''!log:trunk@1:3''', '''![2:5/trunk]''' + * Diffs (requires [trac:milestone:0.10 0.10]): '''!diff:@1:3''', '''!diff:tags/trac-0.9.2/wiki-default//tags/trac-0.9.3/wiki-default''' or '''!diff:trunk/trac@3538//sandbox/vc-refactoring@3539''' + * Wiki pages: '''!CamelCase''' or '''!wiki:CamelCase''' + * Parent page: '''![..]''' + * Milestones: '''!milestone:1.0''' + * Attachment: '''!attachment:example.tgz''' (for current page attachment), '''!attachment:attachment.1073.diff:ticket:944''' +(absolute path) + * Files: '''!source:trunk/COPYING''' + * A specific file revision: '''!source:/trunk/COPYING@200''' + * A particular line of a specific file revision: '''!source:/trunk/COPYING@200#L25''' +Display: + * Tickets: #1 or ticket:1 + * Ticket comments: comment:1:ticket:2 + * Reports: {1} or report:1 + * Changesets: r1, [1], changeset:1 or (restricted) [1/trunk], changeset:1/trunk + * Revision log: r1:3, [1:3] or log:@1:3, log:trunk@1:3, [2:5/trunk] + * Diffs (requires [milestone:0.10 0.10]): diff:@1:3, diff:tags/trac-0.9.2/wiki-default//tags/trac-0.9.3/wiki-default or diff:trunk/trac@3538//sandbox/vc-refactoring@3539 + * Wiki pages: CamelCase or wiki:CamelCase + * Parent page: [..] + * Milestones: milestone:1.0 + * Attachment: attachment:example.tgz (for current page attachment), attachment:attachment.1073.diff:ticket:944 +(absolute path) + * Files: source:trunk/COPYING + * A specific file revision: source:/trunk/COPYING@200 + * A particular line of a specific file revision: source:/trunk/COPYING@200#L25 + +'''Note:''' The wiki:CamelCase form is rarely used, but it can be convenient to refer to +pages whose names do not follow WikiPageNames rules, i.e., single words, +non-alphabetic characters, etc. See WikiPageNames for more about features specific +to links to Wiki page names. + +Trac links using the full (non-shorthand) notation can also be given a custom +link title like this: + +{{{ +[ticket:1 This is a link to ticket number one]. +}}} + +Display: [ticket:1 This is a link to ticket number one]. + +If the title is omitted, only the id (the part after the colon) is displayed: + +{{{ +[ticket:1] +}}} + +Display: [ticket:1] + +`wiki` is the default if the namespace part of a full link is omitted (''since version 0.10''): + +{{{ +[SandBox the sandbox] +}}} + +Display: [SandBox the sandbox] + +TracLinks are a very simple idea, but actually allow quite a complex network of information. In practice, it's very intuitive and simple to use, and we've found the "link trail" extremely helpful to better understand what's happening in a project or why a particular change was made. + + +== Advanced use of TracLinks == + +=== Relative links === + +To create a link to a specific anchor in a page, use '#': +{{{ + [#Relativelinks relative links] +}}} +Displays: + [#Relativelinks relative links] + +Hint: when you move your mouse over the title of a section, a '¶' character will be displayed. This is a link to that specific section and you can use this to copy the `#...` part inside a relative link to an anchor. + +To create a link to a [trac:SubWiki SubWiki]-page just use a '/': +{{{ + WikiPage/SubWikiPage or ./SubWikiPage +}}} + +To link from a [trac:SubWiki SubWiki] page to a parent, simply use a '..': +{{{ + [..] +}}} + +To link from a [trac:SubWiki SubWiki] page to a sibling page, use a '../': +{{{ + [../Sibling see next sibling] +}}} + +''(Changed in 0.11)'' Note that in Trac 0.10, using e.g. `[../newticket]` may have worked for linking to the /newticket top-level URL, but now in 0.11 it will stay in the wiki namespace and link to a sibling page. See [#Server-relativelinks] for the new syntax. + +=== InterWiki links === + +Other prefixes can be defined freely and made to point to resources in other Web applications. The definition of those prefixes as well as the URLs of the corresponding Web applications is defined in a special Wiki page, the InterMapTxt page. Note that while this could be used to create links to other Trac environments, there's a more specialized way to register other Trac environments which offers greater flexibility. + +=== InterTrac links === + +This can be seen as a kind of InterWiki link specialized for targeting other Trac projects. + +Any type of Trac links could be written in one Trac environment and actually refer to resources present in another Trac environment, provided the Trac link is prefixed by the name of that other Trac environment followed by a colon. That other Trac environment must be registered, under its name or an alias. See InterTrac for details. + +A distinctive advantage of InterTrac links over InterWiki links is that the shorthand form of Trac links usually have a way to understand the InterTrac prefixes. For example, links to Trac tickets can be written #T234 (if T was set as an alias for Trac), links to Trac changesets can be written [trac 1508]. + +=== Server-relative links === + +It is often useful to be able to link to objects in your project that +have no built-in Trac linking mechanism, such as static resources, `newticket`, +a shared `/register` page on the server, etc. + +To link to resources inside the project, use either an absolute path from the project root, +or a relative link from the URL of the current page: + +{{{ +[/ticket.php/new Create a new ticket] +[/ home] +}}} + +Display: [/ticket.php/new newticket] [/ home] + +To link to another location on the server (outside the project), use the '//location' link syntax: + +{{{ +[//register Register Here] +}}} + +Display: [//register Register Here] + +=== Quoting space in TracLinks === + +Immediately after a TracLinks prefix, targets containing space characters should +be enclosed in a pair of quotes or double quotes. + +Examples: + * !wiki:"The whitespace convention" + * !attachment:'the file.txt' or + * !attachment:"the file.txt" + * !attachment:"the file.txt:ticket:123" + +Display: + * wiki:"The whitespace convention" + * attachment:'the file.txt' or + * attachment:"the file.txt" + * attachment:"the file.txt:ticket:123" + + +=== Escaping Links === + +To prevent parsing of a !TracLink, you can escape it by preceding it with a '!' (exclamation mark). +{{{ + !NoLinkHere. + ![42] is not a link either. +}}} + +Display: + !NoLinkHere. + ![42] is not a link either. + + +=== Parameterized Trac links === + +The Trac links target Trac resources which have generally more than one way to be rendered, according to some extra parameters. For example, a Wiki page can accept a `version` or a `format` parameter, a report can make use of dynamic variables, etc. + +Any Trac links can support an arbitrary set of parameters, written in the same way as they would be for the corresponding URL. Some examples: + - `wiki:Today?format=txt` - wiki:Today?format=txt + - `wiki:Today?version=1` - wiki:Today?version=1 + - `[/newticket?component=module1 create a ticket for module1]` + + +== TracLinks Reference == +The following sections describe the individual link types in detail, as well as several notes advanced usage of links. + +=== attachment: links === + +The link syntax for attachments is as follows: + * !attachment:the_file.txt creates a link to the attachment the_file.txt of the current object + * !attachment:the_file.txt:wiki:MyPage creates a link to the attachment the_file.txt of the !MyPage wiki page + * !attachment:the_file.txt:ticket:753 creates a link to the attachment the_file.txt of the ticket 753 + +Note that the older way, putting the filename at the end, is still supported: !attachment:ticket:753:the_file.txt. + +If you'd like to create a direct link to the content of the attached file instead of a link to the attachment page, simply use `raw-attachment:` instead of `attachment:`. + +This can be useful for pointing directly to an HTML document, for example. Note that for this use case, you'd have to allow the web browser to render the content by setting `[attachment] render_unsafe_content = yes` (see TracIni#attachment-section). Caveat: only do that in environments for which you're 100% confident you can trust the people who are able to attach files, as otherwise this would open up your site to [wikipedia:Cross-site_scripting cross-site scripting] attacks. + +See also [#export:links]. + +=== comment: links === + +When you're inside a given tickets, you can simply write e.g. !comment:3 to link to the third change comment. +It's also possible to link to a comment of a specific ticket from anywhere using one of the following syntax: + - !comment:3:ticket:123 - comment:3:ticket:123 + - !ticket:123#comment:3 - ticket:123#comment:3 (note that you can't write !#123#!comment:3!) + +=== query: links === + +See TracQuery#UsingTracLinks and [#ticket:links]. + +=== search: links === + +See TracSearch#SearchLinks + +=== ticket: links === + +Besides the obvious `ticket:id` form, it is also possible to specify a list of tickets or even a range of tickets instead of the `id`. This generates a link to a custom query view containing this fixed set of tickets. + +Example: + - `ticket:5000-6000` - ticket:5000-6000 + - `ticket:1,150` - ticket:1,150 + +=== timeline: links === + +Links to the timeline can be created by specifying a date in the ISO:8601 format. The date can be optionally followed by a time specification. The time is interpreted as being UTC time, but alternatively you can specify your local time, followed by your timezone if you don't want to compute the UTC time. + +Examples: + - `timeline:2008-01-29` + - `timeline:2008-01-29T15:48` + - `timeline:2008-01-29T16:48Z+01` + +''(since Trac 0.11)'' + +=== wiki: links === + +See WikiPageNames and [#QuotingspaceinTracLinks quoting space in TracLinks] above. + +=== Version Control related links === +==== source: links ==== + +The default behavior for a source:/some/path link is to open the directory browser +if the path points to a directory and otherwise open the log view. + +It's also possible to link directly to a specific revision of a file like this: + - `source:/some/file@123` - source:/some/file@123 - link to the file's revision 123 + - `source:/some/file@head` - link explicitly to the latest revision of the file + +If the revision is specified, one can even link to a specific line number: + - `source:/some/file@123#L10` + - `source:/tag/0.10@head#L10` + +Finally, one can also highlight an arbitrary set of lines: + - `source:/some/file@123:10-20,100,103#L99` - source:/some/file@123:10-20,100,103#L99 - highlight lines 10 to 20, and lines 100 and 103. + +==== export: links ==== + +To force the download of a file in the repository, as opposed to displaying it in the browser, use the `export` link. Several forms are available: + * `export:/some/file` - get the HEAD revision of the specified file + * `export:123:/some/file` - get revision 123 of the specified file + * `export:/some/file@123` - get revision 123 of the specified file + +This can be very useful for displaying XML or HTML documentation with correct stylesheets and images, in case that has been checked in into the repository. Note that for this use case, you'd have to allow the web browser to render the content by setting `[browser] render_unsafe_content = yes` (see TracIni#browser-section), otherwise Trac will force the files to be downloaded as attachments for security concerns. + +If the path is to a directory in the repository instead of a specific file, the source browser will be used to display the directory (identical to the result of `source:/some/dir`). + +==== log: links ==== + +The `log:` links are used to display revision ranges. In its simplest form, it can link to the latest revisions from the specified path, but it can also support displaying an arbitrary set of revisions. + - `log:/` - log:/ - the latest revisions starting at the root of the repository + - `log:/trunk/tools` - the latest revisions in `trunk/tools` + - `log:/trunk/tools@10000` - the revisions in `trunk/tools` starting from revision 10000 + - `log:@20788,20791:20795` - list revision 20788 and the 20791 to 20795 revision range + - `log:/trunk/tools@20788,20791:20795` - list revision 20788 and the revisions from the 20791 to 20795 range which affect the given path + +There are short forms for revision ranges as well: + - `[20788,20791:20795]` + - `[20788,20791:20795/trunk/tools]` + - `r20791:20795` (but not `r20788,20791:20795` nor `r20791:20795/trunk`) + +Finally, note that in all of the above, a revision range can be written indifferently `x:y` or `x-y`. + +---- +See also: WikiFormatting, TracWiki, WikiPageNames, InterTrac, InterWiki + diff --git a/defaults/help/Plugins b/defaults/help/Plugins new file mode 100644 index 00000000..f315c144 --- /dev/null +++ b/defaults/help/Plugins @@ -0,0 +1,13 @@ += Plugins = + +mtrack has a simple plugin system that allows a plugin class to be loaded a configured using the [help:ConfigIni#plugins configuration file]. + +mtrack ships with the following plugins: + +||Name||Purpose|| +||[help:plugin/AuthHTTP MTrackAuth_HTTP]||Use HTTP authentication|| +||[help:plugin/CommitCheckNoEmpty MTrackCommitCheck_NoEmptyLogMessage]||Prevent commits with no log message|| +||[help:plugin/CommitCheckTimeRef MTrackCommitCheck_RequiresTimeReference]||Prevent commits that don't include time tracking information|| +||[help:plugin/Recaptcha MTrackCaptcha_Recaptcha]||Require recaptcha for submissions|| +||[help:plugin/OpenID MTrackAuth_OpenID]||Use OpenID for public authenticated access control|| + diff --git a/defaults/help/SSH b/defaults/help/SSH new file mode 100644 index 00000000..581d9485 --- /dev/null +++ b/defaults/help/SSH @@ -0,0 +1,127 @@ +'''AT THIS TIME I SUGGEST THAT YOU ONLY ENABLE THIS FOR SITES USING CONTROLLED HTTP AUTHENTICATION, NOT OPENID''' + + = Notes on setting up SSH with mtrack. = + +$MTRACK is the path to your mtrack installation. + +Create a user account using your system adduser or useradd tool. +If you're on OSX, you have to perform the creation manually (see below). + +The username you pick will be included in the repo URLs that your +contributors will use, so pick something appropriate. + +Make sure that the primary group of the user matches that of your webserver, +so that both the mtrack web application and the server side SCM tools can +both access the repositories. + +I've picked ''code'' as the username, and have set the home directory +to be in my mtrack instance vardir, $MTRACK/var/codehome. + + == OSX == + +On OSX: manually reate a user. Make sure that PrimaryGroupID matches your +webserver. + +{{{ +sudo -s +dscl . -create /Users/code +dscl . -create /Users/code UserShell $MTRACK/bin/codeshell +dscl . -create /Users/code RealName "SSH wrapper for mtrack" +dscl . -create /Users/code UniqueID 600 +dscl . -create /Users/code PrimaryGroupID 20 +dscl . -create /Users/code NFSHomeDirectory $MTRACK/var/codehome +dscl . -create /Users/code Password '*' +}}} + + == Other Unix == + +Make sure that you set the password to '*' so that regular password based +logins are not allowed for this user. Also make sure that you set the shell to +$MTRACK/bin/codeshell so that the possible set of commands is restricted to +just the configured SCM tools (hg, git, svn). + + == Next step == + +Depending on your system, you may need to create the home directory. +You will also need to create the .ssh directory. + +{{{ +mkdir -p $MTRACK/var/codehome/.ssh +chown code:staff $MTRACK/var/codehome +}}} + + == Mercurial Trust == + +The commit hooks won't operate for repos created by the web server when pushed +to over SSH, unless you tell Mercurial to trust the web server user. + +You can do this by creating an .hgrc in the home directory of your "code" user. +Here, "_www" is the username of my web server (OS/X). + +These are the contents of {{{$MTRACK/var/codehome/.hgrc}}}: + +{{{ +[trusted] +users = _www +}}} + + == Config File == + +There are two setting that need to be placed in your config.ini file. Both are +required. The first is the serverurl, which is the user@host which your users +will use to access your server. This should be the public name or IP of the +system. The second is the location of the authorized_keys2 file for your +"code" user. This must be the full path to the file. + +{{{ +[repos] +serverurl = "code@example.com" +authorized_keys2 = "/Users/wez/Sites/mtrack/var/codehome/.ssh/authorized_keys2" +}}} + +The mtrack repo browser will use the serverurl to display the command that will +be used to check out the code. For example, the following commands are used to +access the "wez/merc", "wez/git" and "wez/svn" repos, which were created in the +code browser as mercurial, git and subversion repositories, respectively. + +{{{ +$ hg clone ssh://code@example.com/wez/merc +$ git clone code@example.com:wez/git +$ svn checkout svn+ssh://code@example.com/wez/svn/BRANCHNAME +}}} + + == SSH Keys == + +Each user can supply their own SSH keys by clicking on their username and +then the "Edit my details" button. + +With SSH key(s) in the system, the next step is to configure the "code" user to see them. + +In your crontab, set up a job to run as the "code" user. This can run as frequently as you like--the longer the interval between runs, the longer it will take for modified SSH keys to take effect. + +{{{ +0,15,30,45 * * * * su code -c "php $MTRACK/bin/make-authorized-keys.php" >/dev/null 2>/dev/null +}}} + +This script will pull out the key information from the user data in the mtrack +database and generate an authorized_keys2 file that routes access via the +"codeshell" script. + +The effect of this is that your users will now be able to access your system +over SSH and will be able to run hg, git or svn in a mode that only allows them +to operate on repositories contained in var/repos. + + == On Security == + +How secure is this? At the time of writing, this configuration has the +following implications: + + * It creates a new user that accepts public-key authentication only over ssh + * Any authenticated mtrack user can add their ssh keys to the allowed set + * Any repos created by mtrack are thus accessible (read/write) to any authenticated mtrack user + +'''IMPORTANT''': if you have enabled OpenID login, this means that ANY entity +with an OpenID can add ssh keys and gain read/write access to all of the repos +created by mtrack, but not gain full shell access. + + diff --git a/defaults/help/Searching b/defaults/help/Searching new file mode 100644 index 00000000..f7b22d92 --- /dev/null +++ b/defaults/help/Searching @@ -0,0 +1,264 @@ += Searching mtrack = + +mtrack maintains a searchable index of the textual portions of tickets +and wiki pages, so that you can quickly find that elusive note when +you need it. + +== Search shortcuts == + +If you type one of the following special strings into the search box +and hit the search button, instead of searching, mtrack will redirect +you to the appropriate page: + + !#123:: + will take you to the ticket page for that numbered ticket + + +== Querying the search index == + +A search query may be broken up into a series of search terms and special +operators. + +=== Terms === + +A query is broken up into terms and operators. There are three types of terms: + + Single Term:: + is a single word such as "test" or "hello" + Phrase:: + is a group of words surrounded by double quotes such as "hello dolly". + Subquery:: + is a query surrounded by parentheses such as "(hello dolly)". + +Multiple terms can be combined together with boolean operators to form complex +queries. + +=== Fields === + +When performing a search you can either specify a field to query against, or +leave the field unspecified to query against all possible fields. + +You can search specific fields by entering the field name followed by a colon, followed by the term you are looking for. + +For example, if you want to search wiki content for the word "search" you might enter the following: + +{{{ + wiki:search +}}} + +If you are looking for a ticket with a particular summary and a specific word +in the description: + +{{{ + summary:"failed open" description:"file not found" +}}} + +Note that the following is not the same as the above, as it will only search +the summary field for the word "failed", the description field for the word +"file" and all the rest of the words will be searched against all of the +possible fields: + +{{{ + summary:failed open description:file not found +}}} + +==== Available fields ==== + +||Item||Field||Purpose|| +||Ticket||summary||The one-line ticket summary|| +||Ticket||description||The ticket description|| +||Ticket||changelog||The changelog field|| +||Wiki||wiki||The content of the wiki page|| + +=== Wildcards === + +You may use single and multiple character wildcard searches within single +terms, but not within phrase queries. + +To perform a single character wildcard search, use the "?" symbol. + +To perform a multiple character wildcard search, use the "*" symbol. + +The single character wildcard search looks for strings that match the term with the "?" replaced by any single character. For example, to search for "text" or "test" you can use the search: + +{{{ + te?t +}}} + +Multiple character wildcard searches look for 0 or more characters when +matching strings against terms. For example, to search test, tests or tester, +you can use the search: + +{{{ + test* +}}} + +You can use "?", "*" or both at any position of the term, but wildcard matches +require a non-wildcard prefix of at least 3 characters, otherwise the search +will not be allowed to continue. + +=== Fuzzy Searching === + +You may append the tilde "~" character to a search term to specify +that a fuzzy search be used, based on the Levenshtein Distance between +similar words. + +To search for a word similar in spelling to "roam": + +{{{ + roam~ +}}} + +The above will find terms like "foam" and "roams". + +Additional (optional) parameters can specify the required similarity, with +possible values being fractional numbers between 0 and 1. As this parameter +gets closer to 1, it increases the level of similarity required between the two +words before they will match. + +{{{ + roam~0.8 +}}} + +If you do not specify the fuzzy factor, the default value of {{{0.5}}} will be +used. + + +=== Range Searches === + +Range queries allow the developer or user to match field(s) whose values are +between an upper and lower bound, either inclusively or exclusively. Sorting +is performed lexicographically, and is not limited to numeric values. + +mtrack stores dates and times in the form {{{YYYY-MM-DDTHH:MM:SS}}} so that +they can be meaningfully compared in this fashion. + +To perform an inclusive range query: + +{{{ + updated:[2009-08-01 TO 2009-09-01] +}}} + +To perform an exclusive range query: + +{{{ + summary:{bug TO feature} +}}} + + +=== Proximity Searches === + +To find words from a phrase that are within a certain number of words apart +from each other in a document, you can append the tilde "~" character to the +end of the phrase. For example, to match text where the words "bug" and +"report" appear within 10 words of each other: + +{{{ + "bug report"~10 +}}} + + +=== Boosting a Term === + +The search results are returned based on the relevance of the match, as +computed by the search engine for the terms that it found. To boost the +relevance of a term you may use the caret "^" symbol followed by a boost factor +at the end of the term or subquery that you are searching. The higher the +boost factor, the more relevant the term will be and the higher ranking it will +have in the results when it matches: + +{{{ + "crash trace"^4 analysis +}}} + +=== Boolean Operators === + +Boolean operators allow terms to be combined through logic operators. If you +include multiple terms in your search, and do not specify a logic operator to +combine them, then the search engine assumes that you meant to use the "OR" +operator and will match documents that match any of your criteria. + +You may use parentheses to group terms together to construct complex criteria. + +The following operators are defined: + +==== AND ==== + +The AND operator means that all terms in the group must match some part of the +search field(s). + +{{{ + bug AND report +}}} + +{{{ + "stack trace" and valgrind +}}} + +You may use {{{&&}}} as a synonym for AND. + +==== OR ==== + +The OR operator divides the query into several optional terms. + +{{{ + bug or crash +}}} + +You may use {{{||}}} as a synonym for OR. + +==== NOT ==== + +The NOT operator excludes documents that contain the term after NOT. + +{{{ + bug and not crash +}}} + +You may use "!" as a synonym for NOT. + +==== + ==== #required + +The "+", or "required", operator stipulates that the term after the "+" symbol +must match the document. + +The following matches text that must contain the word "bug" and may contain the +word "report": + +{{{ + +bug report +}}} + +==== - ==== #prohibit + +The "-", or "prohibit", operator excludes documents that match the term after +the "-" symbol. + +This matches documents that may contain the word "bug" and that do not contain +the word "report": + +{{{ + bug -report +}}} + +=== Escaping Special Characters === + +The following characters are recognized as special characters by the search +engine, and must be escaped if you need to use them as part of your search +terms: + +{{{ + + - && || ! ( ) { } [ ] ^ " ~ * ? : \ +}}} + +The "+" and "-" characters are only special when they appear at the start or +end of a search term and do not need to be escaped when they appear in the +middle of a term. + +The backslash character {{{\}}} can be used to escape these special characters. +For example, if you intend to search for {{{(1+1):2}}}: + +{{{ + \(1\+1\)\:2 +}}} + diff --git a/defaults/help/TicketQuery b/defaults/help/TicketQuery new file mode 100644 index 00000000..fd30b8a3 --- /dev/null +++ b/defaults/help/TicketQuery @@ -0,0 +1,106 @@ += Trac Ticket Queries = +[[TracGuideToc]] + +In addition to [wiki:TracReports reports], Trac provides support for ''custom ticket queries'', used to display lists of tickets meeting a specified set of criteria. + +To configure and execute a custom query, switch to the ''View Tickets'' module from the navigation bar, and select the ''Custom Query'' link. + +== Filters == +When you first go to the query page the default filters will display all open tickets, or if you're logged in it will display open tickets assigned to you. Current filters can be removed by clicking the button to the right with the minus sign on the label. New filters are added from the pulldown list in the bottom-right corner of the filters box. Filters with either a text box or a pulldown menu of options can be added multiple times to perform an ''or'' of the criteria. + +You can use the fields just below the filters box to group the results based on a field, or display the full description for each ticket. + +Once you've edited your filters click the ''Update'' button to refresh your results. + +== Navigating Tickets == +Clicking on one of the query results will take you to that ticket. You can navigate through the results by clicking the ''Next Ticket'' or ''Previous Ticket'' links just below the main menu bar, or click the ''Back to Query'' link to return to the query page. + +You can safely edit any of the tickets and continue to navigate through the results using the ''Next/Previous/Back to Query'' links after saving your results. When you return to the query ''any tickets which were edited'' will be displayed with italicized text. If one of the tickets was edited such that [[html(it no longer matches the query criteria )]] the text will also be greyed. Lastly, if '''a new ticket matching the query criteria has been created''', it will be shown in bold. + +The query results can be refreshed and cleared of these status indicators by clicking the ''Update'' button again. + +== Saving Queries == + +While Trac does not yet allow saving a named query and somehow making it available in a navigable list, you can save references to queries in Wiki content, as described below. + +=== Using TracLinks === + +You may want to save some queries so that you can come back to them later. You can do this by making a link to the query from any Wiki page. +{{{ +[query:status=new|assigned|reopened&version=1.0 Active tickets against 1.0] +}}} + +Which is displayed as: + [query:status=new|assigned|reopened&version=1.0 Active tickets against 1.0] + +This uses a very simple query language to specify the criteria (see [wiki:TracQuery#QueryLanguage Query Language]). + +Alternatively, you can copy the query string of a query and paste that into the Wiki link, including the leading `?` character: +{{{ +[query:?status=new&status=assigned&status=reopened&group=owner Assigned tickets by owner] +}}} + +Which is displayed as: + [query:?status=new&status=assigned&status=reopened&group=owner Assigned tickets by owner] + +=== Using the `[[TicketQuery]]` Macro === + +The [trac:TicketQuery TicketQuery] macro lets you display lists of tickets matching certain criteria anywhere you can use WikiFormatting. + +Example: +{{{ +[[TicketQuery(version=0.6|0.7&resolution=duplicate)]] +}}} + +This is displayed as: + [[TicketQuery(version=0.6|0.7&resolution=duplicate)]] + +Just like the [wiki:TracQuery#UsingTracLinks query: wiki links], the parameter of this macro expects a query string formatted according to the rules of the simple [wiki:TracQuery#QueryLanguage ticket query language]. + +A more compact representation without the ticket summaries is also available: +{{{ +[[TicketQuery(version=0.6|0.7&resolution=duplicate, compact)]] +}}} + +This is displayed as: + [[TicketQuery(version=0.6|0.7&resolution=duplicate, compact)]] + +Finally if you wish to receive only the number of defects that match the query using the ``count`` parameter. + +{{{ +[[TicketQuery(version=0.6|0.7&resolution=duplicate, count)]] +}}} + +This is displayed as: + [[TicketQuery(version=0.6|0.7&resolution=duplicate, count)]] + +=== Customizing the ''table'' format === +You can also customize the columns displayed in the table format (''format=table'') by using ''col='' - you can specify multiple fields and what order they are displayed by placing pipes (`|`) between the columns like below: + +{{{ +[[TicketQuery(max=3,open=0,order=tid,desc=1,format=table,col=resolution|summary|owner)]] +}}} + +This is displayed as: +[[TicketQuery(max=3,open=0,order=tid,desc=1,format=table,col=resolution|summary|owner)]] + + +=== Query Language === + +`query:` TracLinks and the `[[TicketQuery]]` macro both use a mini “query language” for specifying query filters. Basically, the filters are separated by ampersands (`&`). Each filter then consists of the ticket field name, an operator, and one or more values. More than one value are separated by a pipe (`|`), meaning that the filter matches any of the values. + +The available operators are: +|| '''`=`''' || the field content exactly matches the one of the values || +|| '''`~=`''' || the field content contains one or more of the values || +|| '''`^=`''' || the field content starts with one of the values || +|| '''`$=`''' || the field content ends with one of the values || + +All of these operators can also be negated: +|| '''`!=`''' || the field content matches none of the values || +|| '''`!~=`''' || the field content does not contain any of the values || +|| '''`!^=`''' || the field content does not start with any of the values || +|| '''`!$=`''' || the field content does not end with any of the values || + + +---- +See also: TracTickets, TracReports, TracGuide diff --git a/defaults/help/TracReports b/defaults/help/TracReports new file mode 100644 index 00000000..4fbfcea2 --- /dev/null +++ b/defaults/help/TracReports @@ -0,0 +1,249 @@ += Trac Reports = +[[TracGuideToc]] + +The Trac reports module provides a simple, yet powerful reporting facility +to present information about tickets in the Trac database. + +Rather than have its own report definition format, TracReports relies on standard SQL +`SELECT` statements for custom report definition. + + '''Note:''' ''The report module is being phased out in its current form because it seriously limits the ability of the Trac team to make adjustments to the underlying database schema. We believe that the [wiki:TracQuery query module] is a good replacement that provides more flexibility and better usability. While there are certain reports that cannot yet be handled by the query module, we intend to further enhance it so that at some point the reports module can be completely removed. This also means that there will be no major enhancements to the report module anymore.'' + + ''You can already completely replace the reports module by the query module simply by disabling the former in [wiki:TracIni trac.ini]:'' + {{{ + [components] + trac.ticket.report.* = disabled + }}} + ''This will make the query module the default handler for the “View Tickets” navigation item. We encourage you to try this configuration and report back what kind of features of reports you are missing, if any.'' + + '''''You will almost definitely need to restart your httpd at this point.''''' + +A report consists of these basic parts: + * '''ID''' -- Unique (sequential) identifier + * '''Title''' -- Descriptive title + * '''Description''' -- A brief description of the report, in WikiFormatting text. + * '''Report Body''' -- List of results from report query, formatted according to the methods described below. + * '''Footer''' -- Links to alternative download formats for this report. + +== Changing Sort Order == +Simple reports - ungrouped reports to be specific - can be changed to be sorted by any column simply by clicking the column header. + +If a column header is a hyperlink (red), click the column you would like to sort by. Clicking the same header again reverses the order. + +== Changing Report Numbering == +There may be instances where you need to change the ID of the report, perhaps to organize the reports better. At present this requires changes to the trac database. The ''report'' table has the following schema (as of 0.10): + * id integer PRIMARY KEY + * author text + * title text + * query text + * description text +Changing the ID changes the shown order and number in the ''Available Reports'' list and the report's perma-link. This is done by running something like: +{{{ +update report set id=5 where id=3; +}}} +Keep in mind that the integrity has to be maintained (i.e., ID has to be unique, and you don't want to exceed the max, since that's managed by SQLite someplace). + +== Navigating Tickets == +Clicking on one of the report results will take you to that ticket. You can navigate through the results by clicking the ''Next Ticket'' or ''Previous Ticket'' links just below the main menu bar, or click the ''Back to Report'' link to return to the report page. + +You can safely edit any of the tickets and continue to navigate through the results using the Next/Previous/Back to Report links after saving your results, but when you return to the report, there will be no hint about what has changed, as would happen if you were navigating a list of tickets obtained from a query (see TracQuery#NavigatingTickets). ''(since 0.11)'' + +== Alternative Download Formats == +Aside from the default HTML view, reports can also be exported in a number of alternative formats. +At the bottom of the report page, you will find a list of available data formats. Click the desired link to +download the alternative report format. + +=== Comma-delimited - CSV (Comma Separated Values) === +Export the report as plain text, each row on its own line, columns separated by a single comma (','). +'''Note:''' Carriage returns, line feeds, and commas are stripped from column data to preserve the CSV structure. + +=== Tab-delimited === +Like above, but uses tabs (\t) instead of comma. + +=== RSS - XML Content Syndication === +All reports support syndication using XML/RSS 2.0. To subscribe to an RSS feed, click the orange 'XML' icon at the bottom of the page. See TracRss for general information on RSS support in Trac. + +---- + +== Creating Custom Reports == + +''Creating a custom report requires a comfortable knowledge of SQL.'' + +A report is basically a single named SQL query, executed and presented by +Trac. Reports can be viewed and created from a custom SQL expression directly +in from the web interface. + +Typically, a report consists of a SELECT-expression from the 'ticket' table, +using the available columns and sorting the way you want it. + +== Ticket columns == +The ''ticket'' table has the following columns: + * id + * type + * time + * changetime + * component + * severity + * priority + * owner + * reporter + * cc + * version + * milestone + * status + * resolution + * summary + * description + * keywords + +See TracTickets for a detailed description of the column fields. + +'''all active tickets, sorted by priority and time''' + +'''Example:''' ''All active tickets, sorted by priority and time'' +{{{ +SELECT id AS ticket, status, severity, priority, owner, + time as created, summary FROM ticket + WHERE status IN ('new', 'assigned', 'reopened') + ORDER BY priority, time +}}} + + +---- + + +== Advanced Reports: Dynamic Variables == +For more flexible reports, Trac supports the use of ''dynamic variables'' in report SQL statements. +In short, dynamic variables are ''special'' strings that are replaced by custom data before query execution. + +=== Using Variables in a Query === +The syntax for dynamic variables is simple, any upper case word beginning with '$' is considered a variable. + +Example: +{{{ +SELECT id AS ticket,summary FROM ticket WHERE priority=$PRIORITY +}}} + +To assign a value to $PRIORITY when viewing the report, you must define it as an argument in the report URL, leaving out the leading '$'. + +Example: +{{{ + http://trac.edgewall.org/reports/14?PRIORITY=high +}}} + +To use multiple variables, separate them with an '&'. + +Example: +{{{ + http://trac.edgewall.org/reports/14?PRIORITY=high&SEVERITY=critical +}}} + + +=== Special/Constant Variables === +There is one ''magic'' dynamic variable to allow practical reports, its value automatically set without having to change the URL. + + * $USER -- Username of logged in user. + +Example (''List all tickets assigned to me''): +{{{ +SELECT id AS ticket,summary FROM ticket WHERE owner=$USER +}}} + + +---- + + +== Advanced Reports: Custom Formatting == +Trac is also capable of more advanced reports, including custom layouts, +result grouping and user-defined CSS styles. To create such reports, we'll use +specialized SQL statements to control the output of the Trac report engine. + +== Special Columns == +To format reports, TracReports looks for 'magic' column names in the query +result. These 'magic' names are processed and affect the layout and style of the +final report. + +=== Automatically formatted columns === + * '''ticket''' -- Ticket ID number. Becomes a hyperlink to that ticket. + * '''created, modified, date, time''' -- Format cell as a date and/or time. + + * '''description''' -- Ticket description field, parsed through the wiki engine. + +'''Example:''' +{{{ +SELECT id as ticket, created, status, summary FROM ticket +}}} + +=== Custom formatting columns === +Columns whose names begin and end with 2 underscores (Example: '''`__color__`''') are +assumed to be ''formatting hints'', affecting the appearance of the row. + + * '''`__group__`''' -- Group results based on values in this column. Each group will have its own header and table. + * '''`__color__`''' -- Should be a numeric value ranging from 1 to 5 to select a pre-defined row color. Typically used to color rows by issue priority. +{{{ +#!html +
Defaults: +Color 1 +Color 2 +Color 3 +Color 4 +Color 5 +
+}}} + * '''`__style__`''' -- A custom CSS style expression to use for the current row. + +'''Example:''' ''List active tickets, grouped by milestone, colored by priority'' +{{{ +SELECT p.value AS __color__, + t.milestone AS __group__, + (CASE owner WHEN 'daniel' THEN 'font-weight: bold; background: red;' ELSE '' END) AS __style__, + t.id AS ticket, summary + FROM ticket t,enum p + WHERE t.status IN ('new', 'assigned', 'reopened') + AND p.name=t.priority AND p.type='priority' + ORDER BY t.milestone, p.value, t.severity, t.time +}}} + +'''Note:''' A table join is used to match ''ticket'' priorities with their +numeric representation from the ''enum'' table. + +=== Changing layout of report rows === +By default, all columns on each row are display on a single row in the HTML +report, possibly formatted according to the descriptions above. However, it's +also possible to create multi-line report entries. + + * '''`column_`''' -- ''Break row after this''. By appending an underscore ('_') to the column name, the remaining columns will be be continued on a second line. + + * '''`_column_`''' -- ''Full row''. By adding an underscore ('_') both at the beginning and the end of a column name, the data will be shown on a separate row. + + * '''`_column`''' -- ''Hide data''. Prepending an underscore ('_') to a column name instructs Trac to hide the contents from the HTML output. This is useful for information to be visible only if downloaded in other formats (like CSV or RSS/XML). + +'''Example:''' ''List active tickets, grouped by milestone, colored by priority, with description and multi-line layout'' + +{{{ +SELECT p.value AS __color__, + t.milestone AS __group__, + (CASE owner + WHEN 'daniel' THEN 'font-weight: bold; background: red;' + ELSE '' END) AS __style__, + t.id AS ticket, summary AS summary_, -- ## Break line here + component,version, severity, milestone, status, owner, + time AS created, changetime AS modified, -- ## Dates are formatted + description AS _description_, -- ## Uses a full row + changetime AS _changetime, reporter AS _reporter -- ## Hidden from HTML output + FROM ticket t,enum p + WHERE t.status IN ('new', 'assigned', 'reopened') + AND p.name=t.priority AND p.type='priority' + ORDER BY t.milestone, p.value, t.severity, t.time +}}} + +=== Reporting on custom fields === + +If you have added custom fields to your tickets (a feature since v0.8, see TracTicketsCustomFields), you can write a SQL query to cover them. You'll need to make a join on the ticket_custom table, but this isn't especially easy. + +If you have tickets in the database ''before'' you declare the extra fields in trac.ini, there will be no associated data in the ticket_custom table. To get around this, use SQL's "LEFT OUTER JOIN" clauses. See [trac:TracIniReportCustomFieldSample TracIniReportCustomFieldSample] for some examples. + +'''Note that you need to set up permissions in order to see the buttons for adding or editing reports.''' + +---- +See also: TracTickets, TracQuery, TracGuide, [http://www.sqlite.org/lang_expr.html Query Language Understood by SQLite] \ No newline at end of file diff --git a/defaults/help/WikiFormatting b/defaults/help/WikiFormatting new file mode 100644 index 00000000..92e7c714 --- /dev/null +++ b/defaults/help/WikiFormatting @@ -0,0 +1,403 @@ += !WikiFormatting = + +Wiki markup is a core feature, tightly integrating all the other parts of mtrack into a flexible and powerful whole. + +mtrack has a built in small and powerful wiki rendering engine. This wiki engine implements an ever growing subset of the commands from other popular Wikis, +especially [http://moinmoin.wikiwikiweb.de/ MoinMoin]. + + +This page demonstrates the formatting syntax available anywhere !WikiFormatting is allowed. + + +== Font Styles == + +The Trac wiki supports the following font styles: +{{{ + * '''bold''', '''!''' can be bold too''', and '''! ''' + * ''italic'' + * '''''bold italic''''' + * __underline__ + * {{{monospace}}} or `monospace` + * ~~strike-through~~ + * ^superscript^ + * ,,subscript,, +}}} + +Display: + * '''bold''', '''!''' can be bold too''', and '''! ''' + * ''italic'' + * '''''bold italic''''' + * __underline__ + * {{{monospace}}} or `monospace` + * ~~strike-through~~ + * ^superscript^ + * ,,subscript,, + +Notes: + * `{{{...}}}` and {{{`...`}}} commands not only select a monospace font, but also treat their content as verbatim text, meaning that no further wiki processing is done on this text. + * {{{ ! }}} tells wiki parser to not take the following characters as wiki format, so pay attention to put a space after !, e.g. when ending bold. + +== Headings == + +You can create heading by starting a line with one up to five ''equal'' characters ("=") +followed by a single space and the headline text. The line should end with a space +followed by the same number of ''='' characters. +The heading might optionally be followed by an explicit id. If not, an implicit but nevertheless readable id will be generated. + +Example: +{{{ += Heading = +== Subheading == +=== About ''this'' === +=== Explicit id === #using-explicit-id-in-heading +}}} + +Display: += Heading = +== Subheading == +=== About ''this'' === +=== Explicit id === #using-explicit-id-in-heading + +== Paragraphs == + +A new text paragraph is created whenever two blocks of text are separated by one or more empty lines. + +A forced line break can also be inserted, using: +{{{ +Line 1[[BR]]Line 2 +}}} +Display: + +Line 1[[BR]]Line 2 + + +== Lists == + +The wiki supports both ordered/numbered and unordered lists. + +Example: +{{{ + * Item 1 + * Item 1.1 + * Item 1.1.1 + * Item 1.1.2 + * Item 1.1.3 + * Item 1.2 + * Item 2 + + 1. Item 1 + a. Item 1.a + a. Item 1.b + i. Item 1.b.i + i. Item 1.b.ii + 1. Item 2 +And numbered lists can also be given an explicit number: + 3. Item 3 +}}} + +Display: + * Item 1 + * Item 1.1 + * Item 1.1.1 + * Item 1.1.2 + * Item 1.1.3 + * Item 1.2 + * Item 2 + + 1. Item 1 + a. Item 1.a + a. Item 1.b + i. Item 1.b.i + i. Item 1.b.ii + 1. Item 2 +And numbered lists can also be given an explicit number: + 3. Item 3 + +Note that there must be one or more spaces preceding the list item markers, otherwise the list will be treated as a normal paragraph. + + +== Definition Lists == + + +The wiki also supports definition lists. + +Example: +{{{ + llama:: + some kind of mammal, with hair + ppython:: + some kind of reptile, without hair + (can you spot the typo?) +}}} + +Display: + llama:: + some kind of mammal, with hair + ppython:: + some kind of reptile, without hair + (can you spot the typo?) + +Note that you need a space in front of the defined term. + + +== Preformatted Text == + +Block containing preformatted text are suitable for source code snippets, notes and examples. Use three ''curly braces'' wrapped around the text to define a block quote. The curly braces need to be on a separate line. + +Example: +{{{ + {{{ + def HelloWorld(): + print "Hello World" + }}} +}}} + +Display: +{{{ + def HelloWorld(): + print "Hello World" +}}} + + +== Blockquotes == + +In order to mark a paragraph as blockquote, indent that paragraph with two spaces. + +Example: +{{{ + This text is a quote from someone else. +}}} + +Display: + This text is a quote from someone else. + +== Discussion Citations == + +To delineate a citation in an ongoing discussion thread, such as the ticket comment area, e-mail-like citation marks (">", ">>", etc.) may be used. + +Example: +{{{ +>> Someone's original text +> Someone else's reply text +My reply text +}}} + +Display: +>> Someone's original text +> Someone else's reply text +My reply text + +''Note: Some !WikiFormatting elements, such as lists and preformatted text, are lost in the citation area. Some reformatting may be necessary to create a clear citation.'' + +== Tables == + +Simple tables can be created like this: +{{{ +||Cell 1||Cell 2||Cell 3|| +||Cell 4||Cell 5||Cell 6|| +}}} + +Display: +||Cell 1||Cell 2||Cell 3|| +||Cell 4||Cell 5||Cell 6|| + +== Links == + +Hyperlinks are automatically created for WikiPageNames and URLs. !WikiPageLinks can be disabled by prepending an exclamation mark "!" character, such as {{{!WikiPageLink}}}. + +Example: +{{{ + TitleIndex, http://www.edgewall.com/, !NotAlink +}}} + +Display: + TitleIndex, http://www.edgewall.com/, !NotAlink + +Links can be given a more descriptive title by writing the link followed by a space and a title and all this inside square brackets. If the descriptive title is omitted, then the explicit prefix is discarded, unless the link is an external link. This can be useful for wiki pages not adhering to the WikiPageNames convention. + +Example: +{{{ + * [http://www.edgewall.com/ Edgewall Software] + * [wiki:TitleIndex Title Index] + * [wiki:ISO9000] +}}} + +Display: + * [http://www.edgewall.com/ Edgewall Software] + * [wiki:TitleIndex Title Index] + * [wiki:ISO9000] + +== Trac Links == + +Wiki pages can link directly to other parts of the Trac system. Pages can refer to tickets, reports, changesets, milestones, source files and other Wiki pages using the following notations: +{{{ + * Tickets: #1 or ticket:1 + * Reports: {1} or report:1 + * Changesets: r1, [1] or changeset:1 + * ... +}}} + +Display: + * Tickets: #1 or ticket:1 + * Reports: {1} or report:1 + * Changesets: r1, [1] or changeset:1 + * ... + +There are many more flavors of Trac links, see TracLinks for more in-depth information. + + +== Escaping Links and WikiPageNames == + +You may avoid making hyperlinks out of TracLinks by preceding an expression with a single "!" (exclamation mark). + +Example: +{{{ + !NoHyperLink + !#42 is not a link +}}} + +Display: + !NoHyperLink + !#42 is not a link + + +{{{ +#!comment +== Images == + +Urls ending with `.png`, `.gif` or `.jpg` are no longer automatically interpreted as image links, and converted to `` tags. + +You now have to use the ![[Image]] macro. The simplest way to include an image is to upload it as attachment to the current page, and put the filename in a macro call like `[[Image(picture.gif)]]`. + +In addition to the current page, it is possible to refer to other resources: + * `[[Image(wiki:WikiFormatting:picture.gif)]]` (referring to attachment on another page) + * `[[Image(ticket:1:picture.gif)]]` (file attached to a ticket) + * `[[Image(htdocs:picture.gif)]]` (referring to a file inside project htdocs) + * `[[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]]` (a file in repository) + +Example display: [[Image(htdocs:../common/trac_logo_mini.png)]] + + +See WikiMacros for further documentation on the `[[Image()]]` macro. + +}}} + +== Macros == + +Macros are ''custom functions'' to insert dynamic content in a page. + +Example: +{{{ + [[RecentChanges(Trac,3)]] +}}} + +Display: + [[RecentChanges(Trac,3)]] + +See WikiMacros for more information, and a list of installed macros. + + +== Processors == + +Trac supports alternative markup formats using WikiProcessors. For example, processors are used to write pages in HTML. + +Example 1: +{{{ +#!html +
{{{
+#!html
+<h1 style="text-align: right; color: blue">HTML Test</h1>
+}}}
+}}} + +Display: +{{{ +#!html +

HTML Test

+}}} + +Example: +{{{ +#!html +
{{{
+#!python
+class Test:
+
+    def __init__(self):
+        print "Hello World"
+if __name__ == '__main__':
+   Test()
+}}}
+}}} + +Display: +{{{ +#!python +class Test: + def __init__(self): + print "Hello World" +if __name__ == '__main__': + Test() +}}} + +Perl: +{{{ +#!perl +my ($test) = 0; +if ($test > 0) { + print "hello"; +} +}}} + +See WikiProcessors for more information. + + +== Comments == + +Comments can be added to the plain text. These will not be rendered and will not display in any other format than plain text. +{{{ +{{{ +#!comment +Your comment here +}}} +}}} + +== Data output from SQL command line utilities == + +If you have text that you want to copy and paste from a command line utility, +such as psql, then you can enclose it in the ''dataset'' processor: + +{{{ +{{{ +#!dataset + current_query | procpid | usename | client_addr | elapsed +--------------------------------------+---------+---------+--------------+----------------- + SELECT * FROM build_mailing(59508) | 6595 | user | 10.16.40.80 | 00:04:24.377262 + FETCH NEXT FROM "" | 27597 | user | 10.16.40.80 | 00:00:44.208982 + commit | 19188 | user | 10.16.40.67 | 00:00:00.013402 + COMMIT | 26390 | user | 10.16.1.56 | 00:00:00.007778 +}}} +}}} + +{{{ +#!dataset + current_query | procpid | usename | client_addr | elapsed +--------------------------------------+---------+---------+--------------+----------------- + SELECT * FROM build_mailing(59508) | 6595 | user | 10.16.40.80 | 00:04:24.377262 + FETCH NEXT FROM "" | 27597 | user | 10.16.40.80 | 00:00:44.208982 + commit | 19188 | user | 10.16.40.67 | 00:00:00.013402 + COMMIT | 26390 | user | 10.16.1.56 | 00:00:00.007778 +}}} + +== Miscellaneous == + +Four or more dashes will be replaced by a horizontal line (
) + +Example: +{{{ + ---- +}}} + +Display: +---- + diff --git a/defaults/help/bin/Init b/defaults/help/bin/Init new file mode 100644 index 00000000..33405c4b --- /dev/null +++ b/defaults/help/bin/Init @@ -0,0 +1,136 @@ += bin/init.php = + +This script is used to initialize a new mtrack instance. If you want to modify +an existing mtrack instance, you should try to use the administration +interface, or use [help:bin/Modify bin/modify.php] instead as ''init.php'' will +exit when you attempt to use it on an already initialized mtrack instance. + +== Synopsis == + +{{{ +% cd $MTRACK +% php bin/init.php ...parameters... +}}} + +== Parameters == + +=== --disable-index === #disable-index + +Disables full-text index generation during setup (affects Trac import). + +=== --repo !{name} !{type} !{repopath} === #repo + +Defines a source repository named !{name} of type !{type} that can be found on +the local filesystem at !{repopath}. + +Supported repository types are ''hg'' for Mercurial and ''svn'' for Subversion. + +You will typically also want to use {{{--link}}} to associate the repository to +a project. + +=== --link !{project} !{repo} !{location} === #link + +Defines a link between the project identified by short name !{project} and the +repository named !{name} by the source location identified by the regex +!{location}. + +To associate the entire repository with a project you would use a simple +{{{/}}} as the !{location} parameter: + +{{{ +% php bin/init.php --repo myrepo svn /path/to/repo \ + --link myproject myrepo / +}}} + +To have changes made under "trunk/docs" be associated with the doc project, and all others be associated with myproject: + +{{{ +% php bin/init.php --repo myrepo svn /path/to/repo \ + --link myproject myrepo / \ + --link doc myrepo /trunk/docs/ +}}} + +=== --trac !{project} !{tracenv} === #trac + +Imports data from a the trac environment on the local filesystem at !{tracenv}, and associate it with the project named !{project}. + +!{tracenv} is the same environment path that you would use when running the +trac admin command line utility. + +mtrack can only be used to import SQLite based Trac instances at this time. + +You may import multiple trac environments; the first one will be imported in +as-is, but subsequent trac environments will be imported with some changes to +avoid the possibility of collisions between ticket numbers and wiki pages. + +Subsequent trac imports will prefix ticket numbers with the project name, so +instead of {{{#123}}}, if you import it to a project named {{{mc}}}, the ticket +will be {{{#mc123}}}. Similarly, the wiki pages will be adjusted to live under +a directory named after the project, so you would end up with +{{{mc/WikiStart}}} for the main wiki page from that trac instance. + +The import will skip Trac wiki pages that contain Trac specific docs (even if +you modified them in your trac instance); mtrack prefers to keep its own +documentation out of your wiki history so that it doesn't clutter up what is +important to you, and also updates automatically when you update mtrack. + +Another note about the wiki import is that mtrack stores wiki pages in a +repository. If you used hierarchical wiki page names (in other words, have the +{{{/}}} character in the page names) these are mapped to directories in the +repository. If you created a page named {{{Foo}}} and a page named +{{{Foo/Bar}}} you have a collision between the {{{Foo}}} page and the directory +named {{{Foo}}} that contains the page named {{{Bar}}}. The import resolves +this collision by renaming the {{{Foo}}} page to {{{FooPage}}}. + +=== --vardir !{dir} === #vardir + +Where to store the database, attachments and search engine state. + +If not specified, defaults to a directory named {{{var}}} in the mtrack +directory. + +This location, whether specified explicitly or not, will be created if it does +not already exist. + +=== --config-file !{filename} === #config-file + +Where to create the configuration file. + +If not specified, defaults to {{{config.ini}}} in the mtrack directory. + +=== --author-alias !{filename} === #author-alias + +where to find an authors file that maps usernames. This is used to initialize +the canonicalizations used by the system. The format is a file of the form: + +{{{ +sourcename=canonicalname +}}} + +The import will replace all instances of sourcename with canonicalname in the +history, and will record the mapping so that future items will respect it. + +You do not need to use this option if you have only a single repository, or if +you have never changed usernames for any of your contributors. + +=== --author-info !{filename} === #author-info + +Where to find a file that will be used to initialize the userinfo table. The +format is: + +{{{ +canonid,fullname,email,active,timezone +}}} + +where canonid is the canonical username. + +for example: + +{{{ +wez,Wez Furlong,wez.spam@netevil.org,1,EST +}}} + +The ''active'' flag indicates whether the account is eligible to be assigned as +a responsible user when changing tickets. + + diff --git a/defaults/help/bin/Modify b/defaults/help/bin/Modify new file mode 100644 index 00000000..98349d00 --- /dev/null +++ b/defaults/help/bin/Modify @@ -0,0 +1,45 @@ += bin/modify.php = + +This script can be used to modify an existing mtrack instance. You should try +to use the administration interface where possible. + +== Synopsis == + +{{{ +% cd $MTRACK +% php bin/modify.php ...parameters... +}}} + +== Parameters == + +=== --repo !{name} !{type} !{repopath} === #repo + +Adds a source repository. This works in the same way as the +[help:bin/Init#repo repo option for bin/init.php]. + +=== --link !{project} !{repo} !{location} === #link + +Defines a link between the project identified by short name !{project} and the +repository named !{name} by the source location identified by the regex +!{location}. + +This works in the same way as the [help:bin/Init#link link option for bin/init.php]. + +=== --trac !{project} !{tracenv} === #trac + +Imports data from a the trac environment on the local filesystem at !{tracenv}, and associate it with the project named !{project}. + +This works in the same way as the +[help:bin/Init#trac trac option for bin/init.php], +''except that the trac imports will always be treated as +secondary instances''. This means that the tickets and wiki pages will all be +prefixed with the project name. + +=== --config-file !{filename} === #config-file + +Where to find the pre-existing configuration file. + +If not specified, defaults to {{{config.ini}}} in the mtrack directory. + + + diff --git a/defaults/help/plugin/AuthHTTP b/defaults/help/plugin/AuthHTTP new file mode 100644 index 00000000..fbf627f0 --- /dev/null +++ b/defaults/help/plugin/AuthHTTP @@ -0,0 +1,50 @@ += MTrackAuth_HTTP = + +By default, mtrack considers every user accessing the application via the web +server as an anonymous user. Enabling this plugin will cause mtrack to either +recognize the HTTP authentication employed by your web server, or in the case +where the web server does not have authentication enabled, causes mtrack to +initiate HTTP authentication for itself. + +== configuration == + +The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]: + +{{{ +[plugins] +MTrackAuth_HTTP = /var/tmp/repos/svn.htgroup, /var/tmp/repos/svn.htpasswd +}}} + +The first parameter is the path to an Apache style group file and the second is +the path to an Apache style password file. + +== Basic vs Digest authentication == + +If your web server is not configured to perform authentication, mtrack will +initiate it for itself. You have the option is implementing Basic or Digest +authentication. Basic is more widely supported but should be used in +conjunction with SSL or other network level security so that the password +cannot be snooped. Digest authentication does not transmit the password in +clear text so there is no risk of the password being snooped in the same way as +Basic auth. + +If you choose to use Basic authentication, it should be noted that mtrack +supports only crypt based password encoding at this time. + +=== Enabling Digest authentication === + +By default, mtrack uses Basic authentication. To use Digest authentication you +need to create a digest password file instead of the regular password file and +then tell mtrack to use digest: by prefixing the password file path with +{{{digest:}}} + +{{{ +[plugins] +MTrackAuth_HTTP = /var/tmp/repos/svn.htgroup, digest:/var/tmp/repos/svn.htpasswd +}}} + += Groups = + +On successful authentication, the groups file is read and the groups of the +authenticated user are used to determine what rights the user has. + diff --git a/defaults/help/plugin/CommitCheckNoEmpty b/defaults/help/plugin/CommitCheckNoEmpty new file mode 100644 index 00000000..014bb4f0 --- /dev/null +++ b/defaults/help/plugin/CommitCheckNoEmpty @@ -0,0 +1,15 @@ += MTrackCommitCheck_NoEmptyLogMessage = + +When this plugin is enabled, it prevents commits from taking place if the +commit has an empty log message. + +This restriction will only apply to repositories that have been configured to +use the mtrack pre-commit hook. + +== configuration == + +{{{ +[plugins] +MTrackCommitCheck_NoEmptyLogMessage = +}}} + diff --git a/defaults/help/plugin/CommitCheckTimeRef b/defaults/help/plugin/CommitCheckTimeRef new file mode 100644 index 00000000..b7edad15 --- /dev/null +++ b/defaults/help/plugin/CommitCheckTimeRef @@ -0,0 +1,26 @@ += MTrackCommitCheck_RequiresTimeReference = + +When this plugin is enabled, it prevents commits from taking place if the +commit does not reference a ticket and include time tracking information. + +An example of the time reference is shown below, which adds 3.5 hours of effort +to ticket #123: + +{{{ +Compensate for the foo issue; it was hard to track down. +refs #123 (spent 3.5) +}}} + +If the commit does not reference a ticket, it will be denied. + +The log message may reference multiple tickets; this plugin does not require +that every referenced ticket have an effort associated with it, so long as at +least one ticket has effort tracked, the commit will be allowed. + +== configuration == + +{{{ +[plugins] +MTrackCommitCheck_RequiresTimeReference = +}}} + diff --git a/defaults/help/plugin/OpenID b/defaults/help/plugin/OpenID new file mode 100644 index 00000000..98250138 --- /dev/null +++ b/defaults/help/plugin/OpenID @@ -0,0 +1,56 @@ += MTrackAuth_OpenID = + +When used in a public facing environment, where you desire to have external +users contributing to your project in terms of bug reports or wiki content, you +may want to enable OpenID as an authentication mechanism. + +This allows users to access your site without having to request credentials and remember passwords and such for your site. + +The OpenID implementation in mtrack classes users as anonymous until they +either explicitly log in or arrive at a page that throws a privilege exception. +If a privilege exception is raised while the user is anonymous, they will be +redirected to the OpenID login page to authenticate. + +mtrack will not automatically create a local user record for OpenID users as +they log in. This is for security purposes; even though the authentication is +outsourced, the mechanism does not prevent the user from sending erroneous +information as part of the sign in, and this could potentially be used to +hijack a pre-existing local user record. + +The impact of this is that an OpenID user can comment and contribute to the +wiki (assuming that permissions are set accordingly; by default the OpenID user +will be classified as an authenticated user class and thus have rights to the +wiki and tickets), and their contributions will be attributed to their OpenID +identity URL. + +To establish a local user identity for the OpenID user, an admin user can edit the OpenID user by clicking on their name in the UI. When the edits are saved, the user will become a local user. + +If the OpenID user is also a contributor to the code via the SCM, the admin +user can add an alias for that user. For example, the user "wez" is a code +contributor and also has the OpenID identity URL "http://netevil.org/". The +recommended approach for configuring mtrack is to edit the "wez" user details +and add an alias for "http://netevil.org/". Now, when "wez" logs in via OpenID +he will be recognized as "wez" throughout the system, rather then +"http://netevil.org/" because "wez" is the canonical identifier for that user. + +Because OpenID is not a guarantee that the user is trustworthy, you may also want to consider [help:plugin/Recaptcha enabling captcha support]. + +== configuration == + +The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]: + +{{{ +[plugins] +MTrackAuth_OpenID = +}}} + +You may also assign user_classes to OpenID URLs; for instance, the following +configuration gives Wez Furlong admin rights to your mtrack instance: + +{{{ +[user_classes] +http://netevil.org/ = admin +}}} + +Note that the trailing slash character in the URL is significant. + diff --git a/defaults/help/plugin/Recaptcha b/defaults/help/plugin/Recaptcha new file mode 100644 index 00000000..ad9e6d80 --- /dev/null +++ b/defaults/help/plugin/Recaptcha @@ -0,0 +1,23 @@ += MTrackCaptcha_Recaptcha = + +When used in a public facing environment, in order to reduce automated spam, +you may want to enable a CAPTCHA. Mtrack has an API that allows different +captcha implementations to be used, and ships with support for the reCaptcha +service. + +== configuration == + +The plugin is loaded by adding a line like the following to your [help:ConfigIni config.ini]: + +{{{ +[plugins] +MTrackCaptcha_Recaptcha = publickey, privatekey, userclass +}}} + +The first parameter is your publickey key and the second is your privatekey. +You can obtain keys from [http://recaptcha.net/api/getkey?app=mtrack recaptcha.net]. + +The userclass parameter indicates which classes (separated by a pipe character) +of user should have the captcha applied. The default value is +{{{anonymous|authenticated}}} which means that everyone except for admin users +will be presented with a captcha. diff --git a/defaults/reports/ActiveTickets b/defaults/reports/ActiveTickets new file mode 100644 index 00000000..2b18192f --- /dev/null +++ b/defaults/reports/ActiveTickets @@ -0,0 +1,34 @@ +SELECT + pri.value AS __color__, + (case when t.nsident is null then t.tid else t.nsident end) as ticket, + summary, + (select mtrack_group_concat(name) from + ticket_components tcm + left join components c on (tcm.compid = c.compid) + where tcm.tid = t.tid) as component, + (select mtrack_group_concat(name) from + ticket_milestones tm + left join milestones m on (tm.mid = m.mid) + where + tm.tid = t.tid + ) as milestone, + classification as type, + severity, + creat.changedate as created +FROM + tickets t + left join priorities pri on (t.priority = pri.priorityname) + left join severities sev on (t.severity = sev.sevname) + left join changes creat on (t.created = creat.cid) +WHERE + t.status in ('new', 'assigned', 'reopened') +ORDER BY + pri.value, sev.ordinal, + t.created + + += Active Tickets = + + * List all active tickets by priority. + * Color each row based on priority. + diff --git a/defaults/reports/Mine b/defaults/reports/Mine new file mode 100644 index 00000000..7bd92137 --- /dev/null +++ b/defaults/reports/Mine @@ -0,0 +1,49 @@ +SELECT + pri.value as __color__, + (case when t.nsident is null then t.tid else t.nsident end) as ticket, + summary, + classification as type, + (select mtrack_group_concat(name) from + ticket_components tcm + left join components c on (tcm.compid = c.compid) + where tcm.tid = t.tid) as component, + (select min(duedate) from + ticket_milestones tm + left join milestones m on (tm.mid = m.mid) + where + tm.tid = t.tid + and m.duedate is not null + ) as due, + (select mtrack_group_concat(name) from + ticket_milestones tm + left join milestones m on (tm.mid = m.mid) + where + tm.tid = t.tid + ) as milestone, + severity, + priority, + estimated - spent as remaining +FROM + tickets t + left join priorities pri on (t.priority = pri.priorityname) + left join severities sev on (t.severity = sev.sevname) +WHERE + t.status <> 'closed' + AND owner = $USER +ORDER BY + case when (select count(duedate) from + ticket_milestones tm + left join milestones m on (tm.mid = m.mid) + where tm.tid = t.tid and m.duedate is not null) > 0 then 1 else 0 end, + pri.value, sev.ordinal, + due, + t.created + + += My tickets = + +This report shows tickets assigned to the logged-in user with the highest +priority items listed first. + +This report is run as part of the [wiki:Today] page, and any changes made here +will affect that page. diff --git a/defaults/wiki/Today b/defaults/wiki/Today new file mode 100644 index 00000000..6081b800 --- /dev/null +++ b/defaults/wiki/Today @@ -0,0 +1,10 @@ += Today = + +Welcome to the "Today" page. It shows you the most pertinent tasks for the day. + +[[RunReport(Mine)]] + +'''Want to change this page?''' + +You can edit the content at [wiki:Today], and the ticket list at {Mine}. + diff --git a/defaults/wiki/WikiStart b/defaults/wiki/WikiStart new file mode 100644 index 00000000..4753d4a6 --- /dev/null +++ b/defaults/wiki/WikiStart @@ -0,0 +1,6 @@ +{{{ +#!comment +You may safely delete everything here and replace it at your convenience. The +introductory text is available as a help page +}}} +[[IncludeHelpPage(Introduction)]] diff --git a/inc/UUID.php b/inc/UUID.php new file mode 100644 index 00000000..bcdb05e2 --- /dev/null +++ b/inc/UUID.php @@ -0,0 +1,158 @@ +binary = pack('H*', $src); + break; + case 16: /* binary string */ + $this->binary = $src; + break; + case 24: /* base64 encoded binary */ + $this->binary = base64_decode( + str_replace( + array('@', '-', '_'), + array('=', '/', '+'), + $src)); + break; + default: + $this->binary = null; + } + } else { + $this->generate(); + } + } + + /** + * returns a 32-bit integer that identifies this host. + * The node identifier needs to be unique among nodes + * in a cluster for a given application in order to + * avoid collisions between generated identifiers. + * You may extend and override this method if you + * want to substitute an alternative means of determining + * the node identifier */ + protected function getNodeId() { + if (isset($_SERVER['SERVER_ADDR'])) { + $node = ip2long($_SERVER['SERVER_ADDR']); + } else { + /* running from the CLI most likely; + * inspect the environment to see if we can + * deduce the hostname, and from there, the + * IP address */ + static $names = array('HOSTNAME', 'HOST'); + foreach ($names as $name) { + if (isset($_ENV[$name])) { + $host = $_ENV[$name]; + } else { + $host = getenv($name); + } + if (strlen($host)) break; + } + if (!strlen($host)) { + // punt + $node = ip2long('127.0.0.1'); + } else { + $ip = gethostbyname($host); + if (strlen($ip)) { + $node = ip2long($ip); + } else { + // punt + $node = crc32($host); + } + } + } + return $node; + } + + /** + * returns a process identifier. + * In multi-process servers, this should be the system process ID. + * In multi-threaded servers, this should be some unique ID to + * prevent two threads from generating precisely the same UUID + * at the same time. + */ + protected function getLockId() { + if (function_exists('zend_thread_id')) { + return zend_thread_id(); + } + return getmypid(); + } + + /** + * generate an RFC 4122 UUID. + * This is psuedo-random UUID influenced by the system clock, IP + * address and process ID. + * + * The intended use is to generate an identifier that can uniquely + * identify user generated posts, comments etc. made to a website. + * This generation process should be sufficient to avoid collisions + * between nodes in a cluster, and between apache children on the + * same host. + * + */ + function generate() { + $node = $this->getNodeId(); + $pid = $this->getLockId(); + + list($time_mid, $time_lo) = explode(' ', microtime()); + $time_lo = (int)$time_lo; + $time_mid = (int)substr($time_mid, 2); + + $time_hi = mt_rand(0, 0xfff); + /* version 4 UUID */ + $time_hi |= 0x4000; + + $clock_lo = mt_rand(0, 0xff); + $node_lo = $pid; + + /* type is psuedo-random */ + $clock_hi = mt_rand(0, 0x3f); + $clock_hi |= 0x80; + + $this->binary = pack('NnnCCnN', + $time_lo, $time_mid & 0xffff, $time_hi, + $clock_hi, $clock_lo, $node_lo, $node); + } + + /** + * render the UUID as an RFC4122 standard string representation + * of the binary bits. + */ + function toRFC4122String($dashes = true) { + $uuid = unpack('Ntl/ntm/nth/Cch/Ccl/nnl/Nn', $this->binary); + $fmt = $dashes ? + "%08x-%04x-%04x-%02x%02x-%04x%08x" : + "%08x%04x%04x%02x%02x%04x%08x"; + return sprintf($fmt, + $uuid['tl'], $uuid['tm'], $uuid['th'], + $uuid['ch'], $uuid['cl'], $uuid['nl'], $uuid['n']); + } + + /** + * render the UUID using a modified base64 representation + * of the binary bits. This string is shorter than the standard + * representation, but is not part of any standard specification. + */ + function toShortString() { + return str_replace( + array('=', '/', '+'), + array('@', '-', '_'), + base64_encode($this->binary)); + } + +} + +?> diff --git a/inc/acl.php b/inc/acl.php new file mode 100644 index 00000000..f56adf44 --- /dev/null +++ b/inc/acl.php @@ -0,0 +1,596 @@ +rights = $rights; + } +} + +/* Each object in the system has an identifier, like 'ticket:XYZ' that + * indicates the type of object as well as its own identifier. + * + * Each object may also have a discressionary access control list (DACL) that + * describes what actions members of particular roles are permitted to + * the object. The DACL can apply either to the object itself, or be + * a cascading (or inherited) entry that applies only to objects that are + * children of the object in question. + * + * When determining whether access is permitted, the DACL is walked from + * the object being accessed up to the root. As soon as the allow/deny + * status us known for a specific (role, action) combination, the search + * stops. + * + * DACL entries can be explicitly ordered so that a particular user from + * a group can be excepted from a blanket allow/deny rule that follows. + */ + +class MTrackACL { + static $cache = array(); + + static public function addRootObjectAndRoles($name) { + /* construct some roles that encapsulate read, modify, write */ + $rolebase = preg_replace('/s$/', '', $name); + + $ents = array( + array("{$rolebase}Viewer", "read", true), + array("{$rolebase}Editor", "read", true), + array("{$rolebase}Editor", "modify", true), + array("{$rolebase}Creator", "read", true), + array("{$rolebase}Creator", "modify", true), + array("{$rolebase}Creator", "create", true), + array("{$rolebase}Creator", "delete", true), + ); + MTrackACL::setACL($name, true, $ents); + $ents = array( + array("{$rolebase}Viewer", "read", true), + array("{$rolebase}Editor", "read", true), + array("{$rolebase}Creator", "read", true), + array("{$rolebase}Creator", "modify", true), + array("{$rolebase}Creator", "create", true), + array("{$rolebase}Creator", "delete", true), + ); + MTrackACL::setACL($name, false, $ents); + } + + /* functions that we can call to determine ancestry */ + static $genealogist = array(); + static public function registerAncestry($objtype, $func) { + self::$genealogist[$objtype] = $func; + } + + /* returns the objectid path that leads from the root to the specified + * object, including the object itself as the last element */ + static public function getParentPath($objectid, $steps = -1) + { + $path = array(); + while (strlen($objectid)) { + if ($steps != -1 && $steps-- == 0) { + break; + } + $path[] = $objectid; + if (isset(self::$genealogist[$objectid])) { + $func = self::$genealogist[$objectid]; + if (is_string($func)) { + $parent = $func; + } else { + $parent = call_user_func($func, $objectid); + } + if (!$parent) break; + $objectid = $parent; + continue; + } + if (preg_match("/^(.*):([^:]+)$/", $objectid, $M)) { + $class = $M[1]; + if (isset(self::$genealogist[$class])) { + $func = self::$genealogist[$class]; + if (is_string($func)) { + $parent = $func; + } else { + $parent = call_user_func($func, $objectid); + } + if (!$parent) break; + $objectid = $parent; + continue; + } + $objectid = $class; + continue; + } + break; + } + return $path; + } + + /* computes the overall ACL as it applies to someone that belongs to the + * indicated set of roles. */ + static public function computeACL($objectid, $role_list) + { + $key = $objectid . '~' . join('~', $role_list); + + if (isset(self::$cache[$key])) { + return self::$cache[$key]; + } + + /* we calculate the path to the object from its parent, and pull + * out all ACL entries on those objects that match the provided + * role list, ordering from the object up to the root. + */ + + $rlist = array(); + $db = MTrackDB::get(); + foreach ($role_list as $r => $rname) { + $rlist[] = $db->quote($r); + } + // Always want the special wildcard 'everybody' entry + $rlist[] = $db->quote('*'); + $role_list = join(',', $rlist); + + $actions = array(); + + $oidlist = array(); + $path = self::getParentPath($objectid); + foreach ($path as $oid) { + $oidlist[] = $db->quote($oid); + } + $oidlist = join(',', $oidlist); + + $sql = <<fetchAll(PDO::FETCH_ASSOC) as $row) { + $res_by_oid[$row['id']][] = $row; + } + foreach ($path as $oid) { + if (!isset($res_by_oid[$oid])) continue; + foreach ($res_by_oid[$oid] as $row) { + + if ($row['id'] == $objectid && $row['cascade']) { + /* ignore items below the object of interest */ + continue; + } + + if (!isset($actions[$row['action']])) { + $actions[$row['action']] = $row['allow'] ? true : false; + } + } + } + + self::$cache[$key] = $actions; + + return $actions; + } + + /* Entries is an array of [role, action, allow] tuples in the order + * that they should be checked. + * If cascade is true, then these entries will replace the + * inheritable set, otherwise they will replace the entries + * on the object. + * If entries is an empty array, or not an array, then the appropriate + * ACL will be removed. + */ + static public function setACL($object, $cascade, $entries) + { + self::$cache = array(); + + $cascade = (int)$cascade; + MTrackDB::q('delete from acl where objectid = ? and cascade = ?', + $object, $cascade); + $seq = 0; + if (is_array($entries)) { + foreach ($entries as $ent) { + if (isset($ent['role'])) { + $role = $ent['role']; + $action = $ent['action']; + $allow = $ent['allow']; + } else { + list($role, $action, $allow) = $ent; + } + MTrackDB::q('insert into acl (objectid, cascade, seq, role, + action, allow) values (?, ?, ?, ?, ?, ?)', + $object, $cascade, $seq++, + $role, $action, (int)$allow); + } + } + } + + /* Obtains the ACL entries for the specified object. + * If cascade is true, it will return the inheritable ACL. + */ + static public function getACL($object, $cascade) + { + return MTrackDB::q('select role, action, allow from acl + where objectid = ? and cascade = ? order by seq', + $object, (int)$cascade)->fetchAll(PDO::FETCH_ASSOC); + } + + static public function hasAllRights($object, $rights) + { + if (MTrackAuth::getUserClass() == 'admin') { + return true; + } + if (!is_array($rights)) { + $rights = array($rights); + } + if (!count($rights)) { + throw new Exception("can't have all of no rights"); + } + $acl = self::computeACL($object, MTrackAuth::getGroups()); +# echo "ACL: $object
"; +# var_dump($rights); +# echo "
"; +# var_dump($acl); +# echo "
"; + + foreach ($rights as $action) { + if (!isset($acl[$action]) || !$acl[$action]) { + return false; + } + } + return true; + } + + static public function hasAnyRights($object, $rights) + { + if (MTrackAuth::getUserClass() == 'admin') { + return true; + } + if (!is_array($rights)) { + $rights = array($rights); + } + if (!count($rights)) { + throw new Exception("can't have any of no rights"); + } + $acl = self::computeACL($object, MTrackAuth::getGroups()); + + $ok = false; + foreach ($rights as $action) { + if (isset($acl[$action]) && $acl[$action]) { + $ok = true; + } + } + return $ok; + } + + static public function requireAnyRights($object, $rights) + { + if (!self::hasAnyRights($object, $rights)) { + throw new MTrackAuthorizationException("Not authorized", $rights); + } + } + + static public function requireAllRights($object, $rights) + { + if (!self::hasAllRights($object, $rights)) { + throw new MTrackAuthorizationException("Not authorized", $rights); + } + } + + /* helper for generating an ACL editor. + * As parameters, takes an objectid indicating the object being edited, + * and an action map which breaks down tasks into groups. + * Each group consists of a set of permissions, starting with the least + * permissive in that group, through to most permissive. + * Each group will be rendered as a select box, and a synthetic "none" + * entry will be generated for the group that explicitly excludes each + * of the other permission levels in that group. + * + * The form element that is generated will contain a JSON representation + * of an "ents" array that can be passed to setACL(). + */ + static public function renderACLForm($formprefix, $objectid, $map) { + $ident = preg_replace("/[^a-zA-Z]/", '', $formprefix); + $entities = array(); + $groups = MTrackAuth::enumGroups(); + /* merge in users */ + foreach (MTrackDB::q('select userid, fullname from userinfo where active = 1') + ->fetchAll() as $row) { + if (isset($groups[$row[0]])) continue; + if (strlen($row[1])) { + $disp = "$row[0] - $row[1]"; + } else { + $disp = $row[0]; + } + $groups[$row[0]] = $disp; + } + if (!isset($groups['*'])) { + $groups['*'] = '(Everybody)'; + } + + // Encode the map into an object + $mobj = new stdClass; + + $reng = array(); + $rank = array(); + + foreach ($map as $group => $actions) { + // Each subsequent action in a group implies access greater than + // the item that preceeds it + + $all_perms = array_keys($actions); + $prohibit = array(); + foreach ($all_perms as $p) { + $prohibit[$p] = "-$p"; + } + $none = join('|', $prohibit); + $a = array(); + $a[] = array($none, 'None'); + $accum = array(); + $i = 0; + foreach ($actions as $perm => $label) { + $accum[] = $perm; + unset($prohibit[$perm]); + $p = join('|', array_merge($accum, $prohibit)); + $a[] = array($p, $label); + /* save this for reverse engineering the right group in the current + * ACL data */ + $reng[$perm] = $group; + $rank[$group][$perm] = $i++; + } + $mobj->{$group} = $a; + } + $mobj = json_encode($mobj); + + $roledefs = new stdclass; + $acl = self::getACL($objectid, 0); + foreach ($acl as $ent) { + $group = $reng[$ent['action']]; + $act = ($ent['allow'] ? '' : '-') . $ent['action']; + $roledefs->{$ent['role']}->{$group}[] = $act; + + if (!isset($groups[$ent['role']])) { + $groups[$ent['role']] = $ent['role']; + } + } + $roledefs = json_encode($roledefs); + + /* let's figure out the inherited ACL */ + $path = self::getParentPath($objectid, 2); + $inherited = new stdclass; + if (count($path) == 2) { + $pacl = self::getACL($path[1], 1); + foreach ($pacl as $ent) { + // Not relevant per the specified action map + if (!isset($reng[$ent['action']])) continue; + + $group = $reng[$ent['action']]; + $act = ($ent['allow'] ? '' : '-') . $ent['action']; + $inherited->{$ent['role']}->{$group}[] = $act; + + if (!isset($groups[$ent['role']])) { + $groups[$ent['role']] = $ent['role']; + } + } + + // Inheritable set may not be specified directly in + // the same terms as the action_map, so we need to infer it + // Example: we may have read|modify leaving delete unspecified. + // We treat this as read|modify|-delete + foreach ($inherited as $role => $agroups) { + foreach ($agroups as $group => $actions) { + $highest = null; + foreach ($actions as $act) { + if ($act[0] == '-') continue; + if ($highest === null || $rank[$group][$act] > $highest) { + $highest = $rank[$group][$act]; + $hact = $act; + } + } + if ($highest === null) { + unset($inherited->{$role}->{$group}); + continue; + } + // Compute full value + $comp = array(); + foreach ($rank[$group] as $act => $i) { + if ($i <= $highest) { + $comp[] = $act; + } else { + $comp[] = "-$act"; + } + } + $inherited->{$role}->{$group} = join('|', $comp); + } + } + } + $inherited = json_encode($inherited); + + //var_dump($acl); + + $groups = json_encode($groups); + $cat_order = json_encode(array_keys($map)); + + echo << +

+ Permissions +

+

+ Select "Add" to define permissions for an entity. + The first matching permission is taken as definitive, + so if a given user belongs to multiple groups and matches + multiple permission rows, the first is taken. You may + drag to re-order permissions. + +

+

+ Permissions inherited from the parent of this object are + shown as non-editable entries at the top of the list. You may + override them by adding your own explicit entry. +

+
+ + + + + + + + +
Entity
+ + +HTML; + + } +} + diff --git a/inc/attachment.php b/inc/attachment.php new file mode 100644 index 00000000..bca1fc4a --- /dev/null +++ b/inc/attachment.php @@ -0,0 +1,213 @@ +prepare( + 'insert into attachments (object, hash, filename, size, cid, payload) + values (?, ?, ?, ?, ?, ?)'); + $q->bindValue(1, $object); + $q->bindValue(2, $hash); + $q->bindValue(3, $filename); + $q->bindValue(4, $size); + $q->bindValue(5, $CS->cid); + $q->bindValue(6, $fp, PDO::PARAM_LOB); + $q->execute(); + $CS->add("$object:@attachment:", '', $filename); + } + + static function process_delete($relobj, MTrackChangeset $CS) { + if (!isset($_POST['delete_attachment'])) return; + if (!is_array($_POST['delete_attachment'])) return; + foreach ($_POST['delete_attachment'] as $name) { + $vars = explode('/', $name); + $filename = array_pop($vars); + $cid = array_pop($vars); + $object = join('/', $vars); + + if ($object != $relobj) return; + MTrackDB::q('delete from attachments where object = ? and + cid = ? and filename = ?', $object, $cid, $filename); + $CS->add("$object:@attachment:", $filename, ''); + } + } + + /* this function is registered into sqlite and invoked from + * a trigger whenever an attachment row is deleted */ + static function attachment_row_deleted($hash, $count) + { + if ($count == 0) { + // unlink the underlying file here + unlink(self::local_path($hash, false)); + } + return $count; + } + + static function hash_file($filename) + { + return sha1_file($filename); + } + + static function local_path($hash, $fetch = true) + { + $adir = MTrackConfig::get('core', 'vardir') . '/attach'; + + /* 40 hex digits: split into 16, 16, 4, 4 */ + $a = substr($hash, 0, 16); + $b = substr($hash, 16, 16); + $c = substr($hash, 32, 4); + $d = substr($hash, 36, 4); + + $dir = "$adir/$a/$b/$c"; + if (!is_dir($dir)) { + $mask = umask(0); + mkdir($dir, 02777, true); + umask($mask); + } + $filename = $dir . "/$d"; + + if ($fetch) { + // Tricky locking bit + $fp = @fopen($filename, 'c+'); + flock($fp, LOCK_EX); + $st = fstat($fp); + if ($st['size'] == 0) { + /* we get to fill it out */ + + $db = MTrackDB::get(); + $q = $db->prepare( + 'select payload from attachments where hash = ?'); + $q->execute(array($hash)); + $q->bindColumn(1, $blob, PDO::PARAM_LOB); + $q->fetch(); + if (is_string($blob)) { + fwrite($fp, $blob); + } else { + stream_copy_to_stream($blob, $fp); + } + rewind($fp); + } + } + + return $filename; + } + + /* calculates the hash of the filename. If another file with + * the same hash does not already exist in the attachment area, + * the file is copied in. + * Returns the hash */ + static function import_file($filename) + { + $h = self::hash_file($filename); + $dest = self::local_path($h, false); + if (!file_exists($dest)) { + if (is_uploaded_file($filename)) { + move_uploaded_file($filename, $dest); + } else if (!is_file($filename)) { + throw new Exception("$filename does not exist"); + } else { + copy($filename, $dest); + } + } + return $h; + } + + static function renderDeleteList($object) + { + global $ABSWEB; + + $atts = MTrackDB::q(' + select * from attachments + left join changes on (attachments.cid = changes.cid) + where attachments.object = ? order by changedate, filename', + $object)->fetchAll(PDO::FETCH_ASSOC); + + if (count($atts) == 0) return ''; + + $max_dim = 150; + + $html = <<Select the checkbox to delete an attachment + + + + + + + +HTML; + + foreach ($atts as $row) { + $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]"; + $html .= << + + + + \n"; + } + $html .= "
 AttachmentSizeAdded
$row[filename]$row[size] +HTML; + $html .= mtrack_username($row['who'], array( + 'no_image' => true + )) . + " " . mtrack_date($row['changedate']) . "

"; + return $html; + } + + /* renders the attachment list for a given object */ + static function renderList($object) + { + global $ABSWEB; + + $atts = MTrackDB::q(' + select * from attachments + left join changes on (attachments.cid = changes.cid) + where attachments.object = ? order by changedate, filename', + $object)->fetchAll(PDO::FETCH_ASSOC); + + if (count($atts) == 0) return ''; + + $max_dim = 150; + + $html = "
Attachments
    "; + foreach ($atts as $row) { + $url = "{$ABSWEB}attachment.php/$object/$row[cid]/$row[filename]"; + $html .= "
  • ". + "$row[filename] ($row[size]) added by " . + mtrack_username($row['who'], array( + 'no_image' => true + )) . + " " . mtrack_date($row['changedate']); + + list($width, $height) = getimagesize(self::local_path($row['hash'])); + if ($width + $height) { + /* limit maximum size */ + if ($width > $max_dim) { + $height *= $max_dim / $width; + $width = $max_dim; + } + if ($height > $max_dim) { + $width *= $max_dim / $height; + $height = $max_dim; + } + $html .= "
    "; + } + + $html .= "
  • \n"; + } + $html .= "
"; + return $html; + } +} diff --git a/inc/auth.php b/inc/auth.php new file mode 100644 index 00000000..749f41e8 --- /dev/null +++ b/inc/auth.php @@ -0,0 +1,312 @@ +authenticate(); + if ($name !== null) { + return $name; + } + } + + /* always fall back on the unix username when running from + * the console */ + if (php_sapi_name() == 'cli') { + static $envs = array('MTRACK_LOGNAME', 'LOGNAME', 'USER'); + foreach ($envs as $name) { + if (isset($_ENV[$name])) { + return $_ENV[$name]; + } + } + } elseif (count(self::$mechs) == 0 && + MTrackConfig::get('core', 'admin_party') == 1 + && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || + $_SERVER['REMOTE_ADDR'] == '::1')) { + return 'adminparty'; + } + + return null; + } + + public static function isAuthConfigured() { + return count(self::$mechs) ? true : false; + } + + /** determine the current identity. If doauth is true (default), + * then the authentication hook will be invoked */ + public static function whoami($doauth = true) { + if (count(self::$stack) == 0 && $doauth) { + try { + $who = self::authenticate(); + if ($who === null) { + foreach (self::$mechs as $mech) { + $who = $mech->doAuthenticate(); + if ($who !== null) { + break; + } + } + } + if ($who !== null) { + self::su($who); + } + } catch (Exception $e) { + if (php_sapi_name() != 'cli') { + header('HTTP/1.0 401 Unauthorized'); + echo "

Not authorized

"; + echo htmlentities($e->getMessage()); + } else { + echo " ** Not authorized\n\n"; + echo $e->getMessage() . "\n"; + } + error_log($e->getMessage()); + exit(1); + } + } + if (!count(self::$stack)) { + return "anonymous"; + } + return self::$stack[0]; + } + + static function getUserClass($user = null) { + if ($user === null) { + $user = self::whoami(); + } + if (MTrackConfig::get('core', 'admin_party') == 1 + && $user == 'adminparty' + && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || + $_SERVER['REMOTE_ADDR'] == '::1')) { + return 'admin'; + } + + $user_class = MTrackConfig::get('user_classes', $user); + if ($user_class === null) { + if ($user == 'anonymous') { + return 'anonymous'; + } + return 'authenticated'; + } + return $user_class; + } + + static $userdata_cache = array(); + static function getUserData($username) { + $username = mtrack_canon_username($username); + + if (array_key_exists($username, self::$userdata_cache)) { + return self::$userdata_cache[$username]; + } + $data = null; + foreach (self::$mechs as $mech) { + $data = $mech->getUserData($username); + if ($data !== null) { + break; + } + } + if ($data === null) { + foreach (MTrackDB::q( + 'select fullname, email from userinfo where userid = ?', + $username)->fetchAll(PDO::FETCH_ASSOC) as $row) { + $data = $row; + break; + } + } + if ($data === null) { + $data = array( + 'fullname' => $username + ); + } + + if (!isset($data['email'])) { + if (preg_match('/<([a-z0-9_.+=-]+@[a-z0-9.-]+)>/', $username, $M)) { + // username contains an email address + $data['email'] = $M[1]; + } else if (preg_match('/^([a-z0-9_.+=-]+@[a-z0-9.-]+)$/', $username)) { + // username is an email address + $data['email'] = $username; + } else if (preg_match('/^[a-z0-9_.+=-]+$/', $username)) { + // valid localpart; assume a domain and construct an email address + $dom = MTrackConfig::get('core', 'default_email_domain'); + if ($dom !== null) { + $data['email'] = $username . '@' . $dom; + } + } + } + + self::$userdata_cache[$username] = $data; + + return $data; + } + + /* enumerates possible groups from the auth plugin layer */ + static function enumGroups() { + $groups = array(); + foreach (self::$mechs as $mech) { + $g = $mech->enumGroups(); + if (is_array($g)) { + foreach ($g as $i => $grp) { + if (is_integer($i)) { + $groups[$grp] = $grp; + } else { + $groups[$i] = $grp; + } + } + } + } + /* merge in our project groups */ + foreach (MTrackDB::q('select project, g.name, p.name from groups g left join projects p on g.project = p.projid') + as $row) { + $gid = "project:$row[0]:$row[1]"; + $groups[$gid] = "$row[1] ($row[2])"; + } + return $groups; + } + + /* returns groups of which the authenticated user is a member */ + static function getGroups($user = null) { + if ($user === null) { + $user = self::whoami(); + } + $canon = mtrack_canon_username($user); + + if (isset(self::$group_assoc[$user])) { + return self::$group_assoc[$user]; + } + + $roles = array($canon => $canon); + + $user_class = self::getUserClass($user); // FIXME: $canon? + $class_roles = MTrackConfig::get('user_class_roles', $user_class); + foreach (preg_split('/\s*,\s*/', $class_roles) as $role) { + $roles[$role] = $role; + } + + foreach (self::$mechs as $mech) { + $g = $mech->getGroups($user); + if (is_array($g)) { + foreach ($g as $i => $grp) { + if (is_integer($i)) { + $roles[$grp] = $grp; + } else { + $roles[$i] = $grp; + } + } + } + } + /* merge in our project group membership */ + foreach (MTrackDB::q('select project, groupname, p.name from group_membership gm left join projects p on gm.project = p.projid where username = ?', + $canon)->fetchAll() as $row) { + $gid = "project:$row[0]:$row[1]"; + $roles[$gid] = "$row[1] ($row[2])"; + } + + self::$group_assoc[$user] = $roles; + return $roles; + } + + static function forceAuthenticate() { + try { + $who = self::authenticate(); + if ($who === null) { + foreach (self::$mechs as $mech) { + $who = $mech->doAuthenticate(true); + if ($who !== null) { + break; + } + } + } + if ($who !== null) { + self::su($who); + } + } catch (Exception $e) { + } + } +} + diff --git a/inc/auth/http.php b/inc/auth/http.php new file mode 100644 index 00000000..0fbcd6b1 --- /dev/null +++ b/inc/auth/http.php @@ -0,0 +1,329 @@ +htgroup = $group; + if ($passwd !== null) { + if (!strncmp('digest:', $passwd, 7)) { + $this->use_digest = true; + $passwd = substr($passwd, 7); + } + $this->htpasswd = $passwd; + } + MTrackAuth::registerMech($this); + } + + function parseDigest($string) + { + $resp = trim($string); + $DIG = array(); + while (strlen($resp)) { + if (!preg_match('/^([a-z-]+)\s*=\s*(.*)$/', $resp, $M)) { +# error_log("unable to parse $string [$resp]"); + return null; + } + $name = $M[1]; + $param = null; + + $rest = $M[2]; + + if ($rest[0] == '"' || $rest[0] == "'") { + $delim = $rest[0]; + $delim_offset = 1; + } else { + $delim = ','; + $delim_offset = 0; + } + $len = strlen($rest); + $i = $delim_offset; + while ($i < $len) { + if ($delim != ',' && $rest[$i] == '\\') { + $i += 2; + if ($i >= $len) { +# error_log("unable to parse $string (unterminated quotes)"); + return null; + } + continue; + } + if ($rest[$i] == $delim) { + $param = substr($rest, $delim_offset, $i - $delim_offset); + $resp = substr($rest, $i + 1); + break; + } + $i++; + } + if ($param === null && $delim != ',') { +# error_log("unable to parse $string, unterminated delim $delim"); + return null; + } + if ($param === null) { + $param = $rest; + $resp = ''; + } + $DIG[$name] = $param; + + if (preg_match('/^,\s*(.*)$/', $resp, $M)) { + $resp = $M[1]; + } + $resp = trim($resp); + } + return $DIG; + } + + /* Leave authentication to the web server configuration */ + function authenticate() { + /* web server based auth */ + if (isset($_SERVER['REMOTE_USER'])) { + return $_SERVER['REMOTE_USER']; + } + + /* PHP based auth */ + if (($this->use_digest && isset($_SERVER['PHP_AUTH_DIGEST'])) || + (!$this->use_digest && isset($_SERVER['PHP_AUTH_USER']))) + { + /* validate the password */ + if ($this->use_digest) { + /* parse the digest response */ + + $DIG = $this->parseDigest($_SERVER['PHP_AUTH_DIGEST']); + + if ($DIG['nc'] != '00000001') { + // only allow a nonce-count of 1 + return null; + } + if ($DIG['realm'] != $this->realm) { + return null; + } + $secret = $this->getSecret(); + global $ABSWEB; + $domain = $ABSWEB; + $opaque = sha1($domain . $secret); + + if ($DIG['opaque'] != $opaque) { + // secret expired + error_log("secret expired"); + return null; + } + + $user = $DIG['username']; + + } else { + $user = $_SERVER['PHP_AUTH_USER']; + } + + if (!strlen($user)) { + return null; + } + + if ($this->htpasswd === null) { + error_log("no password file defined, unable to validate $user"); + return null; + } + + $fp = fopen($this->htpasswd, 'r'); + if (!$fp) { + error_log("unable to open password file to validate user $user"); + return null; + } + + if (!flock($fp, LOCK_SH)) { + error_log("unable to lock password file to validate user $user"); + return null; + } + + $puser = preg_quote($user); + $correct_password = null; + + while (true) { + $line = fgets($fp); + if ($line === false) { + $user = false; + break; + } + + if ($this->use_digest) { + if (preg_match("/^$puser:(.*):(.*)$/", $line, $M)) { + if ($M[1] != $this->realm) { + continue; + } + // $M[2] is: md5($user . ":" . $realm . ":" . $pw) + $expect = $M[2]; + $uri = md5($_SERVER['REQUEST_METHOD'] . ':' . $DIG['uri']); + $resp = md5("$expect:$DIG[nonce]:$DIG[nc]:$DIG[cnonce]:$DIG[qop]:$uri"); + if ($resp != $DIG['response']) { + /* invalid */ + $user = null; + } + break; + } + } else { + if (preg_match("/^$puser\s*:\s*(\S+)/", $line, $M)) { + if (crypt($_SERVER['PHP_AUTH_PW'], $M[1]) != $M[1]) { + /* invalid */ + $user = null; + } + break; + } + } + } + flock($fp, LOCK_UN); + $fp = null; + + return $user; + } + + return null; + } + + function getSecret() { + $secret_file = MTrackConfig::get('core', 'vardir') . '/.digest.secret'; + if (file_exists($secret_file)) { + if (filemtime($secret_file) + 300 > time()) { + $res = file_get_contents($secret_file); + if ($res === false) { + error_log( + "Unable to read HTTP secret for mtrack; logins will likely fail"); + } + return $res; + } + unlink($secret_file); + } + $secret = uniqid(); + if (!file_put_contents($secret_file, $secret)) { + error_log( + "Unable to write HTTP secret for mtrack; logins will likely fail"); + } + return $secret; + } + + function doAuthenticate($force = false) { + /* This is only triggered if the web server isn't configured + * to handle auth itself */ + + $realm = $this->realm; + + if ($this->use_digest) { + $secret = $this->getSecret(); + $nonce = sha1(uniqid() . $secret); + global $ABSWEB; + $domain = $ABSWEB; + $opaque = sha1($domain . $secret); + header("WWW-Authenticate: Digest realm=\"$realm\",qop=\"auth\",nonce=\"$nonce\",opaque=\"$opaque\""); + } else { + header("WWW-Authenticate: Basic realm=\"$realm\""); + } + header('HTTP/1.0 401 Unauthorized'); + +?> +

Authentication Required

+ +

I need to know who you are to allow you to access to this site.

+htgroup)) { + list($groups, $users) = $this->readGroupFile($this->htgroup); + return array_keys($groups); + } + return null; + } + + function getGroups($username) { + if (strlen($this->htgroup)) { + list($groups, $users) = $this->readGroupFile($this->htgroup); + return $users[$username]; + } + return null; + } + + function addToGroup($username, $groupname) + { + return null; + } + + function removeFromGroup($username, $groupname) + { + return null; + } + + function getUserData($username) { + return null; + } + + /** a bit of a hack; this helper enables the HTTP password to be set + * by the user admin screen */ + function setUserPassword($username, $password) { + if (!$this->use_digest) { + throw new Exception("not supported"); + } + $pwline = "mtrack:" . + md5("$username:mtrack:" . $password); + $fp = fopen($this->htpasswd, 'r+'); + if (!$fp && !file_exists($this->htpasswd)) { + $fp = fopen($this->htpasswd, 'w'); + } + if (!$fp) { + throw new Exception("failed to write to $this->htpasswd"); + } + flock($fp, LOCK_EX); + $lines = array(); + while (($line = fgets($fp)) !== false) { + $bits = explode(':', $line, 2); + if (count($bits) >= 2) { + $lines[$bits[0]] = $bits[1]; + } + } + $lines[$username] = $pwline; + fseek($fp, 0); + ftruncate($fp, 0); + foreach ($lines as $user => $rest) { + fwrite($fp, "$user:$rest\n"); + } + flock($fp, LOCK_UN); + $fp = null; + } +} + diff --git a/inc/auth/openid.php b/inc/auth/openid.php new file mode 100644 index 00000000..69a8d4d7 --- /dev/null +++ b/inc/auth/openid.php @@ -0,0 +1,66 @@ +Log off"; + } else { + $content = "Log In"; + } + } + + function augmentNavigation($id, &$items) { + } + + function authenticate() { + if (!strlen(session_id()) && php_sapi_name() != 'cli') { + session_start(); + } + if (isset($_SESSION['openid.id'])) { + if (isset($_SESSION['openid.userid'])) { + return $_SESSION['openid.userid']; + } + return $_SESSION['openid.id']; + } + return null; + } + + function doAuthenticate($force = false) { + if ($force) { + global $ABSWEB; + header("Location: {$ABSWEB}openid.php"); + exit; + } + return null; + } + + function enumGroups() { + return null; + } + + function getGroups($username) { + return null; + } + + function addToGroup($username, $groupname) { + return null; + } + + function removeFromGroup($username, $groupname) { + return null; + } + + function getUserData($username) { + return null; + } +} + + diff --git a/inc/cache.php b/inc/cache.php new file mode 100644 index 00000000..b6dd4c7f --- /dev/null +++ b/inc/cache.php @@ -0,0 +1,155 @@ + $element) { + if ($data->key[$i] != $element) { + $match = false; + break; + } + } + if ($match) { + unlink("$cachedir/$name"); + } + } +} + +function mtrack_cache_blow($key) +{ + $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache'; + foreach (scandir($cachedir) as $name) { + if (!is_file($name)) { + continue; + } + $fp = @fopen("$cachedir/$name", 'r'); + if (!$fp) { + continue; + } + flock($fp, LOCK_SH); + $data = unserialize(stream_get_contents($fp)); + flock($fp, LOCK_UN); + $fp = null; + + if ($key == $data->key) { + unlink("$cachedir/$name"); + } + } +} + +function mtrack_cache($func, $args, $cache_life = 300, $key = null) +{ + $cachedir = MTrackConfig::get('core', 'vardir') . '/cmdcache'; + if (!is_dir($cachedir)) { + mkdir($cachedir); + } + if ($key === null) { + $fkey = var_export($args, true); + $key = $fkey; + } else { + $fkey = var_export($key, true); + } + if (is_string($func)) { + $fkey = "$func$fkey"; + } else { + $fkey = var_export($func, true) . $fkey; + } + + $cachefile = $cachedir . '/' . sha1($fkey); + + $updating = false; + for ($i = 0; $i < 10; $i++) { + $fp = @fopen($cachefile, 'r+'); + if ($fp) { + flock($fp, LOCK_SH); + /* is it current? */ + $st = fstat($fp); + if ($st['size'] == 0) { + /* not valid to have 0 size; we're likely racing with the + * creator */ + flock($fp, LOCK_UN); + $fp = null; + usleep(100); + continue; + } + if ($st['mtime'] + $cache_life < time()) { + /* no longer current; we'll make it current */ + $updating = true; + flock($fp, LOCK_EX); + /* we have exclusive access; someone else may have + * made it current in the meantime */ + $st = fstat($fp); + if ($st['mtime'] + $cache_life >= time()) { + $updating = false; + } + } + break; + } + /* we're going to create it */ + $fp = @fopen($cachefile, 'x+'); + if ($fp) { + flock($fp, LOCK_EX); + $updating = true; + break; + } + } + + if ($fp) { + if ($updating) { + ftruncate($fp, 0); + + $result = call_user_func_array($func, $args); + $data = new stdclass; + $data->key = $key; + $data->res = $result; + fwrite($fp, serialize($data)); + flock($fp, LOCK_UN); + return $result; + } + + $data = unserialize(stream_get_contents($fp)); + flock($fp, LOCK_UN); + return $data->res; + } + /* if we didn't get a file pointer, just run the command */ + return call_user_func_array($func, $args); +} + diff --git a/inc/captcha.php b/inc/captcha.php new file mode 100644 index 00000000..9d74912d --- /dev/null +++ b/inc/captcha.php @@ -0,0 +1,111 @@ +emit($form); + } + return ''; + } + + static function check($form) + { + if (self::$impl !== null) { + return self::$impl->check($form); + } + return true; + } +} + +class MTrackCaptcha_Recaptcha implements IMTrackCaptchImplementation { + public $errcode = null; + public $pub; + public $priv; + public $userclass; + + function __construct($pub, $priv, $userclass = 'anonymous|authenticated') { + $this->pub = $pub; + $this->priv = $priv; + $this->userclass = explode("|", $userclass); + MTrackCaptcha::register($this); + } + + function emit($form) + { + $class = MTrackAuth::getUserClass(); + if (!in_array($class, $this->userclass)) { + return ''; + } + $pub = $this->pub; + $err = $this->errcode === null ? '' : "&error=$this->errcode"; + return << + +HTML; + } + + function check($form) + { + $class = MTrackAuth::getUserClass(); + if (!in_array($class, $this->userclass)) { + return true; + } + if (empty($_POST['recaptcha_challenge_field']) or + empty($_POST['recaptcha_response_field'])) { + return array('false', 'incorrect-captcha-sol'); + } + + $data = http_build_query(array( + 'privatekey' => $this->priv, + 'remoteip' => $_SERVER['REMOTE_ADDR'], + 'challenge' => $_POST['recaptcha_challenge_field'], + 'response' => $_POST['recaptcha_response_field'], + )); + $params = array( + 'http' => array( + 'method' => 'POST', + 'content' => $data, + ), + ); + $ctx = stream_context_create($params); + + /* first line: true/false + * second line: error code + */ + $res = array(); + foreach (file('http://api-verify.recaptcha.net/verify', 0, $ctx) as $line) { + $res[] = trim($line); + } + if ($res[0] == 'true') { + return true; + } + $this->errcode = $res[1]; + return false; + } + +} + diff --git a/inc/changeset.php b/inc/changeset.php new file mode 100644 index 00000000..415ca5da --- /dev/null +++ b/inc/changeset.php @@ -0,0 +1,110 @@ +fetchAll() as $row) { + $CS = new MTrackChangeset; + $CS->cid = $cid; + $CS->who = $row['who']; + $CS->object = $row['object']; + $CS->reason = $row['reason']; + $CS->when = $row['changedate']; + return $CS; + } + throw new Exception("invalid changeset id $cid"); + } + + static function begin($object, $reason = '', $when = null) { + $CS = new MTrackChangeset; + + $db = MTrackDB::get(); + if (self::$use_txn) { + $db->beginTransaction(); + } + + $CS->who = MTrackAuth::whoami(); + $CS->object = $object; + $CS->reason = $reason; + + if ($when === null) { + $CS->when = MTrackDB::unixtime(time()); + $q = MTrackDB::q( + "INSERT INTO changes (who, object, reason, changedate) + values (?,?,?,?)", + $CS->who, $CS->object, $CS->reason, $CS->when); + } else { + $CS->when = MTrackDB::unixtime($when); + $q = MTrackDB::q( + "INSERT INTO changes (who, object, reason, changedate) + values (?,?,?,?)", + $CS->who, $CS->object, $CS->reason, $CS->when); + } + + $CS->cid = MTrackDB::lastInsertId('changes', 'cid'); + + return $CS; + } + + function commit() + { + if ($this->count == 0) { +// throw new Exception("no changes were made as part of this changeset"); + } + if (self::$use_txn) { + $db = MTrackDB::get(); + $db->commit(); + } + } + + function addentry($fieldname, $action, $old, $value = null) + { + MTrackDB::q("INSERT INTO change_audit + (cid, fieldname, action, oldvalue, value) + VALUES (?, ?, ?, ?, ?)", + $this->cid, $fieldname, $action, $old, $value); + $this->count++; + } + + function add($fieldname, $old, $new) + { + if ($old == $new) { + return; + } + if (!strlen($old)) { + $this->addentry($fieldname, 'set', $old, $new); + return; + } + if (!strlen($new)) { + $this->addentry($fieldname, 'deleted', $old, $new); + return; + } + $this->addentry($fieldname, 'changed', $old, $new); + } + + function setObject($object) + { + $this->object = $object; + MTrackDB::q('update changes set object = ? where cid = ?', + $this->object, $this->cid); + } + + function setReason($reason) + { + $this->reason = $reason; + MTrackDB::q('update changes set reason = ? where cid = ?', + $this->reason, $this->cid); + } + +} diff --git a/inc/commit-hook.php b/inc/commit-hook.php new file mode 100644 index 00000000..292696c7 --- /dev/null +++ b/inc/commit-hook.php @@ -0,0 +1,423 @@ + 'checkPHP', + ); + static $listeners = array(); + var $repo; + + static function registerListener(IMTrackCommitListener $l) + { + self::$listeners[] = $l; + } + + function checkVeto() + { + $args = func_get_args(); + $method = array_shift($args); + $reasons = array(); + + foreach (self::$listeners as $l) { + $v = call_user_func_array(array($l, $method), $args); + if ($v !== true) { + if ($v === null || $v === false) { + $reasons[] = sprintf("%s:%s() returned %s", + get_class($l), $method, $v === null ? 'null' : 'false'); + } elseif (is_array($v)) { + foreach ($v as $m) { + $reasons[] = $m; + } + } else { + $reasons[] = $v; + } + } + } + if (count($reasons)) { + throw new MTrackVetoException($reasons); + } + } + + function __construct($repo) { + $this->repo = $repo; + } + + function parseCommitMessage($msg) { + // Parse the commit message and look for commands; + // returns each recognized command and its args in an array + + $close = array('resolves', 'resolved', 'close', 'closed', + 'closes', 'fix', 'fixed', 'fixes'); + $refs = array('addresses', 'references', 'referenced', + 'refs', 'ref', 'see', 're'); + + $cmds = join('|', $close) . '|' . join('|', $refs); + $timepat = '(?:\s*\((?:spent|sp)\s*(-?[0-9]*(?:\.[0-9]+)?)\s*(?:hours?|hrs)?\s*\))?'; + $tktref = "(?:#|(?:(?:ticket|issue|bug):?\s*))([a-z]*[0-9]+)$timepat"; + + $pat = "(?P(?:$cmds))\s*(?P$tktref(?:(?:[, &]*|\s+and\s+)$tktref)*)"; + + $M = array(); + $actions = array(); + + if (preg_match_all("/$pat/smi", $msg, $M, PREG_SET_ORDER)) { + foreach ($M as $match) { + if (in_array($match['action'], $close)) { + $action = 'close'; + } else { + $action = 'ref'; + } + $tickets = array(); + $T = array(); + if (preg_match_all("/$tktref/smi", $match['ticket'], + $T, PREG_SET_ORDER)) { + + foreach ($T as $tmatch) { + if (isset($tmatch[2])) { + // [ action, ticket, spent ] + $actions[] = array($action, $tmatch[1], $tmatch[2]); + } else { + // [ action, ticket ] + $actions[] = array($action, $tmatch[1]); + } + } + } + } + } + return $actions; + } + + function preCommit(IMTrackCommitHookBridge $bridge) { + MTrackACL::requireAllRights("repo:" . $this->repo->repoid, 'commit'); + $files = $bridge->enumChangedOrModifiedFileNames(); + $fqfiles = array(); + foreach ($files as $filename) { + $fqfiles[] = $this->repo->shortname . '/' . $filename; + $pi = pathinfo($filename); + if (isset(self::$fileChecks[$pi['extension']])) { + $lint = self::$fileChecks[$pi['extension']]; + $fp = $bridge->getFileStream($filename); + $this->$lint($filename, $fp); + $fp = null; + } + } + $changes = $this->_getChanges($bridge); + foreach ($changes as $c) { + $log = $c->changelog; + $actions = $this->parseCommitMessage($log); + + // check permissions on the tickets + $tickets = array(); + foreach ($actions as $act) { + $tkt = $act[1]; + $tickets[$tkt] = $tkt; + } + $reasons = array(); + foreach ($tickets as $tkt) { + if (strlen($tkt) == 32) { + $T = MTrackIssue::loadById($tkt); + } else { + $T = MTrackIssue::loadByNSIdent($tkt); + } + + if ($T === null) { + $reasons[] = "#$tkt is not a valid ticket\n"; + continue; + } + + $accounted = false; + if ($c->hash !== null) { + list($accounted) = MTrackDB::q( + 'select count(hash) from ticket_changeset_hashes + where tid = ? and hash = ?', + $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0); + if ($accounted) { + continue; + } + } + + if (!MTrackACL::hasAllRights("ticket:$T->tid", "modify")) { + $reasons[] = MTrackAuth::whoami() . " does not have permission to modify #$tkt\n"; + } else if (!$T->isOpen()) { + $reasons[] = " ** #$tkt is already closed.\n ** You must either re-open it (if it has not already shipped)\n ** or open a new ticket to track this issue\n"; + } + } + } + if (count($reasons) > 0) { + throw new MTrackVetoException($reasons); + } + $this->checkVeto('vetoCommit', $log, $files, $actions); + } + + private function _getChanges(IMTrackCommitHookBridge $bridge) + { + $changes = array(); + if ($bridge instanceof IMTrackCommitHookBridge2) { + $changes = $bridge->getChanges(); + } else { + $c = new MTrackCommitHookChangeEvent; + $c->rev = $bridge->getChangesetDescriptor(); + $c->changelog = $bridge->getCommitMessage(); + $c->changeby = MTrackAuth::whoami(); + $c->ctime = time(); + $changes[] = $c; + } + return $changes; + } + + function postCommit(IMTrackCommitHookBridge $bridge) + { + $files = $bridge->enumChangedOrModifiedFileNames(); + $fqfiles = array(); + foreach ($files as $filename) { + $fqfiles[] = $this->repo->shortname . '/' . $filename; + } + + // build up overall picture of what needs to be applied to tickets + $changes = $this->_getChanges($bridge); + + // Deferred by tid + $deferred = array(); + $T_by_tid = array(); + $hashed = array(); + + // For correct attribution of spent time + $spent_by_tid_by_user = array(); + + // Changes that didn't ref a ticket; we want to show something + // on the timeline + $no_ticket = array(); + + $me = mtrack_canon_username(MTrackAuth::whoami()); + + foreach ($changes as $c) { + $tickets = array(); + $log = $c->changelog; + + $actions = $this->parseCommitMessage($log); + foreach ($actions as $act) { + $what = $act[0]; + $tkt = $act[1]; + $tickets[$tkt][$what] = $what; + if (isset($act[2])) { + $tickets[$tkt]['spent'] += $act[2]; + } + } + if (count($tickets) == 0) { + $no_ticket[] = $c; + continue; + } + // apply changes to tickets + foreach ($tickets as $tkt => $act) { + if (strlen($tkt) == 32 && isset($T_by_tid[$tkt])) { + $T = $T_by_tid[$tkt]; + } else { + if (strlen($tkt) == 32) { + $T = MTrackIssue::loadById($tkt); + } else { + $T = MTrackIssue::loadByNSIdent($tkt); + } + $T_by_tid[$T->tid] = $T; + } + + $accounted = false; + if ($c->hash !== null) { + if (isset($hashed[$T->tid][$c->hash])) { + $accounted = true; + } else { + list($accounted) = MTrackDB::q( + 'select count(hash) from ticket_changeset_hashes + where tid = ? and hash = ?', + $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0); + if (!$accounted) { + $hashed[$T->tid][$c->hash] = $c->hash; + } + } + } + + if ($accounted) { + $deferred[$T->tid]['comments'][] = + "(In $c->rev) merged to [repo:" . + $this->repo->getBrowseRootName() . "]"; + continue; + } + $log = "(In " . $c->rev . ") "; + if ($c->changeby != $me) { + $log .= " (on behalf of [user:$c->changeby]) "; + } + $log .= $c->changelog; + $deferred[$T->tid]['comments'][] = $log; + if (isset($act['spent']) && $c->changeby != $me) { + $spent_by_tid_by_user[$T->tid][$c->changeby][] = $act['spent']; + unset($act['spent']); + } + $deferred[$T->tid]['act'][] = $act; + + } + $this->checkVeto('postCommit', $log, $fqfiles, $actions); + } + + foreach ($deferred as $tid => $info) { + $T = $T_by_tid[$tid]; + + $log = join("\n\n", $info['comments']); + + $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log); + + if (isset($hashed[$T->tid])) { + foreach ($hashed[$T->tid] as $hash) { + MTrackDB::q( + 'insert into ticket_changeset_hashes(tid, hash) values (?, ?)', + $T->tid, $hash); + } + } + + $T->addComment($log); + if (isset($info['act'])) foreach ($info['act'] as $act) { + if (isset($act['close'])) { + $T->resolution = 'fixed'; + $T->close(); + } + if (isset($act['spent'])) { + $T->addEffort($act['spent']); + } + } + $T->save($CS); + $CS->commit(); + } + foreach ($spent_by_tid_by_user as $tid => $sdata) { + // Load it fresh here, as there seems to be an issue with saving + // a second set of changes on a pre-existing object + $T = MTrackIssue::loadById($tid); + foreach ($sdata as $user => $time) { + MTrackAuth::su($user); + $CS = MTrackChangeset::begin("ticket:" . $T->tid, + "Tracking time from prior push"); + MTrackAuth::drop(); + foreach ($time as $spent) { + $T->addEffort($spent); + } + $T->save($CS); + $CS->commit(); + } + } + $log = ''; + foreach ($no_ticket as $c) { + $log .= "(In " . $c->rev . ") "; + if ($c->changeby != $me) { + $log .= " (on behalf of [user:$c->changeby]) "; + } + $log .= $c->changelog . "\n\n"; + } + $CS = MTrackChangeset::begin("repo:" . $this->repo->repoid, $log); + $CS->commit(); + } + + function checkPHP($filename, $fp) { + $pipes = null; + $proc = proc_open(MTrackConfig::get('tools', 'php') . " -l", array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w') + ), $pipes); + + // send in data + stream_copy_to_stream($fp, $pipes[0]); + $fp = null; + $pipes[0] = null; + + $output = stream_get_contents($pipes[1]); + $output .= stream_get_contents($pipes[2]); + $st = proc_get_status($proc); + if ($st['running']) { + proc_terminate($proc); + sleep(1); + $st = proc_get_status($proc); + } + if ($st['exitcode'] != 0) { + throw new Exception("$filename: $output"); + } + return true; + } +} + diff --git a/inc/common.php b/inc/common.php new file mode 100644 index 00000000..4698d0de --- /dev/null +++ b/inc/common.php @@ -0,0 +1,66 @@ +fetchAll() as $row) { + $timezone = $row[0]; + } +} +if (empty($timezone)) { + $timezone = MTrackConfig::get('core', 'timezone'); +} +if (!empty($timezone)) { + $timezone_crutch = array( + 'PST' => 'America/Los_Angeles', + 'PDT' => 'America/Los_Angeles', + 'EDT' => 'America/New_York', + 'EST' => 'America/New_York', + ); + if (isset($timezone_crutch[$timezone])) { + $timezone = $timezone_crutch[$timezone]; + } + date_default_timezone_set($timezone); +} +} + diff --git a/inc/configuration.php b/inc/configuration.php new file mode 100644 index 00000000..9289e500 --- /dev/null +++ b/inc/configuration.php @@ -0,0 +1,152 @@ + $opts) { + fwrite($fp, "[$section]\n"); + foreach ($opts as $k => $v) { + $v = addcslashes($v, "\"\r\n\t"); + fwrite($fp, "$k = \"$v\"\n"); + } + fwrite($fp, "\n"); + } + flock($fp, LOCK_UN); + $fp = null; + } + + static function get($section, $option) { + self::parseIni(); + return self::_get($section, $option); + } + + static function _get($section, $option) { + $ini = self::$ini; + if (isset(self::$ini[$section][$option])) { + $val = self::$ini[$section][$option]; + } else if (isset(self::$runtime[$section][$option])) { + $val = self::$runtime[$section][$option]; + } else { + return null; + } + + while (preg_match('/@\{([a-zA-Z0-9_]+):([a-zA-Z0-9_]+)\}/', $val, $M)) { + $rep = self::_get($M[1], $M[2]); + $val = str_replace($M[0], $rep, $val); + } + + return $val; + } + + static function getSection($section) { + self::parseIni(); + if (isset(self::$ini[$section])) { + $S = self::$ini[$section]; + } else { + $S = null; + } + if (isset(self::$runtime[$section])) { + $R = self::$runtime[$section]; + } else { + $R = null; + } + if ($S && $R) { + return array_merge($S, $R); + } + if ($S) { + return $S; + } + if ($R) { + return $R; + } + return array(); + } + + static function append($section, $option, $value) { + if (self::$ini[$section][$option] != $value) { + $location = self::getLocation(); + $data = file_get_contents($location); + $data .= "\n[$section]\n$option = $value\n"; + file_put_contents($location, $data); + self::$ini[$section][$option] = $value; + } + } + + /* loads plugins */ + static function boot() { + if (isset($_GLOBALS['MTRACK_CONFIG_SKIP_BOOT'])) { + return; + } + $inc = self::get('core', 'includes'); + if ($inc !== null) { + foreach (preg_split("/\s*,\s*/", $inc) as $filename) { + require_once $filename; + } + } + $plugins = self::getSection('plugins'); + if (is_array($plugins)) foreach ($plugins as $classpat => $paramline) { + $params = preg_split("/\s*,\s*/", $paramline); + + $rcls = new ReflectionClass($classpat); + $obj = $rcls->newInstanceArgs($params); + } + } +} + diff --git a/inc/customfield.php b/inc/customfield.php new file mode 100644 index 00000000..ce731b89 --- /dev/null +++ b/inc/customfield.php @@ -0,0 +1,252 @@ +name = $name; + + $field->type = MTrackConfig::get('ticket.custom', "$name.type"); + $field->label = MTrackConfig::get('ticket.custom', "$name.label"); + $field->group = MTrackConfig::get('ticket.custom', "$name.group"); + $field->order = (int)MTrackConfig::get('ticket.custom', "$name.order"); + $field->default = MTrackConfig::get('ticket.custom', "$name.default"); + $field->options = MTrackConfig::get('ticket.custom', "$name.options"); + + return $field; + } + + function save() { + if (!preg_match("/^x_[a-z_]+$/", $this->name)) { + throw new Exception("invalid field name $this->name"); + } + $name = $this->name; + MTrackConfig::set('ticket.custom', "$name.type", $this->type); + MTrackConfig::set('ticket.custom', "$name.label", $this->label); + MTrackConfig::set('ticket.custom', "$name.group", $this->group); + MTrackConfig::set('ticket.custom', "$name.order", (int)$this->order); + MTrackConfig::set('ticket.custom', "$name.default", $this->default); + MTrackConfig::set('ticket.custom', "$name.options", $this->options); + } + + function ticketData() { + /* compatible with the $FIELDSET data used in web/ticket.php */ + $data = array( + 'label' => $this->label, + 'type' => $this->type, + ); + + if (strlen($this->default)) { + $data['default'] = $this->default; + } + + switch ($this->type) { + case 'multi': + case 'wiki': + case 'shortwiki': + $data['ownrow'] = true; + $data['rows'] = 5; + $data['cols'] = 78; + break; + case 'select': + case 'multiselect': + $options = array('' => ' --- '); + foreach (explode('|', $this->options) as $opt) { + $options[$opt] = $opt; + } + $data['options'] = $options; + break; + } + return $data; + } +} + +class MTrackTicket_CustomFields + implements IMTrackIssueListener +{ + var $fields = array(); + + var $field_types = array( + 'text' => 'Text (single line)', + 'multi' => 'Text (multi-line)', + 'wiki' => 'Wiki', + 'shortwiki' => 'Wiki (shorter height)', + 'select' => 'Select box (choice of one)', +// Don't allow multi-select for now; need a sane way to make the value +// into an array. +// 'multiselect' => 'Multiple select', + ); + + function save() { + $this->alterSchema(); + + $fieldlist = join(',', array_keys($this->fields)); + MTrackConfig::set('ticket', 'customfields', $fieldlist); + + foreach ($this->fields as $field) { + $field->save(); + } + } + + function fieldByName($name, $create = false) { + $name = MTrackTicket_CustomField::canonName($name); + if (!isset($this->fields[$name]) && $create) { + $field = new MTrackTicket_CustomField; + $field->name = $name; + $this->fields[$name] = $field; + } else if (!isset($this->fields[$name])) { + return null; + } + return $this->fields[$name]; + } + + function deleteField($field) { + if (!($field instanceof MTrackTicket_CustomField)) { + $field = $this->fieldByName($field); + } + if (!($field instanceof MTrackTicket_CustomField)) { + throw new Exception("can't delete an unknown field"); + } + unset($this->fields[$field->name]); + } + + function vetoMilestone(MTrackIssue $issue, + MTrackMilestone $ms, $assoc = true) { + return true; + } + function vetoKeyword(MTrackIssue $issue, + MTrackKeyword $kw, $assoc = true) { + return true; + } + function vetoComponent(MTrackIssue $issue, + MTrackComponent $comp, $assoc = true) { + return true; + } + function vetoProject(MTrackIssue $issue, + MTrackProject $proj, $assoc = true) { + return true; + } + function vetoComment(MTrackIssue $issue, $comment) { + return true; + } + function vetoSave(MTrackIssue $issue, $oldFields) { + return true; + } + + function _orderField($a, $b) { + $diff = $a->order - $b->order; + if ($diff == 0) { + return strnatcasecmp($a->label, $b->label); + } + return $diff; + } + + function getGroupedFields() { + $grouped = array(); + foreach ($this->fields as $field) { + $grouped[$field->group][$field->name] = $field; + } + $result = array(); + $names = array_keys($grouped); + asort($grouped); + foreach ($grouped as $name => $group) { + uasort($group, array($this, '_orderField')); + $result[$name] = $group; + } + return $result; + } + + function augmentFormFields(MTrackIssue $issue, &$fieldset) { + $grouped = $this->getGroupedFields(); + foreach ($grouped as $group) { + foreach ($group as $field) { + $fieldset[$field->group][$field->name] = $field->ticketData(); + } + } + } + + function augmentSaveParams(MTrackIssue $issue, &$params) { + foreach ($this->fields as $field) { + $params[$field->name] = $issue->{$field->name}; + } + } + function augmentIndexerFields(MTrackIssue $issue, &$idx) { + foreach ($this->fields as $field) { + $idx[$field->name] = $issue->{$field->name}; + } + } + + function applyPOSTData(MTrackIssue $issue, $post) { + foreach ($this->fields as $field) { + if ($field->type == 'multiselect') { + $issue->{$field->name} = join('|', $post[$field->name]); + } else { + $issue->{$field->name} = $post[$field->name]; + } + } + } + + function alterSchema() { + $names = array(); + foreach ($this->fields as $field) { + $names[] = $field->name; + } + $db = MTrackDB::get(); + try { + $db->exec("select " . join(', ', $names) . ' from tickets limit 1'); + } catch (Exception $e) { + foreach ($names as $name) { + try { + $db->exec("ALTER TABLE tickets add column $name text"); + } catch (Exception $e) { + } + } + } + } + + function __construct() { + MTrackIssue::registerListener($this); + + /* read in custom fields from ini */ + $fieldlist = MTrackConfig::get('ticket', 'customfields'); + if ($fieldlist) { + $fieldlist = preg_split("/\s*,\s*/", $fieldlist); + foreach ($fieldlist as $fieldname) { + $field = MTrackTicket_CustomField::load($fieldname); + $this->fields[$field->name] = $field; + } + } + } + + static $me = null; + static function getInstance() { + if (self::$me !== null) { + return self::$me; + } + self::$me = new MTrackTicket_CustomFields; + return self::$me; + } +} + +MTrackTicket_CustomFields::getInstance(); + + diff --git a/inc/database.php b/inc/database.php new file mode 100644 index 00000000..0bdeacaf --- /dev/null +++ b/inc/database.php @@ -0,0 +1,500 @@ +name != $other->name) { + throw new Exception("can only compare tables with the same name!"); + } + foreach (array('fields', 'keys', 'triggers') as $propname) { + if (!is_array($this->{$propname})) continue; + foreach ($this->{$propname} as $f) { + if (!isset($other->{$propname}[$f->name])) { +# echo "$propname $f->name is new\n"; + return false; + } + $o = clone $other->{$propname}[$f->name]; + $f = clone $f; + unset($o->comment); + unset($f->comment); + if ($f != $o) { +# echo "$propname $f->name are not equal\n"; +# var_dump($f); +# var_dump($o); + return false; + } + } + if (!is_array($other->{$propname})) continue; + foreach ($other->{$propname} as $f) { + if (!isset($this->{$propname}[$f->name])) { +# echo "$propname $f->name was deleted\n"; + return false; + } + } + } + + return true; + } +} + +interface IMTrackDBSchema_Driver { + function setDB(PDO $db); + function determineVersion(); + function createTable(MTrackDBSchema_Table $table); + function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to); + function dropTable(MTrackDBSchema_Table $table); +}; + +class MTrackDBSchema_Generic implements IMTrackDBSchema_Driver { + var $db; + var $typemap = array(); + + function setDB(PDO $db) { + $this->db = $db; + } + + function determineVersion() { + try { + $q = $this->db->query('select version from mtrack_schema'); + if ($q) { + foreach ($q as $row) { + return $row[0]; + } + } + } catch (Exception $e) { + } + return null; + } + + function computeFieldCreate($f) { + $str = "\t$f->name "; + $str .= isset($this->typemap[$f->type]) ? $this->typemap[$f->type] : $f->type; + if (isset($f->nullable) && $f->nullable == '0') { + $str .= ' NOT NULL '; + } + if (isset($f->default)) { + if (!strlen($f->default)) { + $str .= " DEFAULT ''"; + } else { + $str .= " DEFAULT $f->default"; + } + } + return $str; + } + + function computeIndexCreate($table, $k) { + switch ($k->type) { + case 'unique': + $kt = ' UNIQUE '; + break; + case 'multiple': + default: + $kt = ''; + } + return "CREATE $kt INDEX $k->name on $table->name (" . join(', ', $k->fields) . ")"; + } + + function createTable(MTrackDBSchema_Table $table) + { + echo "Create $table->name\n"; + + $pri_key = null; + + $sql = array(); + foreach ($table->fields as $f) { + if ($f->type == 'autoinc') { + $pri_key = $f->name; + } + $str = $this->computeFieldCreate($f); + $sql[] = $str; + } + + if (is_array($table->keys)) foreach ($table->keys as $k) { + if ($k->type != 'primary') continue; + if ($pri_key !== null) continue; + $sql[] = "\tprimary key (" . join(', ', $k->fields) . ")"; + } + + $sql = "CREATE TABLE $table->name (\n" . + join(",\n", $sql) . + ")\n"; + +# echo $sql; + + $this->db->exec($sql); + + if (is_array($table->keys)) foreach ($table->keys as $k) { + if ($k->type == 'primary') continue; + $this->db->exec($this->computeIndexCreate($table, $k)); + } + } + + function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to) + { + /* if keys have changed, we drop the old key definitions before changing the columns */ + + echo "Need to alter $from->name\n"; + throw new Exception("bang!"); + } + + function dropTable(MTrackDBSchema_Table $table) + { + echo "Drop $table->name\n"; + $this->db->exec("drop table $table->name"); + } +} + +class MTrackDBSchema_SQLite extends MTrackDBSchema_Generic { + + function determineVersion() { + /* older versions did not have a schema version table, so we dance + * around a little bit, but only for sqlite, as those older versions + * didn't support other databases */ + try { + $q = $this->db->query('select version from mtrack_schema'); + if ($q) { + foreach ($q as $row) { + return $row[0]; + } + } + } catch (Exception $e) { + } + + /* do we have any tables at all? if we do, we treat that as schema + * version 0 */ + foreach ($this->db->query('select count(*) from sqlite_master') as $row) { + if ($row[0] > 0) { + $this->db->exec( + 'create table mtrack_schema (version integer not null)'); + return 0; + } + } + return null; + } + + var $typemap = array( + 'autoinc' => 'INTEGER PRIMARY KEY AUTOINCREMENT', + ); + + function createTable(MTrackDBSchema_Table $table) + { + parent::createTable($table); + } + + function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to) + { + $tname = $from->name . '_' . uniqid(); + + $sql = array(); + foreach ($to->fields as $f) { + if ($f->type == 'autoinc') { + $pri_key = $f->name; + } + $str = $this->computeFieldCreate($f); + $sql[] = $str; + } + + $sql = "CREATE TEMPORARY TABLE $tname (\n" . + join(",\n", $sql) . + ")\n"; + + $this->db->exec($sql); + + /* copy old data into this table */ + $sql = "INSERT INTO $tname ("; + $names = array(); + foreach ($from->fields as $f) { + if (!isset($to->fields[$f->name])) continue; + $names[] = $f->name; + } + $sql .= join(', ', $names); + $sql .= ") SELECT " . join(', ', $names) . " from $from->name"; + + #echo "$sql\n"; + $this->db->exec($sql); + + $this->db->exec("DROP TABLE $from->name"); + $this->createTable($to); + $sql = "INSERT INTO $from->name ("; + $names = array(); + foreach ($from->fields as $f) { + if (!isset($to->fields[$f->name])) continue; + $names[] = $f->name; + } + $sql .= join(', ', $names); + $sql .= ") SELECT " . join(', ', $names) . " from $tname"; + #echo "$sql\n"; + $this->db->exec($sql); + $this->db->exec("DROP TABLE $tname"); + } + + +} + +class MTrackDBSchema_pgsql extends MTrackDBSchema_Generic { + var $typemap = array( + 'autoinc' => 'SERIAL UNIQUE', + 'timestamp' => 'timestamp with time zone', + 'blob' => 'bytea', + ); + + function alterTable(MTrackDBSchema_Table $from, MTrackDBSchema_Table $to) + { + $sql = array(); + $actions = array(); + + /* if keys have changed, we drop the old key definitions before changing the columns */ + if (is_array($from->keys)) foreach ($from->keys as $k) { + if (!isset($to->keys[$k->name]) || $to->keys[$k->name] != $k) { + if ($k->type == 'primary') { + $actions[] = "DROP CONSTRAINT {$from->name}_pkey"; + } else { + $sql[] = "DROP INDEX $k->name"; + } + } + } + + foreach ($from->fields as $f) { + if (!isset($to->fields[$f->name])) { + $actions[] = "DROP COLUMN $f->name"; + continue; + } + } + foreach ($to->fields as $f) { + if (isset($from->fields[$f->name])) continue; + $actions[] = "ADD COLUMN " . $this->computeFieldCreate($f); + } + + /* changed and new keys */ + if (is_array($from->keys)) foreach ($from->keys as $k) { + if (isset($to->keys[$k->name]) && $to->keys[$k->name] != $k) { + if ($k->type == 'primary') { + $actions[] = "ADD primary key (" . join(', ', $k->fields) . ")"; + } else { + $sql[] = $this->computeIndexCreate($to, $k); + } + } + } + if (is_array($to->keys)) foreach ($to->keys as $k) { + if (isset($from->keys[$k->name])) continue; + if ($k->type == 'primary') { + $actions[] = "ADD primary key (" . join(', ', $k->fields) . ")"; + } else { + $sql[] = $this->computeIndexCreate($to, $k); + } + } + + if (count($actions)) { + $sql[] = "ALTER TABLE $from->name " . join(",\n", $actions); + } + echo "Need to alter $from->name\n"; + echo "SQL:\n"; + var_dump($sql); + foreach ($sql as $s) { + $this->db->exec($s); + } + } +} + +class MTrackDBSchema { + var $tables; + var $version; + var $post; + + function __construct($filename) { + $s = simplexml_load_file($filename); + + $this->version = (int)$s['version']; + + /* fabricate a table to hold the schema info */ + $table = new MTrackDBSchema_Table; + $table->name = 'mtrack_schema'; + $f = new stdclass; + $f->name = 'version'; + $f->type = 'integer'; + $f->nullable = '0'; + $table->fields[$f->name] = $f; + $this->tables[$table->name] = $table; + + foreach ($s->table as $t) { + $table = new MTrackDBSchema_Table; + $table->name = (string)$t['name']; + + foreach ($t->field as $f) { + $F = new stdclass; + foreach ($f->attributes() as $k => $v) { + $F->{(string)$k} = (string)$v; + } + if (isset($f->comment)) { + $F->comment = (string)$f->comment; + } + $table->fields[$F->name] = $F; + } + foreach ($t->key as $k) { + $K = new stdclass; + $K->fields = array(); + if (isset($k['type'])) { + $K->type = (string)$k['type']; + } else { + $K->type = 'primary'; + } + foreach ($k->field as $f) { + $K->fields[] = (string)$f; + } + if (isset($k['name'])) { + $K->name = (string)$k['name']; + } else { + $K->name = sprintf("idx_%s_%s", $table->name, join('_', $K->fields)); + } + $table->keys[$K->name] = $K; + } + + $this->tables[$table->name] = $table; + } + foreach ($s->post as $p) { + $this->post[(string)$p['driver']] = (string)$p; + } + + /* apply custom ticket fields */ + if (isset($this->tables['tickets'])) { + $table = $this->tables['tickets']; + $custom = MTrackTicket_CustomFields::getInstance(); + foreach ($custom->fields as $field) { + $f = new stdclass; + $f->name = $field->name; + $f->type = 'text'; + $table->fields[$f->name] = $f; + } + } + } +} + +class MTrackDB { + static $db = null; + static $extensions = array(); + static $queries = 0; + static $query_strings = array(); + + static function registerExtension(IMTrackDBExtension $ext) { + self::$extensions[] = $ext; + } + + // given a unix timestamp, return a value timestamp string + // suitable for use with the database + static function unixtime($unix) { + list($unix) = explode('.', $unix, 2); + if ($unix == 0) { + return null; + } + if ($unix < 10) { + throw new Exception("unix time $unix is too small\n"); + } + $d = date_create("@$unix", new DateTimeZone('UTC')); + // 2008-12-22T05:42:42.285445Z + if (!is_object($d)) { + throw new Exception("failed to create date for time $unix"); + } + return $d->format('Y-m-d\TH:i:s.u\Z'); + } + + static function get() { + if (self::$db == null) { + $dsn = MTrackConfig::get('core', 'dsn'); + if ($dsn === null) { + $dsn = 'sqlite:' . MTrackConfig::get('core', 'dblocation'); + } + $db = new PDO($dsn); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$db = $db; + + if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'sqlite') { + $db->sqliteCreateAggregate('mtrack_group_concat', + array('MTrackDB', 'group_concat_step'), + array('MTrackDB', 'group_concat_final')); + + $db->sqliteCreateFunction('mtrack_cleanup_attachments', + array('MTrackAttachment', 'attachment_row_deleted')); + } + + foreach (self::$extensions as $ext) { + $ext->onHandleCreated($db); + } + } + return self::$db; + } + + static function lastInsertId($tablename, $keyfield) { + if (!strlen($tablename) || !strlen($keyfield)) { + throw new Exception("missing tablename or keyfield"); + } + if (self::$db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') { + return self::$db->lastInsertId($tablename . '_' . $keyfield . '_seq'); + } else { + return self::$db->lastInsertId(); + } + } + + static function group_concat_step($context, $rownum, $value) + { + if (!is_array($context)) { + $context = array(); + } + $context[] = $value; + return $context; + } + + static function group_concat_final($context, $rownum) + { + if ($context === null) { + return null; + } + asort($context); + return join(", ", $context); + } + + static function esc($str) { + return "'" . str_replace("'", "''", $str) . "'"; + } + + /* issue a query, passing optional parameters */ + static function q($sql) { + self::$queries++; + if (isset(self::$query_strings[$sql])) { + self::$query_strings[$sql]++; + } else { + self::$query_strings[$sql] = 1; + } + $params = func_get_args(); + array_shift($params); + $db = self::get(); +# echo "
SQL: $sql\n"; +# var_dump($params); +#echo "
"; + try { + if (count($params)) { + $q = $db->prepare($sql); + $q->execute($params); + } else { + $q = $db->query($sql); + } + } catch (Exception $e) { + echo $e->getMessage(); + throw $e; + } + return $q; + } +} + diff --git a/inc/hyperlight/cpp.php b/inc/hyperlight/cpp.php new file mode 100644 index 00000000..9d1db03d --- /dev/null +++ b/inc/hyperlight/cpp.php @@ -0,0 +1,87 @@ +setInfo(array( + parent::NAME => 'C++', + parent::VERSION => '0.4', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('c', 'cc', 'cpp', 'h', 'hpp', 'icl', 'ipp')); + + $keyword = array('keyword' => array('', 'type', 'literal', 'operator')); + $common = array( + 'string', 'char', 'number', 'comment', + 'keyword' => array('', 'type', 'literal', 'operator'), + 'identifier', + 'operator' + ); + + $this->addStates(array( + 'init' => array_merge(array('include', 'preprocessor'), $common), + 'include' => array('incpath'), + 'preprocessor' => array_merge($common, array('pp_newline')), + )); + + $this->addRules(array( + 'whitespace' => RULE::ALL_WHITESPACE, + 'operator' => '/<:|:>|<%|%>|%:|%:%:|\+\+|--|&&|\|\||::|<<|>>|##|\.\.\.|\.\*|->|->*|[-+*\/%^&|!~<>.=,;:?()\[\]\{\}]|[-+*\/%^&|=!~<>]=|<<=|>>=/', + 'include' => new Rule('/#\s*include/', '/\n/'), + 'preprocessor' => new Rule('/#\s*\w+/', '/\n/'), + //'pp_newline' => '/[^\\\\](?\\\\*?)(?P=bs)\\\\\n/', + 'pp_newline' => '/(? '/<[^>]*>|"[^"]*"/', + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'number' => Rule::C_NUMBER, + 'comment' => Rule::C_COMMENT, + 'keyword' => array( + array( + 'asm', 'auto', 'break', 'case', 'catch', + 'const_cast', 'continue', 'default', 'do', 'dynamic_cast', + 'else', 'explicit', 'export', 'extern', 'for', + 'friend', 'goto', 'if', 'mutable', 'namespace', + 'operator', 'private', 'protected', 'public', 'register', + 'reinterpret_cast', 'return', 'sizeof', + 'static_cast', 'switch', 'template', 'throw', + 'try', 'typedef', 'typename', 'using', 'virtual', + 'volatile', 'while' + ), + 'type' => array( + 'bool', 'char', 'double', 'float', 'int', 'long', 'short', + 'signed', 'unsigned', 'void', 'wchar_t', 'struct', 'union', + 'class', 'static', 'inline', 'enum', 'const', + 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', + 'int8_t', 'int16_t', 'int32_t', 'int64_t', 'FILE', 'DIR', + ), + 'literal' => array( + 'false', 'this', 'true', 'NULL', + ), + 'operator' => array( + 'and', 'and_eq', 'bitand', 'bitor', 'compl', 'delete', + 'new', 'not', 'not_eq', 'or', 'or_eq', 'typeid', 'xor', + 'xor_eq' + ), + ), + 'identifier' => Rule::C_IDENTIFIER, + )); + + $this->addMappings(array( + 'operator' => '', + 'include' => 'preprocessor', + 'incpath' => 'tag', + )); + } +} + +?> diff --git a/inc/hyperlight/csharp.php b/inc/hyperlight/csharp.php new file mode 100644 index 00000000..4b935c01 --- /dev/null +++ b/inc/hyperlight/csharp.php @@ -0,0 +1,79 @@ +setInfo(array( + parent::NAME => 'C#', + parent::VERSION => '0.3', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('cs')); + + $this->setCaseInsensitive(false); + + $this->addStates(array( + 'init' => array( + 'string', + 'char', + 'number', + 'comment' => array('', 'doc'), + 'keyword' => array('', 'type', 'literal', 'operator', 'preprocessor'), + 'identifier', + 'operator', + 'whitespace', + ), + 'comment doc' => 'doc', + )); + + $this->addRules(array( + 'whitespace' => Rule::ALL_WHITESPACE, + 'operator' => '/[-+*\/%&|^!~=<>?{}()\[\].,:;]|&&|\|\||<<|>>|[-=!<>+*\/%&|^]=|<<=|>>=|->/', + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'number' => Rule::C_NUMBER, + 'comment' => array( + '#//(?:[^/].*?)?\n|/\*.*?\*/#s', + 'doc' => new Rule('#///#', '/$/m') + ), + 'doc' => '/<(?:".*?"|\'.*?\'|[^>])*>/', + 'keyword' => array( + array( + 'abstract', 'break', 'case', 'catch', 'checked', 'class', + 'const', 'continue', 'default', 'delegate', 'do', 'else', + 'enum', 'event', 'explicit', 'extern', 'finally', 'fixed', + 'for', 'foreach', 'goto', 'if', 'implicit', 'in', 'interface', + 'internal', 'lock', 'namespace', 'operator', 'out', 'override', + 'params', 'private', 'protected', 'public', 'readonly', 'ref', + 'return', 'sealed', 'static', 'struct', 'switch', 'throw', + 'try', 'unchecked', 'unsafe', 'using', 'var', 'virtual', + 'volatile', 'while' + ), + 'type' => array( + 'bool', 'byte', 'char', 'decimal', 'double', 'float', 'int', + 'long', 'object', 'sbyte', 'short', 'string', 'uint', 'ulong', + 'ushort', 'void' + ), + 'literal' => array( + 'base', 'false', 'null', 'this', 'true', + ), + 'operator' => array( + 'as', 'is', 'new', 'sizeof', 'stackallock', 'typeof', + ), + 'preprocessor' => '/#(?:if|else|elif|endif|define|undef|warning|error|line|region|endregion)/' + ), + 'identifier' => '/@?[a-z_][a-z0-9_]*/i', + )); + + $this->addMappings(array( + 'whitespace' => '', + 'operator' => '', + )); + } +} + +?> diff --git a/inc/hyperlight/css.php b/inc/hyperlight/css.php new file mode 100644 index 00000000..100c7b4b --- /dev/null +++ b/inc/hyperlight/css.php @@ -0,0 +1,67 @@ +setInfo(array( + parent::NAME => 'CSS', + parent::VERSION => '0.8', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('css')); + + // The following does not conform to the specs but it is necessary + // else numbers wouldn't be recognized any more. + $nmstart = '-?[a-z]'; + $nmchar = '[a-z0-9-]'; + $hex = '[0-9a-f]'; + list($string, $strmod) = preg_strip(Rule::STRING); + $strmod = implode('', $strmod); + + $this->addStates(array( + 'init' => array('comment', 'uri', 'meta', 'id', 'class', 'pseudoclass', 'element', 'block', 'constraint', 'string'), + 'block' => array('comment', 'attribute', 'value'), + 'constraint' => array('identifier', 'string'), + 'value' => array('comment', 'string', 'color', 'number', 'uri', 'identifier', 'important'), + )); + + $this->addRules(array( + 'attribute' => "/$nmstart$nmchar*/i", + 'value' => new Rule('/:/', '/;|(?=\})/'), + 'comment' => Rule::C_MULTILINECOMMENT, + 'meta' => "/@$nmstart$nmchar*/i", + 'id' => "/#$nmstart$nmchar*/i", + 'class' => "/\.$nmstart$nmchar*/", + // Pay attention not to match rules such as ::selection! + 'pseudoclass' => "/(? "/$nmstart$nmchar*/i", + 'block' => new Rule('/\{/', '/\}/'), + 'constraint' => new Rule('/\[/', '/\]/'), + 'number' => '/[+-]?(?:\d+(\.\d+)?|\d*\.\d+)(%|em|ex|px|pt|in|cm|mm|pc|deg|g?rad|m?s|k?Hz)?/', + 'uri' => "/url\(\s*(?:$string|[^\)]*)\s*\)/$strmod", + 'identifier' => "/$nmstart$nmchar*/i", + 'string' => "/$string/$strmod", + 'color' => "/#$hex{3}(?:$hex{3})?/i", + 'important' => '/!\s*important/', + )); + + $this->addMappings(array( + 'element' => 'keyword', + 'id' => 'keyword type', + 'class' => 'keyword builtin', + 'pseudoclass' => 'preprocessor', + 'block' => '', + 'constraint' => '', + 'value' => '', + 'color' => 'string', + 'uri' => 'char', + 'meta' => 'keyword', + )); + } +} + +?> diff --git a/inc/hyperlight/hyperlight.php b/inc/hyperlight/hyperlight.php new file mode 100644 index 00000000..e7a541af --- /dev/null +++ b/inc/hyperlight/hyperlight.php @@ -0,0 +1,1033 @@ +"> + * + * (Remove space between `?` and `>`). + * Although this no longer occurs, it is fixed by checking for `$token === ''` + * in the `emit*` methods. This should never happen anyway. Probably something + * to do with the zero-width lookahead in the PHP syntax definition. + * + * - `hyperlight_calculate_fold_marks`: refactor, write proper handler + * + * - Line numbers (on client-side?) + * + */ + +/** + * Hyperlight source code highlighter for PHP. + * @package hyperlight + */ + +/** @ignore */ +require_once dirname(__FILE__) . '/preg_helper.php'; + +if (!function_exists('array_peek')) { + /** + * @internal + * This does exactly what you think it does. */ + function array_peek(array &$array) { + $cnt = count($array); + return $cnt === 0 ? null : $array[$cnt - 1]; + } +} + +/** + * @internal + * For internal debugging purposes. + */ +function dump($obj, $descr = null) { + if ($descr !== null) + echo "

$descr

"; + ob_start(); + var_dump($obj); + $dump = ob_get_clean(); + ?>
errorSurrounding($code, $position) + ); + } + + // Try to extract the location of the error more or less precisely. + // Only used for a comprehensive display. + private function errorSurrounding($code, $pos) { + $size = 10; + $begin = $pos < $size ? 0 : $pos - $size; + $end = $pos + $size > strlen($code) ? strlen($code) : $pos + $size; + $offs = $pos - $begin; + return substr($code, $begin, $end - $begin) . "\n" . sprintf("%{$offs}s", '^'); + } +} + +/** + * Represents a nesting rule in the grammar of a language definition. + * + * Individual rules can either be represented by raw strings ("simple" rules) or + * by a nesting rule. Nesting rules specify where they can start and end. Inside + * a nesting rule, other rules may be applied (both simple and nesting). + * For example, a nesting rule may define a string literal. Inside that string, + * other rules may be applied that recognize escape sequences. + * + * To use a nesting rule, supply how it may start and end, e.g.: + * + * $string_rule = array('string' => new Rule('/"/', '/"/')); + * + * You also need to specify nested states: + * + * $string_states = array('string' => 'escaped'); + * + * Now you can add another rule for escaped: + * + * $escaped_rule = array('escaped' => '/\\(x\d{1,4}|.)/'); + * + */ +class Rule { + /** + * Common rules. + */ + + const ALL_WHITESPACE = '/(\s|\r|\n)+/'; + const C_IDENTIFIER = '/[a-z_][a-z0-9_]*/i'; + const C_COMMENT = '#//.*?\n|/\*.*?\*/#s'; + const C_MULTILINECOMMENT = '#/\*.*?\*/#s'; + const DOUBLEQUOTESTRING = '/"(?:\\\\"|.)*?"/s'; + const SINGLEQUOTESTRING = "/'(?:\\\\'|.)*?'/s"; + const C_DOUBLEQUOTESTRING = '/L?"(?:\\\\"|.)*?"/s'; + const C_SINGLEQUOTESTRING = "/L?'(?:\\\\'|.)*?'/s"; + const STRING = '/"(?:\\\\"|.)*?"|\'(?:\\\\\'|.)*?\'/s'; + const C_NUMBER = '/ + (?: # Integer followed by optional fractional part. + (?: + 0(?: + x[0-9a-f]+ + | + [0-7]* + ) + | + \d+ + ) + (?:\.\d*)? + (?:e[+-]\d+)? + ) + | + (?: # Just the fractional part. + (?:\.\d+) + (?:e[+-]?\d+)? + ) + /ix'; + + private $_start; + private $_end; + + /** @ignore */ + public function __construct($start, $end = null) { + $this->_start = $start; + $this->_end = $end; + } + + /** + * Returns the pattern with which this rule starts. + * @return string + */ + public function start() { + return $this->_start; + } + + /** + * Returns the pattern with which this rule may end. + * @return string + */ + public function end() { + return $this->_end; + } +} + +/** + * Abstract base class of all Hyperlight language definitions. + * + * In order to define a new language definition, this class is inherited. + * The only function that needs to be overridden is the constructor. Helper + * functions from the base class can then be called to construct the grammar + * and store additional information. + * The name of the subclass must be of the schema {Lang}Language, + * where {Lang} is a short, unique name for the language starting + * with a capital letter and continuing in lower case. For example, + * PhpLanguage is a valid name. The language definition must + * reside in a file located at languages/{lang}.php. Here, + * {lang} is the all-lowercase spelling of the name, e.g. + * languages/php.php. + * + */ +abstract class HyperLanguage { + private $_states = array(); + private $_rules = array(); + private $_mappings = array(); + private $_info = array(); + private $_extensions = array(); + private $_caseInsensitive = false; + private $_postProcessors = array(); + + private static $_languageCache = array(); + private static $_compiledLanguageCache = array(); + private static $_filetypes; + + /** + * Indices for information. + */ + + const NAME = 1; + const VERSION = 2; + const AUTHOR = 10; + const WEBSITE = 5; + const EMAIL = 6; + + /** + * Retrieves a language definition name based on a file extension. + * + * Uses the contents of the languages/filetypes file to + * guess the language definition name from a file name extension. + * This file has to be generated using the + * collect-filetypes.php script every time the language + * definitions have been changed. + * + * @param string $ext the file name extension. + * @return string The language definition name or NULL. + */ + public static function nameFromExt($ext) { + if (self::$_filetypes === null) { + $ft_content = file('languages/filetypes', 1); + + foreach ($ft_content as $line) { + list ($name, $extensions) = explode(':', trim($line)); + $extensions = explode(',', $extensions); + // Inverse lookup. + foreach ($extensions as $extension) + $ft_data[$extension] = $name; + } + self::$_filetypes = $ft_data; + } + $ext = strtolower($ext); + return + array_key_exists($ext, self::$_filetypes) ? + self::$_filetypes[strtolower($ext)] : null; + } + + public static function compile(HyperLanguage $lang) { + $id = $lang->id(); + if (!isset(self::$_compiledLanguageCache[$id])) + self::$_compiledLanguageCache[$id] = $lang->makeCompiledLanguage(); + return self::$_compiledLanguageCache[$id]; + } + + public static function compileFromName($lang) { + return self::compile(self::fromName($lang)); + } + + protected static function exists($lang) { + return isset(self::$_languageCache[$lang]) or + file_exists("languages/$lang.php"); + } + + protected static function fromName($lang) { + if (!isset(self::$_languageCache[$lang])) { + require_once dirname(__FILE__) . "/$lang.php"; + $klass = ucfirst("{$lang}Language"); + self::$_languageCache[$lang] = new $klass(); + } + return self::$_languageCache[$lang]; + } + + public function id() { + $klass = get_class($this); + return strtolower(substr($klass, 0, strlen($klass) - strlen('Language'))); + } + + protected function setCaseInsensitive($value) { + $this->_caseInsensitive = $value; + } + + protected function addStates(array $states) { + $this->_states = self::mergeProperties($this->_states, $states); + } + + protected function getState($key) { + return $this->_states[$key]; + } + + protected function removeState($key) { + unset($this->_states[$key]); + } + + protected function addRules(array $rules) { + $this->_rules = self::mergeProperties($this->_rules, $rules); + } + + protected function getRule($key) { + return $this->_rules[$key]; + } + + protected function removeRule($key) { + unset($this->_rules[$key]); + } + + protected function addMappings(array $mappings) { + // TODO Implement nested mappings. + $this->_mappings = array_merge($this->_mappings, $mappings); + } + + protected function getMapping($key) { + return $this->_mappings[$key]; + } + + protected function removeMapping($key) { + unset($this->_mappings[$key]); + } + + protected function setInfo(array $info) { + $this->_info = $info; + } + + protected function setExtensions(array $extensions) { + $this->_extensions = $extensions; + } + + protected function addPostprocessing($rule, HyperLanguage $language) { + $this->_postProcessors[$rule] = $language; + } + +// protected function addNestedLanguage(HyperLanguage $language, $hoistBackRules) { +// $prefix = get_class($language); +// if (!is_array($hoistBackRules)) +// $hoistBackRules = array($hoistBackRules); +// +// $states = array(); // Step 1: states +// +// foreach ($language->_states as $stateName => $state) { +// $prefixedRules = array(); +// +// if (strstr($stateName, ' ')) { +// $parts = explode(' ', $stateName); +// $prefixed = array(); +// foreach ($parts as $part) +// $prefixed[] = "$prefix$part"; +// $stateName = implode(' ', $prefixed); +// } +// else +// $stateName = "$prefix$stateName"; +// +// foreach ($state as $key => $rule) { +// if (is_string($key) and is_array($rule)) { +// $nestedRules = array(); +// foreach ($rule as $nestedRule) +// $nestedRules[] = ($nestedRule === '') ? '' : +// "$prefix$nestedRule"; +// +// $prefixedRules["$prefix$key"] = $nestedRules; +// } +// else +// $prefixedRules[] = "$prefix$rule"; +// } +// +// if ($stateName === 'init') +// $prefixedRules = array_merge($hoistBackRules, $prefixedRules); +// +// $states[$stateName] = $prefixedRules; +// } +// +// $rules = array(); // Step 2: rules +// // Mappings need to set up already! +// $mappings = array(); +// +// foreach ($language->_rules as $ruleName => $rule) { +// if (is_array($rule)) { +// $nestedRules = array(); +// foreach ($rule as $nestedName => $nestedRule) { +// if (is_string($nestedName)) { +// $nestedRules["$prefix$nestedName"] = $nestedRule; +// $mappings["$prefix$nestedName"] = $nestedName; +// } +// else +// $nestedRules[] = $nestedRule; +// } +// $rules["$prefix$ruleName"] = $nestedRules; +// } +// else { +// $rules["$prefix$ruleName"] = $rule; +// $mappings["$prefix$ruleName"] = $ruleName; +// } +// } +// +// // Step 3: mappings. +// +// foreach ($language->_mappings as $ruleName => $cssClass) { +// if (strstr($ruleName, ' ')) { +// $parts = explode(' ', $ruleName); +// $prefixed = array(); +// foreach ($parts as $part) +// $prefixed[] = "$prefix$part"; +// $mappings[implode(' ', $prefixed)] = $cssClass; +// } +// else +// $mappings["$prefix$ruleName"] = $cssClass; +// } +// +// $this->addStates($states); +// $this->addRules($rules); +// $this->addMappings($mappings); +// +// return $prefix . 'init'; +// } + + private function makeCompiledLanguage() { + return new HyperlightCompiledLanguage( + $this->id(), + $this->_info, + $this->_extensions, + $this->_states, + $this->_rules, + $this->_mappings, + $this->_caseInsensitive, + $this->_postProcessors + ); + } + + private static function mergeProperties(array $old, array $new) { + foreach ($new as $key => $value) { + if (is_string($key)) { + if (isset($old[$key]) and is_array($old[$key])) + $old[$key] = array_merge($old[$key], $new); + else + $old[$key] = $value; + } + else + $old[] = $value; + } + + return $old; + } +} + +class HyperlightCompiledLanguage { + private $_id; + private $_info; + private $_extensions; + private $_states; + private $_rules; + private $_mappings; + private $_caseInsensitive; + private $_postProcessors = array(); + + public function __construct($id, $info, $extensions, $states, $rules, $mappings, $caseInsensitive, $postProcessors) { + $this->_id = $id; + $this->_info = $info; + $this->_extensions = $extensions; + $this->_caseInsensitive = $caseInsensitive; + $this->_states = $this->compileStates($states); + $this->_rules = $this->compileRules($rules); + $this->_mappings = $mappings; + + foreach ($postProcessors as $ppkey => $ppvalue) + $this->_postProcessors[$ppkey] = HyperLanguage::compile($ppvalue); + } + + public function id() { + return $this->_id; + } + + public function name() { + return $this->_info[HyperLanguage::NAME]; + } + + public function authorName() { + if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info)) + return null; + $author = $this->_info[HyperLanguage::AUTHOR]; + if (is_string($author)) + return $author; + if (!array_key_exists(HyperLanguage::NAME, $author)) + return null; + return $author[HyperLanguage::NAME]; + } + + public function authorWebsite() { + if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info) or + !is_array($this->_info[HyperLanguage::AUTHOR]) or + !array_key_exists(HyperLanguage::WEBSITE, $this->_info[HyperLanguage::AUTHOR])) + return null; + return $this->_info[HyperLanguage::AUTHOR][HyperLanguage::WEBSITE]; + } + + public function authorEmail() { + if (!array_key_exists(HyperLanguage::AUTHOR, $this->_info) or + !is_array($this->_info[HyperLanguage::AUTHOR]) or + !array_key_exists(HyperLanguage::EMAIL, $this->_info[HyperLanguage::AUTHOR])) + return null; + return $this->_info[HyperLanguage::AUTHOR][HyperLanguage::EMAIL]; + } + + public function authorContact() { + $email = $this->authorEmail(); + return $email !== null ? $email : $this->authorWebsite(); + } + + public function extensions() { + return $this->_extensions; + } + + public function state($stateName) { + return $this->_states[$stateName]; + } + + public function rule($ruleName) { + return $this->_rules[$ruleName]; + } + + public function className($state) { + if (array_key_exists($state, $this->_mappings)) + return $this->_mappings[$state]; + else if (strstr($state, ' ') === false) + // No mapping for state. + return $state; + else { + // Try mapping parts of nested state name. + $parts = explode(' ', $state); + $ret = array(); + + foreach ($parts as $part) { + if (array_key_exists($part, $this->_mappings)) + $ret[] = $this->_mappings[$part]; + else + $ret[] = $part; + } + + return implode(' ', $ret); + } + } + + public function postProcessors() { + return $this->_postProcessors; + } + + private function compileStates($states) { + $ret = array(); + + foreach ($states as $name => $state) { + $newstate = array(); + + if (!is_array($state)) + $state = array($state); + + foreach ($state as $key => $elem) { + if ($elem === null) + continue; + if (is_string($key)) { + if (!is_array($elem)) + $elem = array($elem); + + foreach ($elem as $el2) { + if ($el2 === '') + $newstate[] = $key; + else + $newstate[] = "$key $el2"; + } + } + else + $newstate[] = $elem; + } + + $ret[$name] = $newstate; + } + + return $ret; + } + + private function compileRules($rules) { + $tmp = array(); + + // Preprocess keyword list and flatten nested lists: + + // End of regular expression matching keywords. + $end = $this->_caseInsensitive ? ')\b/i' : ')\b/'; + + foreach ($rules as $name => $rule) { + if (is_array($rule)) { + if (self::isAssocArray($rule)) { + // Array is a nested list of rules. + foreach ($rule as $key => $value) { + if (is_array($value)) + // Array represents a list of keywords. + $value = '/\b(?:' . implode('|', $value) . $end; + + if (!is_string($key) or strlen($key) === 0) + $tmp[$name] = $value; + else + $tmp["$name $key"] = $value; + } + } + else { + // Array represents a list of keywords. + $rule = '/\b(?:' . implode('|', $rule) . $end; + $tmp[$name] = $rule; + } + } + else { + $tmp[$name] = $rule; + } // if (is_array($rule)) + } // foreach + + $ret = array(); + + foreach ($this->_states as $name => $state) { + $regex_rules = array(); + $regex_names = array(); + $nesting_rules = array(); + + foreach ($state as $rule_name) { + $rule = $tmp[$rule_name]; + if ($rule instanceof Rule) + $nesting_rules[$rule_name] = $rule; + else { + $regex_rules[] = $rule; + $regex_names[] = $rule_name; + } + } + + $ret[$name] = array_merge( + array(preg_merge('|', $regex_rules, $regex_names)), + $nesting_rules + ); + } + + return $ret; + } + + private static function isAssocArray(array $array) { + foreach($array as $key => $_) + if (is_string($key)) + return true; + return false; + } +} + +class Hyperlight { + private $_lang; + private $_result; + private $_states; + private $_omitSpans; + private $_postProcessors = array(); + + public function __construct($lang) { + if (is_string($lang)) + $this->_lang = HyperLanguage::compileFromName(strtolower($lang)); + else if ($lang instanceof HyperlightCompiledLanguage) + $this->_lang = $lang; + else if ($lang instanceof HyperLanguage) + $this->_lang = HyperLanguage::compile($lang); + else + trigger_error( + 'Invalid argument type for $lang to Hyperlight::__construct', + E_USER_ERROR + ); + + foreach ($this->_lang->postProcessors() as $ppkey => $ppvalue) + $this->_postProcessors[$ppkey] = new Hyperlight($ppvalue); + + $this->reset(); + } + + public function language() { + return $this->_lang; + } + + public function reset() { + $this->_states = array('init'); + $this->_omitSpans = array(); + } + + public function render($code) { + // Normalize line breaks. + $this->_code = preg_replace('/\r\n?/', "\n", $code); + $fm = hyperlight_calculate_fold_marks($this->_code, $this->language()->id()); + return hyperlight_apply_fold_marks($this->renderCode(), $fm); + } + + public function renderAndPrint($code) { + echo $this->render($code); + } + + + private function renderCode() { + $code = $this->_code; + $pos = 0; + $len = strlen($code); + $this->_result = ''; + $state = array_peek($this->_states); + + // If there are open states (reentrant parsing), open the corresponding + // tags first: + + for ($i = 1; $i < count($this->_states); ++$i) + if (!$this->_omitSpans[$i - 1]) { + $class = $this->_lang->className($this->_states[$i]); + $this->write(""); + } + + // Emergency break to catch faulty rules. + $prev_pos = -1; + + while ($pos < $len) { + // The token next to the current position, after the inner loop completes. + // i.e. $closest_hit = array($matched_text, $position) + $closest_hit = array('', $len); + // The rule that found this token. + $closest_rule = null; + $rules = $this->_lang->rule($state); + + foreach ($rules as $name => $rule) { + if ($rule instanceof Rule) + $this->matchIfCloser( + $rule->start(), $name, $pos, $closest_hit, $closest_rule + ); + else if (preg_match($rule, $code, $matches, PREG_OFFSET_CAPTURE, $pos) == 1) { + // Search which of the sub-patterns matched. + + foreach ($matches as $group => $match) { + if (!is_string($group)) + continue; + if ($match[1] !== -1) { + $closest_hit = $match; + $closest_rule = str_replace('_', ' ', $group); + break; + } + } + } + } // foreach ($rules) + + // If we're currently inside a rule, check whether we've come to the + // end of it, or the end of any other rule we're nested in. + + if (count($this->_states) > 1) { + $n = count($this->_states) - 1; + do { + $rule = $this->_lang->rule($this->_states[$n - 1]); + $rule = $rule[$this->_states[$n]]; + --$n; + if ($n < 0) + throw new NoMatchingRuleException($this->_states, $pos, $code); + } while ($rule->end() === null); + + $this->matchIfCloser($rule->end(), $n + 1, $pos, $closest_hit, $closest_rule); + } + + // We take the closest hit: + + if ($closest_hit[1] > $pos) + $this->emit(substr($code, $pos, $closest_hit[1] - $pos)); + + $prev_pos = $pos; + $pos = $closest_hit[1] + strlen($closest_hit[0]); + + if ($prev_pos === $pos and is_string($closest_rule)) + if (array_key_exists($closest_rule, $this->_lang->rule($state))) { + array_push($this->_states, $closest_rule); + $state = $closest_rule; + $this->emitPartial('', $closest_rule); + } + + if ($closest_hit[1] === $len) + break; + else if (!is_string($closest_rule)) { + // Pop state. + if (count($this->_states) <= $closest_rule) + throw new NoMatchingRuleException($this->_states, $pos, $code); + + while (count($this->_states) > $closest_rule + 1) { + $lastState = array_pop($this->_states); + $this->emitPop('', $lastState); + } + $lastState = array_pop($this->_states); + $state = array_peek($this->_states); + $this->emitPop($closest_hit[0], $lastState); + } + else if (array_key_exists($closest_rule, $this->_lang->rule($state))) { + // Push state. + array_push($this->_states, $closest_rule); + $state = $closest_rule; + $this->emitPartial($closest_hit[0], $closest_rule); + } + else + $this->emit($closest_hit[0], $closest_rule); + } // while ($pos < $len) + + // Close any tags that are still open (can happen in incomplete code + // fragments that don't necessarily signify an error (consider PHP + // embedded in HTML, or a C++ preprocessor code not ending on newline). + + $omitSpansBackup = $this->_omitSpans; + for ($i = count($this->_states); $i > 1; --$i) + $this->emitPop(); + $this->_omitSpans = $omitSpansBackup; + + return $this->_result; + } + + private function matchIfCloser($expr, $next, $pos, &$closest_hit, &$closest_rule) { + $matches = array(); + if (preg_match($expr, $this->_code, $matches, PREG_OFFSET_CAPTURE, $pos) == 1) { + if ( + ( + // Two hits at same position -- compare length + // For equal lengths: first come, first serve. + $matches[0][1] == $closest_hit[1] and + strlen($matches[0][0]) > strlen($closest_hit[0]) + ) or + $matches[0][1] < $closest_hit[1] + ) { + $closest_hit = $matches[0]; + $closest_rule = $next; + } + } + } + + private function processToken($token) { + if ($token === '') + return ''; + $nest_lang = array_peek($this->_states); + if (array_key_exists($nest_lang, $this->_postProcessors)) + return $this->_postProcessors[$nest_lang]->render($token); + else + #return self::htmlentities($token); + return htmlspecialchars($token, ENT_NOQUOTES); + } + + private function emit($token, $class = '') { + $token = $this->processToken($token); + if ($token === '') + return; + $class = $this->_lang->className($class); + if ($class === '') + $this->write($token); + else + $this->write("$token"); + } + + private function emitPartial($token, $class) { + $token = $this->processToken($token); + $class = $this->_lang->className($class); + if ($class === '') { + if ($token !== '') + $this->write($token); + array_push($this->_omitSpans, true); + } + else { + $this->write("$token"); + array_push($this->_omitSpans, false); + } + } + + private function emitPop($token = '', $class = '') { + $token = $this->processToken($token); + if (array_pop($this->_omitSpans)) + $this->write($token); + else + $this->write("$token"); + } + + private function write($text) { + $this->_result .= $text; + } + +// // DAMN! What did I need them for? Something to do with encoding … +// // but why not use the `$charset` argument on `htmlspecialchars`? +// private static function htmlentitiesCallback($match) { +// switch ($match[0]) { +// case '<': return '<'; +// case '>': return '>'; +// case '&': return '&'; +// } +// } +// +// private static function htmlentities($text) { +// return htmlspecialchars($text, ENT_NOQUOTES); +// return preg_replace_callback( +// '/[<>&]/', array('Hyperlight', 'htmlentitiesCallback'), $text +// ); +// } +} // class Hyperlight + +/** + * echos a highlighted code. + * + * For example, the following + * + * hyperlight('', 'php'); + * + * results in: + * + *
...
+ *
+ * + * @param string $code The code. + * @param string $lang The language of the code. + * @param string $tag The surrounding tag to use. Optional. + * @param array $attributes Attributes to decorate {@link $tag} with. + * If no tag is given, this argument can be passed in its place. This + * behaviour will be assumed if the third argument is an array. + * Attributes must be given as a hash of key value pairs. + */ +function hyperlight($code, $lang, $tag = 'pre', array $attributes = array()) { + if ($code == '') + die("`hyperlight` needs a code to work on!"); + if ($lang == '') + die("`hyperlight` needs to know the code's language!"); + if (is_array($tag) and !empty($attributes)) + die("Can't pass array arguments for \$tag *and* \$attributes to `hyperlight`!"); + if ($tag == '') + $tag = 'pre'; + if (is_array($tag)) { + $attributes = $tag; + $tag = 'pre'; + } + $lang = htmlspecialchars(strtolower($lang)); + $class = "source-code $lang"; + + $attr = array(); + foreach ($attributes as $key => $value) { + if ($key == 'class') + $class .= ' ' . htmlspecialchars($value); + else + $attr[] = htmlspecialchars($key) . '="' . + htmlspecialchars($value) . '"'; + } + + $attr = empty($attr) ? '' : ' ' . implode(' ', $attr); + + $hl = new Hyperlight($lang); + echo "<$tag class=\"$class\"$attr>"; + $hl->renderAndPrint(trim($code)); + echo ""; +} + +/** + * Is the same as: + * + * hyperlight(file_get_contents($filename), $lang, $tag, $attributes); + * + * @see hyperlight() + */ +function hyperlight_file($filename, $lang = null, $tag = 'pre', array $attributes = array()) { + if ($lang == '') { + // Try to guess it from file extension. + $pos = strrpos($filename, '.'); + if ($pos !== false) { + $ext = substr($filename, $pos + 1); + $lang = HyperLanguage::nameFromExt($ext); + } + } + hyperlight(file_get_contents($filename), $lang, $tag, $attributes); +} + +if (defined('HYPERLIGHT_SHORTCUT')) { + function hy() { + $args = func_get_args(); + call_user_func_array('hyperlight', $args); + } + function hyf() { + $args = func_get_args(); + call_user_func_array('hyperlight_file', $args); + } +} + +function hyperlight_calculate_fold_marks($code, $lang) { + $supporting_languages = array('csharp', 'vb'); + + if (!in_array($lang, $supporting_languages)) + return array(); + + $fold_begin_marks = array('/^\s*#Region/', '/^\s*#region/'); + $fold_end_marks = array('/^\s*#End Region/', '/\s*#endregion/'); + + $lines = preg_split('/\r|\n|\r\n/', $code); + + $fold_begin = array(); + foreach ($fold_begin_marks as $fbm) + $fold_begin = $fold_begin + preg_grep($fbm, $lines); + + $fold_end = array(); + foreach ($fold_end_marks as $fem) + $fold_end = $fold_end + preg_grep($fem, $lines); + + if (count($fold_begin) !== count($fold_end) or count($fold_begin) === 0) + return array(); + + $fb = array(); + $fe = array(); + foreach ($fold_begin as $line => $_) + $fb[] = $line; + + foreach ($fold_end as $line => $_) + $fe[] = $line; + + $ret = array(); + for ($i = 0; $i < count($fb); $i++) + $ret[$fb[$i]] = $fe[$i]; + + return $ret; +} + +function hyperlight_apply_fold_marks($code, array $fold_marks) { + if ($fold_marks === null or count($fold_marks) === 0) + return $code; + + $lines = explode("\n", $code); + + foreach ($fold_marks as $begin => $end) { + $lines[$begin] = '' . $lines[$begin] . ' '; + $lines[$begin + 1] = '' . $lines[$begin + 1]; + $lines[$end + 1] = '' . $lines[$end + 1]; + } + + return implode("\n", $lines); +} + +?> diff --git a/inc/hyperlight/iphp.php b/inc/hyperlight/iphp.php new file mode 100644 index 00000000..762a7229 --- /dev/null +++ b/inc/hyperlight/iphp.php @@ -0,0 +1,14 @@ +setExtensions(array()); // Not a whole file, just a fragment. + $this->removeState('init'); + $this->addStates(array('init' => $this->getState('php'))); + } +} + +?> diff --git a/inc/hyperlight/javascript.php b/inc/hyperlight/javascript.php new file mode 100644 index 00000000..cda76e0a --- /dev/null +++ b/inc/hyperlight/javascript.php @@ -0,0 +1,46 @@ +setInfo(array( + parent::NAME => 'Javascript', + )); + $this->setExtensions(array('js', 'json')); + $this->setCaseInsensitive(false); + $this->addStates(array( + 'init' => array( + 'string', + 'char', + 'number', + 'comment', + 'keyword' => array('', 'literal', 'operator'), + 'identifier' + ), + )); + + $this->addRules(array( + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'number' => Rule::C_NUMBER, + 'comment' => Rule::C_COMMENT, + 'keyword' => array( + array( + 'assert', 'break', 'class', 'continue', + 'else', 'except', 'finally', 'for', + 'if', 'in', 'function', + 'throw', 'return', 'try', 'while', 'with', 'typeof' + ), + 'literal' => array( + 'false', 'null', 'true' + ), + 'operator' => '/[-+*\/%&|^!~=<>?{}()\[\].,:;]|&&|\|\||<<|>>|[-=!<>+*\/%&|^]=|<<=|>>=|->/', + ), + 'identifier' => Rule::C_IDENTIFIER, + )); + $this->addMappings(array( + 'char' => 'string', + )); + + } +} diff --git a/inc/hyperlight/perl.php b/inc/hyperlight/perl.php new file mode 100644 index 00000000..1cf7da46 --- /dev/null +++ b/inc/hyperlight/perl.php @@ -0,0 +1,60 @@ +setInfo(array( + parent::NAME => 'Perl', + )); + $this->setExtensions(array('pl')); + $this->setCaseInsensitive(false); + $this->addStates(array( + 'init' => array( + 'string', + 'number', + 'char', + 'ticked', + 'variable', + 'comment', + 'keyword' => array('', 'operator'), + 'identifier' + ), + 'variable' => array('identifier'), + ) + ); + + $this->addRules(array( + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'ticked' => "/\`(?:\\\`|.)*\`/sU", + 'number' => Rule::C_NUMBER, + 'comment' => '/#.*/', + 'keyword' => array( + array( + 'use', 'my', 'our', 'open', 'close', 'tie', + 'exists', 'keys', 'values', 'chomp', + 'last', 'next', 'print', 'unless', + 'and', 'or', 'not', 'defined', 'undef', + 'push', 'unshift', 'shift', 'pop', + 'system', 'exec', 'goto', 'uc', 'lc', + 'length', 'split', + 'sort', 'grep', 'map', 'die', 'eval', + 'require', 'bless', 'sub', 'package', + 'eq', 'ne', 'le', 'lt', 'ge', 'gt', + 'else', 'for', 'foreach', 'then', + 'if', 'in', 'case', 'esac', 'while', + 'end', 'do', 'return', 'elsif', 'exit' + ), + 'operator' => '/&&|\|\||<<|>>|\.=|==|=~|!~|[=;&|!<>\[\].]/', + ), + 'identifier' => Rule::C_IDENTIFIER, + 'variable' => new Rule('/(@\$|%\$|&\$|@|%|&|\$)/', '//'), + )); + $this->addMappings(array( + 'char' => 'string', + 'variable' => 'tag', + 'ticked' => 'string', + )); + + } +} diff --git a/inc/hyperlight/php.php b/inc/hyperlight/php.php new file mode 100644 index 00000000..8cc83607 --- /dev/null +++ b/inc/hyperlight/php.php @@ -0,0 +1,63 @@ +setInfo(array( + parent::NAME => 'PHP', + parent::VERSION => '0.3', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('php', 'php3', 'php4', 'php5', 'inc')); + + $this->addPostProcessing('html', HyperLanguage::fromName('xml')); + + $this->addStates(array( + 'init' => array('php', 'html'), + 'php' => array( + 'comment', 'string', 'char', 'number', + 'keyword' => array('', 'type', 'literal', 'operator', 'builtin'), + 'identifier', 'variable'), + 'variable' => array('identifier'), + 'html' => array() + )); + + $this->addRules(array( + 'php' => new Rule('/<\?php/', '/\?>/'), + 'html' => new Rule('/(?=.)/', '/(?=<\?php)/'), + 'comment' => ",#[^\n]*\n|//.*?\n|/\*.*?\*/,s", + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'number' => Rule::C_NUMBER, + 'identifier' => Rule::C_IDENTIFIER, + 'variable' => new Rule('/\$/', '//'), + 'keyword' => array( + array('break', 'case', 'class', 'const', 'continue', 'declare', 'default', 'do', 'else', 'elseif', 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'extends', 'for', 'foreach', 'function', 'global', 'if', 'return', 'static', 'switch', 'use', 'var', 'while', 'final', 'interface', 'implements', 'public', 'private', 'protected', 'abstract', 'try', 'catch', 'throw', 'final', 'namespace'), + 'type' => array('exception', 'int'), + 'literal' => array('false', 'null', 'true', 'this'), + 'operator' => array('and', 'as', 'or', 'xor', 'new', 'instanceof', 'clone'), + 'builtin' => array('array', 'die', 'echo', 'empty', 'eval', 'exit', 'include', 'include_once', 'isset', 'list', 'print', 'require', 'require_once', 'unset') + ), + )); + + $this->addMappings(array( + 'char' => 'string', + 'variable' => 'tag', + 'html' => 'preprocessor', + )); + } +} + +?> diff --git a/inc/hyperlight/preg_helper.php b/inc/hyperlight/preg_helper.php new file mode 100644 index 00000000..798aa193 --- /dev/null +++ b/inc/hyperlight/preg_helper.php @@ -0,0 +1,170 @@ +different modifiers on the individual expressions. The order of + * sub-matches is preserved as well. Numbered back-references are adapted to + * the new overall sub-match count. This means that it's safe to use numbered + * back-refences in the individual expressions! + * If {@link $names} is given, the individual expressions are captured in + * named sub-matches using the contents of that array as names. + * Matching pair-delimiters (e.g. "{…}") are currently + * not supported. + * + * The function assumes that all regular expressions are well-formed. + * Behaviour is undefined if they aren't. + * + * This function was created after a + * {@link http://stackoverflow.com/questions/244959/ StackOverflow discussion}. + * Much of it was written or thought of by “porneL” and “eyelidlessness”. Many + * thanks to both of them. + * + * @param string $glue A string to insert between the individual expressions. + * This should usually be either the empty string, indicating + * concatenation, or the pipe ("|"), indicating alternation. + * Notice that this string might have to be escaped since it is treated + * as a normal character in a regular expression (i.e. "/" will + * end the expression and result in an invalid output). + * @param array $expressions The expressions to merge. The expressions may + * have arbitrary different delimiters and modifiers. + * @param array $names Optional. This is either an empty array or an array of + * strings of the same length as {@link $expressions}. In that case, + * the strings of this array are used to create named sub-matches for the + * expressions. + * @return string An string representing a regular expression equivalent to the + * merged expressions. Returns FALSE if an error occurred. + */ +function preg_merge($glue, array $expressions, array $names = array()) { + // … then, a miracle occurs. + + // Sanity check … + + $use_names = ($names !== null and count($names) !== 0); + + if ( + $use_names and count($names) !== count($expressions) or + !is_string($glue) + ) + return false; + + $result = array(); + // For keeping track of the names for sub-matches. + $names_count = 0; + // For keeping track of *all* captures to re-adjust backreferences. + $capture_count = 0; + + foreach ($expressions as $expression) { + if ($use_names) + $name = str_replace(' ', '_', $names[$names_count++]); + + // Get delimiters and modifiers: + + $stripped = preg_strip($expression); + + if ($stripped === false) + return false; + + list($sub_expr, $modifiers) = $stripped; + + // Re-adjust backreferences: + // TODO What about \R backreferences (\0 isn't allowed, though)? + + // We assume that the expression is correct and therefore don't check + // for matching parentheses. + + $number_of_captures = preg_match_all('/\([^?]|\(\?[^:]/', $sub_expr, $_); + + if ($number_of_captures === false) + return false; + + if ($number_of_captures > 0) { + $backref_expr = '/ + (?" : '?:'; + $new_expr = "($sub_name$sub_modifiers$sub_expr)"; + $result[] = $new_expr; + } + + return '/' . implode($glue, $result) . '/'; +} + +/** + * Strips a regular expression string off its delimiters and modifiers. + * Additionally, normalizes the delimiters (i.e. reformats the pattern so that + * it could have used "/" as delimiter). + * + * @param string $expression The regular expression string to strip. + * @return array An array whose first entry is the expression itself, the + * second an array of delimiters. If the argument is not a valid regular + * expression, returns FALSE. + * + */ +function preg_strip($expression) { + if (preg_match('/^(.)(.*)\\1([imsxeADSUXJu]*)$/s', $expression, $matches) !== 1) + return false; + + $delim = $matches[1]; + $sub_expr = $matches[2]; + if ($delim !== '/') { + // Replace occurrences by the escaped delimiter by its unescaped + // version and escape new delimiter. + $sub_expr = str_replace("\\$delim", $delim, $sub_expr); + $sub_expr = str_replace('/', '\\/', $sub_expr); + } + $modifiers = $matches[3] === '' ? array() : str_split(trim($matches[3])); + + return array($sub_expr, $modifiers); +} + +?> diff --git a/inc/hyperlight/python.php b/inc/hyperlight/python.php new file mode 100644 index 00000000..c7167eef --- /dev/null +++ b/inc/hyperlight/python.php @@ -0,0 +1,64 @@ + + +class PythonLanguage extends HyperLanguage { + public function __construct() { + $this->setInfo(array( + parent::NAME => 'Python', + parent::VERSION => '0.1', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('py')); + + $this->setCaseInsensitive(false); + + $this->addStates(array( + 'init' => array( + 'string', + 'bytes', + 'number', + 'comment', + 'keyword' => array('', 'literal', 'operator'), + 'identifier' + ), + )); + + $this->addRules(array( + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'bytes' => Rule::C_SINGLEQUOTESTRING, + 'number' => Rule::C_NUMBER, + 'comment' => '/#.*/', + 'keyword' => array( + array( + 'assert', 'break', 'class', 'continue', 'def', 'del', + 'elif', 'else', 'except', 'finally', 'for', 'from', + 'global', 'if', 'import', 'in', 'lambda', 'nonlocal', + 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield' + ), + 'literal' => array( + 'False', 'None', 'True' + ), + 'operator' => array( + 'and', 'as', 'is', 'not', 'or' + ) + ), + 'identifier' => Rule::C_IDENTIFIER, + )); + + $this->addMappings(array( + 'bytes' => 'char' + )); + } +} + +?> diff --git a/inc/hyperlight/shell.php b/inc/hyperlight/shell.php new file mode 100644 index 00000000..d8a8191c --- /dev/null +++ b/inc/hyperlight/shell.php @@ -0,0 +1,44 @@ +setInfo(array( + parent::NAME => 'Shell', + )); + $this->setExtensions(array('sh')); + $this->setCaseInsensitive(false); + $this->addStates(array( + 'init' => array( + 'string', + 'char', + 'ticked', + 'comment', + 'keyword' => array('', 'operator'), + 'identifier' + ), + )); + + $this->addRules(array( + 'string' => Rule::C_DOUBLEQUOTESTRING, + 'char' => Rule::C_SINGLEQUOTESTRING, + 'ticked' => "/\`(?:\\\`|.)*\`/sU", + 'comment' => '/#.*/', + 'keyword' => array( + array( + 'break', 'test', 'continue', + 'else', 'for', 'then', + 'if', 'in', 'case', 'esac', 'while', + 'end', 'fi', 'until', 'return', 'elif', 'exit' + ), + 'operator' => '/[;&|!<>\[\]]|&&|\$\(\(|\$\(|\)\)|\)|\(\|\||<<|>>|=|==/', + ), + 'identifier' => Rule::C_IDENTIFIER, + )); + $this->addMappings(array( + 'char' => 'string', + 'ticked' => 'string', + )); + + } +} diff --git a/inc/hyperlight/vb.php b/inc/hyperlight/vb.php new file mode 100644 index 00000000..f6a9327f --- /dev/null +++ b/inc/hyperlight/vb.php @@ -0,0 +1,107 @@ +setInfo(array( + parent::NAME => 'VB', + parent::VERSION => '1.4', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('vb')); + + $this->setCaseInsensitive(true); + + $this->addStates(array( + 'init' => array( + 'string', + 'number', + 'comment' => array('', 'doc'), + 'keyword' => array('', 'type', 'literal', 'operator', 'preprocessor'), + 'date', + 'identifier', + 'operator', + 'whitespace', + ), + 'string' => 'escaped', + 'comment doc' => 'doc', + )); + + $this->addRules(array( + 'whitespace' => Rule::ALL_WHITESPACE, + 'operator' => '/[-+*\/\\\\^&.=,()<>{}]/', + 'string' => new Rule('/"/', '/"c?/i'), + 'number' => '/(?: # Integer followed by optional fractional part. + (?:&(?:H[0-9a-f]+|O[0-7]+)|\d+) + (?:\.\d*)? + (?:e[+-]\d+)? + U?[SILDFR%@!#&]? + ) + | + (?: # Just the fractional part. + (?:\.\d+) + (?:e[+-]\d+)? + [FR!#]? + ) + /ix', + 'escaped' => '/""/', + 'keyword' => array( + array( + 'addhandler', 'addressof', 'alias', 'as', 'byref', 'byval', + 'call', 'case', 'catch', 'cbool', 'cbyte', 'cchar', + 'cdate', 'cdec', 'cdbl', 'cint', 'class', 'clng', 'cobj', + 'const', 'continue', 'csbyte', 'cshort', 'csng', 'cstr', + 'ctype', 'cuint', 'culng', 'cushort', 'declare', 'default', + 'delegate', 'dim', 'directcast', 'do', 'each', 'else', + 'elseif', 'end', 'endif', 'enum', 'erase', 'error', + 'event', 'exit', 'finally', 'for', 'friend', 'function', + 'get', 'gettype', 'getxmlnamespace', 'global', 'gosub', + 'goto', 'handles', 'if', 'implements', 'imports', 'in', + 'inherits', 'interface', 'let', 'lib', 'loop', 'module', + 'mustinherit', 'mustoverride', 'namespace', 'narrowing', + 'next', 'notinheritable', 'notoverridable', 'of', 'on', + 'operator', 'option', 'optional', 'overloads', + 'overridable', 'overrides', 'paramarray', 'partial', + 'private', 'property', 'protected', 'public', 'raiseevent', + 'readonly', 'redim', 'removehandler', 'resume', 'return', + 'select', 'set', 'shadows', 'shared', 'static', 'step', + 'stop', 'structure', 'sub', 'synclock', 'then', 'throw', + 'to', 'try', 'trycast', 'wend', 'using', 'when', 'while', + 'widening', 'with', 'withevents', 'writeonly' + ), + 'type' => array( + 'boolean', 'byte', 'char', 'date', 'decimal', 'double', + 'long', 'integer', 'object', 'sbyte', 'short', 'single', + 'string', 'variant', 'uinteger', 'ulong', 'ushort' + ), + 'literal' => array( + 'false', 'me', 'mybase', 'myclass', 'nothing', 'true' + ), + 'operator' => array( + 'and', 'andalso', 'is', 'isnot', 'like', 'mod', 'new', + 'not', 'or', 'orelse', 'typeof', 'xor' + ), + 'preprocessor' => '/#(?:const|else|elseif|end if|end region|if|region)/i' + ), + 'comment' => array( + "/(?:'{1,2}[^']|rem\s).*/i", + 'doc' => new Rule("/'''/", '/$/m') + ), + 'date' => '/#.+?#/', + 'identifier' => '/[a-z_][a-z_0-9]*|\[.+?\]/i', + 'doc' => '/<(?:".*?"|\'.*?\'|[^>])*>/', + )); + + $this->addMappings(array( + 'whitespace' => '', + 'operator' => '', + 'date' => 'tag', + )); + } +} + +?> diff --git a/inc/hyperlight/vibrant-ink.css b/inc/hyperlight/vibrant-ink.css new file mode 100644 index 00000000..aa2222df --- /dev/null +++ b/inc/hyperlight/vibrant-ink.css @@ -0,0 +1,50 @@ +/* + * Copyright 2008 Konrad Rudolph + * All rights reserved. + * + * Colour scheme based on the Vibrant Ink scheme by Justin Palmer for the + * TextMate text editor. + * http://alternateidea.com/blog/articles/2006/1/3/textmate-vibrant-ink-theme-and-prototype-bundle + */ + +.source-code.vibrant-ink { + background: black; + color: white; +} + +.source-code.vibrant-ink .keyword { color: #F60; font-weight: bold; } +.source-code.vibrant-ink .keyword.literal { color: #FC0; } +.source-code.vibrant-ink .keyword.type { color: #FC0; } +.source-code.vibrant-ink .keyword.builtin { color: #44B4CC; } +.source-code.vibrant-ink .preprocessor { color: #996; } +.source-code.vibrant-ink .comment { color: #93C; } +.source-code.vibrant-ink .comment .doc { color: #399; font-weight: bold; } +.source-code.vibrant-ink .identifier { color: white; } +.source-code.vibrant-ink .string, .source-code.vibrant-ink .char { color: #6F0; } +.source-code.vibrant-ink .escaped { color: #AAA; } +.source-code.vibrant-ink .number, .source-code.vibrant-ink .tag { color: #FFEE98; } +.source-code.vibrant-ink .regex, .source-code.vibrant-ink .attribute { color: #44B4CC; } +.source-code.vibrant-ink .operator { color: #888; } +.source-code.vibrant-ink .keyword.operator { color: #F60; } +.source-code.vibrant-ink .whitespace { background: #333; } +.source-code.vibrant-ink .error { border-bottom: 1px solid red; } + +.source-code.vibrant-ink .tag .attribute { font-style: italic; } +.source-code.vibrant-ink.xml .preprocessor .keyword { color #996; } +.source-code.vibrant-ink.xml .preprocessor .keyword { color: #996; } +.source-code.vibrant-ink.xml .meta, .source-code.vibrant-ink.xml .meta .keyword { color: #399; } + +.source-code.vibrant-ink.cpp .preprocessor .identifier { color: #996; } + +.source-code.vibrant-ink::-moz-selection, +.source-code.vibrant-ink span::-moz-selection { + background: yellow; + color: black; +} + +.source-code.vibrant-ink::selection, +.source-code.vibrant-ink span::selection { + background: yellow; + color: black; +} + diff --git a/inc/hyperlight/wezterm.css b/inc/hyperlight/wezterm.css new file mode 100644 index 00000000..f0e1af1e --- /dev/null +++ b/inc/hyperlight/wezterm.css @@ -0,0 +1,90 @@ +/* For licensing and copyright terms, see the file named LICENSE */ +/* A colour scheme based on Wez Furlong's terminal and vim preferences */ + +.source-code.wezterm { + background: black; + color: rgb(179,179,179); +} + +.source-code.wezterm .php { + /* tomato */ + color: rgb(255, 85, 85); +} + +.source-code.wezterm .comment { + /* cyan */ + color: rgb(85, 255, 255); +} + +.source-code.wezterm .comment .todo { + /* yellow background */ + background-color: rgb(255, 255, 85); + color: black; +} + +.source-code.wezterm .tag { + /* cyan */ + color: rgb(85, 255, 255); +} +.source-code.wezterm .php .tag { + /* yellow */ + color: rgb(255, 255, 85); +} + +.source-code.wezterm .identifier { + /* regular grey text */ + color: rgb(179,179,179); +} + +.source-code.wezterm .tag .identifier { + /* cyan */ + color: rgb(85, 255, 255); +} + +.source-code.wezterm .keyword { + /* yellow */ + color: rgb(255, 255, 85); +} + +.source-code.wezterm .preprocessor, +.source-code.wezterm .preprocessor .identifier, +.source-code.wezterm .keyword.operator, +.source-code.wezterm .keyword.builtin { + /* blue */ + color: rgb(85, 85, 204); +} +.source-code.wezterm .attribute, +.source-code.wezterm .keyword.type { + /* green */ + color: rgb(85, 204, 85); +} +.source-code.wezterm .number, +.source-code.wezterm .keyword.literal, +.source-code.wezterm .string +{ + /* purple */ + color: rgb(255, 85, 255); +} + +.source-code.wezterm::-moz-selection, +.source-code.wezterm span::-moz-selection +{ + background-color: rgb(77, 77, 255); +} + +.source-code.wezterm::selection, +.source-code.wezterm span::selection +{ + background-color: rgb(77, 77, 255); +} + +.source-code.wezterm .preprocessor .tag, +.source-code.wezterm .preprocessor .number { + /* purple like a string. + * this triggers for things like: #include "name" + */ + color: rgb(255, 85, 255); +} +/* vim:ts=2:sw=2:noet: + * */ + diff --git a/inc/hyperlight/wiki.php b/inc/hyperlight/wiki.php new file mode 100644 index 00000000..fc80330a --- /dev/null +++ b/inc/hyperlight/wiki.php @@ -0,0 +1,38 @@ +setInfo(array( + parent::NAME => 'Wiki', + )); + $this->setExtensions(array('wiki')); + $this->setCaseInsensitive(false); + $this->addStates(array( + 'init' => array( + 'bold', + 'macro', + 'link', + 'replink', + 'keyword' => array('operator'), + ), + )); + + $this->addRules(array( + 'bold' => "/'''(?:\\\\'|.)*?'''/s", + 'macro' => "/\[\[.*\]\]/s", + 'link' => "/\[[a-z]+:.*\]/Us", + 'replink' => "/\{[^}]+\}/Us", + 'keyword' => array( + 'operator' => '/=+/', + ), + )); + $this->addMappings(array( + 'bold' => 'string', + 'link' => 'tag', + 'replink' => 'tag', + 'macro' => 'tag', + )); + + } +} diff --git a/inc/hyperlight/xml.php b/inc/hyperlight/xml.php new file mode 100644 index 00000000..95cba7ba --- /dev/null +++ b/inc/hyperlight/xml.php @@ -0,0 +1,55 @@ +setInfo(array( + parent::NAME => 'XML', + parent::VERSION => '0.3', + parent::AUTHOR => array( + parent::NAME => 'Konrad Rudolph', + parent::WEBSITE => 'madrat.net', + parent::EMAIL => 'konrad_rudolph@madrat.net' + ) + )); + + $this->setExtensions(array('xml', 'xsl', 'xslt', 'xsd', 'manifest')); + + $inline = array('entity'); + $common = array('tagname', 'attribute', 'value' => array('double', 'single')); + + $this->addStates(array( + 'init' => array_merge(array('comment', 'cdata', 'tag'), $inline), + 'tag' => array_merge(array('preprocessor', 'meta'), $common), + 'preprocessor' => $common, + 'meta' => $common, + 'value double' => $inline, + 'value single' => $inline, + )); + + $this->addRules(array( + 'comment' => '//s', + 'cdata' => '//', + 'tag' => new Rule('//'), + 'tagname' => '#(?:(?<=<)|(?<= '/[a-z0-9:-]+/i', + 'preprocessor' => new Rule('/\?/'), + 'meta' => new Rule('/!/'), + 'value' => array( + 'double' => new Rule('/"/', '/"/'), + 'single' => new Rule("/'/", "/'/") + ), + 'entity' => '/&.*?;/', + )); + + $this->addMappings(array( + 'attribute' => 'keyword type', + 'cdata' => '', + 'value double' => 'string', + 'value single' => 'string', + 'entity' => 'escaped', + 'tagname' => 'keyword' + )); + } +} + +?> diff --git a/inc/hyperlight/zenburn.css b/inc/hyperlight/zenburn.css new file mode 100644 index 00000000..2f51d840 --- /dev/null +++ b/inc/hyperlight/zenburn.css @@ -0,0 +1,64 @@ +/* + * Copyright 2008 Konrad Rudolph + * All rights reserved. + * + * Color scheme for code is a simplified version of the VIM Zenburn scheme: + * http://slinky.imukuppi.org/zenburn/ + */ + +.source-code.zenburn { + background: #3F3F3F; + color: #DCDCCC; +} + +.source-code.zenburn .comment { + color: #7F9F7F; + font-style: italic; +} + +.source-code.zenburn .comment .todo { + color: #DFDFDF; + font-weight: bold; +} + +.source-code.zenburn .tag { + color: #EFEF8F; +} + +.source-code.zenburn .identifier { + color: #EFDCBC; +} + +.source-code.zenburn .keyword { + color: #F0DFAF; + font-weight: bold; +} + +.source-code.zenburn .keyword.builtin { + color: #EFEF8F; + font-weight: normal; +} + +.source-code.zenburn .keyword.operator { + color: #FFCFAF; +} + +.source-code.zenburn .number { + color: #8CD0D3; +} + +.source-code.zenburn .string { + color: #CC9393; +} + +.source-code.zenburn::-moz-selection, +.source-code.zenburn span::-moz-selection { + background: #70D2B3; + color: #233322; +} + +.source-code.zenburn::selection, +.source-code.zenburn span::selection { + background: yellow; + color: black; +} diff --git a/inc/issue.php b/inc/issue.php new file mode 100644 index 00000000..462da716 --- /dev/null +++ b/inc/issue.php @@ -0,0 +1,847 @@ +fieldname, $this->fieldvalue, $this->tablename, + $this->fieldvalue)) + ->fetchAll(PDO::FETCH_NUM) + as $row) { + $res[$row[0]] = array( + 'name' => $row[0], + 'value' => $row[1], + 'deleted' => $row[2] == '1' ? true : false + ); + } + } else { + foreach (MTrackDB::q(sprintf("select %s from %s where deleted != '1'", + $this->fieldname, $this->tablename))->fetchAll(PDO::FETCH_NUM) + as $row) { + $res[$row[0]] = $row[0]; + } + } + return $res; + } + + function __construct($name = null) { + if ($name !== null) { + list($row) = MTrackDB::q(sprintf( + "select %s, deleted from %s where %s = ?", + $this->fieldvalue, $this->tablename, $this->fieldname), + $name) + ->fetchAll(); + if (isset($row[0])) { + $this->name = $name; + $this->value = $row[0]; + $this->deleted = $row[1]; + $this->new = false; + return; + } + throw new Exception("unable to find $this->tablename with name = $name"); + } + + $this->deleted = false; + } + + function save(MTrackChangeset $CS) { + if ($this->new) { + MTrackDB::q(sprintf('insert into %s (%s, %s, deleted) values (?, ?, ?)', + $this->tablename, $this->fieldname, $this->fieldvalue), + $this->name, $this->value, (int)$this->deleted); + $old = null; + } else { + list($row) = MTrackDB::q( + sprintf('select %s, deleted from %s where %s = ?', + $this->fieldname, $this->tablename, $this->fieldvalue), + $this->name)->fetchAll(); + $old = $row[0]; + MTrackDB::q(sprintf('update %s set %s = ?, deleted = ? where %s = ?', + $this->tablename, $this->fieldvalue, $this->fieldname), + $this->value, (int)$this->deleted, $this->name); + } + $CS->add($this->tablename . ":" . $this->name . ":" . $this->fieldvalue, + $old, $this->value); + + } +} + +class MTrackTicketState extends MTrackEnumeration { + public $tablename = 'ticketstates'; + protected $fieldname = 'statename'; + protected $fieldvalue = 'ordinal'; + + static function loadByName($name) { + return new MTrackTicketState($name); + } +} + + +class MTrackPriority extends MTrackEnumeration { + public $tablename = 'priorities'; + protected $fieldname = 'priorityname'; + protected $fieldvalue = 'value'; + + static function loadByName($name) { + return new MTrackPriority($name); + } +} + +class MTrackSeverity extends MTrackEnumeration { + public $tablename = 'severities'; + protected $fieldname = 'sevname'; + protected $fieldvalue = 'ordinal'; + + static function loadByName($name) { + return new MTrackSeverity($name); + } +} + +class MTrackResolution extends MTrackEnumeration { + public $tablename = 'resolutions'; + protected $fieldname = 'resname'; + protected $fieldvalue = 'ordinal'; + + static function loadByName($name) { + return new MTrackResolution($name); + } +} + +class MTrackClassification extends MTrackEnumeration { + public $tablename = 'classifications'; + protected $fieldname = 'classname'; + protected $fieldvalue = 'ordinal'; + + static function loadByName($name) { + return new MTrackClassification($name); + } +} + +class MTrackComponent { + public $compid = null; + public $name = null; + public $deleted = null; + protected $projects = null; + protected $origprojects = null; + + static function loadById($id) { + return new MTrackComponent($id); + } + + static function loadByName($name) { + $rows = MTrackDB::q('select compid from components where name = ?', + $name)->fetchAll(PDO::FETCH_COLUMN, 0); + if (isset($rows[0])) { + return self::loadById($rows[0]); + } + return null; + } + + function __construct($id = null) { + if ($id !== null) { + list($row) = MTrackDB::q( + 'select name, deleted from components where compid = ?', + $id)->fetchAll(); + if (isset($row[0])) { + $this->compid = $id; + $this->name = $row[0]; + $this->deleted = $row[1]; + return; + } + throw new Exception("unable to find component with id = $id"); + } + $this->deleted = false; + } + + function getProjects() { + if ($this->origprojects === null) { + $this->origprojects = array(); + foreach (MTrackDB::q('select projid from components_by_project where compid = ? order by projid', $this->compid) as $row) { + $this->origprojects[] = $row[0]; + } + $this->projects = $this->origprojects; + } + return $this->projects; + } + + function setProjects($projlist) { + $this->projects = $projlist; + } + + function save(MTrackChangeset $CS) { + if ($this->compid) { + list($row) = MTrackDB::q( + 'select name, deleted from components where compid = ?', + $id)->fetchAll(); + $old = $row; + MTrackDB::q( + 'update components set name = ?, deleted = ? where compid = ?', + $this->name, (int)$this->deleted, $this->compid); + } else { + MTrackDB::q('insert into components (name, deleted) values (?, ?)', + $this->name, (int)$this->deleted); + $this->compid = MTrackDB::lastInsertId('components', 'compid'); + $old = null; + } + $CS->add("component:" . $this->compid . ":name", $old['name'], $this->name); + $CS->add("component:" . $this->compid . ":deleted", $old['deleted'], $this->deleted); + if ($this->projects !== $this->origprojects) { + $old = is_array($this->origprojects) ? + join(",", $this->origprojects) : ''; + $new = is_array($this->projects) ? + join(",", $this->projects) : ''; + MTrackDB::q('delete from components_by_project where compid = ?', + $this->compid); + if (is_array($this->projects)) { + foreach ($this->projects as $pid) { + MTrackDB::q( + 'insert into components_by_project (compid, projid) values (?, ?)', + $this->compid, $pid); + } + } + $CS->add("component:$this->compid:projects", $old, $new); + } + } +} + +class MTrackProject { + public $projid = null; + public $ordinal = 5; + public $name = null; + public $shortname = null; + public $notifyemail = null; + + static function loadById($id) { + return new MTrackProject($id); + } + + static function loadByName($name) { + list($row) = MTrackDB::q('select projid from projects where shortname = ?', + $name)->fetchAll(); + if (isset($row[0])) { + return self::loadById($row[0]); + } + return null; + } + + function __construct($id = null) { + if ($id !== null) { + list($row) = MTrackDB::q( + 'select * from projects where projid = ?', + $id)->fetchAll(); + if (isset($row[0])) { + $this->projid = $row['projid']; + $this->ordinal = $row['ordinal']; + $this->name = $row['name']; + $this->shortname = $row['shortname']; + $this->notifyemail = $row['notifyemail']; + return; + } + throw new Exception("unable to find project with id = $id"); + } + } + + function save(MTrackChangeset $CS) { + if ($this->projid) { + list($row) = MTrackDB::q( + 'select * from projects where projid = ?', + $this->projid)->fetchAll(); + $old = $row; + MTrackDB::q( + 'update projects set ordinal = ?, name = ?, shortname = ?, + notifyemail = ? where projid = ?', + $this->ordinal, $this->name, $this->shortname, + $this->notifyemail, $this->projid); + } else { + MTrackDB::q('insert into projects (ordinal, name, + shortname, notifyemail) values (?, ?, ?, ?)', + $this->ordinal, $this->name, $this->shortname, + $this->notifyemail); + $this->projid = MTrackDB::lastInsertId('projects', 'projid'); + $old = null; + } + $CS->add("project:" . $this->projid . ":name", $old['name'], $this->name); + $CS->add("project:" . $this->projid . ":ordinal", $old['ordinal'], $this->ordinal); + $CS->add("project:" . $this->projid . ":shortname", $old['shortname'], $this->shortname); + $CS->add("project:" . $this->projid . ":notifyemail", $old['notifyemail'], $this->notifyemail); + } + + function _adjust_ticket_link($M) { + $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname"); + if ($M[1] <= $tktlimit) { + return "#$this->shortname$M[1]"; + } + return $M[0]; + } + + function adjust_links($reason, $use_ticket_prefix) + { + if (!$use_ticket_prefix) { + return $reason; + } + + $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname"); + if ($tktlimit !== null) { + $reason = preg_replace_callback('/#(\d+)/', + array($this, '_adjust_ticket_link'), $reason); + } else { +// don't do this if the number is outside the valid ranges +// may need to be clever about this during trac imports +// $reason = preg_replace('/#(\d+)/', "#$this->shortname\$1", $reason); + } +// FIXME: this and the above need to be more intelligent + $reason = preg_replace('/\[(\d+)\]/', "[$this->shortname\$1]", $reason); + return $reason; + } +} + +/* The listener protocol is to return true if all is good, + * or to return either a string or an array of strings that + * detail why a change is not allowed to proceed */ +interface IMTrackIssueListener { + function vetoMilestone(MTrackIssue $issue, + MTrackMilestone $ms, $assoc = true); + function vetoKeyword(MTrackIssue $issue, + MTrackKeyword $kw, $assoc = true); + function vetoComponent(MTrackIssue $issue, + MTrackComponent $comp, $assoc = true); + function vetoProject(MTrackIssue $issue, + MTrackProject $proj, $assoc = true); + function vetoComment(MTrackIssue $issue, $comment); + function vetoSave(MTrackIssue $issue, $oldFields); + + function augmentFormFields(MTrackIssue $issue, &$fieldset); + function applyPOSTData(MTrackIssue $issue, $data); + function augmentSaveParams(MTrackIssue $issue, &$params); + function augmentIndexerFields(MTrackIssue $issue, &$idx); +} + +class MTrackVetoException extends Exception { + public $reasons; + + function __construct($reasons) { + $this->reasons = $reasons; + parent::__construct(join("\n", $reasons)); + } +} + +class MTrackIssue { + public $tid = null; + public $nsident = null; + public $summary = null; + public $description = null; + public $created = null; + public $updated = null; + public $owner = null; + public $priority = null; + public $severity = null; + public $classification = null; + public $resolution = null; + public $status = null; + public $estimated = null; + public $spent = null; + public $changelog = null; + public $cc = null; + protected $components = null; + protected $origcomponents = null; + protected $milestones = null; + protected $origmilestones = null; + protected $comments_to_add = array(); + protected $keywords = null; + protected $origkeywords = null; + protected $effort = array(); + + static $_listeners = array(); + + static function loadById($id) { + try { + return new MTrackIssue($id); + } catch (Exception $e) { + } + return null; + } + + static function loadByNSIdent($id) { + static $cache = array(); + if (!isset($cache[$id])) { + $ids = MTrackDB::q('select tid from tickets where nsident = ?', $id) + ->fetchAll(PDO::FETCH_COLUMN, 0); + if (count($ids) == 1) { + $cache[$id] = $ids[0]; + } else { + return null; + } + } + return new MTrackIssue($cache[$id]); + } + + static function registerListener(IMTrackIssueListener $l) + { + self::$_listeners[] = $l; + } + + function __construct($tid = null) { + if ($tid === null) { + $this->components = array(); + $this->origcomponents = array(); + $this->milestones = array(); + $this->origmilestones = array(); + $this->keywords = array(); + $this->origkeywords = array(); + $this->status = 'new'; + + foreach (array('classification', 'severity', 'priority') as $f) { + $this->$f = MTrackConfig::get('ticket', "default.$f"); + } + } else { + $data = MTrackDB::q('select * from tickets where tid = ?', + $tid)->fetchAll(); + if (isset($data[0])) { + $row = $data[0]; + } else { + $row = null; + } + if (!is_array($row)) { + throw new Exception("no such issue $tid"); + } + foreach ($row as $k => $v) { + $this->$k = $v; + } + } + } + + function applyPOSTData($data) { + foreach (self::$_listeners as $l) { + $l->applyPOSTData($this, $data); + } + } + + function augmentFormFields(&$FIELDSET) { + foreach (self::$_listeners as $l) { + $l->augmentFormFields($this, $FIELDSET); + } + } + function augmentIndexerFields(&$idx) { + foreach (self::$_listeners as $l) { + $l->augmentIndexerFields($this, $idx); + } + } + function augmentSaveParams(&$params) { + foreach (self::$_listeners as $l) { + $l->augmentSaveParams($this, $params); + } + } + + function checkVeto() + { + $args = func_get_args(); + $method = array_shift($args); + $veto = array(); + + foreach (self::$_listeners as $l) { + $v = call_user_func_array(array($l, $method), $args); + if ($v !== true) { + $veto[] = $v; + } + } + if (count($veto)) { + $reasons = array(); + foreach ($veto as $r) { + if (is_array($r)) { + foreach ($r as $m) { + $reasons[] = $m; + } + } else { + $reasons[] = $r; + } + } + throw new MTrackVetoException($reasons); + } + } + + function save(MTrackChangeset $CS) + { + $db = MTrackDB::get(); + $reindex = false; + + if ($this->tid === null) { + $this->created = $CS->cid; + $oldrow = array(); + $reindex = true; + } else { + list($oldrow) = MTrackDB::q('select * from tickets where tid = ?', + $this->tid)->fetchAll(); + } + + $this->checkVeto('vetoSave', $this, $oldrow); + + $this->updated = $CS->cid; + + $params = array( + 'summary' => $this->summary, + 'description' => $this->description, + 'created' => $this->created, + 'updated' => $this->updated, + 'owner' => $this->owner, + 'changelog' => $this->changelog, + 'priority' => $this->priority, + 'severity' => $this->severity, + 'classification' => $this->classification, + 'resolution' => $this->resolution, + 'status' => $this->status, + 'estimated' => (float)$this->estimated, + 'spent' => (float)$this->spent, + 'nsident' => $this->nsident, + 'cc' => $this->cc, + ); + + $this->augmentSaveParams($params); + + if ($this->tid === null) { + $sql = 'insert into tickets '; + $keys = array(); + $values = array(); + + $new_tid = new OmniTI_Util_UUID; + $new_tid = $new_tid->toRFC4122String(false); + + $keys[] = "tid"; + $values[] = "'$new_tid'"; + + foreach ($params as $key => $value) { + $keys[] = $key; + $values[] = ":$key"; + } + + $sql .= "(" . join(', ', $keys) . ") values (" . + join(', ', $values) . ")"; + } else { + $sql = 'update tickets set '; + $values = array(); + foreach ($params as $key => $value) { + $values[] = "$key = :$key"; + } + $sql .= join(', ', $values) . " where tid = :tid"; + + $params['tid'] = $this->tid; + } + + $q = $db->prepare($sql); + $q->execute($params); + + if ($this->tid === null) { + $this->tid = $new_tid; + $created = true; + } else { + $created = false; + } + + foreach ($params as $key => $value) { + if ($key == 'created' || $key == 'updated' || $key == 'tid') { + continue; + } + if ($key == 'changelog' || $key == 'description' || $key == 'summary') { + if (!isset($oldrow[$key]) || $oldrow[$key] != $value) { + $reindex = true; + } + } + if (!isset($oldrow[$key])) { + $oldrow[$key] = null; + } + $CS->add("ticket:$this->tid:$key", $oldrow[$key], $value); + } + + $this->compute_diff($CS, 'components', 'ticket_components', 'compid', + $this->components, $this->origcomponents); + $this->compute_diff($CS, 'keywords', 'ticket_keywords', 'kid', + $this->keywords, $this->origkeywords); + $this->compute_diff($CS, 'milestones', 'ticket_milestones', 'mid', + $this->milestones, $this->origmilestones); + + foreach ($this->comments_to_add as $text) { + $CS->add("ticket:$this->tid:@comment", null, $text); + } + + foreach ($this->effort as $effort) { + MTrackDB::q('insert into effort (tid, cid, expended, remaining) + values (?, ?, ?, ?)', + $this->tid, $CS->cid, $effort[0], $effort[1]); + } + $this->effort = array(); + } + + static function index_issue($object) + { + list($ignore, $ident) = explode(':', $object, 2); + $i = MTrackIssue::loadById($ident); + if (!$i) return; + echo "Ticket #$i->nsident\n"; + + $CS = MTrackChangeset::get($i->updated); + $CSC = MTrackChangeset::get($i->created); + + $kw = join(' ', array_values($i->getKeywords())); + $idx = array( + 'summary' => $i->summary, + 'description' => $i->description, + 'changelog' => $i->changelog, + 'keyword' => $kw, + 'stored:date' => $CS->when, + 'who' => $CS->who, + 'creator' => $CSC->who, + 'stored:created' => $CSC->when, + 'owner' => $i->owner + ); + $i->augmentIndexerFields($idx); + MTrackSearchDB::add("ticket:$i->tid", $idx, true); + + foreach (MTrackDB::q('select value, changedate, who from + change_audit left join changes using (cid) where fieldname = ?', + "ticket:$ident:@comment") as $row) { + list($text, $when, $who) = $row; + $start = time(); + $id = sha1($text); + $elapsed = time() - $start; + if ($elapsed > 4) { + echo " - comment $who $when took $elapsed to hash\n"; + } + $start = time(); + if (strlen($text) > 8192) { + // A huge paste into a ticket + $text = substr($text, 0, 8192); + } + MTrackSearchDB::add("ticket:$ident:comment:$id", array( + 'description' => $text, + 'stored:date' => $when, + 'who' => $who, + ), true); + + $elapsed = time() - $start; + if ($elapsed > 4) { + echo " - comment $who $when took $elapsed to index\n"; + } + } + } + + private function compute_diff(MTrackChangeset $CS, $label, + $tablename, $keyname, $current, $orig) { + if (!is_array($current)) { + $current = array(); + } + if (!is_array($orig)) { + $orig = array(); + } + $added = array_keys(array_diff_key($current, $orig)); + $removed = array_keys(array_diff_key($orig, $current)); + + $db = MTrackDB::get(); + $ADD = $db->prepare( + "insert into $tablename (tid, $keyname) values (?, ?)"); + $DEL = $db->prepare( + "delete from $tablename where tid = ? AND $keyname = ?"); + foreach ($added as $key) { + $ADD->execute(array($this->tid, $key)); + } + foreach ($removed as $key) { + $DEL->execute(array($this->tid, $key)); + } + if (count($added) + count($removed)) { + $old = join(',', array_keys($orig)); + $new = join(',', array_keys($current)); + $CS->add( + "ticket:$this->tid:@$label", $old, $new); + } + } + function getComponents() + { + if ($this->components === null) { + $comps = MTrackDB::q('select tc.compid, name from ticket_components tc left join components using (compid) where tid = ?', $this->tid)->fetchAll(); + $this->origcomponents = array(); + foreach ($comps as $row) { + $this->origcomponents[$row[0]] = $row[1]; + } + $this->components = $this->origcomponents; + } + return $this->components; + } + + private function resolveComponent($comp) + { + if ($comp instanceof MTrackComponent) { + return $comp; + } + if (ctype_digit($comp)) { + return MTrackComponent::loadById($comp); + } + return MTrackComponent::loadByName($comp); + } + + function assocComponent($comp) + { + $comp = $this->resolveComponent($comp); + $this->getComponents(); + $this->checkVeto('vetoComponent', $this, $comp, true); + $this->components[$comp->compid] = $comp->name; + } + + function dissocComponent($comp) + { + $comp = $this->resolveComponent($comp); + $this->getComponents(); + $this->checkVeto('vetoComponent', $this, $comp, false); + unset($this->components[$comp->compid]); + } + + function getMilestones() + { + if ($this->milestones === null) { + $comps = MTrackDB::q('select tc.mid, name from ticket_milestones tc left join milestones using (mid) where tid = ? order by duedate, name', $this->tid)->fetchAll(); + $this->origmilestones = array(); + foreach ($comps as $row) { + $this->origmilestones[$row[0]] = $row[1]; + } + $this->milestones = $this->origmilestones; + } + return $this->milestones; + } + + private function resolveMilestone($ms) + { + if ($ms instanceof MTrackMilestone) { + return $ms; + } + if (ctype_digit($ms)) { + return MTrackMilestone::loadById($ms); + } + return MTrackMilestone::loadByName($ms); + } + + function assocMilestone($M) + { + $ms = $this->resolveMilestone($M); + if ($ms === null) { + throw new Exception("unable to resolve milestone $M"); + } + $this->getMilestones(); + $this->checkVeto('vetoMilestone', $this, $ms, true); + $this->milestones[$ms->mid] = $ms->name; + } + + function dissocMilestone($M) + { + $ms = $this->resolveMilestone($M); + if ($ms === null) { + throw new Exception("unable to resolve milestone $M"); + } + $this->getMilestones(); + $this->checkVeto('vetoMilestone', $this, $ms, false); + unset($this->milestones[$ms->mid]); + } + + function addComment($comment) + { + $comment = trim($comment); + if (strlen($comment)) { + $this->checkVeto('vetoComment', $this, $comment); + $this->comments_to_add[] = $comment; + } + } + + private function resolveKeyword($kw) + { + if ($kw instanceof MTrackKeyword) { + return $kw; + } + $k = MTrackKeyword::loadByWord($kw); + if ($k === null) { + if (ctype_digit($kw)) { + return MTrackKeyword::loadById($kw); + } + throw new Exception("unknown keyword $kw"); + } + return $k; + } + + function assocKeyword($kw) + { + $kw = $this->resolveKeyword($kw); + $this->getKeywords(); + $this->checkVeto('vetoKeyword', $this, $kw, true); + $this->keywords[$kw->kid] = $kw->keyword; + } + + function dissocKeyword($kw) + { + $kw = $this->resolveKeyword($kw); + $this->getKeywords(); + $this->checkVeto('vetoKeyword', $this, $kw, false); + unset($this->keywords[$kw->kid]); + } + + function getKeywords() + { + if ($this->keywords === null) { + $comps = MTrackDB::q('select tc.kid, keyword from ticket_keywords tc left join keywords using (kid) where tid = ?', $this->tid)->fetchAll(); + $this->origkeywords = array(); + foreach ($comps as $row) { + $this->origkeywords[$row[0]] = $row[1]; + } + $this->keywords = $this->origkeywords; + } + return $this->keywords; + } + + function addEffort($amount, $revised = null) + { + $diff = null; + if ($revised !== null) { + $diff = $revised - $this->estimated; + $this->estimated = $revised; + } + $this->effort[] = array($amount, $diff); + $this->spent += $amount; + } + + function close() + { + $this->status = 'closed'; + $this->addEffort(0, 0); + } + + function isOpen() + { + switch ($this->status) { + case 'closed': + return false; + default: + return true; + } + } + + function reOpen() + { + $this->status = 'reopened'; + $this->resolution = null; + } + +} + +MTrackSearchDB::register_indexer('ticket', array('MTrackIssue', 'index_issue')); +MTrackACL::registerAncestry('enum', 'Enumerations'); +MTrackACL::registerAncestry("component", 'Components'); +MTrackACL::registerAncestry("project", 'Projects'); +MTrackACL::registerAncestry("ticket", "Tickets"); +MTrackWatch::registerEventTypes('ticket', array( + 'ticket' => 'Tickets' +)); diff --git a/inc/keywords.php b/inc/keywords.php new file mode 100644 index 00000000..1d08b6d1 --- /dev/null +++ b/inc/keywords.php @@ -0,0 +1,39 @@ +fetchAll() as $row) { + return new MTrackKeyword($row[0]); + } + return null; + } + + function __construct($id = null) + { + if ($id !== null) { + list($row) = MTrackDB::q('select keyword from keywords where kid = ?', + $id)->fetchAll(); + $this->kid = $id; + $this->keyword = $row[0]; + return; + } + } + + function save(MTrackChangeset $CS) + { + if ($this->kid === null) { + MTrackDB::q('insert into keywords (keyword) values (?)', $this->keyword); + $this->kid = MTrackDB::lastInsertId('keywords', 'kid'); + $CS->add("keywords:keyword", null, $this->keyword); + } else { + throw new Exception("not allowed to rename keywords"); + } + } +} + diff --git a/inc/lib/Auth/COPYING b/inc/lib/Auth/COPYING new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/inc/lib/Auth/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/inc/lib/Auth/OpenID.php b/inc/lib/Auth/OpenID.php new file mode 100644 index 00000000..6556b5b0 --- /dev/null +++ b/inc/lib/Auth/OpenID.php @@ -0,0 +1,552 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * The library version string + */ +define('Auth_OpenID_VERSION', '2.1.2'); + +/** + * Require the fetcher code. + */ +require_once "Auth/Yadis/PlainHTTPFetcher.php"; +require_once "Auth/Yadis/ParanoidHTTPFetcher.php"; +require_once "Auth/OpenID/BigMath.php"; +require_once "Auth/OpenID/URINorm.php"; + +/** + * Status code returned by the server when the only option is to show + * an error page, since we do not have enough information to redirect + * back to the consumer. The associated value is an error message that + * should be displayed on an HTML error page. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_LOCAL_ERROR', 'local_error'); + +/** + * Status code returned when there is an error to return in key-value + * form to the consumer. The caller should return a 400 Bad Request + * response with content-type text/plain and the value as the body. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_REMOTE_ERROR', 'remote_error'); + +/** + * Status code returned when there is a key-value form OK response to + * the consumer. The value associated with this code is the + * response. The caller should return a 200 OK response with + * content-type text/plain and the value as the body. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_REMOTE_OK', 'remote_ok'); + +/** + * Status code returned when there is a redirect back to the + * consumer. The value is the URL to redirect back to. The caller + * should return a 302 Found redirect with a Location: header + * containing the URL. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_REDIRECT', 'redirect'); + +/** + * Status code returned when the caller needs to authenticate the + * user. The associated value is a {@link Auth_OpenID_ServerRequest} + * object that can be used to complete the authentication. If the user + * has taken some authentication action, use the retry() method of the + * {@link Auth_OpenID_ServerRequest} object to complete the request. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_DO_AUTH', 'do_auth'); + +/** + * Status code returned when there were no OpenID arguments + * passed. This code indicates that the caller should return a 200 OK + * response and display an HTML page that says that this is an OpenID + * server endpoint. + * + * @see Auth_OpenID_Server + */ +define('Auth_OpenID_DO_ABOUT', 'do_about'); + +/** + * Defines for regexes and format checking. + */ +define('Auth_OpenID_letters', + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"); + +define('Auth_OpenID_digits', + "0123456789"); + +define('Auth_OpenID_punct', + "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"); + +if (Auth_OpenID_getMathLib() === null) { + Auth_OpenID_setNoMathSupport(); +} + +/** + * The OpenID utility function class. + * + * @package OpenID + * @access private + */ +class Auth_OpenID { + + /** + * Return true if $thing is an Auth_OpenID_FailureResponse object; + * false if not. + * + * @access private + */ + function isFailure($thing) + { + return is_a($thing, 'Auth_OpenID_FailureResponse'); + } + + /** + * Gets the query data from the server environment based on the + * request method used. If GET was used, this looks at + * $_SERVER['QUERY_STRING'] directly. If POST was used, this + * fetches data from the special php://input file stream. + * + * Returns an associative array of the query arguments. + * + * Skips invalid key/value pairs (i.e. keys with no '=value' + * portion). + * + * Returns an empty array if neither GET nor POST was used, or if + * POST was used but php://input cannot be opened. + * + * @access private + */ + function getQuery($query_str=null) + { + $data = array(); + + if ($query_str !== null) { + $data = Auth_OpenID::params_from_string($query_str); + } else if (!array_key_exists('REQUEST_METHOD', $_SERVER)) { + // Do nothing. + } else { + // XXX HACK FIXME HORRIBLE. + // + // POSTing to a URL with query parameters is acceptable, but + // we don't have a clean way to distinguish those parameters + // when we need to do things like return_to verification + // which only want to look at one kind of parameter. We're + // going to emulate the behavior of some other environments + // by defaulting to GET and overwriting with POST if POST + // data is available. + $data = Auth_OpenID::params_from_string($_SERVER['QUERY_STRING']); + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $str = file_get_contents('php://input'); + + if ($str === false) { + $post = array(); + } else { + $post = Auth_OpenID::params_from_string($str); + } + + $data = array_merge($data, $post); + } + } + + return $data; + } + + function params_from_string($str) + { + $chunks = explode("&", $str); + + $data = array(); + foreach ($chunks as $chunk) { + $parts = explode("=", $chunk, 2); + + if (count($parts) != 2) { + continue; + } + + list($k, $v) = $parts; + $data[$k] = urldecode($v); + } + + return $data; + } + + /** + * Create dir_name as a directory if it does not exist. If it + * exists, make sure that it is, in fact, a directory. Returns + * true if the operation succeeded; false if not. + * + * @access private + */ + function ensureDir($dir_name) + { + if (is_dir($dir_name) || @mkdir($dir_name)) { + return true; + } else { + $parent_dir = dirname($dir_name); + + // Terminal case; there is no parent directory to create. + if ($parent_dir == $dir_name) { + return true; + } + + return (Auth_OpenID::ensureDir($parent_dir) && @mkdir($dir_name)); + } + } + + /** + * Adds a string prefix to all values of an array. Returns a new + * array containing the prefixed values. + * + * @access private + */ + function addPrefix($values, $prefix) + { + $new_values = array(); + foreach ($values as $s) { + $new_values[] = $prefix . $s; + } + return $new_values; + } + + /** + * Convenience function for getting array values. Given an array + * $arr and a key $key, get the corresponding value from the array + * or return $default if the key is absent. + * + * @access private + */ + function arrayGet($arr, $key, $fallback = null) + { + if (is_array($arr)) { + if (array_key_exists($key, $arr)) { + return $arr[$key]; + } else { + return $fallback; + } + } else { + trigger_error("Auth_OpenID::arrayGet (key = ".$key.") expected " . + "array as first parameter, got " . + gettype($arr), E_USER_WARNING); + + return false; + } + } + + /** + * Replacement for PHP's broken parse_str. + */ + function parse_str($query) + { + if ($query === null) { + return null; + } + + $parts = explode('&', $query); + + $new_parts = array(); + for ($i = 0; $i < count($parts); $i++) { + $pair = explode('=', $parts[$i]); + + if (count($pair) != 2) { + continue; + } + + list($key, $value) = $pair; + $new_parts[$key] = urldecode($value); + } + + return $new_parts; + } + + /** + * Implements the PHP 5 'http_build_query' functionality. + * + * @access private + * @param array $data Either an array key/value pairs or an array + * of arrays, each of which holding two values: a key and a value, + * sequentially. + * @return string $result The result of url-encoding the key/value + * pairs from $data into a URL query string + * (e.g. "username=bob&id=56"). + */ + function httpBuildQuery($data) + { + $pairs = array(); + foreach ($data as $key => $value) { + if (is_array($value)) { + $pairs[] = urlencode($value[0])."=".urlencode($value[1]); + } else { + $pairs[] = urlencode($key)."=".urlencode($value); + } + } + return implode("&", $pairs); + } + + /** + * "Appends" query arguments onto a URL. The URL may or may not + * already have arguments (following a question mark). + * + * @access private + * @param string $url A URL, which may or may not already have + * arguments. + * @param array $args Either an array key/value pairs or an array of + * arrays, each of which holding two values: a key and a value, + * sequentially. If $args is an ordinary key/value array, the + * parameters will be added to the URL in sorted alphabetical order; + * if $args is an array of arrays, their order will be preserved. + * @return string $url The original URL with the new parameters added. + * + */ + function appendArgs($url, $args) + { + if (count($args) == 0) { + return $url; + } + + // Non-empty array; if it is an array of arrays, use + // multisort; otherwise use sort. + if (array_key_exists(0, $args) && + is_array($args[0])) { + // Do nothing here. + } else { + $keys = array_keys($args); + sort($keys); + $new_args = array(); + foreach ($keys as $key) { + $new_args[] = array($key, $args[$key]); + } + $args = $new_args; + } + + $sep = '?'; + if (strpos($url, '?') !== false) { + $sep = '&'; + } + + return $url . $sep . Auth_OpenID::httpBuildQuery($args); + } + + /** + * Implements python's urlunparse, which is not available in PHP. + * Given the specified components of a URL, this function rebuilds + * and returns the URL. + * + * @access private + * @param string $scheme The scheme (e.g. 'http'). Defaults to 'http'. + * @param string $host The host. Required. + * @param string $port The port. + * @param string $path The path. + * @param string $query The query. + * @param string $fragment The fragment. + * @return string $url The URL resulting from assembling the + * specified components. + */ + function urlunparse($scheme, $host, $port = null, $path = '/', + $query = '', $fragment = '') + { + + if (!$scheme) { + $scheme = 'http'; + } + + if (!$host) { + return false; + } + + if (!$path) { + $path = ''; + } + + $result = $scheme . "://" . $host; + + if ($port) { + $result .= ":" . $port; + } + + $result .= $path; + + if ($query) { + $result .= "?" . $query; + } + + if ($fragment) { + $result .= "#" . $fragment; + } + + return $result; + } + + /** + * Given a URL, this "normalizes" it by adding a trailing slash + * and / or a leading http:// scheme where necessary. Returns + * null if the original URL is malformed and cannot be normalized. + * + * @access private + * @param string $url The URL to be normalized. + * @return mixed $new_url The URL after normalization, or null if + * $url was malformed. + */ + function normalizeUrl($url) + { + @$parsed = parse_url($url); + + if (!$parsed) { + return null; + } + + if (isset($parsed['scheme']) && + isset($parsed['host'])) { + $scheme = strtolower($parsed['scheme']); + if (!in_array($scheme, array('http', 'https'))) { + return null; + } + } else { + $url = 'http://' . $url; + } + + $normalized = Auth_OpenID_urinorm($url); + if ($normalized === null) { + return null; + } + list($defragged, $frag) = Auth_OpenID::urldefrag($normalized); + return $defragged; + } + + /** + * Replacement (wrapper) for PHP's intval() because it's broken. + * + * @access private + */ + function intval($value) + { + $re = "/^\\d+$/"; + + if (!preg_match($re, $value)) { + return false; + } + + return intval($value); + } + + /** + * Count the number of bytes in a string independently of + * multibyte support conditions. + * + * @param string $str The string of bytes to count. + * @return int The number of bytes in $str. + */ + function bytes($str) + { + return strlen(bin2hex($str)) / 2; + } + + /** + * Get the bytes in a string independently of multibyte support + * conditions. + */ + function toBytes($str) + { + $hex = bin2hex($str); + + if (!$hex) { + return array(); + } + + $b = array(); + for ($i = 0; $i < strlen($hex); $i += 2) { + $b[] = chr(base_convert(substr($hex, $i, 2), 16, 10)); + } + + return $b; + } + + function urldefrag($url) + { + $parts = explode("#", $url, 2); + + if (count($parts) == 1) { + return array($parts[0], ""); + } else { + return $parts; + } + } + + function filter($callback, &$sequence) + { + $result = array(); + + foreach ($sequence as $item) { + if (call_user_func_array($callback, array($item))) { + $result[] = $item; + } + } + + return $result; + } + + function update(&$dest, &$src) + { + foreach ($src as $k => $v) { + $dest[$k] = $v; + } + } + + /** + * Wrap PHP's standard error_log functionality. Use this to + * perform all logging. It will interpolate any additional + * arguments into the format string before logging. + * + * @param string $format_string The sprintf format for the message + */ + function log($format_string) + { + $args = func_get_args(); + $message = call_user_func_array('sprintf', $args); + error_log($message); + } + + function autoSubmitHTML($form, $title="OpenId transaction in progress") + { + return("". + "". + $title . + "". + "". + $form . + "". + "". + ""); + } +} +?> diff --git a/inc/lib/Auth/OpenID/AX.php b/inc/lib/Auth/OpenID/AX.php new file mode 100644 index 00000000..4a617ae3 --- /dev/null +++ b/inc/lib/Auth/OpenID/AX.php @@ -0,0 +1,1023 @@ +message = $message; + } +} + +/** + * Abstract class containing common code for attribute exchange + * messages. + * + * @package OpenID + */ +class Auth_OpenID_AX_Message extends Auth_OpenID_Extension { + /** + * ns_alias: The preferred namespace alias for attribute exchange + * messages + */ + var $ns_alias = 'ax'; + + /** + * mode: The type of this attribute exchange message. This must be + * overridden in subclasses. + */ + var $mode = null; + + var $ns_uri = Auth_OpenID_AX_NS_URI; + + /** + * Return Auth_OpenID_AX_Error if the mode in the attribute + * exchange arguments does not match what is expected for this + * class; true otherwise. + * + * @access private + */ + function _checkMode($ax_args) + { + $mode = Auth_OpenID::arrayGet($ax_args, 'mode'); + if ($mode != $this->mode) { + return new Auth_OpenID_AX_Error( + sprintf( + "Expected mode '%s'; got '%s'", + $this->mode, $mode)); + } + + return true; + } + + /** + * Return a set of attribute exchange arguments containing the + * basic information that must be in every attribute exchange + * message. + * + * @access private + */ + function _newArgs() + { + return array('mode' => $this->mode); + } +} + +/** + * Represents a single attribute in an attribute exchange + * request. This should be added to an AXRequest object in order to + * request the attribute. + * + * @package OpenID + */ +class Auth_OpenID_AX_AttrInfo { + /** + * Construct an attribute information object. Do not call this + * directly; call make(...) instead. + * + * @param string $type_uri The type URI for this attribute. + * + * @param int $count The number of values of this type to request. + * + * @param bool $required Whether the attribute will be marked as + * required in the request. + * + * @param string $alias The name that should be given to this + * attribute in the request. + */ + function Auth_OpenID_AX_AttrInfo($type_uri, $count, $required, + $alias) + { + /** + * required: Whether the attribute will be marked as required + * when presented to the subject of the attribute exchange + * request. + */ + $this->required = $required; + + /** + * count: How many values of this type to request from the + * subject. Defaults to one. + */ + $this->count = $count; + + /** + * type_uri: The identifier that determines what the attribute + * represents and how it is serialized. For example, one type + * URI representing dates could represent a Unix timestamp in + * base 10 and another could represent a human-readable + * string. + */ + $this->type_uri = $type_uri; + + /** + * alias: The name that should be given to this attribute in + * the request. If it is not supplied, a generic name will be + * assigned. For example, if you want to call a Unix timestamp + * value 'tstamp', set its alias to that value. If two + * attributes in the same message request to use the same + * alias, the request will fail to be generated. + */ + $this->alias = $alias; + } + + /** + * Construct an attribute information object. For parameter + * details, see the constructor. + */ + function make($type_uri, $count=1, $required=false, + $alias=null) + { + if ($alias !== null) { + $result = Auth_OpenID_AX_checkAlias($alias); + + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + } + + return new Auth_OpenID_AX_AttrInfo($type_uri, $count, $required, + $alias); + } + + /** + * When processing a request for this attribute, the OP should + * call this method to determine whether all available attribute + * values were requested. If self.count == UNLIMITED_VALUES, this + * returns True. Otherwise this returns False, in which case + * self.count is an integer. + */ + function wantsUnlimitedValues() + { + return $this->count === Auth_OpenID_AX_UNLIMITED_VALUES; + } +} + +/** + * Given a namespace mapping and a string containing a comma-separated + * list of namespace aliases, return a list of type URIs that + * correspond to those aliases. + * + * @param $namespace_map The mapping from namespace URI to alias + * @param $alias_list_s The string containing the comma-separated + * list of aliases. May also be None for convenience. + * + * @return $seq The list of namespace URIs that corresponds to the + * supplied list of aliases. If the string was zero-length or None, an + * empty list will be returned. + * + * return null If an alias is present in the list of aliases but + * is not present in the namespace map. + */ +function Auth_OpenID_AX_toTypeURIs(&$namespace_map, $alias_list_s) +{ + $uris = array(); + + if ($alias_list_s) { + foreach (explode(',', $alias_list_s) as $alias) { + $type_uri = $namespace_map->getNamespaceURI($alias); + if ($type_uri === null) { + // raise KeyError( + // 'No type is defined for attribute name %r' % (alias,)) + return new Auth_OpenID_AX_Error( + sprintf('No type is defined for attribute name %s', + $alias) + ); + } else { + $uris[] = $type_uri; + } + } + } + + return $uris; +} + +/** + * An attribute exchange 'fetch_request' message. This message is sent + * by a relying party when it wishes to obtain attributes about the + * subject of an OpenID authentication request. + * + * @package OpenID + */ +class Auth_OpenID_AX_FetchRequest extends Auth_OpenID_AX_Message { + + var $mode = 'fetch_request'; + + function Auth_OpenID_AX_FetchRequest($update_url=null) + { + /** + * requested_attributes: The attributes that have been + * requested thus far, indexed by the type URI. + */ + $this->requested_attributes = array(); + + /** + * update_url: A URL that will accept responses for this + * attribute exchange request, even in the absence of the user + * who made this request. + */ + $this->update_url = $update_url; + } + + /** + * Add an attribute to this attribute exchange request. + * + * @param attribute: The attribute that is being requested + * @return true on success, false when the requested attribute is + * already present in this fetch request. + */ + function add($attribute) + { + if ($this->contains($attribute->type_uri)) { + return new Auth_OpenID_AX_Error( + sprintf("The attribute %s has already been requested", + $attribute->type_uri)); + } + + $this->requested_attributes[$attribute->type_uri] = $attribute; + + return true; + } + + /** + * Get the serialized form of this attribute fetch request. + * + * @returns Auth_OpenID_AX_FetchRequest The fetch request message parameters + */ + function getExtensionArgs() + { + $aliases = new Auth_OpenID_NamespaceMap(); + + $required = array(); + $if_available = array(); + + $ax_args = $this->_newArgs(); + + foreach ($this->requested_attributes as $type_uri => $attribute) { + if ($attribute->alias === null) { + $alias = $aliases->add($type_uri); + } else { + $alias = $aliases->addAlias($type_uri, $attribute->alias); + + if ($alias === null) { + return new Auth_OpenID_AX_Error( + sprintf("Could not add alias %s for URI %s", + $attribute->alias, $type_uri + )); + } + } + + if ($attribute->required) { + $required[] = $alias; + } else { + $if_available[] = $alias; + } + + if ($attribute->count != 1) { + $ax_args['count.' . $alias] = strval($attribute->count); + } + + $ax_args['type.' . $alias] = $type_uri; + } + + if ($required) { + $ax_args['required'] = implode(',', $required); + } + + if ($if_available) { + $ax_args['if_available'] = implode(',', $if_available); + } + + return $ax_args; + } + + /** + * Get the type URIs for all attributes that have been marked as + * required. + * + * @return A list of the type URIs for attributes that have been + * marked as required. + */ + function getRequiredAttrs() + { + $required = array(); + foreach ($this->requested_attributes as $type_uri => $attribute) { + if ($attribute->required) { + $required[] = $type_uri; + } + } + + return $required; + } + + /** + * Extract a FetchRequest from an OpenID message + * + * @param request: The OpenID request containing the attribute + * fetch request + * + * @returns mixed An Auth_OpenID_AX_Error or the + * Auth_OpenID_AX_FetchRequest extracted from the request message if + * successful + */ + function &fromOpenIDRequest($request) + { + $m = $request->message; + $obj = new Auth_OpenID_AX_FetchRequest(); + $ax_args = $m->getArgs($obj->ns_uri); + + $result = $obj->parseExtensionArgs($ax_args); + + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + + if ($obj->update_url) { + // Update URL must match the openid.realm of the + // underlying OpenID 2 message. + $realm = $m->getArg(Auth_OpenID_OPENID_NS, 'realm', + $m->getArg( + Auth_OpenID_OPENID_NS, + 'return_to')); + + if (!$realm) { + $obj = new Auth_OpenID_AX_Error( + sprintf("Cannot validate update_url %s " . + "against absent realm", $obj->update_url)); + } else if (!Auth_OpenID_TrustRoot::match($realm, + $obj->update_url)) { + $obj = new Auth_OpenID_AX_Error( + sprintf("Update URL %s failed validation against realm %s", + $obj->update_url, $realm)); + } + } + + return $obj; + } + + /** + * Given attribute exchange arguments, populate this FetchRequest. + * + * @return $result Auth_OpenID_AX_Error if the data to be parsed + * does not follow the attribute exchange specification. At least + * when 'if_available' or 'required' is not specified for a + * particular attribute type. Returns true otherwise. + */ + function parseExtensionArgs($ax_args) + { + $result = $this->_checkMode($ax_args); + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + + $aliases = new Auth_OpenID_NamespaceMap(); + + foreach ($ax_args as $key => $value) { + if (strpos($key, 'type.') === 0) { + $alias = substr($key, 5); + $type_uri = $value; + + $alias = $aliases->addAlias($type_uri, $alias); + + if ($alias === null) { + return new Auth_OpenID_AX_Error( + sprintf("Could not add alias %s for URI %s", + $alias, $type_uri) + ); + } + + $count_s = Auth_OpenID::arrayGet($ax_args, 'count.' . $alias); + if ($count_s) { + $count = Auth_OpenID::intval($count_s); + if (($count === false) && + ($count_s === Auth_OpenID_AX_UNLIMITED_VALUES)) { + $count = $count_s; + } + } else { + $count = 1; + } + + if ($count === false) { + return new Auth_OpenID_AX_Error( + sprintf("Integer value expected for %s, got %s", + 'count.' . $alias, $count_s)); + } + + $attrinfo = Auth_OpenID_AX_AttrInfo::make($type_uri, $count, + false, $alias); + + if (Auth_OpenID_AX::isError($attrinfo)) { + return $attrinfo; + } + + $this->add($attrinfo); + } + } + + $required = Auth_OpenID_AX_toTypeURIs($aliases, + Auth_OpenID::arrayGet($ax_args, 'required')); + + foreach ($required as $type_uri) { + $attrib =& $this->requested_attributes[$type_uri]; + $attrib->required = true; + } + + $if_available = Auth_OpenID_AX_toTypeURIs($aliases, + Auth_OpenID::arrayGet($ax_args, 'if_available')); + + $all_type_uris = array_merge($required, $if_available); + + foreach ($aliases->iterNamespaceURIs() as $type_uri) { + if (!in_array($type_uri, $all_type_uris)) { + return new Auth_OpenID_AX_Error( + sprintf('Type URI %s was in the request but not ' . + 'present in "required" or "if_available"', + $type_uri)); + + } + } + + $this->update_url = Auth_OpenID::arrayGet($ax_args, 'update_url'); + + return true; + } + + /** + * Iterate over the AttrInfo objects that are contained in this + * fetch_request. + */ + function iterAttrs() + { + return array_values($this->requested_attributes); + } + + function iterTypes() + { + return array_keys($this->requested_attributes); + } + + /** + * Is the given type URI present in this fetch_request? + */ + function contains($type_uri) + { + return in_array($type_uri, $this->iterTypes()); + } +} + +/** + * An abstract class that implements a message that has attribute keys + * and values. It contains the common code between fetch_response and + * store_request. + * + * @package OpenID + */ +class Auth_OpenID_AX_KeyValueMessage extends Auth_OpenID_AX_Message { + + function Auth_OpenID_AX_KeyValueMessage() + { + $this->data = array(); + } + + /** + * Add a single value for the given attribute type to the + * message. If there are already values specified for this type, + * this value will be sent in addition to the values already + * specified. + * + * @param type_uri: The URI for the attribute + * @param value: The value to add to the response to the relying + * party for this attribute + * @return null + */ + function addValue($type_uri, $value) + { + if (!array_key_exists($type_uri, $this->data)) { + $this->data[$type_uri] = array(); + } + + $values =& $this->data[$type_uri]; + $values[] = $value; + } + + /** + * Set the values for the given attribute type. This replaces any + * values that have already been set for this attribute. + * + * @param type_uri: The URI for the attribute + * @param values: A list of values to send for this attribute. + */ + function setValues($type_uri, &$values) + { + $this->data[$type_uri] =& $values; + } + + /** + * Get the extension arguments for the key/value pairs contained + * in this message. + * + * @param aliases: An alias mapping. Set to None if you don't care + * about the aliases for this request. + * + * @access private + */ + function _getExtensionKVArgs(&$aliases) + { + if ($aliases === null) { + $aliases = new Auth_OpenID_NamespaceMap(); + } + + $ax_args = array(); + + foreach ($this->data as $type_uri => $values) { + $alias = $aliases->add($type_uri); + + $ax_args['type.' . $alias] = $type_uri; + $ax_args['count.' . $alias] = strval(count($values)); + + foreach ($values as $i => $value) { + $key = sprintf('value.%s.%d', $alias, $i + 1); + $ax_args[$key] = $value; + } + } + + return $ax_args; + } + + /** + * Parse attribute exchange key/value arguments into this object. + * + * @param ax_args: The attribute exchange fetch_response + * arguments, with namespacing removed. + * + * @return Auth_OpenID_AX_Error or true + */ + function parseExtensionArgs($ax_args) + { + $result = $this->_checkMode($ax_args); + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + + $aliases = new Auth_OpenID_NamespaceMap(); + + foreach ($ax_args as $key => $value) { + if (strpos($key, 'type.') === 0) { + $type_uri = $value; + $alias = substr($key, 5); + + $result = Auth_OpenID_AX_checkAlias($alias); + + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + + $alias = $aliases->addAlias($type_uri, $alias); + + if ($alias === null) { + return new Auth_OpenID_AX_Error( + sprintf("Could not add alias %s for URI %s", + $alias, $type_uri) + ); + } + } + } + + foreach ($aliases->iteritems() as $pair) { + list($type_uri, $alias) = $pair; + + if (array_key_exists('count.' . $alias, $ax_args)) { + + $count_key = 'count.' . $alias; + $count_s = $ax_args[$count_key]; + + $count = Auth_OpenID::intval($count_s); + + if ($count === false) { + return new Auth_OpenID_AX_Error( + sprintf("Integer value expected for %s, got %s", + 'count. %s' . $alias, $count_s, + Auth_OpenID_AX_UNLIMITED_VALUES) + ); + } + + $values = array(); + for ($i = 1; $i < $count + 1; $i++) { + $value_key = sprintf('value.%s.%d', $alias, $i); + + if (!array_key_exists($value_key, $ax_args)) { + return new Auth_OpenID_AX_Error( + sprintf( + "No value found for key %s", + $value_key)); + } + + $value = $ax_args[$value_key]; + $values[] = $value; + } + } else { + $key = 'value.' . $alias; + + if (!array_key_exists($key, $ax_args)) { + return new Auth_OpenID_AX_Error( + sprintf( + "No value found for key %s", + $key)); + } + + $value = $ax_args['value.' . $alias]; + + if ($value == '') { + $values = array(); + } else { + $values = array($value); + } + } + + $this->data[$type_uri] = $values; + } + + return true; + } + + /** + * Get a single value for an attribute. If no value was sent for + * this attribute, use the supplied default. If there is more than + * one value for this attribute, this method will fail. + * + * @param type_uri: The URI for the attribute + * @param default: The value to return if the attribute was not + * sent in the fetch_response. + * + * @return $value Auth_OpenID_AX_Error on failure or the value of + * the attribute in the fetch_response message, or the default + * supplied + */ + function getSingle($type_uri, $default=null) + { + $values = Auth_OpenID::arrayGet($this->data, $type_uri); + if (!$values) { + return $default; + } else if (count($values) == 1) { + return $values[0]; + } else { + return new Auth_OpenID_AX_Error( + sprintf('More than one value present for %s', + $type_uri) + ); + } + } + + /** + * Get the list of values for this attribute in the + * fetch_response. + * + * XXX: what to do if the values are not present? default + * parameter? this is funny because it's always supposed to return + * a list, so the default may break that, though it's provided by + * the user's code, so it might be okay. If no default is + * supplied, should the return be None or []? + * + * @param type_uri: The URI of the attribute + * + * @return $values The list of values for this attribute in the + * response. May be an empty list. If the attribute was not sent + * in the response, returns Auth_OpenID_AX_Error. + */ + function get($type_uri) + { + if (array_key_exists($type_uri, $this->data)) { + return $this->data[$type_uri]; + } else { + return new Auth_OpenID_AX_Error( + sprintf("Type URI %s not found in response", + $type_uri) + ); + } + } + + /** + * Get the number of responses for a particular attribute in this + * fetch_response message. + * + * @param type_uri: The URI of the attribute + * + * @returns int The number of values sent for this attribute. If + * the attribute was not sent in the response, returns + * Auth_OpenID_AX_Error. + */ + function count($type_uri) + { + if (array_key_exists($type_uri, $this->data)) { + return count($this->get($type_uri)); + } else { + return new Auth_OpenID_AX_Error( + sprintf("Type URI %s not found in response", + $type_uri) + ); + } + } +} + +/** + * A fetch_response attribute exchange message. + * + * @package OpenID + */ +class Auth_OpenID_AX_FetchResponse extends Auth_OpenID_AX_KeyValueMessage { + var $mode = 'fetch_response'; + + function Auth_OpenID_AX_FetchResponse($update_url=null) + { + $this->Auth_OpenID_AX_KeyValueMessage(); + $this->update_url = $update_url; + } + + /** + * Serialize this object into arguments in the attribute exchange + * namespace + * + * @return $args The dictionary of unqualified attribute exchange + * arguments that represent this fetch_response, or + * Auth_OpenID_AX_Error on error. + */ + function getExtensionArgs($request=null) + { + $aliases = new Auth_OpenID_NamespaceMap(); + + $zero_value_types = array(); + + if ($request !== null) { + // Validate the data in the context of the request (the + // same attributes should be present in each, and the + // counts in the response must be no more than the counts + // in the request) + + foreach ($this->data as $type_uri => $unused) { + if (!$request->contains($type_uri)) { + return new Auth_OpenID_AX_Error( + sprintf("Response attribute not present in request: %s", + $type_uri) + ); + } + } + + foreach ($request->iterAttrs() as $attr_info) { + // Copy the aliases from the request so that reading + // the response in light of the request is easier + if ($attr_info->alias === null) { + $aliases->add($attr_info->type_uri); + } else { + $alias = $aliases->addAlias($attr_info->type_uri, + $attr_info->alias); + + if ($alias === null) { + return new Auth_OpenID_AX_Error( + sprintf("Could not add alias %s for URI %s", + $attr_info->alias, $attr_info->type_uri) + ); + } + } + + if (array_key_exists($attr_info->type_uri, $this->data)) { + $values = $this->data[$attr_info->type_uri]; + } else { + $values = array(); + $zero_value_types[] = $attr_info; + } + + if (($attr_info->count != Auth_OpenID_AX_UNLIMITED_VALUES) && + ($attr_info->count < count($values))) { + return new Auth_OpenID_AX_Error( + sprintf("More than the number of requested values " . + "were specified for %s", + $attr_info->type_uri) + ); + } + } + } + + $kv_args = $this->_getExtensionKVArgs($aliases); + + // Add the KV args into the response with the args that are + // unique to the fetch_response + $ax_args = $this->_newArgs(); + + // For each requested attribute, put its type/alias and count + // into the response even if no data were returned. + foreach ($zero_value_types as $attr_info) { + $alias = $aliases->getAlias($attr_info->type_uri); + $kv_args['type.' . $alias] = $attr_info->type_uri; + $kv_args['count.' . $alias] = '0'; + } + + $update_url = null; + if ($request) { + $update_url = $request->update_url; + } else { + $update_url = $this->update_url; + } + + if ($update_url) { + $ax_args['update_url'] = $update_url; + } + + Auth_OpenID::update(&$ax_args, $kv_args); + + return $ax_args; + } + + /** + * @return $result Auth_OpenID_AX_Error on failure or true on + * success. + */ + function parseExtensionArgs($ax_args) + { + $result = parent::parseExtensionArgs($ax_args); + + if (Auth_OpenID_AX::isError($result)) { + return $result; + } + + $this->update_url = Auth_OpenID::arrayGet($ax_args, 'update_url'); + + return true; + } + + /** + * Construct a FetchResponse object from an OpenID library + * SuccessResponse object. + * + * @param success_response: A successful id_res response object + * + * @param signed: Whether non-signed args should be processsed. If + * True (the default), only signed arguments will be processsed. + * + * @return $response A FetchResponse containing the data from the + * OpenID message + */ + function fromSuccessResponse($success_response, $signed=true) + { + $obj = new Auth_OpenID_AX_FetchResponse(); + if ($signed) { + $ax_args = $success_response->getSignedNS($obj->ns_uri); + } else { + $ax_args = $success_response->message->getArgs($obj->ns_uri); + } + if ($ax_args === null || Auth_OpenID::isFailure($ax_args) || + sizeof($ax_args) == 0) { + return null; + } + + $result = $obj->parseExtensionArgs($ax_args); + if (Auth_OpenID_AX::isError($result)) { + #XXX log me + return null; + } + return $obj; + } +} + +/** + * A store request attribute exchange message representation. + * + * @package OpenID + */ +class Auth_OpenID_AX_StoreRequest extends Auth_OpenID_AX_KeyValueMessage { + var $mode = 'store_request'; + + /** + * @param array $aliases The namespace aliases to use when making + * this store response. Leave as None to use defaults. + */ + function getExtensionArgs($aliases=null) + { + $ax_args = $this->_newArgs(); + $kv_args = $this->_getExtensionKVArgs($aliases); + Auth_OpenID::update(&$ax_args, $kv_args); + return $ax_args; + } +} + +/** + * An indication that the store request was processed along with this + * OpenID transaction. Use make(), NOT the constructor, to create + * response objects. + * + * @package OpenID + */ +class Auth_OpenID_AX_StoreResponse extends Auth_OpenID_AX_Message { + var $SUCCESS_MODE = 'store_response_success'; + var $FAILURE_MODE = 'store_response_failure'; + + /** + * Returns Auth_OpenID_AX_Error on error or an + * Auth_OpenID_AX_StoreResponse object on success. + */ + function &make($succeeded=true, $error_message=null) + { + if (($succeeded) && ($error_message !== null)) { + return new Auth_OpenID_AX_Error('An error message may only be '. + 'included in a failing fetch response'); + } + + return new Auth_OpenID_AX_StoreResponse($succeeded, $error_message); + } + + function Auth_OpenID_AX_StoreResponse($succeeded=true, $error_message=null) + { + if ($succeeded) { + $this->mode = $this->SUCCESS_MODE; + } else { + $this->mode = $this->FAILURE_MODE; + } + + $this->error_message = $error_message; + } + + /** + * Was this response a success response? + */ + function succeeded() + { + return $this->mode == $this->SUCCESS_MODE; + } + + function getExtensionArgs() + { + $ax_args = $this->_newArgs(); + if ((!$this->succeeded()) && $this->error_message) { + $ax_args['error'] = $this->error_message; + } + + return $ax_args; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Association.php b/inc/lib/Auth/OpenID/Association.php new file mode 100644 index 00000000..37ce0cbf --- /dev/null +++ b/inc/lib/Auth/OpenID/Association.php @@ -0,0 +1,613 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * @access private + */ +require_once 'Auth/OpenID/CryptUtil.php'; + +/** + * @access private + */ +require_once 'Auth/OpenID/KVForm.php'; + +/** + * @access private + */ +require_once 'Auth/OpenID/HMAC.php'; + +/** + * This class represents an association between a server and a + * consumer. In general, users of this library will never see + * instances of this object. The only exception is if you implement a + * custom {@link Auth_OpenID_OpenIDStore}. + * + * If you do implement such a store, it will need to store the values + * of the handle, secret, issued, lifetime, and assoc_type instance + * variables. + * + * @package OpenID + */ +class Auth_OpenID_Association { + + /** + * This is a HMAC-SHA1 specific value. + * + * @access private + */ + var $SIG_LENGTH = 20; + + /** + * The ordering and name of keys as stored by serialize. + * + * @access private + */ + var $assoc_keys = array( + 'version', + 'handle', + 'secret', + 'issued', + 'lifetime', + 'assoc_type' + ); + + var $_macs = array( + 'HMAC-SHA1' => 'Auth_OpenID_HMACSHA1', + 'HMAC-SHA256' => 'Auth_OpenID_HMACSHA256' + ); + + /** + * This is an alternate constructor (factory method) used by the + * OpenID consumer library to create associations. OpenID store + * implementations shouldn't use this constructor. + * + * @access private + * + * @param integer $expires_in This is the amount of time this + * association is good for, measured in seconds since the + * association was issued. + * + * @param string $handle This is the handle the server gave this + * association. + * + * @param string secret This is the shared secret the server + * generated for this association. + * + * @param assoc_type This is the type of association this + * instance represents. The only valid values of this field at + * this time is 'HMAC-SHA1' and 'HMAC-SHA256', but new types may + * be defined in the future. + * + * @return association An {@link Auth_OpenID_Association} + * instance. + */ + function fromExpiresIn($expires_in, $handle, $secret, $assoc_type) + { + $issued = time(); + $lifetime = $expires_in; + return new Auth_OpenID_Association($handle, $secret, + $issued, $lifetime, $assoc_type); + } + + /** + * This is the standard constructor for creating an association. + * The library should create all of the necessary associations, so + * this constructor is not part of the external API. + * + * @access private + * + * @param string $handle This is the handle the server gave this + * association. + * + * @param string $secret This is the shared secret the server + * generated for this association. + * + * @param integer $issued This is the time this association was + * issued, in seconds since 00:00 GMT, January 1, 1970. (ie, a + * unix timestamp) + * + * @param integer $lifetime This is the amount of time this + * association is good for, measured in seconds since the + * association was issued. + * + * @param string $assoc_type This is the type of association this + * instance represents. The only valid values of this field at + * this time is 'HMAC-SHA1' and 'HMAC-SHA256', but new types may + * be defined in the future. + */ + function Auth_OpenID_Association( + $handle, $secret, $issued, $lifetime, $assoc_type) + { + if (!in_array($assoc_type, + Auth_OpenID_getSupportedAssociationTypes())) { + $fmt = 'Unsupported association type (%s)'; + trigger_error(sprintf($fmt, $assoc_type), E_USER_ERROR); + } + + $this->handle = $handle; + $this->secret = $secret; + $this->issued = $issued; + $this->lifetime = $lifetime; + $this->assoc_type = $assoc_type; + } + + /** + * This returns the number of seconds this association is still + * valid for, or 0 if the association is no longer valid. + * + * @return integer $seconds The number of seconds this association + * is still valid for, or 0 if the association is no longer valid. + */ + function getExpiresIn($now = null) + { + if ($now == null) { + $now = time(); + } + + return max(0, $this->issued + $this->lifetime - $now); + } + + /** + * This checks to see if two {@link Auth_OpenID_Association} + * instances represent the same association. + * + * @return bool $result true if the two instances represent the + * same association, false otherwise. + */ + function equal($other) + { + return ((gettype($this) == gettype($other)) + && ($this->handle == $other->handle) + && ($this->secret == $other->secret) + && ($this->issued == $other->issued) + && ($this->lifetime == $other->lifetime) + && ($this->assoc_type == $other->assoc_type)); + } + + /** + * Convert an association to KV form. + * + * @return string $result String in KV form suitable for + * deserialization by deserialize. + */ + function serialize() + { + $data = array( + 'version' => '2', + 'handle' => $this->handle, + 'secret' => base64_encode($this->secret), + 'issued' => strval(intval($this->issued)), + 'lifetime' => strval(intval($this->lifetime)), + 'assoc_type' => $this->assoc_type + ); + + assert(array_keys($data) == $this->assoc_keys); + + return Auth_OpenID_KVForm::fromArray($data, $strict = true); + } + + /** + * Parse an association as stored by serialize(). This is the + * inverse of serialize. + * + * @param string $assoc_s Association as serialized by serialize() + * @return Auth_OpenID_Association $result instance of this class + */ + function deserialize($class_name, $assoc_s) + { + $pairs = Auth_OpenID_KVForm::toArray($assoc_s, $strict = true); + $keys = array(); + $values = array(); + foreach ($pairs as $key => $value) { + if (is_array($value)) { + list($key, $value) = $value; + } + $keys[] = $key; + $values[] = $value; + } + + $class_vars = get_class_vars($class_name); + $class_assoc_keys = $class_vars['assoc_keys']; + + sort($keys); + sort($class_assoc_keys); + + if ($keys != $class_assoc_keys) { + trigger_error('Unexpected key values: ' . var_export($keys, true), + E_USER_WARNING); + return null; + } + + $version = $pairs['version']; + $handle = $pairs['handle']; + $secret = $pairs['secret']; + $issued = $pairs['issued']; + $lifetime = $pairs['lifetime']; + $assoc_type = $pairs['assoc_type']; + + if ($version != '2') { + trigger_error('Unknown version: ' . $version, E_USER_WARNING); + return null; + } + + $issued = intval($issued); + $lifetime = intval($lifetime); + $secret = base64_decode($secret); + + return new $class_name( + $handle, $secret, $issued, $lifetime, $assoc_type); + } + + /** + * Generate a signature for a sequence of (key, value) pairs + * + * @access private + * @param array $pairs The pairs to sign, in order. This is an + * array of two-tuples. + * @return string $signature The binary signature of this sequence + * of pairs + */ + function sign($pairs) + { + $kv = Auth_OpenID_KVForm::fromArray($pairs); + + /* Invalid association types should be caught at constructor */ + $callback = $this->_macs[$this->assoc_type]; + + return call_user_func_array($callback, array($this->secret, $kv)); + } + + /** + * Generate a signature for some fields in a dictionary + * + * @access private + * @param array $fields The fields to sign, in order; this is an + * array of strings. + * @param array $data Dictionary of values to sign (an array of + * string => string pairs). + * @return string $signature The signature, base64 encoded + */ + function signMessage($message) + { + if ($message->hasKey(Auth_OpenID_OPENID_NS, 'sig') || + $message->hasKey(Auth_OpenID_OPENID_NS, 'signed')) { + // Already has a sig + return null; + } + + $extant_handle = $message->getArg(Auth_OpenID_OPENID_NS, + 'assoc_handle'); + + if ($extant_handle && ($extant_handle != $this->handle)) { + // raise ValueError("Message has a different association handle") + return null; + } + + $signed_message = $message; + $signed_message->setArg(Auth_OpenID_OPENID_NS, 'assoc_handle', + $this->handle); + + $message_keys = array_keys($signed_message->toPostArgs()); + $signed_list = array(); + $signed_prefix = 'openid.'; + + foreach ($message_keys as $k) { + if (strpos($k, $signed_prefix) === 0) { + $signed_list[] = substr($k, strlen($signed_prefix)); + } + } + + $signed_list[] = 'signed'; + sort($signed_list); + + $signed_message->setArg(Auth_OpenID_OPENID_NS, 'signed', + implode(',', $signed_list)); + $sig = $this->getMessageSignature($signed_message); + $signed_message->setArg(Auth_OpenID_OPENID_NS, 'sig', $sig); + return $signed_message; + } + + /** + * Given a {@link Auth_OpenID_Message}, return the key/value pairs + * to be signed according to the signed list in the message. If + * the message lacks a signed list, return null. + * + * @access private + */ + function _makePairs(&$message) + { + $signed = $message->getArg(Auth_OpenID_OPENID_NS, 'signed'); + if (!$signed || Auth_OpenID::isFailure($signed)) { + // raise ValueError('Message has no signed list: %s' % (message,)) + return null; + } + + $signed_list = explode(',', $signed); + $pairs = array(); + $data = $message->toPostArgs(); + foreach ($signed_list as $field) { + $pairs[] = array($field, Auth_OpenID::arrayGet($data, + 'openid.' . + $field, '')); + } + return $pairs; + } + + /** + * Given an {@link Auth_OpenID_Message}, return the signature for + * the signed list in the message. + * + * @access private + */ + function getMessageSignature(&$message) + { + $pairs = $this->_makePairs($message); + return base64_encode($this->sign($pairs)); + } + + /** + * Confirm that the signature of these fields matches the + * signature contained in the data. + * + * @access private + */ + function checkMessageSignature(&$message) + { + $sig = $message->getArg(Auth_OpenID_OPENID_NS, + 'sig'); + + if (!$sig || Auth_OpenID::isFailure($sig)) { + return false; + } + + $calculated_sig = $this->getMessageSignature($message); + return $calculated_sig == $sig; + } +} + +function Auth_OpenID_getSecretSize($assoc_type) +{ + if ($assoc_type == 'HMAC-SHA1') { + return 20; + } else if ($assoc_type == 'HMAC-SHA256') { + return 32; + } else { + return null; + } +} + +function Auth_OpenID_getAllAssociationTypes() +{ + return array('HMAC-SHA1', 'HMAC-SHA256'); +} + +function Auth_OpenID_getSupportedAssociationTypes() +{ + $a = array('HMAC-SHA1'); + + if (Auth_OpenID_HMACSHA256_SUPPORTED) { + $a[] = 'HMAC-SHA256'; + } + + return $a; +} + +function Auth_OpenID_getSessionTypes($assoc_type) +{ + $assoc_to_session = array( + 'HMAC-SHA1' => array('DH-SHA1', 'no-encryption')); + + if (Auth_OpenID_HMACSHA256_SUPPORTED) { + $assoc_to_session['HMAC-SHA256'] = + array('DH-SHA256', 'no-encryption'); + } + + return Auth_OpenID::arrayGet($assoc_to_session, $assoc_type, array()); +} + +function Auth_OpenID_checkSessionType($assoc_type, $session_type) +{ + if (!in_array($session_type, + Auth_OpenID_getSessionTypes($assoc_type))) { + return false; + } + + return true; +} + +function Auth_OpenID_getDefaultAssociationOrder() +{ + $order = array(); + + if (!Auth_OpenID_noMathSupport()) { + $order[] = array('HMAC-SHA1', 'DH-SHA1'); + + if (Auth_OpenID_HMACSHA256_SUPPORTED) { + $order[] = array('HMAC-SHA256', 'DH-SHA256'); + } + } + + $order[] = array('HMAC-SHA1', 'no-encryption'); + + if (Auth_OpenID_HMACSHA256_SUPPORTED) { + $order[] = array('HMAC-SHA256', 'no-encryption'); + } + + return $order; +} + +function Auth_OpenID_getOnlyEncryptedOrder() +{ + $result = array(); + + foreach (Auth_OpenID_getDefaultAssociationOrder() as $pair) { + list($assoc, $session) = $pair; + + if ($session != 'no-encryption') { + if (Auth_OpenID_HMACSHA256_SUPPORTED && + ($assoc == 'HMAC-SHA256')) { + $result[] = $pair; + } else if ($assoc != 'HMAC-SHA256') { + $result[] = $pair; + } + } + } + + return $result; +} + +function &Auth_OpenID_getDefaultNegotiator() +{ + $x = new Auth_OpenID_SessionNegotiator( + Auth_OpenID_getDefaultAssociationOrder()); + return $x; +} + +function &Auth_OpenID_getEncryptedNegotiator() +{ + $x = new Auth_OpenID_SessionNegotiator( + Auth_OpenID_getOnlyEncryptedOrder()); + return $x; +} + +/** + * A session negotiator controls the allowed and preferred association + * types and association session types. Both the {@link + * Auth_OpenID_Consumer} and {@link Auth_OpenID_Server} use + * negotiators when creating associations. + * + * You can create and use negotiators if you: + + * - Do not want to do Diffie-Hellman key exchange because you use + * transport-layer encryption (e.g. SSL) + * + * - Want to use only SHA-256 associations + * + * - Do not want to support plain-text associations over a non-secure + * channel + * + * It is up to you to set a policy for what kinds of associations to + * accept. By default, the library will make any kind of association + * that is allowed in the OpenID 2.0 specification. + * + * Use of negotiators in the library + * ================================= + * + * When a consumer makes an association request, it calls {@link + * getAllowedType} to get the preferred association type and + * association session type. + * + * The server gets a request for a particular association/session type + * and calls {@link isAllowed} to determine if it should create an + * association. If it is supported, negotiation is complete. If it is + * not, the server calls {@link getAllowedType} to get an allowed + * association type to return to the consumer. + * + * If the consumer gets an error response indicating that the + * requested association/session type is not supported by the server + * that contains an assocation/session type to try, it calls {@link + * isAllowed} to determine if it should try again with the given + * combination of association/session type. + * + * @package OpenID + */ +class Auth_OpenID_SessionNegotiator { + function Auth_OpenID_SessionNegotiator($allowed_types) + { + $this->allowed_types = array(); + $this->setAllowedTypes($allowed_types); + } + + /** + * Set the allowed association types, checking to make sure each + * combination is valid. + * + * @access private + */ + function setAllowedTypes($allowed_types) + { + foreach ($allowed_types as $pair) { + list($assoc_type, $session_type) = $pair; + if (!Auth_OpenID_checkSessionType($assoc_type, $session_type)) { + return false; + } + } + + $this->allowed_types = $allowed_types; + return true; + } + + /** + * Add an association type and session type to the allowed types + * list. The assocation/session pairs are tried in the order that + * they are added. + * + * @access private + */ + function addAllowedType($assoc_type, $session_type = null) + { + if ($this->allowed_types === null) { + $this->allowed_types = array(); + } + + if ($session_type === null) { + $available = Auth_OpenID_getSessionTypes($assoc_type); + + if (!$available) { + return false; + } + + foreach ($available as $session_type) { + $this->addAllowedType($assoc_type, $session_type); + } + } else { + if (Auth_OpenID_checkSessionType($assoc_type, $session_type)) { + $this->allowed_types[] = array($assoc_type, $session_type); + } else { + return false; + } + } + + return true; + } + + // Is this combination of association type and session type allowed? + function isAllowed($assoc_type, $session_type) + { + $assoc_good = in_array(array($assoc_type, $session_type), + $this->allowed_types); + + $matches = in_array($session_type, + Auth_OpenID_getSessionTypes($assoc_type)); + + return ($assoc_good && $matches); + } + + /** + * Get a pair of assocation type and session type that are + * supported. + */ + function getAllowedType() + { + if (!$this->allowed_types) { + return array(null, null); + } + + return $this->allowed_types[0]; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/BigMath.php b/inc/lib/Auth/OpenID/BigMath.php new file mode 100644 index 00000000..6d99c4cb --- /dev/null +++ b/inc/lib/Auth/OpenID/BigMath.php @@ -0,0 +1,471 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * Needed for random number generation + */ +require_once 'Auth/OpenID/CryptUtil.php'; + +/** + * Need Auth_OpenID::bytes(). + */ +require_once 'Auth/OpenID.php'; + +/** + * The superclass of all big-integer math implementations + * @access private + * @package OpenID + */ +class Auth_OpenID_MathLibrary { + /** + * Given a long integer, returns the number converted to a binary + * string. This function accepts long integer values of arbitrary + * magnitude and uses the local large-number math library when + * available. + * + * @param integer $long The long number (can be a normal PHP + * integer or a number created by one of the available long number + * libraries) + * @return string $binary The binary version of $long + */ + function longToBinary($long) + { + $cmp = $this->cmp($long, 0); + if ($cmp < 0) { + $msg = __FUNCTION__ . " takes only positive integers."; + trigger_error($msg, E_USER_ERROR); + return null; + } + + if ($cmp == 0) { + return "\x00"; + } + + $bytes = array(); + + while ($this->cmp($long, 0) > 0) { + array_unshift($bytes, $this->mod($long, 256)); + $long = $this->div($long, pow(2, 8)); + } + + if ($bytes && ($bytes[0] > 127)) { + array_unshift($bytes, 0); + } + + $string = ''; + foreach ($bytes as $byte) { + $string .= pack('C', $byte); + } + + return $string; + } + + /** + * Given a binary string, returns the binary string converted to a + * long number. + * + * @param string $binary The binary version of a long number, + * probably as a result of calling longToBinary + * @return integer $long The long number equivalent of the binary + * string $str + */ + function binaryToLong($str) + { + if ($str === null) { + return null; + } + + // Use array_merge to return a zero-indexed array instead of a + // one-indexed array. + $bytes = array_merge(unpack('C*', $str)); + + $n = $this->init(0); + + if ($bytes && ($bytes[0] > 127)) { + trigger_error("bytesToNum works only for positive integers.", + E_USER_WARNING); + return null; + } + + foreach ($bytes as $byte) { + $n = $this->mul($n, pow(2, 8)); + $n = $this->add($n, $byte); + } + + return $n; + } + + function base64ToLong($str) + { + $b64 = base64_decode($str); + + if ($b64 === false) { + return false; + } + + return $this->binaryToLong($b64); + } + + function longToBase64($str) + { + return base64_encode($this->longToBinary($str)); + } + + /** + * Returns a random number in the specified range. This function + * accepts $start, $stop, and $step values of arbitrary magnitude + * and will utilize the local large-number math library when + * available. + * + * @param integer $start The start of the range, or the minimum + * random number to return + * @param integer $stop The end of the range, or the maximum + * random number to return + * @param integer $step The step size, such that $result - ($step + * * N) = $start for some N + * @return integer $result The resulting randomly-generated number + */ + function rand($stop) + { + static $duplicate_cache = array(); + + // Used as the key for the duplicate cache + $rbytes = $this->longToBinary($stop); + + if (array_key_exists($rbytes, $duplicate_cache)) { + list($duplicate, $nbytes) = $duplicate_cache[$rbytes]; + } else { + if ($rbytes[0] == "\x00") { + $nbytes = Auth_OpenID::bytes($rbytes) - 1; + } else { + $nbytes = Auth_OpenID::bytes($rbytes); + } + + $mxrand = $this->pow(256, $nbytes); + + // If we get a number less than this, then it is in the + // duplicated range. + $duplicate = $this->mod($mxrand, $stop); + + if (count($duplicate_cache) > 10) { + $duplicate_cache = array(); + } + + $duplicate_cache[$rbytes] = array($duplicate, $nbytes); + } + + do { + $bytes = "\x00" . Auth_OpenID_CryptUtil::getBytes($nbytes); + $n = $this->binaryToLong($bytes); + // Keep looping if this value is in the low duplicated range + } while ($this->cmp($n, $duplicate) < 0); + + return $this->mod($n, $stop); + } +} + +/** + * Exposes BCmath math library functionality. + * + * {@link Auth_OpenID_BcMathWrapper} wraps the functionality provided + * by the BCMath extension. + * + * @access private + * @package OpenID + */ +class Auth_OpenID_BcMathWrapper extends Auth_OpenID_MathLibrary{ + var $type = 'bcmath'; + + function add($x, $y) + { + return bcadd($x, $y); + } + + function sub($x, $y) + { + return bcsub($x, $y); + } + + function pow($base, $exponent) + { + return bcpow($base, $exponent); + } + + function cmp($x, $y) + { + return bccomp($x, $y); + } + + function init($number, $base = 10) + { + return $number; + } + + function mod($base, $modulus) + { + return bcmod($base, $modulus); + } + + function mul($x, $y) + { + return bcmul($x, $y); + } + + function div($x, $y) + { + return bcdiv($x, $y); + } + + /** + * Same as bcpowmod when bcpowmod is missing + * + * @access private + */ + function _powmod($base, $exponent, $modulus) + { + $square = $this->mod($base, $modulus); + $result = 1; + while($this->cmp($exponent, 0) > 0) { + if ($this->mod($exponent, 2)) { + $result = $this->mod($this->mul($result, $square), $modulus); + } + $square = $this->mod($this->mul($square, $square), $modulus); + $exponent = $this->div($exponent, 2); + } + return $result; + } + + function powmod($base, $exponent, $modulus) + { + if (function_exists('bcpowmod')) { + return bcpowmod($base, $exponent, $modulus); + } else { + return $this->_powmod($base, $exponent, $modulus); + } + } + + function toString($num) + { + return $num; + } +} + +/** + * Exposes GMP math library functionality. + * + * {@link Auth_OpenID_GmpMathWrapper} wraps the functionality provided + * by the GMP extension. + * + * @access private + * @package OpenID + */ +class Auth_OpenID_GmpMathWrapper extends Auth_OpenID_MathLibrary{ + var $type = 'gmp'; + + function add($x, $y) + { + return gmp_add($x, $y); + } + + function sub($x, $y) + { + return gmp_sub($x, $y); + } + + function pow($base, $exponent) + { + return gmp_pow($base, $exponent); + } + + function cmp($x, $y) + { + return gmp_cmp($x, $y); + } + + function init($number, $base = 10) + { + return gmp_init($number, $base); + } + + function mod($base, $modulus) + { + return gmp_mod($base, $modulus); + } + + function mul($x, $y) + { + return gmp_mul($x, $y); + } + + function div($x, $y) + { + return gmp_div_q($x, $y); + } + + function powmod($base, $exponent, $modulus) + { + return gmp_powm($base, $exponent, $modulus); + } + + function toString($num) + { + return gmp_strval($num); + } +} + +/** + * Define the supported extensions. An extension array has keys + * 'modules', 'extension', and 'class'. 'modules' is an array of PHP + * module names which the loading code will attempt to load. These + * values will be suffixed with a library file extension (e.g. ".so"). + * 'extension' is the name of a PHP extension which will be tested + * before 'modules' are loaded. 'class' is the string name of a + * {@link Auth_OpenID_MathWrapper} subclass which should be + * instantiated if a given extension is present. + * + * You can define new math library implementations and add them to + * this array. + */ +function Auth_OpenID_math_extensions() +{ + $result = array(); + + if (!defined('Auth_OpenID_BUGGY_GMP')) { + $result[] = + array('modules' => array('gmp', 'php_gmp'), + 'extension' => 'gmp', + 'class' => 'Auth_OpenID_GmpMathWrapper'); + } + + $result[] = array( + 'modules' => array('bcmath', 'php_bcmath'), + 'extension' => 'bcmath', + 'class' => 'Auth_OpenID_BcMathWrapper'); + + return $result; +} + +/** + * Detect which (if any) math library is available + */ +function Auth_OpenID_detectMathLibrary($exts) +{ + $loaded = false; + + foreach ($exts as $extension) { + // See if the extension specified is already loaded. + if ($extension['extension'] && + extension_loaded($extension['extension'])) { + $loaded = true; + } + + // Try to load dynamic modules. + if (!$loaded && function_exists('dl')) { + foreach ($extension['modules'] as $module) { + if (@dl($module . "." . PHP_SHLIB_SUFFIX)) { + $loaded = true; + break; + } + } + } + + // If the load succeeded, supply an instance of + // Auth_OpenID_MathWrapper which wraps the specified + // module's functionality. + if ($loaded) { + return $extension; + } + } + + return false; +} + +/** + * {@link Auth_OpenID_getMathLib} checks for the presence of long + * number extension modules and returns an instance of + * {@link Auth_OpenID_MathWrapper} which exposes the module's + * functionality. + * + * Checks for the existence of an extension module described by the + * result of {@link Auth_OpenID_math_extensions()} and returns an + * instance of a wrapper for that extension module. If no extension + * module is found, an instance of {@link Auth_OpenID_MathWrapper} is + * returned, which wraps the native PHP integer implementation. The + * proper calling convention for this method is $lib =& + * Auth_OpenID_getMathLib(). + * + * This function checks for the existence of specific long number + * implementations in the following order: GMP followed by BCmath. + * + * @return Auth_OpenID_MathWrapper $instance An instance of + * {@link Auth_OpenID_MathWrapper} or one of its subclasses + * + * @package OpenID + */ +function &Auth_OpenID_getMathLib() +{ + // The instance of Auth_OpenID_MathWrapper that we choose to + // supply will be stored here, so that subseqent calls to this + // method will return a reference to the same object. + static $lib = null; + + if (isset($lib)) { + return $lib; + } + + if (Auth_OpenID_noMathSupport()) { + $null = null; + return $null; + } + + // If this method has not been called before, look at + // Auth_OpenID_math_extensions and try to find an extension that + // works. + $ext = Auth_OpenID_detectMathLibrary(Auth_OpenID_math_extensions()); + if ($ext === false) { + $tried = array(); + foreach (Auth_OpenID_math_extensions() as $extinfo) { + $tried[] = $extinfo['extension']; + } + $triedstr = implode(", ", $tried); + + Auth_OpenID_setNoMathSupport(); + + $result = null; + return $result; + } + + // Instantiate a new wrapper + $class = $ext['class']; + $lib = new $class(); + + return $lib; +} + +function Auth_OpenID_setNoMathSupport() +{ + if (!defined('Auth_OpenID_NO_MATH_SUPPORT')) { + define('Auth_OpenID_NO_MATH_SUPPORT', true); + } +} + +function Auth_OpenID_noMathSupport() +{ + return defined('Auth_OpenID_NO_MATH_SUPPORT'); +} + +?> diff --git a/inc/lib/Auth/OpenID/Consumer.php b/inc/lib/Auth/OpenID/Consumer.php new file mode 100644 index 00000000..cc0ab247 --- /dev/null +++ b/inc/lib/Auth/OpenID/Consumer.php @@ -0,0 +1,2230 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * Require utility classes and functions for the consumer. + */ +require_once "Auth/OpenID.php"; +require_once "Auth/OpenID/Message.php"; +require_once "Auth/OpenID/HMAC.php"; +require_once "Auth/OpenID/Association.php"; +require_once "Auth/OpenID/CryptUtil.php"; +require_once "Auth/OpenID/DiffieHellman.php"; +require_once "Auth/OpenID/KVForm.php"; +require_once "Auth/OpenID/Nonce.php"; +require_once "Auth/OpenID/Discover.php"; +require_once "Auth/OpenID/URINorm.php"; +require_once "Auth/Yadis/Manager.php"; +require_once "Auth/Yadis/XRI.php"; + +/** + * This is the status code returned when the complete method returns + * successfully. + */ +define('Auth_OpenID_SUCCESS', 'success'); + +/** + * Status to indicate cancellation of OpenID authentication. + */ +define('Auth_OpenID_CANCEL', 'cancel'); + +/** + * This is the status code completeAuth returns when the value it + * received indicated an invalid login. + */ +define('Auth_OpenID_FAILURE', 'failure'); + +/** + * This is the status code completeAuth returns when the + * {@link Auth_OpenID_Consumer} instance is in immediate mode, and the + * identity server sends back a URL to send the user to to complete his + * or her login. + */ +define('Auth_OpenID_SETUP_NEEDED', 'setup needed'); + +/** + * This is the status code beginAuth returns when the page fetched + * from the entered OpenID URL doesn't contain the necessary link tags + * to function as an identity page. + */ +define('Auth_OpenID_PARSE_ERROR', 'parse error'); + +/** + * An OpenID consumer implementation that performs discovery and does + * session management. See the Consumer.php file documentation for + * more information. + * + * @package OpenID + */ +class Auth_OpenID_Consumer { + + /** + * @access private + */ + var $discoverMethod = 'Auth_OpenID_discover'; + + /** + * @access private + */ + var $session_key_prefix = "_openid_consumer_"; + + /** + * @access private + */ + var $_token_suffix = "last_token"; + + /** + * Initialize a Consumer instance. + * + * You should create a new instance of the Consumer object with + * every HTTP request that handles OpenID transactions. + * + * @param Auth_OpenID_OpenIDStore $store This must be an object + * that implements the interface in {@link + * Auth_OpenID_OpenIDStore}. Several concrete implementations are + * provided, to cover most common use cases. For stores backed by + * MySQL, PostgreSQL, or SQLite, see the {@link + * Auth_OpenID_SQLStore} class and its sublcasses. For a + * filesystem-backed store, see the {@link Auth_OpenID_FileStore} + * module. As a last resort, if it isn't possible for the server + * to store state at all, an instance of {@link + * Auth_OpenID_DumbStore} can be used. + * + * @param mixed $session An object which implements the interface + * of the {@link Auth_Yadis_PHPSession} class. Particularly, this + * object is expected to have these methods: get($key), set($key), + * $value), and del($key). This defaults to a session object + * which wraps PHP's native session machinery. You should only + * need to pass something here if you have your own sessioning + * implementation. + * + * @param str $consumer_cls The name of the class to instantiate + * when creating the internal consumer object. This is used for + * testing. + */ + function Auth_OpenID_Consumer(&$store, $session = null, + $consumer_cls = null) + { + if ($session === null) { + $session = new Auth_Yadis_PHPSession(); + } + + $this->session =& $session; + + if ($consumer_cls !== null) { + $this->consumer = new $consumer_cls($store); + } else { + $this->consumer = new Auth_OpenID_GenericConsumer($store); + } + + $this->_token_key = $this->session_key_prefix . $this->_token_suffix; + } + + /** + * Used in testing to define the discovery mechanism. + * + * @access private + */ + function getDiscoveryObject(&$session, $openid_url, + $session_key_prefix) + { + return new Auth_Yadis_Discovery($session, $openid_url, + $session_key_prefix); + } + + /** + * Start the OpenID authentication process. See steps 1-2 in the + * overview at the top of this file. + * + * @param string $user_url Identity URL given by the user. This + * method performs a textual transformation of the URL to try and + * make sure it is normalized. For example, a user_url of + * example.com will be normalized to http://example.com/ + * normalizing and resolving any redirects the server might issue. + * + * @param bool $anonymous True if the OpenID request is to be sent + * to the server without any identifier information. Use this + * when you want to transport data but don't want to do OpenID + * authentication with identifiers. + * + * @return Auth_OpenID_AuthRequest $auth_request An object + * containing the discovered information will be returned, with a + * method for building a redirect URL to the server, as described + * in step 3 of the overview. This object may also be used to add + * extension arguments to the request, using its 'addExtensionArg' + * method. + */ + function begin($user_url, $anonymous=false) + { + $openid_url = $user_url; + + $disco = $this->getDiscoveryObject($this->session, + $openid_url, + $this->session_key_prefix); + + // Set the 'stale' attribute of the manager. If discovery + // fails in a fatal way, the stale flag will cause the manager + // to be cleaned up next time discovery is attempted. + + $m = $disco->getManager(); + $loader = new Auth_Yadis_ManagerLoader(); + + if ($m) { + if ($m->stale) { + $disco->destroyManager(); + } else { + $m->stale = true; + $disco->session->set($disco->session_key, + serialize($loader->toSession($m))); + } + } + + $endpoint = $disco->getNextService($this->discoverMethod, + $this->consumer->fetcher); + + // Reset the 'stale' attribute of the manager. + $m =& $disco->getManager(); + if ($m) { + $m->stale = false; + $disco->session->set($disco->session_key, + serialize($loader->toSession($m))); + } + + if ($endpoint === null) { + return null; + } else { + return $this->beginWithoutDiscovery($endpoint, + $anonymous); + } + } + + /** + * Start OpenID verification without doing OpenID server + * discovery. This method is used internally by Consumer.begin + * after discovery is performed, and exists to provide an + * interface for library users needing to perform their own + * discovery. + * + * @param Auth_OpenID_ServiceEndpoint $endpoint an OpenID service + * endpoint descriptor. + * + * @param bool anonymous Set to true if you want to perform OpenID + * without identifiers. + * + * @return Auth_OpenID_AuthRequest $auth_request An OpenID + * authentication request object. + */ + function &beginWithoutDiscovery($endpoint, $anonymous=false) + { + $loader = new Auth_OpenID_ServiceEndpointLoader(); + $auth_req = $this->consumer->begin($endpoint); + $this->session->set($this->_token_key, + $loader->toSession($auth_req->endpoint)); + if (!$auth_req->setAnonymous($anonymous)) { + return new Auth_OpenID_FailureResponse(null, + "OpenID 1 requests MUST include the identifier " . + "in the request."); + } + return $auth_req; + } + + /** + * Called to interpret the server's response to an OpenID + * request. It is called in step 4 of the flow described in the + * consumer overview. + * + * @param string $current_url The URL used to invoke the application. + * Extract the URL from your application's web + * request framework and specify it here to have it checked + * against the openid.current_url value in the response. If + * the current_url URL check fails, the status of the + * completion will be FAILURE. + * + * @param array $query An array of the query parameters (key => + * value pairs) for this HTTP request. Defaults to null. If + * null, the GET or POST data are automatically gotten from the + * PHP environment. It is only useful to override $query for + * testing. + * + * @return Auth_OpenID_ConsumerResponse $response A instance of an + * Auth_OpenID_ConsumerResponse subclass. The type of response is + * indicated by the status attribute, which will be one of + * SUCCESS, CANCEL, FAILURE, or SETUP_NEEDED. + */ + function complete($current_url, $query=null) + { + if ($current_url && !is_string($current_url)) { + // This is ugly, but we need to complain loudly when + // someone uses the API incorrectly. + trigger_error("current_url must be a string; see NEWS file " . + "for upgrading notes.", + E_USER_ERROR); + } + + if ($query === null) { + $query = Auth_OpenID::getQuery(); + } + + $loader = new Auth_OpenID_ServiceEndpointLoader(); + $endpoint_data = $this->session->get($this->_token_key); + $endpoint = + $loader->fromSession($endpoint_data); + + $message = Auth_OpenID_Message::fromPostArgs($query); + $response = $this->consumer->complete($message, $endpoint, + $current_url); + $this->session->del($this->_token_key); + + if (in_array($response->status, array(Auth_OpenID_SUCCESS, + Auth_OpenID_CANCEL))) { + if ($response->identity_url !== null) { + $disco = $this->getDiscoveryObject($this->session, + $response->identity_url, + $this->session_key_prefix); + $disco->cleanup(true); + } + } + + return $response; + } +} + +/** + * A class implementing HMAC/DH-SHA1 consumer sessions. + * + * @package OpenID + */ +class Auth_OpenID_DiffieHellmanSHA1ConsumerSession { + var $session_type = 'DH-SHA1'; + var $hash_func = 'Auth_OpenID_SHA1'; + var $secret_size = 20; + var $allowed_assoc_types = array('HMAC-SHA1'); + + function Auth_OpenID_DiffieHellmanSHA1ConsumerSession($dh = null) + { + if ($dh === null) { + $dh = new Auth_OpenID_DiffieHellman(); + } + + $this->dh = $dh; + } + + function getRequest() + { + $math =& Auth_OpenID_getMathLib(); + + $cpub = $math->longToBase64($this->dh->public); + + $args = array('dh_consumer_public' => $cpub); + + if (!$this->dh->usingDefaultValues()) { + $args = array_merge($args, array( + 'dh_modulus' => + $math->longToBase64($this->dh->mod), + 'dh_gen' => + $math->longToBase64($this->dh->gen))); + } + + return $args; + } + + function extractSecret($response) + { + if (!$response->hasKey(Auth_OpenID_OPENID_NS, + 'dh_server_public')) { + return null; + } + + if (!$response->hasKey(Auth_OpenID_OPENID_NS, + 'enc_mac_key')) { + return null; + } + + $math =& Auth_OpenID_getMathLib(); + + $spub = $math->base64ToLong($response->getArg(Auth_OpenID_OPENID_NS, + 'dh_server_public')); + $enc_mac_key = base64_decode($response->getArg(Auth_OpenID_OPENID_NS, + 'enc_mac_key')); + + return $this->dh->xorSecret($spub, $enc_mac_key, $this->hash_func); + } +} + +/** + * A class implementing HMAC/DH-SHA256 consumer sessions. + * + * @package OpenID + */ +class Auth_OpenID_DiffieHellmanSHA256ConsumerSession extends + Auth_OpenID_DiffieHellmanSHA1ConsumerSession { + var $session_type = 'DH-SHA256'; + var $hash_func = 'Auth_OpenID_SHA256'; + var $secret_size = 32; + var $allowed_assoc_types = array('HMAC-SHA256'); +} + +/** + * A class implementing plaintext consumer sessions. + * + * @package OpenID + */ +class Auth_OpenID_PlainTextConsumerSession { + var $session_type = 'no-encryption'; + var $allowed_assoc_types = array('HMAC-SHA1', 'HMAC-SHA256'); + + function getRequest() + { + return array(); + } + + function extractSecret($response) + { + if (!$response->hasKey(Auth_OpenID_OPENID_NS, 'mac_key')) { + return null; + } + + return base64_decode($response->getArg(Auth_OpenID_OPENID_NS, + 'mac_key')); + } +} + +/** + * Returns available session types. + */ +function Auth_OpenID_getAvailableSessionTypes() +{ + $types = array( + 'no-encryption' => 'Auth_OpenID_PlainTextConsumerSession', + 'DH-SHA1' => 'Auth_OpenID_DiffieHellmanSHA1ConsumerSession', + 'DH-SHA256' => 'Auth_OpenID_DiffieHellmanSHA256ConsumerSession'); + + return $types; +} + +/** + * This class is the interface to the OpenID consumer logic. + * Instances of it maintain no per-request state, so they can be + * reused (or even used by multiple threads concurrently) as needed. + * + * @package OpenID + */ +class Auth_OpenID_GenericConsumer { + /** + * @access private + */ + var $discoverMethod = 'Auth_OpenID_discover'; + + /** + * This consumer's store object. + */ + var $store; + + /** + * @access private + */ + var $_use_assocs; + + /** + * @access private + */ + var $openid1_nonce_query_arg_name = 'janrain_nonce'; + + /** + * Another query parameter that gets added to the return_to for + * OpenID 1; if the user's session state is lost, use this claimed + * identifier to do discovery when verifying the response. + */ + var $openid1_return_to_identifier_name = 'openid1_claimed_id'; + + /** + * This method initializes a new {@link Auth_OpenID_Consumer} + * instance to access the library. + * + * @param Auth_OpenID_OpenIDStore $store This must be an object + * that implements the interface in {@link Auth_OpenID_OpenIDStore}. + * Several concrete implementations are provided, to cover most common use + * cases. For stores backed by MySQL, PostgreSQL, or SQLite, see + * the {@link Auth_OpenID_SQLStore} class and its sublcasses. For a + * filesystem-backed store, see the {@link Auth_OpenID_FileStore} module. + * As a last resort, if it isn't possible for the server to store + * state at all, an instance of {@link Auth_OpenID_DumbStore} can be used. + * + * @param bool $immediate This is an optional boolean value. It + * controls whether the library uses immediate mode, as explained + * in the module description. The default value is False, which + * disables immediate mode. + */ + function Auth_OpenID_GenericConsumer(&$store) + { + $this->store =& $store; + $this->negotiator =& Auth_OpenID_getDefaultNegotiator(); + $this->_use_assocs = ($this->store ? true : false); + + $this->fetcher = Auth_Yadis_Yadis::getHTTPFetcher(); + + $this->session_types = Auth_OpenID_getAvailableSessionTypes(); + } + + /** + * Called to begin OpenID authentication using the specified + * {@link Auth_OpenID_ServiceEndpoint}. + * + * @access private + */ + function begin($service_endpoint) + { + $assoc = $this->_getAssociation($service_endpoint); + $r = new Auth_OpenID_AuthRequest($service_endpoint, $assoc); + $r->return_to_args[$this->openid1_nonce_query_arg_name] = + Auth_OpenID_mkNonce(); + + if ($r->message->isOpenID1()) { + $r->return_to_args[$this->openid1_return_to_identifier_name] = + $r->endpoint->claimed_id; + } + + return $r; + } + + /** + * Given an {@link Auth_OpenID_Message}, {@link + * Auth_OpenID_ServiceEndpoint} and optional return_to URL, + * complete OpenID authentication. + * + * @access private + */ + function complete($message, $endpoint, $return_to) + { + $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode', + ''); + + $mode_methods = array( + 'cancel' => '_complete_cancel', + 'error' => '_complete_error', + 'setup_needed' => '_complete_setup_needed', + 'id_res' => '_complete_id_res', + ); + + $method = Auth_OpenID::arrayGet($mode_methods, $mode, + '_completeInvalid'); + + return call_user_func_array(array(&$this, $method), + array($message, &$endpoint, $return_to)); + } + + /** + * @access private + */ + function _completeInvalid($message, &$endpoint, $unused) + { + $mode = $message->getArg(Auth_OpenID_OPENID_NS, 'mode', + ''); + + return new Auth_OpenID_FailureResponse($endpoint, + sprintf("Invalid openid.mode '%s'", $mode)); + } + + /** + * @access private + */ + function _complete_cancel($message, &$endpoint, $unused) + { + return new Auth_OpenID_CancelResponse($endpoint); + } + + /** + * @access private + */ + function _complete_error($message, &$endpoint, $unused) + { + $error = $message->getArg(Auth_OpenID_OPENID_NS, 'error'); + $contact = $message->getArg(Auth_OpenID_OPENID_NS, 'contact'); + $reference = $message->getArg(Auth_OpenID_OPENID_NS, 'reference'); + + return new Auth_OpenID_FailureResponse($endpoint, $error, + $contact, $reference); + } + + /** + * @access private + */ + function _complete_setup_needed($message, &$endpoint, $unused) + { + if (!$message->isOpenID2()) { + return $this->_completeInvalid($message, $endpoint); + } + + $user_setup_url = $message->getArg(Auth_OpenID_OPENID2_NS, + 'user_setup_url'); + return new Auth_OpenID_SetupNeededResponse($endpoint, $user_setup_url); + } + + /** + * @access private + */ + function _complete_id_res($message, &$endpoint, $return_to) + { + $user_setup_url = $message->getArg(Auth_OpenID_OPENID1_NS, + 'user_setup_url'); + + if ($this->_checkSetupNeeded($message)) { + return new Auth_OpenID_SetupNeededResponse( + $endpoint, $user_setup_url); + } else { + return $this->_doIdRes($message, $endpoint, $return_to); + } + } + + /** + * @access private + */ + function _checkSetupNeeded($message) + { + // In OpenID 1, we check to see if this is a cancel from + // immediate mode by the presence of the user_setup_url + // parameter. + if ($message->isOpenID1()) { + $user_setup_url = $message->getArg(Auth_OpenID_OPENID1_NS, + 'user_setup_url'); + if ($user_setup_url !== null) { + return true; + } + } + + return false; + } + + /** + * @access private + */ + function _doIdRes($message, $endpoint, $return_to) + { + // Checks for presence of appropriate fields (and checks + // signed list fields) + $result = $this->_idResCheckForFields($message); + + if (Auth_OpenID::isFailure($result)) { + return $result; + } + + if (!$this->_checkReturnTo($message, $return_to)) { + return new Auth_OpenID_FailureResponse(null, + sprintf("return_to does not match return URL. Expected %s, got %s", + $return_to, + $message->getArg(Auth_OpenID_OPENID_NS, 'return_to'))); + } + + // Verify discovery information: + $result = $this->_verifyDiscoveryResults($message, $endpoint); + + if (Auth_OpenID::isFailure($result)) { + return $result; + } + + $endpoint = $result; + + $result = $this->_idResCheckSignature($message, + $endpoint->server_url); + + if (Auth_OpenID::isFailure($result)) { + return $result; + } + + $result = $this->_idResCheckNonce($message, $endpoint); + + if (Auth_OpenID::isFailure($result)) { + return $result; + } + + $signed_list_str = $message->getArg(Auth_OpenID_OPENID_NS, 'signed', + Auth_OpenID_NO_DEFAULT); + if (Auth_OpenID::isFailure($signed_list_str)) { + return $signed_list_str; + } + $signed_list = explode(',', $signed_list_str); + + $signed_fields = Auth_OpenID::addPrefix($signed_list, "openid."); + + return new Auth_OpenID_SuccessResponse($endpoint, $message, + $signed_fields); + + } + + /** + * @access private + */ + function _checkReturnTo($message, $return_to) + { + // Check an OpenID message and its openid.return_to value + // against a return_to URL from an application. Return True + // on success, False on failure. + + // Check the openid.return_to args against args in the + // original message. + $result = Auth_OpenID_GenericConsumer::_verifyReturnToArgs( + $message->toPostArgs()); + if (Auth_OpenID::isFailure($result)) { + return false; + } + + // Check the return_to base URL against the one in the + // message. + $msg_return_to = $message->getArg(Auth_OpenID_OPENID_NS, + 'return_to'); + if (Auth_OpenID::isFailure($return_to)) { + // XXX log me + return false; + } + + $return_to_parts = parse_url(Auth_OpenID_urinorm($return_to)); + $msg_return_to_parts = parse_url(Auth_OpenID_urinorm($msg_return_to)); + + // If port is absent from both, add it so it's equal in the + // check below. + if ((!array_key_exists('port', $return_to_parts)) && + (!array_key_exists('port', $msg_return_to_parts))) { + $return_to_parts['port'] = null; + $msg_return_to_parts['port'] = null; + } + + // If path is absent from both, add it so it's equal in the + // check below. + if ((!array_key_exists('path', $return_to_parts)) && + (!array_key_exists('path', $msg_return_to_parts))) { + $return_to_parts['path'] = null; + $msg_return_to_parts['path'] = null; + } + + // The URL scheme, authority, and path MUST be the same + // between the two URLs. + foreach (array('scheme', 'host', 'port', 'path') as $component) { + // If the url component is absent in either URL, fail. + // There should always be a scheme, host, port, and path. + if (!array_key_exists($component, $return_to_parts)) { + return false; + } + + if (!array_key_exists($component, $msg_return_to_parts)) { + return false; + } + + if (Auth_OpenID::arrayGet($return_to_parts, $component) !== + Auth_OpenID::arrayGet($msg_return_to_parts, $component)) { + return false; + } + } + + return true; + } + + /** + * @access private + */ + function _verifyReturnToArgs($query) + { + // Verify that the arguments in the return_to URL are present in this + // response. + + $message = Auth_OpenID_Message::fromPostArgs($query); + $return_to = $message->getArg(Auth_OpenID_OPENID_NS, 'return_to'); + + if (Auth_OpenID::isFailure($return_to)) { + return $return_to; + } + // XXX: this should be checked by _idResCheckForFields + if (!$return_to) { + return new Auth_OpenID_FailureResponse(null, + "Response has no return_to"); + } + + $parsed_url = parse_url($return_to); + + $q = array(); + if (array_key_exists('query', $parsed_url)) { + $rt_query = $parsed_url['query']; + $q = Auth_OpenID::parse_str($rt_query); + } + + foreach ($q as $rt_key => $rt_value) { + if (!array_key_exists($rt_key, $query)) { + return new Auth_OpenID_FailureResponse(null, + sprintf("return_to parameter %s absent from query", $rt_key)); + } else { + $value = $query[$rt_key]; + if ($rt_value != $value) { + return new Auth_OpenID_FailureResponse(null, + sprintf("parameter %s value %s does not match " . + "return_to value %s", $rt_key, + $value, $rt_value)); + } + } + } + + // Make sure all non-OpenID arguments in the response are also + // in the signed return_to. + $bare_args = $message->getArgs(Auth_OpenID_BARE_NS); + foreach ($bare_args as $key => $value) { + if (Auth_OpenID::arrayGet($q, $key) != $value) { + return new Auth_OpenID_FailureResponse(null, + sprintf("Parameter %s = %s not in return_to URL", + $key, $value)); + } + } + + return true; + } + + /** + * @access private + */ + function _idResCheckSignature($message, $server_url) + { + $assoc_handle = $message->getArg(Auth_OpenID_OPENID_NS, + 'assoc_handle'); + if (Auth_OpenID::isFailure($assoc_handle)) { + return $assoc_handle; + } + + $assoc = $this->store->getAssociation($server_url, $assoc_handle); + + if ($assoc) { + if ($assoc->getExpiresIn() <= 0) { + // XXX: It might be a good idea sometimes to re-start + // the authentication with a new association. Doing it + // automatically opens the possibility for + // denial-of-service by a server that just returns + // expired associations (or really short-lived + // associations) + return new Auth_OpenID_FailureResponse(null, + 'Association with ' . $server_url . ' expired'); + } + + if (!$assoc->checkMessageSignature($message)) { + return new Auth_OpenID_FailureResponse(null, + "Bad signature"); + } + } else { + // It's not an association we know about. Stateless mode + // is our only possible path for recovery. XXX - async + // framework will not want to block on this call to + // _checkAuth. + if (!$this->_checkAuth($message, $server_url)) { + return new Auth_OpenID_FailureResponse(null, + "Server denied check_authentication"); + } + } + + return null; + } + + /** + * @access private + */ + function _verifyDiscoveryResults($message, $endpoint=null) + { + if ($message->getOpenIDNamespace() == Auth_OpenID_OPENID2_NS) { + return $this->_verifyDiscoveryResultsOpenID2($message, + $endpoint); + } else { + return $this->_verifyDiscoveryResultsOpenID1($message, + $endpoint); + } + } + + /** + * @access private + */ + function _verifyDiscoveryResultsOpenID1($message, $endpoint) + { + $claimed_id = $message->getArg(Auth_OpenID_BARE_NS, + $this->openid1_return_to_identifier_name); + + if (($endpoint === null) && ($claimed_id === null)) { + return new Auth_OpenID_FailureResponse($endpoint, + 'When using OpenID 1, the claimed ID must be supplied, ' . + 'either by passing it through as a return_to parameter ' . + 'or by using a session, and supplied to the GenericConsumer ' . + 'as the argument to complete()'); + } else if (($endpoint !== null) && ($claimed_id === null)) { + $claimed_id = $endpoint->claimed_id; + } + + $to_match = new Auth_OpenID_ServiceEndpoint(); + $to_match->type_uris = array(Auth_OpenID_TYPE_1_1); + $to_match->local_id = $message->getArg(Auth_OpenID_OPENID1_NS, + 'identity'); + + // Restore delegate information from the initiation phase + $to_match->claimed_id = $claimed_id; + + if ($to_match->local_id === null) { + return new Auth_OpenID_FailureResponse($endpoint, + "Missing required field openid.identity"); + } + + $to_match_1_0 = $to_match->copy(); + $to_match_1_0->type_uris = array(Auth_OpenID_TYPE_1_0); + + if ($endpoint !== null) { + $result = $this->_verifyDiscoverySingle($endpoint, $to_match); + + if (is_a($result, 'Auth_OpenID_TypeURIMismatch')) { + $result = $this->_verifyDiscoverySingle($endpoint, + $to_match_1_0); + } + + if (Auth_OpenID::isFailure($result)) { + // oidutil.log("Error attempting to use stored + // discovery information: " + str(e)) + // oidutil.log("Attempting discovery to + // verify endpoint") + } else { + return $endpoint; + } + } + + // Endpoint is either bad (failed verification) or None + return $this->_discoverAndVerify($to_match->claimed_id, + array($to_match, $to_match_1_0)); + } + + /** + * @access private + */ + function _verifyDiscoverySingle($endpoint, $to_match) + { + // Every type URI that's in the to_match endpoint has to be + // present in the discovered endpoint. + foreach ($to_match->type_uris as $type_uri) { + if (!$endpoint->usesExtension($type_uri)) { + return new Auth_OpenID_TypeURIMismatch($endpoint, + "Required type ".$type_uri." not present"); + } + } + + // Fragments do not influence discovery, so we can't compare a + // claimed identifier with a fragment to discovered + // information. + list($defragged_claimed_id, $_) = + Auth_OpenID::urldefrag($to_match->claimed_id); + + if ($defragged_claimed_id != $endpoint->claimed_id) { + return new Auth_OpenID_FailureResponse($endpoint, + sprintf('Claimed ID does not match (different subjects!), ' . + 'Expected %s, got %s', $defragged_claimed_id, + $endpoint->claimed_id)); + } + + if ($to_match->getLocalID() != $endpoint->getLocalID()) { + return new Auth_OpenID_FailureResponse($endpoint, + sprintf('local_id mismatch. Expected %s, got %s', + $to_match->getLocalID(), $endpoint->getLocalID())); + } + + // If the server URL is None, this must be an OpenID 1 + // response, because op_endpoint is a required parameter in + // OpenID 2. In that case, we don't actually care what the + // discovered server_url is, because signature checking or + // check_auth should take care of that check for us. + if ($to_match->server_url === null) { + if ($to_match->preferredNamespace() != Auth_OpenID_OPENID1_NS) { + return new Auth_OpenID_FailureResponse($endpoint, + "Preferred namespace mismatch (bug)"); + } + } else if ($to_match->server_url != $endpoint->server_url) { + return new Auth_OpenID_FailureResponse($endpoint, + sprintf('OP Endpoint mismatch. Expected %s, got %s', + $to_match->server_url, $endpoint->server_url)); + } + + return null; + } + + /** + * @access private + */ + function _verifyDiscoveryResultsOpenID2($message, $endpoint) + { + $to_match = new Auth_OpenID_ServiceEndpoint(); + $to_match->type_uris = array(Auth_OpenID_TYPE_2_0); + $to_match->claimed_id = $message->getArg(Auth_OpenID_OPENID2_NS, + 'claimed_id'); + + $to_match->local_id = $message->getArg(Auth_OpenID_OPENID2_NS, + 'identity'); + + $to_match->server_url = $message->getArg(Auth_OpenID_OPENID2_NS, + 'op_endpoint'); + + if ($to_match->server_url === null) { + return new Auth_OpenID_FailureResponse($endpoint, + "OP Endpoint URL missing"); + } + + // claimed_id and identifier must both be present or both be + // absent + if (($to_match->claimed_id === null) && + ($to_match->local_id !== null)) { + return new Auth_OpenID_FailureResponse($endpoint, + 'openid.identity is present without openid.claimed_id'); + } + + if (($to_match->claimed_id !== null) && + ($to_match->local_id === null)) { + return new Auth_OpenID_FailureResponse($endpoint, + 'openid.claimed_id is present without openid.identity'); + } + + if ($to_match->claimed_id === null) { + // This is a response without identifiers, so there's + // really no checking that we can do, so return an + // endpoint that's for the specified `openid.op_endpoint' + return Auth_OpenID_ServiceEndpoint::fromOPEndpointURL( + $to_match->server_url); + } + + if (!$endpoint) { + // The claimed ID doesn't match, so we have to do + // discovery again. This covers not using sessions, OP + // identifier endpoints and responses that didn't match + // the original request. + // oidutil.log('No pre-discovered information supplied.') + return $this->_discoverAndVerify($to_match->claimed_id, + array($to_match)); + } else { + + // The claimed ID matches, so we use the endpoint that we + // discovered in initiation. This should be the most + // common case. + $result = $this->_verifyDiscoverySingle($endpoint, $to_match); + + if (Auth_OpenID::isFailure($result)) { + $endpoint = $this->_discoverAndVerify($to_match->claimed_id, + array($to_match)); + if (Auth_OpenID::isFailure($endpoint)) { + return $endpoint; + } + } + } + + // The endpoint we return should have the claimed ID from the + // message we just verified, fragment and all. + if ($endpoint->claimed_id != $to_match->claimed_id) { + $endpoint->claimed_id = $to_match->claimed_id; + } + + return $endpoint; + } + + /** + * @access private + */ + function _discoverAndVerify($claimed_id, $to_match_endpoints) + { + // oidutil.log('Performing discovery on %s' % (claimed_id,)) + list($unused, $services) = call_user_func($this->discoverMethod, + $claimed_id, + &$this->fetcher); + + if (!$services) { + return new Auth_OpenID_FailureResponse(null, + sprintf("No OpenID information found at %s", + $claimed_id)); + } + + return $this->_verifyDiscoveryServices($claimed_id, $services, + $to_match_endpoints); + } + + /** + * @access private + */ + function _verifyDiscoveryServices($claimed_id, + &$services, &$to_match_endpoints) + { + // Search the services resulting from discovery to find one + // that matches the information from the assertion + + foreach ($services as $endpoint) { + foreach ($to_match_endpoints as $to_match_endpoint) { + $result = $this->_verifyDiscoverySingle($endpoint, + $to_match_endpoint); + + if (!Auth_OpenID::isFailure($result)) { + // It matches, so discover verification has + // succeeded. Return this endpoint. + return $endpoint; + } + } + } + + return new Auth_OpenID_FailureResponse(null, + sprintf('No matching endpoint found after discovering %s', + $claimed_id)); + } + + /** + * Extract the nonce from an OpenID 1 response. Return the nonce + * from the BARE_NS since we independently check the return_to + * arguments are the same as those in the response message. + * + * See the openid1_nonce_query_arg_name class variable + * + * @returns $nonce The nonce as a string or null + * + * @access private + */ + function _idResGetNonceOpenID1($message, $endpoint) + { + return $message->getArg(Auth_OpenID_BARE_NS, + $this->openid1_nonce_query_arg_name); + } + + /** + * @access private + */ + function _idResCheckNonce($message, $endpoint) + { + if ($message->isOpenID1()) { + // This indicates that the nonce was generated by the consumer + $nonce = $this->_idResGetNonceOpenID1($message, $endpoint); + $server_url = ''; + } else { + $nonce = $message->getArg(Auth_OpenID_OPENID2_NS, + 'response_nonce'); + + $server_url = $endpoint->server_url; + } + + if ($nonce === null) { + return new Auth_OpenID_FailureResponse($endpoint, + "Nonce missing from response"); + } + + $parts = Auth_OpenID_splitNonce($nonce); + + if ($parts === null) { + return new Auth_OpenID_FailureResponse($endpoint, + "Malformed nonce in response"); + } + + list($timestamp, $salt) = $parts; + + if (!$this->store->useNonce($server_url, $timestamp, $salt)) { + return new Auth_OpenID_FailureResponse($endpoint, + "Nonce already used or out of range"); + } + + return null; + } + + /** + * @access private + */ + function _idResCheckForFields($message) + { + $basic_fields = array('return_to', 'assoc_handle', 'sig', 'signed'); + $basic_sig_fields = array('return_to', 'identity'); + + $require_fields = array( + Auth_OpenID_OPENID2_NS => array_merge($basic_fields, + array('op_endpoint')), + + Auth_OpenID_OPENID1_NS => array_merge($basic_fields, + array('identity')) + ); + + $require_sigs = array( + Auth_OpenID_OPENID2_NS => array_merge($basic_sig_fields, + array('response_nonce', + 'claimed_id', + 'assoc_handle', + 'op_endpoint')), + Auth_OpenID_OPENID1_NS => array_merge($basic_sig_fields, + array('nonce')) + ); + + foreach ($require_fields[$message->getOpenIDNamespace()] as $field) { + if (!$message->hasKey(Auth_OpenID_OPENID_NS, $field)) { + return new Auth_OpenID_FailureResponse(null, + "Missing required field '".$field."'"); + } + } + + $signed_list_str = $message->getArg(Auth_OpenID_OPENID_NS, + 'signed', + Auth_OpenID_NO_DEFAULT); + if (Auth_OpenID::isFailure($signed_list_str)) { + return $signed_list_str; + } + $signed_list = explode(',', $signed_list_str); + + foreach ($require_sigs[$message->getOpenIDNamespace()] as $field) { + // Field is present and not in signed list + if ($message->hasKey(Auth_OpenID_OPENID_NS, $field) && + (!in_array($field, $signed_list))) { + return new Auth_OpenID_FailureResponse(null, + "'".$field."' not signed"); + } + } + + return null; + } + + /** + * @access private + */ + function _checkAuth($message, $server_url) + { + $request = $this->_createCheckAuthRequest($message); + if ($request === null) { + return false; + } + + $resp_message = $this->_makeKVPost($request, $server_url); + if (($resp_message === null) || + (is_a($resp_message, 'Auth_OpenID_ServerErrorContainer'))) { + return false; + } + + return $this->_processCheckAuthResponse($resp_message, $server_url); + } + + /** + * @access private + */ + function _createCheckAuthRequest($message) + { + $signed = $message->getArg(Auth_OpenID_OPENID_NS, 'signed'); + if ($signed) { + foreach (explode(',', $signed) as $k) { + $value = $message->getAliasedArg($k); + if ($value === null) { + return null; + } + } + } + $ca_message = $message->copy(); + $ca_message->setArg(Auth_OpenID_OPENID_NS, 'mode', + 'check_authentication'); + return $ca_message; + } + + /** + * @access private + */ + function _processCheckAuthResponse($response, $server_url) + { + $is_valid = $response->getArg(Auth_OpenID_OPENID_NS, 'is_valid', + 'false'); + + $invalidate_handle = $response->getArg(Auth_OpenID_OPENID_NS, + 'invalidate_handle'); + + if ($invalidate_handle !== null) { + $this->store->removeAssociation($server_url, + $invalidate_handle); + } + + if ($is_valid == 'true') { + return true; + } + + return false; + } + + /** + * Adapt a POST response to a Message. + * + * @param $response Result of a POST to an OpenID endpoint. + * + * @access private + */ + function _httpResponseToMessage($response, $server_url) + { + // Should this function be named Message.fromHTTPResponse instead? + $response_message = Auth_OpenID_Message::fromKVForm($response->body); + + if ($response->status == 400) { + return Auth_OpenID_ServerErrorContainer::fromMessage( + $response_message); + } else if ($response->status != 200 and $response->status != 206) { + return null; + } + + return $response_message; + } + + /** + * @access private + */ + function _makeKVPost($message, $server_url) + { + $body = $message->toURLEncoded(); + $resp = $this->fetcher->post($server_url, $body); + + if ($resp === null) { + return null; + } + + return $this->_httpResponseToMessage($resp, $server_url); + } + + /** + * @access private + */ + function _getAssociation($endpoint) + { + if (!$this->_use_assocs) { + return null; + } + + $assoc = $this->store->getAssociation($endpoint->server_url); + + if (($assoc === null) || + ($assoc->getExpiresIn() <= 0)) { + + $assoc = $this->_negotiateAssociation($endpoint); + + if ($assoc !== null) { + $this->store->storeAssociation($endpoint->server_url, + $assoc); + } + } + + return $assoc; + } + + /** + * Handle ServerErrors resulting from association requests. + * + * @return $result If server replied with an C{unsupported-type} + * error, return a tuple of supported C{association_type}, + * C{session_type}. Otherwise logs the error and returns null. + * + * @access private + */ + function _extractSupportedAssociationType(&$server_error, &$endpoint, + $assoc_type) + { + // Any error message whose code is not 'unsupported-type' + // should be considered a total failure. + if (($server_error->error_code != 'unsupported-type') || + ($server_error->message->isOpenID1())) { + return null; + } + + // The server didn't like the association/session type that we + // sent, and it sent us back a message that might tell us how + // to handle it. + + // Extract the session_type and assoc_type from the error + // message + $assoc_type = $server_error->message->getArg(Auth_OpenID_OPENID_NS, + 'assoc_type'); + + $session_type = $server_error->message->getArg(Auth_OpenID_OPENID_NS, + 'session_type'); + + if (($assoc_type === null) || ($session_type === null)) { + return null; + } else if (!$this->negotiator->isAllowed($assoc_type, + $session_type)) { + return null; + } else { + return array($assoc_type, $session_type); + } + } + + /** + * @access private + */ + function _negotiateAssociation($endpoint) + { + // Get our preferred session/association type from the negotiatior. + list($assoc_type, $session_type) = $this->negotiator->getAllowedType(); + + $assoc = $this->_requestAssociation( + $endpoint, $assoc_type, $session_type); + + if (Auth_OpenID::isFailure($assoc)) { + return null; + } + + if (is_a($assoc, 'Auth_OpenID_ServerErrorContainer')) { + $why = $assoc; + + $supportedTypes = $this->_extractSupportedAssociationType( + $why, $endpoint, $assoc_type); + + if ($supportedTypes !== null) { + list($assoc_type, $session_type) = $supportedTypes; + + // Attempt to create an association from the assoc_type + // and session_type that the server told us it + // supported. + $assoc = $this->_requestAssociation( + $endpoint, $assoc_type, $session_type); + + if (is_a($assoc, 'Auth_OpenID_ServerErrorContainer')) { + // Do not keep trying, since it rejected the + // association type that it told us to use. + // oidutil.log('Server %s refused its suggested association + // 'type: session_type=%s, assoc_type=%s' + // % (endpoint.server_url, session_type, + // assoc_type)) + return null; + } else { + return $assoc; + } + } else { + return null; + } + } else { + return $assoc; + } + } + + /** + * @access private + */ + function _requestAssociation($endpoint, $assoc_type, $session_type) + { + list($assoc_session, $args) = $this->_createAssociateRequest( + $endpoint, $assoc_type, $session_type); + + $response_message = $this->_makeKVPost($args, $endpoint->server_url); + + if ($response_message === null) { + // oidutil.log('openid.associate request failed: %s' % (why[0],)) + return null; + } else if (is_a($response_message, + 'Auth_OpenID_ServerErrorContainer')) { + return $response_message; + } + + return $this->_extractAssociation($response_message, $assoc_session); + } + + /** + * @access private + */ + function _extractAssociation(&$assoc_response, &$assoc_session) + { + // Extract the common fields from the response, raising an + // exception if they are not found + $assoc_type = $assoc_response->getArg( + Auth_OpenID_OPENID_NS, 'assoc_type', + Auth_OpenID_NO_DEFAULT); + + if (Auth_OpenID::isFailure($assoc_type)) { + return $assoc_type; + } + + $assoc_handle = $assoc_response->getArg( + Auth_OpenID_OPENID_NS, 'assoc_handle', + Auth_OpenID_NO_DEFAULT); + + if (Auth_OpenID::isFailure($assoc_handle)) { + return $assoc_handle; + } + + // expires_in is a base-10 string. The Python parsing will + // accept literals that have whitespace around them and will + // accept negative values. Neither of these are really in-spec, + // but we think it's OK to accept them. + $expires_in_str = $assoc_response->getArg( + Auth_OpenID_OPENID_NS, 'expires_in', + Auth_OpenID_NO_DEFAULT); + + if (Auth_OpenID::isFailure($expires_in_str)) { + return $expires_in_str; + } + + $expires_in = Auth_OpenID::intval($expires_in_str); + if ($expires_in === false) { + + $err = sprintf("Could not parse expires_in from association ". + "response %s", print_r($assoc_response, true)); + return new Auth_OpenID_FailureResponse(null, $err); + } + + // OpenID 1 has funny association session behaviour. + if ($assoc_response->isOpenID1()) { + $session_type = $this->_getOpenID1SessionType($assoc_response); + } else { + $session_type = $assoc_response->getArg( + Auth_OpenID_OPENID2_NS, 'session_type', + Auth_OpenID_NO_DEFAULT); + + if (Auth_OpenID::isFailure($session_type)) { + return $session_type; + } + } + + // Session type mismatch + if ($assoc_session->session_type != $session_type) { + if ($assoc_response->isOpenID1() && + ($session_type == 'no-encryption')) { + // In OpenID 1, any association request can result in + // a 'no-encryption' association response. Setting + // assoc_session to a new no-encryption session should + // make the rest of this function work properly for + // that case. + $assoc_session = new Auth_OpenID_PlainTextConsumerSession(); + } else { + // Any other mismatch, regardless of protocol version + // results in the failure of the association session + // altogether. + return null; + } + } + + // Make sure assoc_type is valid for session_type + if (!in_array($assoc_type, $assoc_session->allowed_assoc_types)) { + return null; + } + + // Delegate to the association session to extract the secret + // from the response, however is appropriate for that session + // type. + $secret = $assoc_session->extractSecret($assoc_response); + + if ($secret === null) { + return null; + } + + return Auth_OpenID_Association::fromExpiresIn( + $expires_in, $assoc_handle, $secret, $assoc_type); + } + + /** + * @access private + */ + function _createAssociateRequest($endpoint, $assoc_type, $session_type) + { + if (array_key_exists($session_type, $this->session_types)) { + $session_type_class = $this->session_types[$session_type]; + + if (is_callable($session_type_class)) { + $assoc_session = $session_type_class(); + } else { + $assoc_session = new $session_type_class(); + } + } else { + return null; + } + + $args = array( + 'mode' => 'associate', + 'assoc_type' => $assoc_type); + + if (!$endpoint->compatibilityMode()) { + $args['ns'] = Auth_OpenID_OPENID2_NS; + } + + // Leave out the session type if we're in compatibility mode + // *and* it's no-encryption. + if ((!$endpoint->compatibilityMode()) || + ($assoc_session->session_type != 'no-encryption')) { + $args['session_type'] = $assoc_session->session_type; + } + + $args = array_merge($args, $assoc_session->getRequest()); + $message = Auth_OpenID_Message::fromOpenIDArgs($args); + return array($assoc_session, $message); + } + + /** + * Given an association response message, extract the OpenID 1.X + * session type. + * + * This function mostly takes care of the 'no-encryption' default + * behavior in OpenID 1. + * + * If the association type is plain-text, this function will + * return 'no-encryption' + * + * @access private + * @return $typ The association type for this message + */ + function _getOpenID1SessionType($assoc_response) + { + // If it's an OpenID 1 message, allow session_type to default + // to None (which signifies "no-encryption") + $session_type = $assoc_response->getArg(Auth_OpenID_OPENID1_NS, + 'session_type'); + + // Handle the differences between no-encryption association + // respones in OpenID 1 and 2: + + // no-encryption is not really a valid session type for OpenID + // 1, but we'll accept it anyway, while issuing a warning. + if ($session_type == 'no-encryption') { + // oidutil.log('WARNING: OpenID server sent "no-encryption"' + // 'for OpenID 1.X') + } else if (($session_type == '') || ($session_type === null)) { + // Missing or empty session type is the way to flag a + // 'no-encryption' response. Change the session type to + // 'no-encryption' so that it can be handled in the same + // way as OpenID 2 'no-encryption' respones. + $session_type = 'no-encryption'; + } + + return $session_type; + } +} + +/** + * This class represents an authentication request from a consumer to + * an OpenID server. + * + * @package OpenID + */ +class Auth_OpenID_AuthRequest { + + /** + * Initialize an authentication request with the specified token, + * association, and endpoint. + * + * Users of this library should not create instances of this + * class. Instances of this class are created by the library when + * needed. + */ + function Auth_OpenID_AuthRequest(&$endpoint, $assoc) + { + $this->assoc = $assoc; + $this->endpoint =& $endpoint; + $this->return_to_args = array(); + $this->message = new Auth_OpenID_Message( + $endpoint->preferredNamespace()); + $this->_anonymous = false; + } + + /** + * Add an extension to this checkid request. + * + * $extension_request: An object that implements the extension + * request interface for adding arguments to an OpenID message. + */ + function addExtension(&$extension_request) + { + $extension_request->toMessage($this->message); + } + + /** + * Add an extension argument to this OpenID authentication + * request. + * + * Use caution when adding arguments, because they will be + * URL-escaped and appended to the redirect URL, which can easily + * get quite long. + * + * @param string $namespace The namespace for the extension. For + * example, the simple registration extension uses the namespace + * 'sreg'. + * + * @param string $key The key within the extension namespace. For + * example, the nickname field in the simple registration + * extension's key is 'nickname'. + * + * @param string $value The value to provide to the server for + * this argument. + */ + function addExtensionArg($namespace, $key, $value) + { + return $this->message->setArg($namespace, $key, $value); + } + + /** + * Set whether this request should be made anonymously. If a + * request is anonymous, the identifier will not be sent in the + * request. This is only useful if you are making another kind of + * request with an extension in this request. + * + * Anonymous requests are not allowed when the request is made + * with OpenID 1. + */ + function setAnonymous($is_anonymous) + { + if ($is_anonymous && $this->message->isOpenID1()) { + return false; + } else { + $this->_anonymous = $is_anonymous; + return true; + } + } + + /** + * Produce a {@link Auth_OpenID_Message} representing this + * request. + * + * @param string $realm The URL (or URL pattern) that identifies + * your web site to the user when she is authorizing it. + * + * @param string $return_to The URL that the OpenID provider will + * send the user back to after attempting to verify her identity. + * + * Not specifying a return_to URL means that the user will not be + * returned to the site issuing the request upon its completion. + * + * @param bool $immediate If true, the OpenID provider is to send + * back a response immediately, useful for behind-the-scenes + * authentication attempts. Otherwise the OpenID provider may + * engage the user before providing a response. This is the + * default case, as the user may need to provide credentials or + * approve the request before a positive response can be sent. + */ + function getMessage($realm, $return_to=null, $immediate=false) + { + if ($return_to) { + $return_to = Auth_OpenID::appendArgs($return_to, + $this->return_to_args); + } else if ($immediate) { + // raise ValueError( + // '"return_to" is mandatory when + //using "checkid_immediate"') + return new Auth_OpenID_FailureResponse(null, + "'return_to' is mandatory when using checkid_immediate"); + } else if ($this->message->isOpenID1()) { + // raise ValueError('"return_to" is + // mandatory for OpenID 1 requests') + return new Auth_OpenID_FailureResponse(null, + "'return_to' is mandatory for OpenID 1 requests"); + } else if ($this->return_to_args) { + // raise ValueError('extra "return_to" arguments + // were specified, but no return_to was specified') + return new Auth_OpenID_FailureResponse(null, + "extra 'return_to' arguments where specified, " . + "but no return_to was specified"); + } + + if ($immediate) { + $mode = 'checkid_immediate'; + } else { + $mode = 'checkid_setup'; + } + + $message = $this->message->copy(); + if ($message->isOpenID1()) { + $realm_key = 'trust_root'; + } else { + $realm_key = 'realm'; + } + + $message->updateArgs(Auth_OpenID_OPENID_NS, + array( + $realm_key => $realm, + 'mode' => $mode, + 'return_to' => $return_to)); + + if (!$this->_anonymous) { + if ($this->endpoint->isOPIdentifier()) { + // This will never happen when we're in compatibility + // mode, as long as isOPIdentifier() returns False + // whenever preferredNamespace() returns OPENID1_NS. + $claimed_id = $request_identity = + Auth_OpenID_IDENTIFIER_SELECT; + } else { + $request_identity = $this->endpoint->getLocalID(); + $claimed_id = $this->endpoint->claimed_id; + } + + // This is true for both OpenID 1 and 2 + $message->setArg(Auth_OpenID_OPENID_NS, 'identity', + $request_identity); + + if ($message->isOpenID2()) { + $message->setArg(Auth_OpenID_OPENID2_NS, 'claimed_id', + $claimed_id); + } + } + + if ($this->assoc) { + $message->setArg(Auth_OpenID_OPENID_NS, 'assoc_handle', + $this->assoc->handle); + } + + return $message; + } + + function redirectURL($realm, $return_to = null, + $immediate = false) + { + $message = $this->getMessage($realm, $return_to, $immediate); + + if (Auth_OpenID::isFailure($message)) { + return $message; + } + + return $message->toURL($this->endpoint->server_url); + } + + /** + * Get html for a form to submit this request to the IDP. + * + * form_tag_attrs: An array of attributes to be added to the form + * tag. 'accept-charset' and 'enctype' have defaults that can be + * overridden. If a value is supplied for 'action' or 'method', it + * will be replaced. + */ + function formMarkup($realm, $return_to=null, $immediate=false, + $form_tag_attrs=null) + { + $message = $this->getMessage($realm, $return_to, $immediate); + + if (Auth_OpenID::isFailure($message)) { + return $message; + } + + return $message->toFormMarkup($this->endpoint->server_url, + $form_tag_attrs); + } + + /** + * Get a complete html document that will autosubmit the request + * to the IDP. + * + * Wraps formMarkup. See the documentation for that function. + */ + function htmlMarkup($realm, $return_to=null, $immediate=false, + $form_tag_attrs=null) + { + $form = $this->formMarkup($realm, $return_to, $immediate, + $form_tag_attrs); + + if (Auth_OpenID::isFailure($form)) { + return $form; + } + return Auth_OpenID::autoSubmitHTML($form); + } + + function shouldSendRedirect() + { + return $this->endpoint->compatibilityMode(); + } +} + +/** + * The base class for responses from the Auth_OpenID_Consumer. + * + * @package OpenID + */ +class Auth_OpenID_ConsumerResponse { + var $status = null; + + function setEndpoint($endpoint) + { + $this->endpoint = $endpoint; + if ($endpoint === null) { + $this->identity_url = null; + } else { + $this->identity_url = $endpoint->claimed_id; + } + } + + /** + * Return the display identifier for this response. + * + * The display identifier is related to the Claimed Identifier, but the + * two are not always identical. The display identifier is something the + * user should recognize as what they entered, whereas the response's + * claimed identifier (in the identity_url attribute) may have extra + * information for better persistence. + * + * URLs will be stripped of their fragments for display. XRIs will + * display the human-readable identifier (i-name) instead of the + * persistent identifier (i-number). + * + * Use the display identifier in your user interface. Use + * identity_url for querying your database or authorization server. + * + */ + function getDisplayIdentifier() + { + if ($this->endpoint !== null) { + return $this->endpoint->getDisplayIdentifier(); + } + return null; + } +} + +/** + * A response with a status of Auth_OpenID_SUCCESS. Indicates that + * this request is a successful acknowledgement from the OpenID server + * that the supplied URL is, indeed controlled by the requesting + * agent. This has three relevant attributes: + * + * claimed_id - The identity URL that has been authenticated + * + * signed_args - The arguments in the server's response that were + * signed and verified. + * + * status - Auth_OpenID_SUCCESS. + * + * @package OpenID + */ +class Auth_OpenID_SuccessResponse extends Auth_OpenID_ConsumerResponse { + var $status = Auth_OpenID_SUCCESS; + + /** + * @access private + */ + function Auth_OpenID_SuccessResponse($endpoint, $message, $signed_args=null) + { + $this->endpoint = $endpoint; + $this->identity_url = $endpoint->claimed_id; + $this->signed_args = $signed_args; + $this->message = $message; + + if ($this->signed_args === null) { + $this->signed_args = array(); + } + } + + /** + * Extract signed extension data from the server's response. + * + * @param string $prefix The extension namespace from which to + * extract the extension data. + */ + function extensionResponse($namespace_uri, $require_signed) + { + if ($require_signed) { + return $this->getSignedNS($namespace_uri); + } else { + return $this->message->getArgs($namespace_uri); + } + } + + function isOpenID1() + { + return $this->message->isOpenID1(); + } + + function isSigned($ns_uri, $ns_key) + { + // Return whether a particular key is signed, regardless of + // its namespace alias + return in_array($this->message->getKey($ns_uri, $ns_key), + $this->signed_args); + } + + function getSigned($ns_uri, $ns_key, $default = null) + { + // Return the specified signed field if available, otherwise + // return default + if ($this->isSigned($ns_uri, $ns_key)) { + return $this->message->getArg($ns_uri, $ns_key, $default); + } else { + return $default; + } + } + + function getSignedNS($ns_uri) + { + $args = array(); + + $msg_args = $this->message->getArgs($ns_uri); + if (Auth_OpenID::isFailure($msg_args)) { + return null; + } + + foreach ($msg_args as $key => $value) { + if (!$this->isSigned($ns_uri, $key)) { + return null; + } + } + + return $msg_args; + } + + /** + * Get the openid.return_to argument from this response. + * + * This is useful for verifying that this request was initiated by + * this consumer. + * + * @return string $return_to The return_to URL supplied to the + * server on the initial request, or null if the response did not + * contain an 'openid.return_to' argument. + */ + function getReturnTo() + { + return $this->getSigned(Auth_OpenID_OPENID_NS, 'return_to'); + } +} + +/** + * A response with a status of Auth_OpenID_FAILURE. Indicates that the + * OpenID protocol has failed. This could be locally or remotely + * triggered. This has three relevant attributes: + * + * claimed_id - The identity URL for which authentication was + * attempted, if it can be determined. Otherwise, null. + * + * message - A message indicating why the request failed, if one is + * supplied. Otherwise, null. + * + * status - Auth_OpenID_FAILURE. + * + * @package OpenID + */ +class Auth_OpenID_FailureResponse extends Auth_OpenID_ConsumerResponse { + var $status = Auth_OpenID_FAILURE; + + function Auth_OpenID_FailureResponse($endpoint, $message = null, + $contact = null, $reference = null) + { + $this->setEndpoint($endpoint); + $this->message = $message; + $this->contact = $contact; + $this->reference = $reference; + } +} + +/** + * A specific, internal failure used to detect type URI mismatch. + * + * @package OpenID + */ +class Auth_OpenID_TypeURIMismatch extends Auth_OpenID_FailureResponse { +} + +/** + * Exception that is raised when the server returns a 400 response + * code to a direct request. + * + * @package OpenID + */ +class Auth_OpenID_ServerErrorContainer { + function Auth_OpenID_ServerErrorContainer($error_text, + $error_code, + $message) + { + $this->error_text = $error_text; + $this->error_code = $error_code; + $this->message = $message; + } + + /** + * @access private + */ + function fromMessage($message) + { + $error_text = $message->getArg( + Auth_OpenID_OPENID_NS, 'error', ''); + $error_code = $message->getArg(Auth_OpenID_OPENID_NS, 'error_code'); + return new Auth_OpenID_ServerErrorContainer($error_text, + $error_code, + $message); + } +} + +/** + * A response with a status of Auth_OpenID_CANCEL. Indicates that the + * user cancelled the OpenID authentication request. This has two + * relevant attributes: + * + * claimed_id - The identity URL for which authentication was + * attempted, if it can be determined. Otherwise, null. + * + * status - Auth_OpenID_SUCCESS. + * + * @package OpenID + */ +class Auth_OpenID_CancelResponse extends Auth_OpenID_ConsumerResponse { + var $status = Auth_OpenID_CANCEL; + + function Auth_OpenID_CancelResponse($endpoint) + { + $this->setEndpoint($endpoint); + } +} + +/** + * A response with a status of Auth_OpenID_SETUP_NEEDED. Indicates + * that the request was in immediate mode, and the server is unable to + * authenticate the user without further interaction. + * + * claimed_id - The identity URL for which authentication was + * attempted. + * + * setup_url - A URL that can be used to send the user to the server + * to set up for authentication. The user should be redirected in to + * the setup_url, either in the current window or in a new browser + * window. Null in OpenID 2. + * + * status - Auth_OpenID_SETUP_NEEDED. + * + * @package OpenID + */ +class Auth_OpenID_SetupNeededResponse extends Auth_OpenID_ConsumerResponse { + var $status = Auth_OpenID_SETUP_NEEDED; + + function Auth_OpenID_SetupNeededResponse($endpoint, + $setup_url = null) + { + $this->setEndpoint($endpoint); + $this->setup_url = $setup_url; + } +} + +?> diff --git a/inc/lib/Auth/OpenID/CryptUtil.php b/inc/lib/Auth/OpenID/CryptUtil.php new file mode 100644 index 00000000..aacc3cd3 --- /dev/null +++ b/inc/lib/Auth/OpenID/CryptUtil.php @@ -0,0 +1,109 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +if (!defined('Auth_OpenID_RAND_SOURCE')) { + /** + * The filename for a source of random bytes. Define this yourself + * if you have a different source of randomness. + */ + define('Auth_OpenID_RAND_SOURCE', '/dev/urandom'); +} + +class Auth_OpenID_CryptUtil { + /** + * Get the specified number of random bytes. + * + * Attempts to use a cryptographically secure (not predictable) + * source of randomness if available. If there is no high-entropy + * randomness source available, it will fail. As a last resort, + * for non-critical systems, define + * Auth_OpenID_RAND_SOURCE as null, and + * the code will fall back on a pseudo-random number generator. + * + * @param int $num_bytes The length of the return value + * @return string $bytes random bytes + */ + function getBytes($num_bytes) + { + static $f = null; + $bytes = ''; + if ($f === null) { + if (Auth_OpenID_RAND_SOURCE === null) { + $f = false; + } else { + $f = @fopen(Auth_OpenID_RAND_SOURCE, "r"); + if ($f === false) { + $msg = 'Define Auth_OpenID_RAND_SOURCE as null to ' . + ' continue with an insecure random number generator.'; + trigger_error($msg, E_USER_ERROR); + } + } + } + if ($f === false) { + // pseudorandom used + $bytes = ''; + for ($i = 0; $i < $num_bytes; $i += 4) { + $bytes .= pack('L', mt_rand()); + } + $bytes = substr($bytes, 0, $num_bytes); + } else { + $bytes = fread($f, $num_bytes); + } + return $bytes; + } + + /** + * Produce a string of length random bytes, chosen from chrs. If + * $chrs is null, the resulting string may contain any characters. + * + * @param integer $length The length of the resulting + * randomly-generated string + * @param string $chrs A string of characters from which to choose + * to build the new string + * @return string $result A string of randomly-chosen characters + * from $chrs + */ + function randomString($length, $population = null) + { + if ($population === null) { + return Auth_OpenID_CryptUtil::getBytes($length); + } + + $popsize = strlen($population); + + if ($popsize > 256) { + $msg = 'More than 256 characters supplied to ' . __FUNCTION__; + trigger_error($msg, E_USER_ERROR); + } + + $duplicate = 256 % $popsize; + + $str = ""; + for ($i = 0; $i < $length; $i++) { + do { + $n = ord(Auth_OpenID_CryptUtil::getBytes(1)); + } while ($n < $duplicate); + + $n %= $popsize; + $str .= $population[$n]; + } + + return $str; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/DatabaseConnection.php b/inc/lib/Auth/OpenID/DatabaseConnection.php new file mode 100644 index 00000000..9db6e0eb --- /dev/null +++ b/inc/lib/Auth/OpenID/DatabaseConnection.php @@ -0,0 +1,131 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * An empty base class intended to emulate PEAR connection + * functionality in applications that supply their own database + * abstraction mechanisms. See {@link Auth_OpenID_SQLStore} for more + * information. You should subclass this class if you need to create + * an SQL store that needs to access its database using an + * application's database abstraction layer instead of a PEAR database + * connection. Any subclass of Auth_OpenID_DatabaseConnection MUST + * adhere to the interface specified here. + * + * @package OpenID + */ +class Auth_OpenID_DatabaseConnection { + /** + * Sets auto-commit mode on this database connection. + * + * @param bool $mode True if auto-commit is to be used; false if + * not. + */ + function autoCommit($mode) + { + } + + /** + * Run an SQL query with the specified parameters, if any. + * + * @param string $sql An SQL string with placeholders. The + * placeholders are assumed to be specific to the database engine + * for this connection. + * + * @param array $params An array of parameters to insert into the + * SQL string using this connection's escaping mechanism. + * + * @return mixed $result The result of calling this connection's + * internal query function. The type of result depends on the + * underlying database engine. This method is usually used when + * the result of a query is not important, like a DDL query. + */ + function query($sql, $params = array()) + { + } + + /** + * Starts a transaction on this connection, if supported. + */ + function begin() + { + } + + /** + * Commits a transaction on this connection, if supported. + */ + function commit() + { + } + + /** + * Performs a rollback on this connection, if supported. + */ + function rollback() + { + } + + /** + * Run an SQL query and return the first column of the first row + * of the result set, if any. + * + * @param string $sql An SQL string with placeholders. The + * placeholders are assumed to be specific to the database engine + * for this connection. + * + * @param array $params An array of parameters to insert into the + * SQL string using this connection's escaping mechanism. + * + * @return mixed $result The value of the first column of the + * first row of the result set. False if no such result was + * found. + */ + function getOne($sql, $params = array()) + { + } + + /** + * Run an SQL query and return the first row of the result set, if + * any. + * + * @param string $sql An SQL string with placeholders. The + * placeholders are assumed to be specific to the database engine + * for this connection. + * + * @param array $params An array of parameters to insert into the + * SQL string using this connection's escaping mechanism. + * + * @return array $result The first row of the result set, if any, + * keyed on column name. False if no such result was found. + */ + function getRow($sql, $params = array()) + { + } + + /** + * Run an SQL query with the specified parameters, if any. + * + * @param string $sql An SQL string with placeholders. The + * placeholders are assumed to be specific to the database engine + * for this connection. + * + * @param array $params An array of parameters to insert into the + * SQL string using this connection's escaping mechanism. + * + * @return array $result An array of arrays representing the + * result of the query; each array is keyed on column name. + */ + function getAll($sql, $params = array()) + { + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/DiffieHellman.php b/inc/lib/Auth/OpenID/DiffieHellman.php new file mode 100644 index 00000000..f4ded7eb --- /dev/null +++ b/inc/lib/Auth/OpenID/DiffieHellman.php @@ -0,0 +1,113 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +require_once 'Auth/OpenID.php'; +require_once 'Auth/OpenID/BigMath.php'; + +function Auth_OpenID_getDefaultMod() +{ + return '155172898181473697471232257763715539915724801'. + '966915404479707795314057629378541917580651227423'. + '698188993727816152646631438561595825688188889951'. + '272158842675419950341258706556549803580104870537'. + '681476726513255747040765857479291291572334510643'. + '245094715007229621094194349783925984760375594985'. + '848253359305585439638443'; +} + +function Auth_OpenID_getDefaultGen() +{ + return '2'; +} + +/** + * The Diffie-Hellman key exchange class. This class relies on + * {@link Auth_OpenID_MathLibrary} to perform large number operations. + * + * @access private + * @package OpenID + */ +class Auth_OpenID_DiffieHellman { + + var $mod; + var $gen; + var $private; + var $lib = null; + + function Auth_OpenID_DiffieHellman($mod = null, $gen = null, + $private = null, $lib = null) + { + if ($lib === null) { + $this->lib =& Auth_OpenID_getMathLib(); + } else { + $this->lib =& $lib; + } + + if ($mod === null) { + $this->mod = $this->lib->init(Auth_OpenID_getDefaultMod()); + } else { + $this->mod = $mod; + } + + if ($gen === null) { + $this->gen = $this->lib->init(Auth_OpenID_getDefaultGen()); + } else { + $this->gen = $gen; + } + + if ($private === null) { + $r = $this->lib->rand($this->mod); + $this->private = $this->lib->add($r, 1); + } else { + $this->private = $private; + } + + $this->public = $this->lib->powmod($this->gen, $this->private, + $this->mod); + } + + function getSharedSecret($composite) + { + return $this->lib->powmod($composite, $this->private, $this->mod); + } + + function getPublicKey() + { + return $this->public; + } + + function usingDefaultValues() + { + return ($this->mod == Auth_OpenID_getDefaultMod() && + $this->gen == Auth_OpenID_getDefaultGen()); + } + + function xorSecret($composite, $secret, $hash_func) + { + $dh_shared = $this->getSharedSecret($composite); + $dh_shared_str = $this->lib->longToBinary($dh_shared); + $hash_dh_shared = $hash_func($dh_shared_str); + + $xsecret = ""; + for ($i = 0; $i < Auth_OpenID::bytes($secret); $i++) { + $xsecret .= chr(ord($secret[$i]) ^ ord($hash_dh_shared[$i])); + } + + return $xsecret; + } +} + +?> diff --git a/inc/lib/Auth/OpenID/Discover.php b/inc/lib/Auth/OpenID/Discover.php new file mode 100644 index 00000000..62aeb1d2 --- /dev/null +++ b/inc/lib/Auth/OpenID/Discover.php @@ -0,0 +1,548 @@ +claimed_id = null; + $this->server_url = null; + $this->type_uris = array(); + $this->local_id = null; + $this->canonicalID = null; + $this->used_yadis = false; // whether this came from an XRDS + $this->display_identifier = null; + } + + function getDisplayIdentifier() + { + if ($this->display_identifier) { + return $this->display_identifier; + } + if (! $this->claimed_id) { + return $this->claimed_id; + } + $parsed = parse_url($this->claimed_id); + $scheme = $parsed['scheme']; + $host = $parsed['host']; + $path = $parsed['path']; + if (array_key_exists('query', $parsed)) { + $query = $parsed['query']; + $no_frag = "$scheme://$host$path?$query"; + } else { + $no_frag = "$scheme://$host$path"; + } + return $no_frag; + } + + function usesExtension($extension_uri) + { + return in_array($extension_uri, $this->type_uris); + } + + function preferredNamespace() + { + if (in_array(Auth_OpenID_TYPE_2_0_IDP, $this->type_uris) || + in_array(Auth_OpenID_TYPE_2_0, $this->type_uris)) { + return Auth_OpenID_OPENID2_NS; + } else { + return Auth_OpenID_OPENID1_NS; + } + } + + /* + * Query this endpoint to see if it has any of the given type + * URIs. This is useful for implementing other endpoint classes + * that e.g. need to check for the presence of multiple versions + * of a single protocol. + * + * @param $type_uris The URIs that you wish to check + * + * @return all types that are in both in type_uris and + * $this->type_uris + */ + function matchTypes($type_uris) + { + $result = array(); + foreach ($type_uris as $test_uri) { + if ($this->supportsType($test_uri)) { + $result[] = $test_uri; + } + } + + return $result; + } + + function supportsType($type_uri) + { + // Does this endpoint support this type? + return ((in_array($type_uri, $this->type_uris)) || + (($type_uri == Auth_OpenID_TYPE_2_0) && + $this->isOPIdentifier())); + } + + function compatibilityMode() + { + return $this->preferredNamespace() != Auth_OpenID_OPENID2_NS; + } + + function isOPIdentifier() + { + return in_array(Auth_OpenID_TYPE_2_0_IDP, $this->type_uris); + } + + function fromOPEndpointURL($op_endpoint_url) + { + // Construct an OP-Identifier OpenIDServiceEndpoint object for + // a given OP Endpoint URL + $obj = new Auth_OpenID_ServiceEndpoint(); + $obj->server_url = $op_endpoint_url; + $obj->type_uris = array(Auth_OpenID_TYPE_2_0_IDP); + return $obj; + } + + function parseService($yadis_url, $uri, $type_uris, $service_element) + { + // Set the state of this object based on the contents of the + // service element. Return true if successful, false if not + // (if findOPLocalIdentifier returns false). + $this->type_uris = $type_uris; + $this->server_url = $uri; + $this->used_yadis = true; + + if (!$this->isOPIdentifier()) { + $this->claimed_id = $yadis_url; + $this->local_id = Auth_OpenID_findOPLocalIdentifier( + $service_element, + $this->type_uris); + if ($this->local_id === false) { + return false; + } + } + + return true; + } + + function getLocalID() + { + // Return the identifier that should be sent as the + // openid.identity_url parameter to the server. + if ($this->local_id === null && $this->canonicalID === null) { + return $this->claimed_id; + } else { + if ($this->local_id) { + return $this->local_id; + } else { + return $this->canonicalID; + } + } + } + + /* + * Parse the given document as XRDS looking for OpenID services. + * + * @return array of Auth_OpenID_ServiceEndpoint or null if the + * document cannot be parsed. + */ + function fromXRDS($uri, $xrds_text) + { + $xrds =& Auth_Yadis_XRDS::parseXRDS($xrds_text); + + if ($xrds) { + $yadis_services = + $xrds->services(array('filter_MatchesAnyOpenIDType')); + return Auth_OpenID_makeOpenIDEndpoints($uri, $yadis_services); + } + + return null; + } + + /* + * Create endpoints from a DiscoveryResult. + * + * @param discoveryResult Auth_Yadis_DiscoveryResult + * @return array of Auth_OpenID_ServiceEndpoint or null if + * endpoints cannot be created. + */ + function fromDiscoveryResult($discoveryResult) + { + if ($discoveryResult->isXRDS()) { + return Auth_OpenID_ServiceEndpoint::fromXRDS( + $discoveryResult->normalized_uri, + $discoveryResult->response_text); + } else { + return Auth_OpenID_ServiceEndpoint::fromHTML( + $discoveryResult->normalized_uri, + $discoveryResult->response_text); + } + } + + function fromHTML($uri, $html) + { + $discovery_types = array( + array(Auth_OpenID_TYPE_2_0, + 'openid2.provider', 'openid2.local_id'), + array(Auth_OpenID_TYPE_1_1, + 'openid.server', 'openid.delegate') + ); + + $services = array(); + + foreach ($discovery_types as $triple) { + list($type_uri, $server_rel, $delegate_rel) = $triple; + + $urls = Auth_OpenID_legacy_discover($html, $server_rel, + $delegate_rel); + + if ($urls === false) { + continue; + } + + list($delegate_url, $server_url) = $urls; + + $service = new Auth_OpenID_ServiceEndpoint(); + $service->claimed_id = $uri; + $service->local_id = $delegate_url; + $service->server_url = $server_url; + $service->type_uris = array($type_uri); + + $services[] = $service; + } + + return $services; + } + + function copy() + { + $x = new Auth_OpenID_ServiceEndpoint(); + + $x->claimed_id = $this->claimed_id; + $x->server_url = $this->server_url; + $x->type_uris = $this->type_uris; + $x->local_id = $this->local_id; + $x->canonicalID = $this->canonicalID; + $x->used_yadis = $this->used_yadis; + + return $x; + } +} + +function Auth_OpenID_findOPLocalIdentifier($service, $type_uris) +{ + // Extract a openid:Delegate value from a Yadis Service element. + // If no delegate is found, returns null. Returns false on + // discovery failure (when multiple delegate/localID tags have + // different values). + + $service->parser->registerNamespace('openid', + Auth_OpenID_XMLNS_1_0); + + $service->parser->registerNamespace('xrd', + Auth_Yadis_XMLNS_XRD_2_0); + + $parser =& $service->parser; + + $permitted_tags = array(); + + if (in_array(Auth_OpenID_TYPE_1_1, $type_uris) || + in_array(Auth_OpenID_TYPE_1_0, $type_uris)) { + $permitted_tags[] = 'openid:Delegate'; + } + + if (in_array(Auth_OpenID_TYPE_2_0, $type_uris)) { + $permitted_tags[] = 'xrd:LocalID'; + } + + $local_id = null; + + foreach ($permitted_tags as $tag_name) { + $tags = $service->getElements($tag_name); + + foreach ($tags as $tag) { + $content = $parser->content($tag); + + if ($local_id === null) { + $local_id = $content; + } else if ($local_id != $content) { + return false; + } + } + } + + return $local_id; +} + +function filter_MatchesAnyOpenIDType(&$service) +{ + $uris = $service->getTypes(); + + foreach ($uris as $uri) { + if (in_array($uri, Auth_OpenID_getOpenIDTypeURIs())) { + return true; + } + } + + return false; +} + +function Auth_OpenID_bestMatchingService($service, $preferred_types) +{ + // Return the index of the first matching type, or something + // higher if no type matches. + // + // This provides an ordering in which service elements that + // contain a type that comes earlier in the preferred types list + // come before service elements that come later. If a service + // element has more than one type, the most preferred one wins. + + foreach ($preferred_types as $index => $typ) { + if (in_array($typ, $service->type_uris)) { + return $index; + } + } + + return count($preferred_types); +} + +function Auth_OpenID_arrangeByType($service_list, $preferred_types) +{ + // Rearrange service_list in a new list so services are ordered by + // types listed in preferred_types. Return the new list. + + // Build a list with the service elements in tuples whose + // comparison will prefer the one with the best matching service + $prio_services = array(); + foreach ($service_list as $index => $service) { + $prio_services[] = array(Auth_OpenID_bestMatchingService($service, + $preferred_types), + $index, $service); + } + + sort($prio_services); + + // Now that the services are sorted by priority, remove the sort + // keys from the list. + foreach ($prio_services as $index => $s) { + $prio_services[$index] = $prio_services[$index][2]; + } + + return $prio_services; +} + +// Extract OP Identifier services. If none found, return the rest, +// sorted with most preferred first according to +// OpenIDServiceEndpoint.openid_type_uris. +// +// openid_services is a list of OpenIDServiceEndpoint objects. +// +// Returns a list of OpenIDServiceEndpoint objects.""" +function Auth_OpenID_getOPOrUserServices($openid_services) +{ + $op_services = Auth_OpenID_arrangeByType($openid_services, + array(Auth_OpenID_TYPE_2_0_IDP)); + + $openid_services = Auth_OpenID_arrangeByType($openid_services, + Auth_OpenID_getOpenIDTypeURIs()); + + if ($op_services) { + return $op_services; + } else { + return $openid_services; + } +} + +function Auth_OpenID_makeOpenIDEndpoints($uri, $yadis_services) +{ + $s = array(); + + if (!$yadis_services) { + return $s; + } + + foreach ($yadis_services as $service) { + $type_uris = $service->getTypes(); + $uris = $service->getURIs(); + + // If any Type URIs match and there is an endpoint URI + // specified, then this is an OpenID endpoint + if ($type_uris && + $uris) { + foreach ($uris as $service_uri) { + $openid_endpoint = new Auth_OpenID_ServiceEndpoint(); + if ($openid_endpoint->parseService($uri, + $service_uri, + $type_uris, + $service)) { + $s[] = $openid_endpoint; + } + } + } + } + + return $s; +} + +function Auth_OpenID_discoverWithYadis($uri, &$fetcher, + $endpoint_filter='Auth_OpenID_getOPOrUserServices', + $discover_function=null) +{ + // Discover OpenID services for a URI. Tries Yadis and falls back + // on old-style discovery if Yadis fails. + + // Might raise a yadis.discover.DiscoveryFailure if no document + // came back for that URI at all. I don't think falling back to + // OpenID 1.0 discovery on the same URL will help, so don't bother + // to catch it. + if ($discover_function === null) { + $discover_function = array('Auth_Yadis_Yadis', 'discover'); + } + + $openid_services = array(); + + $response = call_user_func_array($discover_function, + array($uri, &$fetcher)); + + $yadis_url = $response->normalized_uri; + $yadis_services = array(); + + if ($response->isFailure()) { + return array($uri, array()); + } + + $openid_services = Auth_OpenID_ServiceEndpoint::fromXRDS( + $yadis_url, + $response->response_text); + + if (!$openid_services) { + if ($response->isXRDS()) { + return Auth_OpenID_discoverWithoutYadis($uri, + $fetcher); + } + + // Try to parse the response as HTML to get OpenID 1.0/1.1 + // + $openid_services = Auth_OpenID_ServiceEndpoint::fromHTML( + $yadis_url, + $response->response_text); + } + + $openid_services = call_user_func_array($endpoint_filter, + array(&$openid_services)); + + return array($yadis_url, $openid_services); +} + +function Auth_OpenID_discoverURI($uri, &$fetcher) +{ + $uri = Auth_OpenID::normalizeUrl($uri); + return Auth_OpenID_discoverWithYadis($uri, $fetcher); +} + +function Auth_OpenID_discoverWithoutYadis($uri, &$fetcher) +{ + $http_resp = @$fetcher->get($uri); + + if ($http_resp->status != 200 and $http_resp->status != 206) { + return array($uri, array()); + } + + $identity_url = $http_resp->final_url; + + // Try to parse the response as HTML to get OpenID 1.0/1.1 + $openid_services = Auth_OpenID_ServiceEndpoint::fromHTML( + $identity_url, + $http_resp->body); + + return array($identity_url, $openid_services); +} + +function Auth_OpenID_discoverXRI($iname, &$fetcher) +{ + $resolver = new Auth_Yadis_ProxyResolver($fetcher); + list($canonicalID, $yadis_services) = + $resolver->query($iname, + Auth_OpenID_getOpenIDTypeURIs(), + array('filter_MatchesAnyOpenIDType')); + + $openid_services = Auth_OpenID_makeOpenIDEndpoints($iname, + $yadis_services); + + $openid_services = Auth_OpenID_getOPOrUserServices($openid_services); + + for ($i = 0; $i < count($openid_services); $i++) { + $openid_services[$i]->canonicalID = $canonicalID; + $openid_services[$i]->claimed_id = $canonicalID; + $openid_services[$i]->display_identifier = $iname; + } + + // FIXME: returned xri should probably be in some normal form + return array($iname, $openid_services); +} + +function Auth_OpenID_discover($uri, &$fetcher) +{ + // If the fetcher (i.e., PHP) doesn't support SSL, we can't do + // discovery on an HTTPS URL. + if ($fetcher->isHTTPS($uri) && !$fetcher->supportsSSL()) { + return array($uri, array()); + } + + if (Auth_Yadis_identifierScheme($uri) == 'XRI') { + $result = Auth_OpenID_discoverXRI($uri, $fetcher); + } else { + $result = Auth_OpenID_discoverURI($uri, $fetcher); + } + + // If the fetcher doesn't support SSL, we can't interact with + // HTTPS server URLs; remove those endpoints from the list. + if (!$fetcher->supportsSSL()) { + $http_endpoints = array(); + list($new_uri, $endpoints) = $result; + + foreach ($endpoints as $e) { + if (!$fetcher->isHTTPS($e->server_url)) { + $http_endpoints[] = $e; + } + } + + $result = array($new_uri, $http_endpoints); + } + + return $result; +} + +?> diff --git a/inc/lib/Auth/OpenID/DumbStore.php b/inc/lib/Auth/OpenID/DumbStore.php new file mode 100644 index 00000000..22fd2d36 --- /dev/null +++ b/inc/lib/Auth/OpenID/DumbStore.php @@ -0,0 +1,100 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * Import the interface for creating a new store class. + */ +require_once 'Auth/OpenID/Interface.php'; +require_once 'Auth/OpenID/HMAC.php'; + +/** + * This is a store for use in the worst case, when you have no way of + * saving state on the consumer site. Using this store makes the + * consumer vulnerable to replay attacks, as it's unable to use + * nonces. Avoid using this store if it is at all possible. + * + * Most of the methods of this class are implementation details. + * Users of this class need to worry only about the constructor. + * + * @package OpenID + */ +class Auth_OpenID_DumbStore extends Auth_OpenID_OpenIDStore { + + /** + * Creates a new {@link Auth_OpenID_DumbStore} instance. For the security + * of the tokens generated by the library, this class attempts to + * at least have a secure implementation of getAuthKey. + * + * When you create an instance of this class, pass in a secret + * phrase. The phrase is hashed with sha1 to make it the correct + * length and form for an auth key. That allows you to use a long + * string as the secret phrase, which means you can make it very + * difficult to guess. + * + * Each {@link Auth_OpenID_DumbStore} instance that is created for use by + * your consumer site needs to use the same $secret_phrase. + * + * @param string secret_phrase The phrase used to create the auth + * key returned by getAuthKey + */ + function Auth_OpenID_DumbStore($secret_phrase) + { + $this->auth_key = Auth_OpenID_SHA1($secret_phrase); + } + + /** + * This implementation does nothing. + */ + function storeAssociation($server_url, $association) + { + } + + /** + * This implementation always returns null. + */ + function getAssociation($server_url, $handle = null) + { + return null; + } + + /** + * This implementation always returns false. + */ + function removeAssociation($server_url, $handle) + { + return false; + } + + /** + * In a system truly limited to dumb mode, nonces must all be + * accepted. This therefore always returns true, which makes + * replay attacks feasible. + */ + function useNonce($server_url, $timestamp, $salt) + { + return true; + } + + /** + * This method returns the auth key generated by the constructor. + */ + function getAuthKey() + { + return $this->auth_key; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Extension.php b/inc/lib/Auth/OpenID/Extension.php new file mode 100644 index 00000000..f362a4b3 --- /dev/null +++ b/inc/lib/Auth/OpenID/Extension.php @@ -0,0 +1,62 @@ +isOpenID1(); + $added = $message->namespaces->addAlias($this->ns_uri, + $this->ns_alias, + $implicit); + + if ($added === null) { + if ($message->namespaces->getAlias($this->ns_uri) != + $this->ns_alias) { + return null; + } + } + + $message->updateArgs($this->ns_uri, + $this->getExtensionArgs()); + return $message; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/FileStore.php b/inc/lib/Auth/OpenID/FileStore.php new file mode 100644 index 00000000..29d8d20e --- /dev/null +++ b/inc/lib/Auth/OpenID/FileStore.php @@ -0,0 +1,618 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * Require base class for creating a new interface. + */ +require_once 'Auth/OpenID.php'; +require_once 'Auth/OpenID/Interface.php'; +require_once 'Auth/OpenID/HMAC.php'; +require_once 'Auth/OpenID/Nonce.php'; + +/** + * This is a filesystem-based store for OpenID associations and + * nonces. This store should be safe for use in concurrent systems on + * both windows and unix (excluding NFS filesystems). There are a + * couple race conditions in the system, but those failure cases have + * been set up in such a way that the worst-case behavior is someone + * having to try to log in a second time. + * + * Most of the methods of this class are implementation details. + * People wishing to just use this store need only pay attention to + * the constructor. + * + * @package OpenID + */ +class Auth_OpenID_FileStore extends Auth_OpenID_OpenIDStore { + + /** + * Initializes a new {@link Auth_OpenID_FileStore}. This + * initializes the nonce and association directories, which are + * subdirectories of the directory passed in. + * + * @param string $directory This is the directory to put the store + * directories in. + */ + function Auth_OpenID_FileStore($directory) + { + if (!Auth_OpenID::ensureDir($directory)) { + trigger_error('Not a directory and failed to create: ' + . $directory, E_USER_ERROR); + } + $directory = realpath($directory); + + $this->directory = $directory; + $this->active = true; + + $this->nonce_dir = $directory . DIRECTORY_SEPARATOR . 'nonces'; + + $this->association_dir = $directory . DIRECTORY_SEPARATOR . + 'associations'; + + // Temp dir must be on the same filesystem as the assciations + // $directory. + $this->temp_dir = $directory . DIRECTORY_SEPARATOR . 'temp'; + + $this->max_nonce_age = 6 * 60 * 60; // Six hours, in seconds + + if (!$this->_setup()) { + trigger_error('Failed to initialize OpenID file store in ' . + $directory, E_USER_ERROR); + } + } + + function destroy() + { + Auth_OpenID_FileStore::_rmtree($this->directory); + $this->active = false; + } + + /** + * Make sure that the directories in which we store our data + * exist. + * + * @access private + */ + function _setup() + { + return (Auth_OpenID::ensureDir($this->nonce_dir) && + Auth_OpenID::ensureDir($this->association_dir) && + Auth_OpenID::ensureDir($this->temp_dir)); + } + + /** + * Create a temporary file on the same filesystem as + * $this->association_dir. + * + * The temporary directory should not be cleaned if there are any + * processes using the store. If there is no active process using + * the store, it is safe to remove all of the files in the + * temporary directory. + * + * @return array ($fd, $filename) + * @access private + */ + function _mktemp() + { + $name = Auth_OpenID_FileStore::_mkstemp($dir = $this->temp_dir); + $file_obj = @fopen($name, 'wb'); + if ($file_obj !== false) { + return array($file_obj, $name); + } else { + Auth_OpenID_FileStore::_removeIfPresent($name); + } + } + + function cleanupNonces() + { + global $Auth_OpenID_SKEW; + + $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir); + $now = time(); + + $removed = 0; + // Check all nonces for expiry + foreach ($nonces as $nonce_fname) { + $base = basename($nonce_fname); + $parts = explode('-', $base, 2); + $timestamp = $parts[0]; + $timestamp = intval($timestamp, 16); + if (abs($timestamp - $now) > $Auth_OpenID_SKEW) { + Auth_OpenID_FileStore::_removeIfPresent($nonce_fname); + $removed += 1; + } + } + return $removed; + } + + /** + * Create a unique filename for a given server url and + * handle. This implementation does not assume anything about the + * format of the handle. The filename that is returned will + * contain the domain name from the server URL for ease of human + * inspection of the data directory. + * + * @return string $filename + */ + function getAssociationFilename($server_url, $handle) + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + if (strpos($server_url, '://') === false) { + trigger_error(sprintf("Bad server URL: %s", $server_url), + E_USER_WARNING); + return null; + } + + list($proto, $rest) = explode('://', $server_url, 2); + $parts = explode('/', $rest); + $domain = Auth_OpenID_FileStore::_filenameEscape($parts[0]); + $url_hash = Auth_OpenID_FileStore::_safe64($server_url); + if ($handle) { + $handle_hash = Auth_OpenID_FileStore::_safe64($handle); + } else { + $handle_hash = ''; + } + + $filename = sprintf('%s-%s-%s-%s', $proto, $domain, $url_hash, + $handle_hash); + + return $this->association_dir. DIRECTORY_SEPARATOR . $filename; + } + + /** + * Store an association in the association directory. + */ + function storeAssociation($server_url, $association) + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return false; + } + + $association_s = $association->serialize(); + $filename = $this->getAssociationFilename($server_url, + $association->handle); + list($tmp_file, $tmp) = $this->_mktemp(); + + if (!$tmp_file) { + trigger_error("_mktemp didn't return a valid file descriptor", + E_USER_WARNING); + return false; + } + + fwrite($tmp_file, $association_s); + + fflush($tmp_file); + + fclose($tmp_file); + + if (@rename($tmp, $filename)) { + return true; + } else { + // In case we are running on Windows, try unlinking the + // file in case it exists. + @unlink($filename); + + // Now the target should not exist. Try renaming again, + // giving up if it fails. + if (@rename($tmp, $filename)) { + return true; + } + } + + // If there was an error, don't leave the temporary file + // around. + Auth_OpenID_FileStore::_removeIfPresent($tmp); + return false; + } + + /** + * Retrieve an association. If no handle is specified, return the + * association with the most recent issue time. + * + * @return mixed $association + */ + function getAssociation($server_url, $handle = null) + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + if ($handle === null) { + $handle = ''; + } + + // The filename with the empty handle is a prefix of all other + // associations for the given server URL. + $filename = $this->getAssociationFilename($server_url, $handle); + + if ($handle) { + return $this->_getAssociation($filename); + } else { + $association_files = + Auth_OpenID_FileStore::_listdir($this->association_dir); + $matching_files = array(); + + // strip off the path to do the comparison + $name = basename($filename); + foreach ($association_files as $association_file) { + $base = basename($association_file); + if (strpos($base, $name) === 0) { + $matching_files[] = $association_file; + } + } + + $matching_associations = array(); + // read the matching files and sort by time issued + foreach ($matching_files as $full_name) { + $association = $this->_getAssociation($full_name); + if ($association !== null) { + $matching_associations[] = array($association->issued, + $association); + } + } + + $issued = array(); + $assocs = array(); + foreach ($matching_associations as $key => $assoc) { + $issued[$key] = $assoc[0]; + $assocs[$key] = $assoc[1]; + } + + array_multisort($issued, SORT_DESC, $assocs, SORT_DESC, + $matching_associations); + + // return the most recently issued one. + if ($matching_associations) { + list($issued, $assoc) = $matching_associations[0]; + return $assoc; + } else { + return null; + } + } + } + + /** + * @access private + */ + function _getAssociation($filename) + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + $assoc_file = @fopen($filename, 'rb'); + + if ($assoc_file === false) { + return null; + } + + $assoc_s = fread($assoc_file, filesize($filename)); + fclose($assoc_file); + + if (!$assoc_s) { + return null; + } + + $association = + Auth_OpenID_Association::deserialize('Auth_OpenID_Association', + $assoc_s); + + if (!$association) { + Auth_OpenID_FileStore::_removeIfPresent($filename); + return null; + } + + if ($association->getExpiresIn() == 0) { + Auth_OpenID_FileStore::_removeIfPresent($filename); + return null; + } else { + return $association; + } + } + + /** + * Remove an association if it exists. Do nothing if it does not. + * + * @return bool $success + */ + function removeAssociation($server_url, $handle) + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + $assoc = $this->getAssociation($server_url, $handle); + if ($assoc === null) { + return false; + } else { + $filename = $this->getAssociationFilename($server_url, $handle); + return Auth_OpenID_FileStore::_removeIfPresent($filename); + } + } + + /** + * Return whether this nonce is present. As a side effect, mark it + * as no longer present. + * + * @return bool $present + */ + function useNonce($server_url, $timestamp, $salt) + { + global $Auth_OpenID_SKEW; + + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + if ( abs($timestamp - time()) > $Auth_OpenID_SKEW ) { + return False; + } + + if ($server_url) { + list($proto, $rest) = explode('://', $server_url, 2); + } else { + $proto = ''; + $rest = ''; + } + + $parts = explode('/', $rest, 2); + $domain = $this->_filenameEscape($parts[0]); + $url_hash = $this->_safe64($server_url); + $salt_hash = $this->_safe64($salt); + + $filename = sprintf('%08x-%s-%s-%s-%s', $timestamp, $proto, + $domain, $url_hash, $salt_hash); + $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $filename; + + $result = @fopen($filename, 'x'); + + if ($result === false) { + return false; + } else { + fclose($result); + return true; + } + } + + /** + * Remove expired entries from the database. This is potentially + * expensive, so only run when it is acceptable to take time. + * + * @access private + */ + function _allAssocs() + { + $all_associations = array(); + + $association_filenames = + Auth_OpenID_FileStore::_listdir($this->association_dir); + + foreach ($association_filenames as $association_filename) { + $association_file = fopen($association_filename, 'rb'); + + if ($association_file !== false) { + $assoc_s = fread($association_file, + filesize($association_filename)); + fclose($association_file); + + // Remove expired or corrupted associations + $association = + Auth_OpenID_Association::deserialize( + 'Auth_OpenID_Association', $assoc_s); + + if ($association === null) { + Auth_OpenID_FileStore::_removeIfPresent( + $association_filename); + } else { + if ($association->getExpiresIn() == 0) { + $all_associations[] = array($association_filename, + $association); + } + } + } + } + + return $all_associations; + } + + function clean() + { + if (!$this->active) { + trigger_error("FileStore no longer active", E_USER_ERROR); + return null; + } + + $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir); + $now = time(); + + // Check all nonces for expiry + foreach ($nonces as $nonce) { + if (!Auth_OpenID_checkTimestamp($nonce, $now)) { + $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $nonce; + Auth_OpenID_FileStore::_removeIfPresent($filename); + } + } + + foreach ($this->_allAssocs() as $pair) { + list($assoc_filename, $assoc) = $pair; + if ($assoc->getExpiresIn() == 0) { + Auth_OpenID_FileStore::_removeIfPresent($assoc_filename); + } + } + } + + /** + * @access private + */ + function _rmtree($dir) + { + if ($dir[strlen($dir) - 1] != DIRECTORY_SEPARATOR) { + $dir .= DIRECTORY_SEPARATOR; + } + + if ($handle = opendir($dir)) { + while ($item = readdir($handle)) { + if (!in_array($item, array('.', '..'))) { + if (is_dir($dir . $item)) { + + if (!Auth_OpenID_FileStore::_rmtree($dir . $item)) { + return false; + } + } else if (is_file($dir . $item)) { + if (!unlink($dir . $item)) { + return false; + } + } + } + } + + closedir($handle); + + if (!@rmdir($dir)) { + return false; + } + + return true; + } else { + // Couldn't open directory. + return false; + } + } + + /** + * @access private + */ + function _mkstemp($dir) + { + foreach (range(0, 4) as $i) { + $name = tempnam($dir, "php_openid_filestore_"); + + if ($name !== false) { + return $name; + } + } + return false; + } + + /** + * @access private + */ + function _mkdtemp($dir) + { + foreach (range(0, 4) as $i) { + $name = $dir . strval(DIRECTORY_SEPARATOR) . strval(getmypid()) . + "-" . strval(rand(1, time())); + if (!mkdir($name, 0700)) { + return false; + } else { + return $name; + } + } + return false; + } + + /** + * @access private + */ + function _listdir($dir) + { + $handle = opendir($dir); + $files = array(); + while (false !== ($filename = readdir($handle))) { + if (!in_array($filename, array('.', '..'))) { + $files[] = $dir . DIRECTORY_SEPARATOR . $filename; + } + } + return $files; + } + + /** + * @access private + */ + function _isFilenameSafe($char) + { + $_Auth_OpenID_filename_allowed = Auth_OpenID_letters . + Auth_OpenID_digits . "."; + return (strpos($_Auth_OpenID_filename_allowed, $char) !== false); + } + + /** + * @access private + */ + function _safe64($str) + { + $h64 = base64_encode(Auth_OpenID_SHA1($str)); + $h64 = str_replace('+', '_', $h64); + $h64 = str_replace('/', '.', $h64); + $h64 = str_replace('=', '', $h64); + return $h64; + } + + /** + * @access private + */ + function _filenameEscape($str) + { + $filename = ""; + $b = Auth_OpenID::toBytes($str); + + for ($i = 0; $i < count($b); $i++) { + $c = $b[$i]; + if (Auth_OpenID_FileStore::_isFilenameSafe($c)) { + $filename .= $c; + } else { + $filename .= sprintf("_%02X", ord($c)); + } + } + return $filename; + } + + /** + * Attempt to remove a file, returning whether the file existed at + * the time of the call. + * + * @access private + * @return bool $result True if the file was present, false if not. + */ + function _removeIfPresent($filename) + { + return @unlink($filename); + } + + function cleanupAssociations() + { + $removed = 0; + foreach ($this->_allAssocs() as $pair) { + list($assoc_filename, $assoc) = $pair; + if ($assoc->getExpiresIn() == 0) { + $this->_removeIfPresent($assoc_filename); + $removed += 1; + } + } + return $removed; + } +} + +?> diff --git a/inc/lib/Auth/OpenID/HMAC.php b/inc/lib/Auth/OpenID/HMAC.php new file mode 100644 index 00000000..ec42db8d --- /dev/null +++ b/inc/lib/Auth/OpenID/HMAC.php @@ -0,0 +1,99 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +require_once 'Auth/OpenID.php'; + +/** + * SHA1_BLOCKSIZE is this module's SHA1 blocksize used by the fallback + * implementation. + */ +define('Auth_OpenID_SHA1_BLOCKSIZE', 64); + +function Auth_OpenID_SHA1($text) +{ + if (function_exists('hash') && + function_exists('hash_algos') && + (in_array('sha1', hash_algos()))) { + // PHP 5 case (sometimes): 'hash' available and 'sha1' algo + // supported. + return hash('sha1', $text, true); + } else if (function_exists('sha1')) { + // PHP 4 case: 'sha1' available. + $hex = sha1($text); + $raw = ''; + for ($i = 0; $i < 40; $i += 2) { + $hexcode = substr($hex, $i, 2); + $charcode = (int)base_convert($hexcode, 16, 10); + $raw .= chr($charcode); + } + return $raw; + } else { + // Explode. + trigger_error('No SHA1 function found', E_USER_ERROR); + } +} + +/** + * Compute an HMAC/SHA1 hash. + * + * @access private + * @param string $key The HMAC key + * @param string $text The message text to hash + * @return string $mac The MAC + */ +function Auth_OpenID_HMACSHA1($key, $text) +{ + if (Auth_OpenID::bytes($key) > Auth_OpenID_SHA1_BLOCKSIZE) { + $key = Auth_OpenID_SHA1($key, true); + } + + $key = str_pad($key, Auth_OpenID_SHA1_BLOCKSIZE, chr(0x00)); + $ipad = str_repeat(chr(0x36), Auth_OpenID_SHA1_BLOCKSIZE); + $opad = str_repeat(chr(0x5c), Auth_OpenID_SHA1_BLOCKSIZE); + $hash1 = Auth_OpenID_SHA1(($key ^ $ipad) . $text, true); + $hmac = Auth_OpenID_SHA1(($key ^ $opad) . $hash1, true); + return $hmac; +} + +if (function_exists('hash') && + function_exists('hash_algos') && + (in_array('sha256', hash_algos()))) { + function Auth_OpenID_SHA256($text) + { + // PHP 5 case: 'hash' available and 'sha256' algo supported. + return hash('sha256', $text, true); + } + define('Auth_OpenID_SHA256_SUPPORTED', true); +} else { + define('Auth_OpenID_SHA256_SUPPORTED', false); +} + +if (function_exists('hash_hmac') && + function_exists('hash_algos') && + (in_array('sha256', hash_algos()))) { + + function Auth_OpenID_HMACSHA256($key, $text) + { + // Return raw MAC (not hex string). + return hash_hmac('sha256', $text, $key, true); + } + + define('Auth_OpenID_HMACSHA256_SUPPORTED', true); +} else { + define('Auth_OpenID_HMACSHA256_SUPPORTED', false); +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Interface.php b/inc/lib/Auth/OpenID/Interface.php new file mode 100644 index 00000000..f4c6062f --- /dev/null +++ b/inc/lib/Auth/OpenID/Interface.php @@ -0,0 +1,197 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * This is the interface for the store objects the OpenID library + * uses. It is a single class that provides all of the persistence + * mechanisms that the OpenID library needs, for both servers and + * consumers. If you want to create an SQL-driven store, please see + * then {@link Auth_OpenID_SQLStore} class. + * + * Change: Version 2.0 removed the storeNonce, getAuthKey, and isDumb + * methods, and changed the behavior of the useNonce method to support + * one-way nonces. + * + * @package OpenID + * @author JanRain, Inc. + */ +class Auth_OpenID_OpenIDStore { + /** + * This method puts an Association object into storage, + * retrievable by server URL and handle. + * + * @param string $server_url The URL of the identity server that + * this association is with. Because of the way the server portion + * of the library uses this interface, don't assume there are any + * limitations on the character set of the input string. In + * particular, expect to see unescaped non-url-safe characters in + * the server_url field. + * + * @param Association $association The Association to store. + */ + function storeAssociation($server_url, $association) + { + trigger_error("Auth_OpenID_OpenIDStore::storeAssociation ". + "not implemented", E_USER_ERROR); + } + + /* + * Remove expired nonces from the store. + * + * Discards any nonce from storage that is old enough that its + * timestamp would not pass useNonce(). + * + * This method is not called in the normal operation of the + * library. It provides a way for store admins to keep their + * storage from filling up with expired data. + * + * @return the number of nonces expired + */ + function cleanupNonces() + { + trigger_error("Auth_OpenID_OpenIDStore::cleanupNonces ". + "not implemented", E_USER_ERROR); + } + + /* + * Remove expired associations from the store. + * + * This method is not called in the normal operation of the + * library. It provides a way for store admins to keep their + * storage from filling up with expired data. + * + * @return the number of associations expired. + */ + function cleanupAssociations() + { + trigger_error("Auth_OpenID_OpenIDStore::cleanupAssociations ". + "not implemented", E_USER_ERROR); + } + + /* + * Shortcut for cleanupNonces(), cleanupAssociations(). + * + * This method is not called in the normal operation of the + * library. It provides a way for store admins to keep their + * storage from filling up with expired data. + */ + function cleanup() + { + return array($this->cleanupNonces(), + $this->cleanupAssociations()); + } + + /** + * Report whether this storage supports cleanup + */ + function supportsCleanup() + { + return true; + } + + /** + * This method returns an Association object from storage that + * matches the server URL and, if specified, handle. It returns + * null if no such association is found or if the matching + * association is expired. + * + * If no handle is specified, the store may return any association + * which matches the server URL. If multiple associations are + * valid, the recommended return value for this method is the one + * most recently issued. + * + * This method is allowed (and encouraged) to garbage collect + * expired associations when found. This method must not return + * expired associations. + * + * @param string $server_url The URL of the identity server to get + * the association for. Because of the way the server portion of + * the library uses this interface, don't assume there are any + * limitations on the character set of the input string. In + * particular, expect to see unescaped non-url-safe characters in + * the server_url field. + * + * @param mixed $handle This optional parameter is the handle of + * the specific association to get. If no specific handle is + * provided, any valid association matching the server URL is + * returned. + * + * @return Association The Association for the given identity + * server. + */ + function getAssociation($server_url, $handle = null) + { + trigger_error("Auth_OpenID_OpenIDStore::getAssociation ". + "not implemented", E_USER_ERROR); + } + + /** + * This method removes the matching association if it's found, and + * returns whether the association was removed or not. + * + * @param string $server_url The URL of the identity server the + * association to remove belongs to. Because of the way the server + * portion of the library uses this interface, don't assume there + * are any limitations on the character set of the input + * string. In particular, expect to see unescaped non-url-safe + * characters in the server_url field. + * + * @param string $handle This is the handle of the association to + * remove. If there isn't an association found that matches both + * the given URL and handle, then there was no matching handle + * found. + * + * @return mixed Returns whether or not the given association existed. + */ + function removeAssociation($server_url, $handle) + { + trigger_error("Auth_OpenID_OpenIDStore::removeAssociation ". + "not implemented", E_USER_ERROR); + } + + /** + * Called when using a nonce. + * + * This method should return C{True} if the nonce has not been + * used before, and store it for a while to make sure nobody + * tries to use the same value again. If the nonce has already + * been used, return C{False}. + * + * Change: In earlier versions, round-trip nonces were used and a + * nonce was only valid if it had been previously stored with + * storeNonce. Version 2.0 uses one-way nonces, requiring a + * different implementation here that does not depend on a + * storeNonce call. (storeNonce is no longer part of the + * interface. + * + * @param string $nonce The nonce to use. + * + * @return bool Whether or not the nonce was valid. + */ + function useNonce($server_url, $timestamp, $salt) + { + trigger_error("Auth_OpenID_OpenIDStore::useNonce ". + "not implemented", E_USER_ERROR); + } + + /** + * Removes all entries from the store; implementation is optional. + */ + function reset() + { + } + +} +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/KVForm.php b/inc/lib/Auth/OpenID/KVForm.php new file mode 100644 index 00000000..fb342a00 --- /dev/null +++ b/inc/lib/Auth/OpenID/KVForm.php @@ -0,0 +1,112 @@ + + * @copyright 2005-2008 Janrain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + */ + +/** + * Container for key-value/comma-newline OpenID format and parsing + */ +class Auth_OpenID_KVForm { + /** + * Convert an OpenID colon/newline separated string into an + * associative array + * + * @static + * @access private + */ + function toArray($kvs, $strict=false) + { + $lines = explode("\n", $kvs); + + $last = array_pop($lines); + if ($last !== '') { + array_push($lines, $last); + if ($strict) { + return false; + } + } + + $values = array(); + + for ($lineno = 0; $lineno < count($lines); $lineno++) { + $line = $lines[$lineno]; + $kv = explode(':', $line, 2); + if (count($kv) != 2) { + if ($strict) { + return false; + } + continue; + } + + $key = $kv[0]; + $tkey = trim($key); + if ($tkey != $key) { + if ($strict) { + return false; + } + } + + $value = $kv[1]; + $tval = trim($value); + if ($tval != $value) { + if ($strict) { + return false; + } + } + + $values[$tkey] = $tval; + } + + return $values; + } + + /** + * Convert an array into an OpenID colon/newline separated string + * + * @static + * @access private + */ + function fromArray($values) + { + if ($values === null) { + return null; + } + + ksort($values); + + $serialized = ''; + foreach ($values as $key => $value) { + if (is_array($value)) { + list($key, $value) = array($value[0], $value[1]); + } + + if (strpos($key, ':') !== false) { + return null; + } + + if (strpos($key, "\n") !== false) { + return null; + } + + if (strpos($value, "\n") !== false) { + return null; + } + $serialized .= "$key:$value\n"; + } + return $serialized; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/MemcachedStore.php b/inc/lib/Auth/OpenID/MemcachedStore.php new file mode 100644 index 00000000..d357c6b1 --- /dev/null +++ b/inc/lib/Auth/OpenID/MemcachedStore.php @@ -0,0 +1,208 @@ + + * @copyright 2008 JanRain, Inc. + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache + * Contributed by Open Web Technologies + */ + +/** + * Import the interface for creating a new store class. + */ +require_once 'Auth/OpenID/Interface.php'; + +/** + * This is a memcached-based store for OpenID associations and + * nonces. + * + * As memcache has limit of 250 chars for key length, + * server_url, handle and salt are hashed with sha1(). + * + * Most of the methods of this class are implementation details. + * People wishing to just use this store need only pay attention to + * the constructor. + * + * @package OpenID + */ +class Auth_OpenID_MemcachedStore extends Auth_OpenID_OpenIDStore { + + /** + * Initializes a new {@link Auth_OpenID_MemcachedStore} instance. + * Just saves memcached object as property. + * + * @param resource connection Memcache connection resourse + */ + function Auth_OpenID_MemcachedStore($connection, $compress = false) + { + $this->connection = $connection; + $this->compress = $compress ? MEMCACHE_COMPRESSED : 0; + } + + /** + * Store association until its expiration time in memcached. + * Overwrites any existing association with same server_url and + * handle. Handles list of associations for every server. + */ + function storeAssociation($server_url, $association) + { + // create memcached keys for association itself + // and list of associations for this server + $associationKey = $this->associationKey($server_url, + $association->handle); + $serverKey = $this->associationServerKey($server_url); + + // get list of associations + $serverAssociations = $this->connection->get($serverKey); + + // if no such list, initialize it with empty array + if (!$serverAssociations) { + $serverAssociations = array(); + } + // and store given association key in it + $serverAssociations[$association->issued] = $associationKey; + + // save associations' keys list + $this->connection->set( + $serverKey, + $serverAssociations, + $this->compress + ); + // save association itself + $this->connection->set( + $associationKey, + $association, + $this->compress, + $association->issued + $association->lifetime); + } + + /** + * Read association from memcached. If no handle given + * and multiple associations found, returns latest issued + */ + function getAssociation($server_url, $handle = null) + { + // simple case: handle given + if ($handle !== null) { + // get association, return null if failed + $association = $this->connection->get( + $this->associationKey($server_url, $handle)); + return $association ? $association : null; + } + + // no handle given, working with list + // create key for list of associations + $serverKey = $this->associationServerKey($server_url); + + // get list of associations + $serverAssociations = $this->connection->get($serverKey); + // return null if failed or got empty list + if (!$serverAssociations) { + return null; + } + + // get key of most recently issued association + $keys = array_keys($serverAssociations); + sort($keys); + $lastKey = $serverAssociations[array_pop($keys)]; + + // get association, return null if failed + $association = $this->connection->get($lastKey); + return $association ? $association : null; + } + + /** + * Immediately delete association from memcache. + */ + function removeAssociation($server_url, $handle) + { + // create memcached keys for association itself + // and list of associations for this server + $serverKey = $this->associationServerKey($server_url); + $associationKey = $this->associationKey($server_url, + $handle); + + // get list of associations + $serverAssociations = $this->connection->get($serverKey); + // return null if failed or got empty list + if (!$serverAssociations) { + return false; + } + + // ensure that given association key exists in list + $serverAssociations = array_flip($serverAssociations); + if (!array_key_exists($associationKey, $serverAssociations)) { + return false; + } + + // remove given association key from list + unset($serverAssociations[$associationKey]); + $serverAssociations = array_flip($serverAssociations); + + // save updated list + $this->connection->set( + $serverKey, + $serverAssociations, + $this->compress + ); + + // delete association + return $this->connection->delete($associationKey); + } + + /** + * Create nonce for server and salt, expiring after + * $Auth_OpenID_SKEW seconds. + */ + function useNonce($server_url, $timestamp, $salt) + { + global $Auth_OpenID_SKEW; + + // save one request to memcache when nonce obviously expired + if (abs($timestamp - time()) > $Auth_OpenID_SKEW) { + return false; + } + + // returns false when nonce already exists + // otherwise adds nonce + return $this->connection->add( + 'openid_nonce_' . sha1($server_url) . '_' . sha1($salt), + 1, // any value here + $this->compress, + $Auth_OpenID_SKEW); + } + + /** + * Memcache key is prefixed with 'openid_association_' string. + */ + function associationKey($server_url, $handle = null) + { + return 'openid_association_' . sha1($server_url) . '_' . sha1($handle); + } + + /** + * Memcache key is prefixed with 'openid_association_' string. + */ + function associationServerKey($server_url) + { + return 'openid_association_server_' . sha1($server_url); + } + + /** + * Report that this storage doesn't support cleanup + */ + function supportsCleanup() + { + return false; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Message.php b/inc/lib/Auth/OpenID/Message.php new file mode 100644 index 00000000..5ab115a8 --- /dev/null +++ b/inc/lib/Auth/OpenID/Message.php @@ -0,0 +1,920 @@ +keys = array(); + $this->values = array(); + + if (is_array($classic_array)) { + foreach ($classic_array as $key => $value) { + $this->set($key, $value); + } + } + } + + /** + * Returns true if $thing is an Auth_OpenID_Mapping object; false + * if not. + */ + function isA($thing) + { + return (is_object($thing) && + strtolower(get_class($thing)) == 'auth_openid_mapping'); + } + + /** + * Returns an array of the keys in the mapping. + */ + function keys() + { + return $this->keys; + } + + /** + * Returns an array of values in the mapping. + */ + function values() + { + return $this->values; + } + + /** + * Returns an array of (key, value) pairs in the mapping. + */ + function items() + { + $temp = array(); + + for ($i = 0; $i < count($this->keys); $i++) { + $temp[] = array($this->keys[$i], + $this->values[$i]); + } + return $temp; + } + + /** + * Returns the "length" of the mapping, or the number of keys. + */ + function len() + { + return count($this->keys); + } + + /** + * Sets a key-value pair in the mapping. If the key already + * exists, its value is replaced with the new value. + */ + function set($key, $value) + { + $index = array_search($key, $this->keys); + + if ($index !== false) { + $this->values[$index] = $value; + } else { + $this->keys[] = $key; + $this->values[] = $value; + } + } + + /** + * Gets a specified value from the mapping, associated with the + * specified key. If the key does not exist in the mapping, + * $default is returned instead. + */ + function get($key, $default = null) + { + $index = array_search($key, $this->keys); + + if ($index !== false) { + return $this->values[$index]; + } else { + return $default; + } + } + + /** + * @access private + */ + function _reflow() + { + // PHP is broken yet again. Sort the arrays to remove the + // hole in the numeric indexes that make up the array. + $old_keys = $this->keys; + $old_values = $this->values; + + $this->keys = array(); + $this->values = array(); + + foreach ($old_keys as $k) { + $this->keys[] = $k; + } + + foreach ($old_values as $v) { + $this->values[] = $v; + } + } + + /** + * Deletes a key-value pair from the mapping with the specified + * key. + */ + function del($key) + { + $index = array_search($key, $this->keys); + + if ($index !== false) { + unset($this->keys[$index]); + unset($this->values[$index]); + $this->_reflow(); + return true; + } + return false; + } + + /** + * Returns true if the specified value has a key in the mapping; + * false if not. + */ + function contains($value) + { + return (array_search($value, $this->keys) !== false); + } +} + +/** + * Maintains a bijective map between namespace uris and aliases. + * + * @package OpenID + */ +class Auth_OpenID_NamespaceMap { + function Auth_OpenID_NamespaceMap() + { + $this->alias_to_namespace = new Auth_OpenID_Mapping(); + $this->namespace_to_alias = new Auth_OpenID_Mapping(); + $this->implicit_namespaces = array(); + } + + function getAlias($namespace_uri) + { + return $this->namespace_to_alias->get($namespace_uri); + } + + function getNamespaceURI($alias) + { + return $this->alias_to_namespace->get($alias); + } + + function iterNamespaceURIs() + { + // Return an iterator over the namespace URIs + return $this->namespace_to_alias->keys(); + } + + function iterAliases() + { + // Return an iterator over the aliases""" + return $this->alias_to_namespace->keys(); + } + + function iteritems() + { + return $this->namespace_to_alias->items(); + } + + function isImplicit($namespace_uri) + { + return in_array($namespace_uri, $this->implicit_namespaces); + } + + function addAlias($namespace_uri, $desired_alias, $implicit=false) + { + // Add an alias from this namespace URI to the desired alias + global $Auth_OpenID_OPENID_PROTOCOL_FIELDS; + + // Check that desired_alias is not an openid protocol field as + // per the spec. + if (in_array($desired_alias, $Auth_OpenID_OPENID_PROTOCOL_FIELDS)) { + Auth_OpenID::log("\"%s\" is not an allowed namespace alias", + $desired_alias); + return null; + } + + // Check that desired_alias does not contain a period as per + // the spec. + if (strpos($desired_alias, '.') !== false) { + Auth_OpenID::log('"%s" must not contain a dot', $desired_alias); + return null; + } + + // Check that there is not a namespace already defined for the + // desired alias + $current_namespace_uri = + $this->alias_to_namespace->get($desired_alias); + + if (($current_namespace_uri !== null) && + ($current_namespace_uri != $namespace_uri)) { + Auth_OpenID::log('Cannot map "%s" because previous mapping exists', + $namespace_uri); + return null; + } + + // Check that there is not already a (different) alias for + // this namespace URI + $alias = $this->namespace_to_alias->get($namespace_uri); + + if (($alias !== null) && ($alias != $desired_alias)) { + Auth_OpenID::log('Cannot map %s to alias %s. ' . + 'It is already mapped to alias %s', + $namespace_uri, $desired_alias, $alias); + return null; + } + + assert((Auth_OpenID_NULL_NAMESPACE === $desired_alias) || + is_string($desired_alias)); + + $this->alias_to_namespace->set($desired_alias, $namespace_uri); + $this->namespace_to_alias->set($namespace_uri, $desired_alias); + if ($implicit) { + array_push($this->implicit_namespaces, $namespace_uri); + } + + return $desired_alias; + } + + function add($namespace_uri) + { + // Add this namespace URI to the mapping, without caring what + // alias it ends up with + + // See if this namespace is already mapped to an alias + $alias = $this->namespace_to_alias->get($namespace_uri); + + if ($alias !== null) { + return $alias; + } + + // Fall back to generating a numerical alias + $i = 0; + while (1) { + $alias = 'ext' . strval($i); + if ($this->addAlias($namespace_uri, $alias) === null) { + $i += 1; + } else { + return $alias; + } + } + + // Should NEVER be reached! + return null; + } + + function contains($namespace_uri) + { + return $this->isDefined($namespace_uri); + } + + function isDefined($namespace_uri) + { + return $this->namespace_to_alias->contains($namespace_uri); + } +} + +/** + * In the implementation of this object, null represents the global + * namespace as well as a namespace with no key. + * + * @package OpenID + */ +class Auth_OpenID_Message { + + function Auth_OpenID_Message($openid_namespace = null) + { + // Create an empty Message + $this->allowed_openid_namespaces = array( + Auth_OpenID_OPENID1_NS, + Auth_OpenID_THE_OTHER_OPENID1_NS, + Auth_OpenID_OPENID2_NS); + + $this->args = new Auth_OpenID_Mapping(); + $this->namespaces = new Auth_OpenID_NamespaceMap(); + if ($openid_namespace === null) { + $this->_openid_ns_uri = null; + } else { + $implicit = Auth_OpenID_isOpenID1($openid_namespace); + $this->setOpenIDNamespace($openid_namespace, $implicit); + } + } + + function isOpenID1() + { + return Auth_OpenID_isOpenID1($this->getOpenIDNamespace()); + } + + function isOpenID2() + { + return $this->getOpenIDNamespace() == Auth_OpenID_OPENID2_NS; + } + + function fromPostArgs($args) + { + // Construct a Message containing a set of POST arguments + $obj = new Auth_OpenID_Message(); + + // Partition into "openid." args and bare args + $openid_args = array(); + foreach ($args as $key => $value) { + + if (is_array($value)) { + return null; + } + + $parts = explode('.', $key, 2); + + if (count($parts) == 2) { + list($prefix, $rest) = $parts; + } else { + $prefix = null; + } + + if ($prefix != 'openid') { + $obj->args->set(array(Auth_OpenID_BARE_NS, $key), $value); + } else { + $openid_args[$rest] = $value; + } + } + + if ($obj->_fromOpenIDArgs($openid_args)) { + return $obj; + } else { + return null; + } + } + + function fromOpenIDArgs($openid_args) + { + // Takes an array. + + // Construct a Message from a parsed KVForm message + $obj = new Auth_OpenID_Message(); + if ($obj->_fromOpenIDArgs($openid_args)) { + return $obj; + } else { + return null; + } + } + + /** + * @access private + */ + function _fromOpenIDArgs($openid_args) + { + global $Auth_OpenID_registered_aliases; + + // Takes an Auth_OpenID_Mapping instance OR an array. + + if (!Auth_OpenID_Mapping::isA($openid_args)) { + $openid_args = new Auth_OpenID_Mapping($openid_args); + } + + $ns_args = array(); + + // Resolve namespaces + foreach ($openid_args->items() as $pair) { + list($rest, $value) = $pair; + + $parts = explode('.', $rest, 2); + + if (count($parts) == 2) { + list($ns_alias, $ns_key) = $parts; + } else { + $ns_alias = Auth_OpenID_NULL_NAMESPACE; + $ns_key = $rest; + } + + if ($ns_alias == 'ns') { + if ($this->namespaces->addAlias($value, $ns_key) === null) { + return false; + } + } else if (($ns_alias == Auth_OpenID_NULL_NAMESPACE) && + ($ns_key == 'ns')) { + // null namespace + if ($this->setOpenIDNamespace($value, false) === false) { + return false; + } + } else { + $ns_args[] = array($ns_alias, $ns_key, $value); + } + } + + if (!$this->getOpenIDNamespace()) { + if ($this->setOpenIDNamespace(Auth_OpenID_OPENID1_NS, true) === + false) { + return false; + } + } + + // Actually put the pairs into the appropriate namespaces + foreach ($ns_args as $triple) { + list($ns_alias, $ns_key, $value) = $triple; + $ns_uri = $this->namespaces->getNamespaceURI($ns_alias); + if ($ns_uri === null) { + $ns_uri = $this->_getDefaultNamespace($ns_alias); + if ($ns_uri === null) { + + $ns_uri = Auth_OpenID_OPENID_NS; + $ns_key = sprintf('%s.%s', $ns_alias, $ns_key); + } else { + $this->namespaces->addAlias($ns_uri, $ns_alias, true); + } + } + + $this->setArg($ns_uri, $ns_key, $value); + } + + return true; + } + + function _getDefaultNamespace($mystery_alias) + { + global $Auth_OpenID_registered_aliases; + if ($this->isOpenID1()) { + return @$Auth_OpenID_registered_aliases[$mystery_alias]; + } + return null; + } + + function setOpenIDNamespace($openid_ns_uri, $implicit) + { + if (!in_array($openid_ns_uri, $this->allowed_openid_namespaces)) { + Auth_OpenID::log('Invalid null namespace: "%s"', $openid_ns_uri); + return false; + } + + $succeeded = $this->namespaces->addAlias($openid_ns_uri, + Auth_OpenID_NULL_NAMESPACE, + $implicit); + if ($succeeded === false) { + return false; + } + + $this->_openid_ns_uri = $openid_ns_uri; + + return true; + } + + function getOpenIDNamespace() + { + return $this->_openid_ns_uri; + } + + function fromKVForm($kvform_string) + { + // Create a Message from a KVForm string + return Auth_OpenID_Message::fromOpenIDArgs( + Auth_OpenID_KVForm::toArray($kvform_string)); + } + + function copy() + { + return $this; + } + + function toPostArgs() + { + // Return all arguments with openid. in front of namespaced + // arguments. + + $args = array(); + + // Add namespace definitions to the output + foreach ($this->namespaces->iteritems() as $pair) { + list($ns_uri, $alias) = $pair; + if ($this->namespaces->isImplicit($ns_uri)) { + continue; + } + if ($alias == Auth_OpenID_NULL_NAMESPACE) { + $ns_key = 'openid.ns'; + } else { + $ns_key = 'openid.ns.' . $alias; + } + $args[$ns_key] = $ns_uri; + } + + foreach ($this->args->items() as $pair) { + list($ns_parts, $value) = $pair; + list($ns_uri, $ns_key) = $ns_parts; + $key = $this->getKey($ns_uri, $ns_key); + $args[$key] = $value; + } + + return $args; + } + + function toArgs() + { + // Return all namespaced arguments, failing if any + // non-namespaced arguments exist. + $post_args = $this->toPostArgs(); + $kvargs = array(); + foreach ($post_args as $k => $v) { + if (strpos($k, 'openid.') !== 0) { + // raise ValueError( + // 'This message can only be encoded as a POST, because it ' + // 'contains arguments that are not prefixed with "openid."') + return null; + } else { + $kvargs[substr($k, 7)] = $v; + } + } + + return $kvargs; + } + + function toFormMarkup($action_url, $form_tag_attrs = null, + $submit_text = "Continue") + { + $form = "
$attr) { + $form .= sprintf(" %s=\"%s\"", $name, $attr); + } + } + + $form .= ">\n"; + + foreach ($this->toPostArgs() as $name => $value) { + $form .= sprintf( + "\n", + $name, $value); + } + + $form .= sprintf("\n", + $submit_text); + + $form .= "
\n"; + + return $form; + } + + function toURL($base_url) + { + // Generate a GET URL with the parameters in this message + // attached as query parameters. + return Auth_OpenID::appendArgs($base_url, $this->toPostArgs()); + } + + function toKVForm() + { + // Generate a KVForm string that contains the parameters in + // this message. This will fail if the message contains + // arguments outside of the 'openid.' prefix. + return Auth_OpenID_KVForm::fromArray($this->toArgs()); + } + + function toURLEncoded() + { + // Generate an x-www-urlencoded string + $args = array(); + + foreach ($this->toPostArgs() as $k => $v) { + $args[] = array($k, $v); + } + + sort($args); + return Auth_OpenID::httpBuildQuery($args); + } + + /** + * @access private + */ + function _fixNS($namespace) + { + // Convert an input value into the internally used values of + // this object + + if ($namespace == Auth_OpenID_OPENID_NS) { + if ($this->_openid_ns_uri === null) { + return new Auth_OpenID_FailureResponse(null, + 'OpenID namespace not set'); + } else { + $namespace = $this->_openid_ns_uri; + } + } + + if (($namespace != Auth_OpenID_BARE_NS) && + (!is_string($namespace))) { + //TypeError + $err_msg = sprintf("Namespace must be Auth_OpenID_BARE_NS, ". + "Auth_OpenID_OPENID_NS or a string. got %s", + print_r($namespace, true)); + return new Auth_OpenID_FailureResponse(null, $err_msg); + } + + if (($namespace != Auth_OpenID_BARE_NS) && + (strpos($namespace, ':') === false)) { + // fmt = 'OpenID 2.0 namespace identifiers SHOULD be URIs. Got %r' + // warnings.warn(fmt % (namespace,), DeprecationWarning) + + if ($namespace == 'sreg') { + // fmt = 'Using %r instead of "sreg" as namespace' + // warnings.warn(fmt % (SREG_URI,), DeprecationWarning,) + return Auth_OpenID_SREG_URI; + } + } + + return $namespace; + } + + function hasKey($namespace, $ns_key) + { + $namespace = $this->_fixNS($namespace); + if (Auth_OpenID::isFailure($namespace)) { + // XXX log me + return false; + } else { + return $this->args->contains(array($namespace, $ns_key)); + } + } + + function getKey($namespace, $ns_key) + { + // Get the key for a particular namespaced argument + $namespace = $this->_fixNS($namespace); + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } + if ($namespace == Auth_OpenID_BARE_NS) { + return $ns_key; + } + + $ns_alias = $this->namespaces->getAlias($namespace); + + // No alias is defined, so no key can exist + if ($ns_alias === null) { + return null; + } + + if ($ns_alias == Auth_OpenID_NULL_NAMESPACE) { + $tail = $ns_key; + } else { + $tail = sprintf('%s.%s', $ns_alias, $ns_key); + } + + return 'openid.' . $tail; + } + + function getArg($namespace, $key, $default = null) + { + // Get a value for a namespaced key. + $namespace = $this->_fixNS($namespace); + + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } else { + if ((!$this->args->contains(array($namespace, $key))) && + ($default == Auth_OpenID_NO_DEFAULT)) { + $err_msg = sprintf("Namespace %s missing required field %s", + $namespace, $key); + return new Auth_OpenID_FailureResponse(null, $err_msg); + } else { + return $this->args->get(array($namespace, $key), $default); + } + } + } + + function getArgs($namespace) + { + // Get the arguments that are defined for this namespace URI + + $namespace = $this->_fixNS($namespace); + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } else { + $stuff = array(); + foreach ($this->args->items() as $pair) { + list($key, $value) = $pair; + list($pair_ns, $ns_key) = $key; + if ($pair_ns == $namespace) { + $stuff[$ns_key] = $value; + } + } + + return $stuff; + } + } + + function updateArgs($namespace, $updates) + { + // Set multiple key/value pairs in one call + + $namespace = $this->_fixNS($namespace); + + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } else { + foreach ($updates as $k => $v) { + $this->setArg($namespace, $k, $v); + } + return true; + } + } + + function setArg($namespace, $key, $value) + { + // Set a single argument in this namespace + $namespace = $this->_fixNS($namespace); + + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } else { + $this->args->set(array($namespace, $key), $value); + if ($namespace !== Auth_OpenID_BARE_NS) { + $this->namespaces->add($namespace); + } + return true; + } + } + + function delArg($namespace, $key) + { + $namespace = $this->_fixNS($namespace); + + if (Auth_OpenID::isFailure($namespace)) { + return $namespace; + } else { + return $this->args->del(array($namespace, $key)); + } + } + + function getAliasedArg($aliased_key, $default = null) + { + if ($aliased_key == 'ns') { + // Return the namespace URI for the OpenID namespace + return $this->getOpenIDNamespace(); + } + + $parts = explode('.', $aliased_key, 2); + + if (count($parts) != 2) { + $ns = null; + } else { + list($alias, $key) = $parts; + + if ($alias == 'ns') { + // Return the namespace URI for a namespace alias + // parameter. + return $this->namespaces->getNamespaceURI($key); + } else { + $ns = $this->namespaces->getNamespaceURI($alias); + } + } + + if ($ns === null) { + $key = $aliased_key; + $ns = $this->getOpenIDNamespace(); + } + + return $this->getArg($ns, $key, $default); + } +} + +?> diff --git a/inc/lib/Auth/OpenID/MySQLStore.php b/inc/lib/Auth/OpenID/MySQLStore.php new file mode 100644 index 00000000..eb08af01 --- /dev/null +++ b/inc/lib/Auth/OpenID/MySQLStore.php @@ -0,0 +1,78 @@ +sql['nonce_table'] = + "CREATE TABLE %s (\n". + " server_url VARCHAR(2047) NOT NULL,\n". + " timestamp INTEGER NOT NULL,\n". + " salt CHAR(40) NOT NULL,\n". + " UNIQUE (server_url(255), timestamp, salt)\n". + ") ENGINE=InnoDB"; + + $this->sql['assoc_table'] = + "CREATE TABLE %s (\n". + " server_url BLOB NOT NULL,\n". + " handle VARCHAR(255) NOT NULL,\n". + " secret BLOB NOT NULL,\n". + " issued INTEGER NOT NULL,\n". + " lifetime INTEGER NOT NULL,\n". + " assoc_type VARCHAR(64) NOT NULL,\n". + " PRIMARY KEY (server_url(255), handle)\n". + ") ENGINE=InnoDB"; + + $this->sql['set_assoc'] = + "REPLACE INTO %s (server_url, handle, secret, issued,\n". + " lifetime, assoc_type) VALUES (?, ?, !, ?, ?, ?)"; + + $this->sql['get_assocs'] = + "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ". + "WHERE server_url = ?"; + + $this->sql['get_assoc'] = + "SELECT handle, secret, issued, lifetime, assoc_type FROM %s ". + "WHERE server_url = ? AND handle = ?"; + + $this->sql['remove_assoc'] = + "DELETE FROM %s WHERE server_url = ? AND handle = ?"; + + $this->sql['add_nonce'] = + "INSERT INTO %s (server_url, timestamp, salt) VALUES (?, ?, ?)"; + + $this->sql['clean_nonce'] = + "DELETE FROM %s WHERE timestamp < ?"; + + $this->sql['clean_assoc'] = + "DELETE FROM %s WHERE issued + lifetime < ?"; + } + + /** + * @access private + */ + function blobEncode($blob) + { + return "0x" . bin2hex($blob); + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Nonce.php b/inc/lib/Auth/OpenID/Nonce.php new file mode 100644 index 00000000..effecac3 --- /dev/null +++ b/inc/lib/Auth/OpenID/Nonce.php @@ -0,0 +1,109 @@ + \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/PAPE.php b/inc/lib/Auth/OpenID/PAPE.php new file mode 100644 index 00000000..62cba8a9 --- /dev/null +++ b/inc/lib/Auth/OpenID/PAPE.php @@ -0,0 +1,301 @@ +preferred_auth_policies = $preferred_auth_policies; + $this->max_auth_age = $max_auth_age; + } + + /** + * Add an acceptable authentication policy URI to this request + * + * This method is intended to be used by the relying party to add + * acceptable authentication types to the request. + * + * policy_uri: The identifier for the preferred type of + * authentication. + */ + function addPolicyURI($policy_uri) + { + if (!in_array($policy_uri, $this->preferred_auth_policies)) { + $this->preferred_auth_policies[] = $policy_uri; + } + } + + function getExtensionArgs() + { + $ns_args = array( + 'preferred_auth_policies' => + implode(' ', $this->preferred_auth_policies) + ); + + if ($this->max_auth_age !== null) { + $ns_args['max_auth_age'] = strval($this->max_auth_age); + } + + return $ns_args; + } + + /** + * Instantiate a Request object from the arguments in a checkid_* + * OpenID message + */ + function fromOpenIDRequest($request) + { + $obj = new Auth_OpenID_PAPE_Request(); + $args = $request->message->getArgs(Auth_OpenID_PAPE_NS_URI); + + if ($args === null || $args === array()) { + return null; + } + + $obj->parseExtensionArgs($args); + return $obj; + } + + /** + * Set the state of this request to be that expressed in these + * PAPE arguments + * + * @param args: The PAPE arguments without a namespace + */ + function parseExtensionArgs($args) + { + // preferred_auth_policies is a space-separated list of policy + // URIs + $this->preferred_auth_policies = array(); + + $policies_str = Auth_OpenID::arrayGet($args, 'preferred_auth_policies'); + if ($policies_str) { + foreach (explode(' ', $policies_str) as $uri) { + if (!in_array($uri, $this->preferred_auth_policies)) { + $this->preferred_auth_policies[] = $uri; + } + } + } + + // max_auth_age is base-10 integer number of seconds + $max_auth_age_str = Auth_OpenID::arrayGet($args, 'max_auth_age'); + if ($max_auth_age_str) { + $this->max_auth_age = Auth_OpenID::intval($max_auth_age_str); + } else { + $this->max_auth_age = null; + } + } + + /** + * Given a list of authentication policy URIs that a provider + * supports, this method returns the subsequence of those types + * that are preferred by the relying party. + * + * @param supported_types: A sequence of authentication policy + * type URIs that are supported by a provider + * + * @return array The sub-sequence of the supported types that are + * preferred by the relying party. This list will be ordered in + * the order that the types appear in the supported_types + * sequence, and may be empty if the provider does not prefer any + * of the supported authentication types. + */ + function preferredTypes($supported_types) + { + $result = array(); + + foreach ($supported_types as $st) { + if (in_array($st, $this->preferred_auth_policies)) { + $result[] = $st; + } + } + return $result; + } +} + +/** + * A Provider Authentication Policy response, sent from a provider to + * a relying party + */ +class Auth_OpenID_PAPE_Response extends Auth_OpenID_Extension { + + var $ns_alias = 'pape'; + var $ns_uri = Auth_OpenID_PAPE_NS_URI; + + function Auth_OpenID_PAPE_Response($auth_policies=null, $auth_time=null, + $nist_auth_level=null) + { + if ($auth_policies) { + $this->auth_policies = $auth_policies; + } else { + $this->auth_policies = array(); + } + + $this->auth_time = $auth_time; + $this->nist_auth_level = $nist_auth_level; + } + + /** + * Add a authentication policy to this response + * + * This method is intended to be used by the provider to add a + * policy that the provider conformed to when authenticating the + * user. + * + * @param policy_uri: The identifier for the preferred type of + * authentication. + */ + function addPolicyURI($policy_uri) + { + if (!in_array($policy_uri, $this->auth_policies)) { + $this->auth_policies[] = $policy_uri; + } + } + + /** + * Create an Auth_OpenID_PAPE_Response object from a successful + * OpenID library response. + * + * @param success_response $success_response A SuccessResponse + * from Auth_OpenID_Consumer::complete() + * + * @returns: A provider authentication policy response from the + * data that was supplied with the id_res response. + */ + function fromSuccessResponse($success_response) + { + $obj = new Auth_OpenID_PAPE_Response(); + + // PAPE requires that the args be signed. + $args = $success_response->getSignedNS(Auth_OpenID_PAPE_NS_URI); + + if ($args === null || $args === array()) { + return null; + } + + $result = $obj->parseExtensionArgs($args); + + if ($result === false) { + return null; + } else { + return $obj; + } + } + + /** + * Parse the provider authentication policy arguments into the + * internal state of this object + * + * @param args: unqualified provider authentication policy + * arguments + * + * @param strict: Whether to return false when bad data is + * encountered + * + * @return null The data is parsed into the internal fields of + * this object. + */ + function parseExtensionArgs($args, $strict=false) + { + $policies_str = Auth_OpenID::arrayGet($args, 'auth_policies'); + if ($policies_str && $policies_str != "none") { + $this->auth_policies = explode(" ", $policies_str); + } + + $nist_level_str = Auth_OpenID::arrayGet($args, 'nist_auth_level'); + if ($nist_level_str !== null) { + $nist_level = Auth_OpenID::intval($nist_level_str); + + if ($nist_level === false) { + if ($strict) { + return false; + } else { + $nist_level = null; + } + } + + if (0 <= $nist_level && $nist_level < 5) { + $this->nist_auth_level = $nist_level; + } else if ($strict) { + return false; + } + } + + $auth_time = Auth_OpenID::arrayGet($args, 'auth_time'); + if ($auth_time !== null) { + if (ereg(PAPE_TIME_VALIDATOR, $auth_time)) { + $this->auth_time = $auth_time; + } else if ($strict) { + return false; + } + } + } + + function getExtensionArgs() + { + $ns_args = array(); + if (count($this->auth_policies) > 0) { + $ns_args['auth_policies'] = implode(' ', $this->auth_policies); + } else { + $ns_args['auth_policies'] = 'none'; + } + + if ($this->nist_auth_level !== null) { + if (!in_array($this->nist_auth_level, range(0, 4), true)) { + return false; + } + $ns_args['nist_auth_level'] = strval($this->nist_auth_level); + } + + if ($this->auth_time !== null) { + if (!ereg(PAPE_TIME_VALIDATOR, $this->auth_time)) { + return false; + } + + $ns_args['auth_time'] = $this->auth_time; + } + + return $ns_args; + } +} + +?> \ No newline at end of file diff --git a/inc/lib/Auth/OpenID/Parse.php b/inc/lib/Auth/OpenID/Parse.php new file mode 100644 index 00000000..546f34f6 --- /dev/null +++ b/inc/lib/Auth/OpenID/Parse.php @@ -0,0 +1,352 @@ + tags + * in the head of HTML or XHTML documents and parses out their + * attributes according to the OpenID spec. It is a liberal parser, + * but it requires these things from the data in order to work: + * + * - There must be an open tag + * + * - There must be an open tag inside of the tag + * + * - Only s that are found inside of the tag are parsed + * (this is by design) + * + * - The parser follows the OpenID specification in resolving the + * attributes of the link tags. This means that the attributes DO + * NOT get resolved as they would by an XML or HTML parser. In + * particular, only certain entities get replaced, and href + * attributes do not get resolved relative to a base URL. + * + * From http://openid.net/specs.bml: + * + * - The openid.server URL MUST be an absolute URL. OpenID consumers + * MUST NOT attempt to resolve relative URLs. + * + * - The openid.server URL MUST NOT include entities other than &, + * <, >, and ". + * + * The parser ignores SGML comments and . Both kinds + * of quoting are allowed for attributes. + * + * The parser deals with invalid markup in these ways: + * + * - Tag names are not case-sensitive + * + * - The tag is accepted even when it is not at the top level + * + * - The tag is accepted even when it is not a direct child of + * the tag, but a tag must be an ancestor of the + * tag + * + * - tags are accepted even when they are not direct children + * of the tag, but a tag must be an ancestor of the + * tag + * + * - If there is no closing tag for an open or tag, the + * remainder of the document is viewed as being inside of the + * tag. If there is no closing tag for a tag, the link tag is + * treated as a short tag. Exceptions to this rule are that + * closes and or closes + * + * - Attributes of the tag are not required to be quoted. + * + * - In the case of duplicated attribute names, the attribute coming + * last in the tag will be the value returned. + * + * - Any text that does not parse as an attribute within a link tag + * will be ignored. (e.g. will + * ignore pumpkin) + * + * - If there are more than one or tag, the parser only + * looks inside of the first one. + * + * - The contents of +"; + + $delta = $init_estimate - $total_exp; + + return + "
Initial estimate: $init_estimate, Work expended: $total_exp
\n" + . $html . "
"; + } + + static function macro_MilestoneSummary($name) { + global $ABSWEB; + + $m = self::loadByName($name); + if (!$m) { + return "milestone: " . htmlentities($name) . " not found
\n"; + } + + if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) { + return "Not authorized to view milestone $name
\n"; + } + + $completed = mtrack_date($m->completed); + $description = $m->description; + if (strpos($description, "[[BurnDown(") === false) { + $description = "[[BurnDown(milestone=$name,width=50%,height=150)]]\n" . + $description; + } + $desc = MTrackWiki::format_to_html($description); + $pname = $name; + if ($m->completed !== NULL) { + $pname = "$name"; + $due = "Completed"; + } elseif ($m->duedate) { + $due = "Due " . mtrack_date($m->duedate); + } else { + $due = null; + } + + $watch = MTrackWatch::getWatchUI('milestone', $m->mid); + + $html = << +

$pname

+$watch +
$due
+$desc
+HTML; + + $estimated = 0; + $remaining = 0; + $open = 0; + $total = 0; + + foreach (MTrackDB::q('select status, estimated, estimated - spent as remaining from ticket_milestones tm left join tickets t on (tm.tid = t.tid) where mid = ?', + $m->mid)->fetchAll(PDO::FETCH_ASSOC) as $row) { + $total++; + if ($row['status'] != 'closed') { + $open++; + } + $estimated += $row['estimated']; + $remaining += $row['remaining']; + } + + $closed = $total - $open; + if ($total) { + $apct = (int)($open / $total * 100); + } else { + $apct = 0; + } + $cpct = 100 - $apct; + $html .= << + + +HTML; + + if ($open) { + $html .= << +HTML; + } + + $ms = urlencode($name); + + $html .= << + +$open open, +$closed closed, +$total total ($cpct % complete) + +HTML; + return $html; + } +} + +MTrackWiki::register_macro('MilestoneSummary', + array('MTrackMilestone', 'macro_MilestoneSummary')); + +MTrackWiki::register_macro('BurnDown', + array('MTrackMilestone', 'macro_BurnDown')); + +MTrackACL::registerAncestry('milestone', 'Roadmap'); +MTrackWatch::registerEventTypes('milestone', array( + 'ticket' => 'Tickets', + 'changeset' => 'Code changes' +)); + diff --git a/inc/report.php b/inc/report.php new file mode 100644 index 00000000..0cd235a5 --- /dev/null +++ b/inc/report.php @@ -0,0 +1,626 @@ +fetchAll(); + if (isset($row[0])) { + return new MTrackReport($row[0]); + } + return null; + } + + function __construct($id = null) { + $this->rid = $id; + if ($this->rid) { + $q = MTrackDB::q('select * from reports where rid = ?', $this->rid); + foreach ($q->fetchAll() as $row) { + $this->summary = $row['summary']; + $this->description = $row['description']; + $this->query = $row['query']; + $this->changed = (int)$row['changed']; + return; + } + throw new Exception("report $id not found"); + } + } + + function save(MTrackChangeset $changeset) { + if ($this->rid) { + + /* figure what we actually changed */ + $q = MTrackDB::q('select * from reports where rid = ?', $this->rid); + list($row) = $q->fetchAll(); + + $changeset->add("report:" . $this->rid . ":summary", + $row['summary'], $this->summary); + $changeset->add("report:" . $this->rid . ":description", + $row['description'], $this->description); + $changeset->add("report:" . $this->rid . ":query", + $row['query'], $this->query); + + $q = MTrackDB::q('update reports set summary = ?, description = ?, query = ?, changed = ? where rid = ?', + $this->summary, $this->description, $this->query, + $changeset->cid, $this->rid); + } else { + $q = MTrackDB::q('insert into reports (summary, description, query, changed) values (?, ?, ?, ?)', + $this->summary, $this->description, $this->query, + $changeset->cid); + $this->rid = MTrackDB::lastInsertId('reports', 'rid'); + $changeset->add("report:" . $this->rid . ":summary", + null, $this->summary); + $changeset->add("report:" . $this->rid . ":description", + null, $this->description); + $changeset->add("report:" . $this->rid . ":query", + null, $this->query); + + } + } + static function renderReport($repstring, $passed_params = null, + $format = 'html') { + global $ABSWEB; + static $jquery_init = false; + + $db = MTrackDB::get(); + + /* process the report string; any $PARAM in there is recognized + * as a parameter and the query munged accordingly to pass in the data */ + + $params = array(); + try { + $n = preg_match_all("/\\$([A-Z]+)/m", $repstring, $matches); + for ($i = 1; $i <= $n; $i++) { + /* default the parameter to no value */ + $params[$matches[$i][0]] = ''; + /* replace with query placeholder */ + $repstring = str_replace('$' . $matches[$i][0], ':' . $matches[$i][0], + $repstring); + } + + /* now to summon parameters */ + if (isset($params['USER'])) { + $params['USER'] = MTrackAuth::whoami(); + } + foreach ($params as $p => $v) { + if (isset($_GET[$p])) { + $params[$p] = $_GET[$p]; + } + } + if (is_array($passed_params)) { + foreach ($params as $p => $v) { + if (isset($passed_params[$p])) { + $params[$p] = $passed_params[$p]; + } + } + } + + $q = $db->prepare($repstring); + $q->execute($params); + + $results = $q->fetchAll(PDO::FETCH_ASSOC); + } catch (Exception $e) { + return "
" . $e->getMessage() . "
" . + htmlentities($repstring, ENT_QUOTES, 'utf-8') . "
"; + } + + $out = ''; + + if (count($results) == 0) { + return "No records matched"; + } + + /* figure out the table headings */ + $captions = array(); + $span = array(); + $rules = array(); + foreach ($results[0] as $name => $value) { + if (preg_match("/^__.*__$/", $name)) { + if ($format == 'html') { + /* special meaning, not a column */ + continue; + } + } + $captions[$name] = preg_replace("/^_(.*)_$/", "\\1", $name); + } + /* for spanning purposes, calculate the longest row */ + $max_width = 0; + $width = 0; + foreach ($captions as $name => $caption) { + if ($name[0] == '_' && substr($name, -1) == '_') { + $width = 1; + } else { + $width++; + } + if ($width > $max_width) { + $max_width = $width; + } + if (substr($name, -1) == '_') { + $width = 1; + } + } + + $group = null; + foreach ($results as $nrow => $row) { + $starting_new_group = false; + + if ($nrow == 0) { + $starting_new_group = true; + } else if ($format == 'html' && + (isset($row['__group__']) && $group !== $row['__group__'])) { + $starting_new_group = true; + } + + if ($starting_new_group) { + /* starting a new group */ + if ($nrow) { + /* close the old one */ + if ($format == 'html') { + $out .= "\n"; + } + } + if ($format == 'html' && isset($row['__group__'])) { + $out .= "

" . + htmlentities($row['__group__'], ENT_COMPAT, 'utf-8') . + "

\n"; + $group = $row['__group__']; + } + + if ($format == 'html') { + $out .= ""; + } + + foreach ($captions as $name => $caption) { + + /* figure out sort info for javascript bits */ + $sort = null; + switch (strtolower($caption)) { + case 'priority': + case 'ticket': + case 'severity': + $sort = strtolower($caption); + break; + case 'created': + case 'modified': + case 'date': + case 'due': + $sort = 'mtrackdate'; + break; + case 'remaining': + $sort = 'digit'; + break; + case 'updated': + case 'time': + case 'content': + case 'summary': + default: + break; + } + + $caption = ucfirst($caption); + if ($name[0] == '_' && substr($name,-1) == '_') { + if ($format == 'html') { + $out .= ""; + } else if ($format == 'tab') { + $out .= "$caption\t"; + } + } elseif ($name[0] == '_') { + continue; + } else { + if ($format == 'html') { + $out .= ""; + $out .= $begin_row; + } + $href = null; + + /* determine if we should link to something for this row */ + if (isset($row['ticket'])) { + $href = $ABSWEB . "ticket.php/$row[ticket]"; + } + + foreach ($captions as $name => $caption) { + $v = $row[$name]; + + /* apply special formatting rules */ + if ($format == 'html') { + switch (strtolower($caption)) { + case 'created': + case 'modified': + case 'date': + case 'due': + case 'updated': + case 'time': + if ($v !== null) { + $v = mtrack_date($v); + } + break; + case 'content': + $v = MTrackWiki::format_to_html($v); + break; + case 'owner': + $v = mtrack_username($v, array('no_image' => true)); + break; + case 'docid': + case 'ticket': + $v = mtrack_ticket($row); + break; + case 'summary': + if ($href) { + $v = htmlentities($v, ENT_QUOTES, 'utf-8'); + $v = "$v"; + } else { + $v = htmlentities($v, ENT_QUOTES, 'utf-8'); + } + break; + case 'milestone': + $oldv = $v; + $v = ''; + foreach (preg_split("/\s*,\s*/", $oldv) as $m) { + if (!strlen($m)) continue; + $v .= "" . + "" . + htmlentities($m, ENT_QUOTES, 'utf-8') . + " "; + } + break; + case 'keyword': + $oldv = $v; + $v = ''; + foreach (preg_split("/\s*,\s*/", $oldv) as $m) { + if (!strlen($m)) continue; + $v .= mtrack_keyword($m) . ' '; + } + break; + default: + $v = htmlentities($v, ENT_QUOTES, 'utf-8'); + } + } else if ($format == 'tab') { + $v = trim(preg_replace("/[\t\n\r]+/sm", " ", $v)); + } + + if ($name[0] == '_' && substr($name, -1) == '_') { + if ($format == 'html') { + $out .= "$begin_row$begin_row"; + } else if ($format == 'tab') { + $out .= "$v\t"; + } + } elseif ($name[0] == '_') { + if ($format == 'tab') { + $out .= "$v\t"; + } else { + continue; + } + } else { + if ($format == 'html') { + $out .= ""; + if (substr($name, -1) == '_') { + $out .= "$begin_row"; + } + } else if ($format == 'tab') { + $out .= "$v\t"; + } + } + } + if ($format == 'html') { + $out .= "\n"; + } else if ($format == 'tab') { + $out .= "\n"; + } + } + if ($format == 'html') { + $out .= "
$caption
$v
$v
"; + } else if ($format == 'tab') { + $out = str_replace("\t\n", "\n", $out); + } + + return $out; + } + + static function macro_RunReport($name, $url_style_params = null) { + $params = array(); + parse_str($url_style_params, $params); + $rep = self::loadBySummary($name); + if ($rep) { + if (MTrackACL::hasAllRights("report:" . $rep->rid, 'read')) { + return $rep->renderReport($rep->query, $params); + } else { + return "Not authorized to run report $name"; + } + } else { + return "Unable to find report $name"; + } + } + + static function parseQuery() + { + $macro_params = array( + 'group' => true, + 'col' => true, + 'order' => true, + 'desc' => true, + 'format' => true, + 'compact' => true, + 'count' => true, + 'max' => true + ); + + $mparams = array( + 'col' => array('ticket', 'summary', 'state', + 'priority', + 'owner', 'type', 'component', + 'remaining'), + 'order' => array('pri.value'), + 'desc' => array('0'), + ); + $params = array(); + + $args = func_get_args(); + foreach ($args as $arg) { + if ($arg === null) continue; + $p = explode('&', $arg); + + foreach ($p as $a) { + $a = urldecode($a); + preg_match('/^([a-zA-Z_]+)(!?(?:=|~=|\^=|\$=))(.*)$/', $a, $M); + + $k = $M[1]; + $op = $M[2]; + $pat = explode('|', $M[3]); + + if (isset($macro_params[$k])) { + $mparams[$k] = $pat; + } else if (isset($params[$k])) { + if ($params[$k][0] == $op) { + // compatible operator; add $pat to possible set + $params[$k][1] = array_merge($pat, $params[$k][1]); + } else { + // ignore + } + } else { + $params[$k] = array($op, $pat); + } + } + } + return array($params, $mparams); + } + + static function macro_TicketQuery() + { + $args = func_get_args(); + list($params, $mparams) = call_user_func_array(array( + 'MTrackReport', 'parseQuery'), $args); + + /* compose that info into a query */ + $sql = 'select '; + + $colmap = array( + 'ticket' => '(case when t.nsident is null then t.tid else t.nsident end) as ticket', + 'component' => '(select mtrack_group_concat(name) from ticket_components + tcm left join components c on (tcm.compid = c.compid) + where tcm.tid = t.tid) as component', + 'keyword' => '(select mtrack_group_concat(keyword) from ticket_keywords + tk left join keywords k on (tk.kid = k.kid) + where tk.tid = t.tid) as keyword', + 'type' => 'classification as type', + 'remaining' => "(case when t.status = 'closed' then 0 else (t.estimated - (select sum(expended) from effort where effort.tid = t.tid)) end) as remaining", + 'state' => "(case when t.status = 'closed' then coalesce(t.resolution, 'closed') else t.status end) as state", + 'milestone' => '(select mtrack_group_concat(name) from ticket_milestones + tmm left join milestones tmmm on (tmm.mid = tmmm.mid) + where tmm.tid = t.tid) as milestone', + ); + + $cols = array( + ' pri.value as __color__ ', + ' (case when t.nsident is null then t.tid else t.nsident end) as ticket ', + " t.status as __status__ ", + ); + + foreach ($mparams['col'] as $colname) { + if ($colname == 'ticket') { + continue; + } + if (isset($colmap[$colname])) { + $cols[$colname] = $colmap[$colname]; + } else { + if (!preg_match("/^[a-zA-Z_]+$/", $colname)) { + throw new Exception("column name $colname is invalid"); + } + $cols[$colname] = $colname; + } + } + + $sql .= join(', ', $cols); + + if (!isset($params['milestone'])) { + $sql .= << 'm.name', + 'tid' => 't.tid', + 'id' => 't.tid', + 'ticket' => 't.tid', + ); + + foreach ($params as $k => $v) { + list($op, $values) = $v; + + if (isset($critmap[$k])) { + $k = $critmap[$k]; + } + + $sql .= " AND "; + + if ($op[0] == '!') { + $sql .= " NOT "; + $op = substr($op, 1); + } + $sql .= "("; + + if ($op == '=') { + + if ($k == 't.tid' && count($values) == 1 && + preg_match('/[,-]/', $values[0])) { + + $crit = array(); + foreach (explode(',', $values[0]) as $range) { + list($rfrom, $rto) = explode('-', $range, 2); + $type = 'integer'; + if (!ctype_digit($rfrom)) { + $rfrom = MTrackDB::esc($rfrom); + $type = 'text'; + } + if ($rto) { + if (!ctype_digit($rto)) { + $rto = MTrackDB::esc($rto); + $type = 'text'; + } + $crit[] = "(cast(t.tid as $type) between $rfrom and $rto)"; + $crit[] = "(cast(t.nsident as $type) between $rfrom and $rto)"; + } else { + $crit[] = "(t.tid = $rfrom)"; + $crit[] = "(t.nsident = $rfrom)"; + } + } + $sql .= join(' OR ', $crit); + } else if (count($values) == 1) { + $sql .= " $k = " . MTrackDB::esc($values[0]) . " "; + } else { + + $sql .= " $k in ("; + foreach ($values as $i => $val) { + $values[$i] = MTrackDB::esc($val); + } + $sql .= join(', ', $values) . ") "; + } + } else { + /* variations on like */ + if ($op == '~=') { + $start = '%'; + $end = '%'; + } else if ($op == '^=') { + $start = ''; + $end = '%'; + } else { + $start = '%'; + $end = ''; + } + + $crit = array(); + + foreach ($values as $val) { + $crit[] = "($k LIKE " . MTrackDB::esc("$start$val$end") . ")"; + } + $sql .= join(" OR ", $crit); + } + + $sql .= ") "; + + } + if (isset($mparams['group'])) { + $g = $mparams['group'][0]; + if (!ctype_alpha($g)) { + throw new Exception("group $g is not alpha"); + } + $sql .= ' GROUP BY ' . $g; + } + + if (isset($mparams['order'])) { + $k = $mparams['order'][0]; + if ($k == 'tid') { + $k = 't.tid'; + } + + $sql .= ' ORDER BY ' . $k; + if (isset($mparams['desc']) && $mparams['desc'][0]) { + $sql .= ' DESC'; + } + } + + if (isset($mparams['max'])) { + $sql .= ' LIMIT ' . (int)$mparams['max'][0]; + } +# return htmlentities($sql); +# return var_export($sql, true); + + return self::renderReport($sql); + + + } +}; + +MTrackWiki::register_macro('RunReport', + array('MTrackReport', 'macro_RunReport')); + +MTrackWiki::register_macro('TicketQuery', + array('MTrackReport', 'macro_TicketQuery')); + +MTrackACL::registerAncestry('report', 'Reports'); + diff --git a/inc/scm.php b/inc/scm.php new file mode 100644 index 00000000..43961ea1 --- /dev/null +++ b/inc/scm.php @@ -0,0 +1,703 @@ +name; + } +} + +class MTrackSCMAnnotation { + /** Revision of changeset identifier for when line was changed */ + public $rev; + + /** who made the change */ + public $changeby; + + /** the content from that line of the file. + * This is null unless $include_line_content was set to true when annotate() + * was called */ + public $line; +} + +abstract class MTrackSCMFile { + /** reference to the associated MTrackSCM object */ + public $repo; + + /** full path to file, with a leading slash (which represents + * the root of its respective repo */ + public $name; + + /** if true, this file represents a directory */ + public $is_dir = false; + + /** revision */ + public $rev; + + function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false) + { + $this->repo = $repo; + $this->name = $name; + $this->rev = $rev; + $this->is_dir = $is_dir; + } + + /** Returns an MTrackSCMEvent corresponding to this revision of + * the file */ + abstract public function getChangeEvent(); + + /** Returns a stream representing the contents of the file at + * this revision */ + abstract public function cat(); + + /** Returns an array of MTrackSCMAnnotation objects that correspond to + * each line of file content, annotating when the line was last + * changed. The array is keyed by line number, 1-based. */ + abstract public function annotate($include_line_content = false); +} + +abstract class MTrackSCMWorkingCopy { + public $dir; + + /** returns the root dir of the working copy */ + function getDir() { + return $this->dir; + } + + /** add a file to the working copy */ + abstract function addFile($path); + /** removes a file from the working copy */ + abstract function delFile($path); + /** commit changes that are pending in the working copy */ + abstract function commit(MTrackChangeset $CS); + /** get an MTrackSCMFile representation of a file */ + abstract function getFile($path); + + /** enumerates files in a path in the working copy */ + function enumFiles($path) + { + return scandir($this->dir . DIRECTORY_SEPARATOR . $path); + } + + /** determines if a file exists in the working copy */ + function file_exists($path) + { + return file_exists($this->dir . DIRECTORY_SEPARATOR . $path); + } + + function __destruct() + { + if (strlen($this->dir) > 1) { + mtrack_rmdir($this->dir); + } + } +} + +abstract class MTrackSCM { + static $repos = array(); + + static function factory(&$repopath) { + /* [ / owner type rest ] */ + $bits = explode('/', $repopath, 4); + if (count($bits) < 3) { + throw new Exception("Invalid repo $repopath"); + } + array_shift($bits); + list($owner, $type) = $bits; + $repo = "$owner/$type"; + + $r = MTrackRepo::loadByName($repo); + if (!$r) { + throw new Exception("invalid repo $repo"); + } + $repopath = isset($bits[2]) ? $bits[2] : ''; + return $r; + } + + /** Returns an array keyed by possible branch names. + * The data associated with the branches is implementation + * defined. + * If the SCM does not have a concept of first-class branch + * objects, this function returns null */ + abstract public function getBranches(); + + /** Returns an array keyed by possible tag names. + * The data associated with the tags is implementation + * defined. + * If the SCM does not have a concept of first-class tag + * objects, this function returns null */ + abstract public function getTags(); + + /** Enumerates the files/dirs that are present in the specified + * location of the repository that match the specified revision, + * branch or tag information. If no revision, branch or tag is + * specified, then the appropriate default is assumed. + * + * The second and third parameters are optional; the second + * parameter is one of 'rev', 'branch', or 'tag', and if specifed + * the third parameter must be the corresponding revision, branch + * or tag identifier. + * + * The return value is an array of MTrackSCMFile objects present + * at that location/revision of the repository. + */ + abstract public function readdir($path, $object = null, $ident = null); + + /** Queries information on a specific file in the repository. + * + * Parameters are as for readdir() above. + * + * This function returns a single MTrackSCMFile for the location + * in question. + */ + abstract public function file($path, $object = null, $ident = null); + + /** Queries history for a particular location in the repo. + * + * Parameters are as for readdir() above, except that path can be + * left unspecified to query the history for the entire repo. + * + * The limit parameter limits the number of entries returned; it it is + * a number, it specifies the number of events, otherwise it is assumed + * to be a date in the past; only events since that date will be returned. + * + * Returns an array of MTrackSCMEvent objects. + */ + abstract public function history($path, $limit = null, $object = null, + $ident = null); + + /** Obtain the diff text representing a change to a file. + * + * You may optionally provide one or two revisions as context. + * + * If no revisions are passed in, then the change associated + * with the location will be assumed. + * + * If one revision is passed, then the change associated with + * that event will be assumed. + * + * If two revisions are passed, then the difference between + * the two events will be assumed. + */ + abstract public function diff($path, $from = null, $to = null); + + /** Determine the next and previous revisions for a given + * changeset. + * + * Returns an array: the 0th element is an array of prior revisions, + * and the 1st element is an array of successor revisions. + * + * There will usually be one prior and one successor revision for a + * given change, but some SCMs will return multiples in the case of + * merges. + */ + abstract public function getRelatedChanges($revision); + + /** Returns a working copy object for the repo + * + * The intended purpose is to support wiki page modifications, and + * as such, is not meant to be an especially efficient means to do so. + */ + abstract public function getWorkingCopy(); + + /** Returns the default 'root' location in the repository. + * For SCMs that have a concept of branches, this is the empty string. + * For SCMs like SVN, this is the trunk dir */ + public function getDefaultRoot() { + return ''; + } + + /** Returns meta information about the SCM type; this is used in the + * UI and tooling to let the user know their options. + * + * Returns an array with the following keys: + * 'name' => 'Mercurial', // human displayable name + * 'tools' => array('hg'), // list of tools to find during setup + */ + abstract public function getSCMMetaData(); + + /* takes an MTrackSCM as a parameter because in some bootstrapping + * cases, we're actually MTrackRepo and not the end-class. + * MTrackRepo calls the end-class method and passes itself in for + * context */ + public function reconcileRepoSettings(MTrackSCM $r) { + throw new Exception( + "Creating/updating a repo of type $this->scmtype is not implemented"); + } + + static function makeBreadcrumbs($pi) { + if (!strlen($pi)) { + $pi = '/'; + } + if ($pi == '/') { + $crumbs = array(''); + } else { + $crumbs = explode('/', $pi); + } + return $crumbs; + } + + static function makeDisplayName($data) { + $parent = ''; + $name = ''; + if (is_object($data)) { + $parent = $data->parent; + $name = $data->shortname; + } else if (is_array($data)) { + $parent = $data['parent']; + $name = $data['shortname']; + } + if ($parent) { + list($type, $owner) = explode(':', $parent); + return "$owner/$name"; + } + return "default/$name"; + } + + public function getBrowseRootName() { + return self::makeDisplayName($this); + } + + public function resolveRevision($rev, $object, $ident) { + if ($rev !== null) { + return $rev; + } + if ($object === null) { + return null; + } + switch ($object) { + case 'rev': + $rev = $ident; + break; + case 'branch': + $branches = $this->getBranches(); + $rev = isset($branches[$ident]) ? $branches[$ident] : null; + break; + case 'tag': + $tags = $this->getTags(); + $rev = isset($tags[$ident]) ? $tags[$ident] : null; + break; + } + if ($rev === null) { + throw new Exception( + "don't know which revision to use ($rev,$object,$ident)"); + } + return $rev; + } +} +MTrackACL::registerAncestry('repo', 'Browser'); +MTrackWatch::registerEventTypes('repo', array( + 'ticket' => 'Tickets', + 'changeset' => 'Code changes' +)); + +class MTrackRepo extends MTrackSCM { + public $repoid = null; + public $shortname = null; + public $scmtype = null; + public $repopath = null; + public $browserurl = null; + public $browsertype = null; + public $description = null; + public $parent = ''; + public $clonedfrom = null; + public $serverurl = null; + private $links_to_add = array(); + private $links_to_remove = array(); + private $links = null; + static $scms = array(); + + static function registerSCM($scmtype, $classname) { + self::$scms[$scmtype] = $classname; + } + static function getAvailableSCMs() { + $ret = array(); + foreach (self::$scms as $t => $classname) { + $o = new $classname; + $ret[$t] = $o; + } + return $ret; + } + + public function reconcileRepoSettings() { + if (!isset(self::$scms[$this->scmtype])) { + throw new Exception("invalid scm type $this->scmtype"); + } + $c = self::$scms[$this->scmtype]; + $s = new $c; + $s->reconcileRepoSettings($this); + } + + public function getSCMMetaData() { + return null; + } + + static function loadById($id) { + list($row) = MTrackDB::q( + 'select repoid, scmtype from repos where repoid = ?', + $id)->fetchAll(); + if (isset($row[0])) { + $type = $row[1]; + if (isset(self::$scms[$type])) { + $class = self::$scms[$type]; + return new $class($row[0]); + } + throw new Exception("unsupported repo type $type"); + } + return null; + } + + static function loadByName($name) { + $bits = explode('/', $name); + if (count($bits) > 1 && $bits[0] == 'default') { + array_shift($bits); + $name = $bits[0]; + } + if (count($bits) > 1) { + /* wez/reponame -> per user repo */ + $u = "user:$bits[0]"; + $p = "project:$bits[0]"; + $rows = MTrackDB::q( + 'select repoid, scmtype from repos where shortname = ? and (parent = ? OR parent = ?)', + $bits[1], $u, $p)->fetchAll(); + } else { + $rows = MTrackDB::q( + "select repoid, scmtype from repos where shortname = ? and parent =''", + $name)->fetchAll(); + } + if (is_array($rows) && isset($rows[0])) { + $row = $rows[0]; + if (isset($row[0])) { + $type = $row[1]; + if (isset(self::$scms[$type])) { + $class = self::$scms[$type]; + return new $class($row[0]); + } + throw new Exception("unsupported repo type $type"); + } + } + return null; + } + + function getServerURL() { + if ($this->serverurl) { + return $this->serverurl; + } + $url = MTrackConfig::get('repos', "$this->scmtype.serverurl"); + if ($url) { + return $url . $this->getBrowseRootName(); + } + return null; + } + + function getCheckoutCommand() { + $url = $this->getServerURL(); + if (strlen($url)) { + return $this->scmtype . ' clone ' . $this->getServerURL(); + } + return null; + } + + function canFork() { + return false; + } + + static function loadByLocation($path) { + list($row) = MTrackDB::q('select repoid, scmtype from repos where repopath = ?', $path)->fetchAll(); + if (isset($row[0])) { + $type = $row[1]; + if (isset(self::$scms[$type])) { + $class = self::$scms[$type]; + return new $class($row[0]); + } + throw new Exception("unsupported repo type $type"); + } + return null; + } + + public function getWorkingCopy() { + throw new Exception("cannot getWorkingCopy from a generic repo object"); + } + + function __construct($id = null) { + if ($id !== null) { + list($row) = MTrackDB::q( + 'select * from repos where repoid = ?', + $id)->fetchAll(); + if (isset($row[0])) { + $this->repoid = $row['repoid']; + $this->shortname = $row['shortname']; + $this->scmtype = $row['scmtype']; + $this->repopath = $row['repopath']; + $this->browserurl = $row['browserurl']; + $this->browsertype = $row['browsertype']; + $this->description = $row['description']; + $this->parent = $row['parent']; + $this->clonedfrom = $row['clonedfrom']; + $this->serverurl = $row['serverurl']; + return; + } + throw new Exception("unable to find repo with id = $id"); + } + } + + function deleteRepo(MTrackChangeset $CS) { + MTrackDB::q('delete from repos where repoid = ?', $this->repoid); + mtrack_rmdir($this->repopath); + } + + function save(MTrackChangeset $CS) { + if (!isset(self::$scms[$this->scmtype])) { + throw new Exception("unsupported repo type " . $this->scmtype); + } + + if ($this->repoid) { + list($row) = MTrackDB::q( + 'select * from repos where repoid = ?', + $this->repoid)->fetchAll(); + $old = $row; + MTrackDB::q( + 'update repos set shortname = ?, scmtype = ?, repopath = ?, + browserurl = ?, browsertype = ?, description = ?, + parent = ?, serverurl = ?, clonedfrom = ? where repoid = ?', + $this->shortname, $this->scmtype, $this->repopath, + $this->browserurl, $this->browsertype, $this->description, + $this->parent, $this->serverurl, $this->clonedfrom, $this->repoid); + } else { + $acl = null; + + if (!strlen($this->repopath)) { + if (!MTrackConfig::get('repos', 'allow_user_repo_creation')) { + throw new Exception("configuration does not allow repo creation"); + } + $repodir = MTrackConfig::get('repos', 'basedir'); + if ($repodir == null) { + $repodir = MTrackConfig::get('core', 'vardir') . '/repos'; + } + if (!is_dir($repodir)) { + mkdir($repodir); + } + + if (!$this->parent) { + $owner = mtrack_canon_username(MTrackAuth::whoami()); + $this->parent = 'user:' . $owner; + } else { + list($type, $owner) = explode(':', $this->parent, 2); + switch ($type) { + case 'project': + $P = MTrackProject::loadByName($owner); + if (!$P) { + throw new Exception("invalid project $owner"); + } + MTrackACL::requireAllRights("project:$P->projid", 'modify'); + break; + case 'user': + if ($owner != mtrack_canon_username(MTrackAuth::whoami())) { + throw new Exception("can't make a repo for another user"); + } + break; + default: + throw new Exception("invalid parent ($this->parent)"); + } + } + if (preg_match("/[^a-zA-Z0-9_.-]/", $owner)) { + throw new Exception("$owner must not contain special characters"); + } + $this->repopath = $repodir . DIRECTORY_SEPARATOR . $owner; + if (!is_dir($this->repopath)) { + mkdir($this->repopath); + } + $this->repopath .= DIRECTORY_SEPARATOR . $this->shortname; + + /* default ACL is allow user all rights, block everybody else */ + $acl = array( + array($owner, 'read', 1), + array($owner, 'modify', 1), + array($owner, 'delete', 1), + array($owner, 'checkout', 1), + array($owner, 'commit', 1), + array('*', 'read', 0), + array('*', 'modify', 0), + array('*', 'delete', 0), + array('*', 'checkout', 0), + array('*', 'commit', 0), + ); + } + + MTrackDB::q('insert into repos (shortname, scmtype, + repopath, browserurl, browsertype, description, parent, + serverurl, clonedfrom) + values (?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->shortname, $this->scmtype, $this->repopath, + $this->browserurl, $this->browsertype, $this->description, + $this->parent, $this->serverurl, $this->clonedfrom); + + $this->repoid = MTrackDB::lastInsertId('repos', 'repoid'); + $old = null; + + if ($acl !== null) { + MTrackACL::setACL("repo:$this->repoid", 0, $acl); + $me = mtrack_canon_username(MTrackAuth::whoami()); + foreach (array('ticket', 'changeset') as $e) { + MTrackDB::q( + 'insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)', + 'repo', $this->repoid, $me, 'email', $e); + } + } + } + $this->reconcileRepoSettings(); + if (!$this->parent) { + /* for SSH access, populate a symlink from the repos basedir to the + * actual path for this repo */ + $repodir = MTrackConfig::get('repos', 'basedir'); + if ($repodir == null) { + $repodir = MTrackConfig::get('core', 'vardir') . '/repos'; + } + if (!is_dir($repodir)) { + mkdir($repodir); + } + $repodir .= '/default'; + if (!is_dir($repodir)) { + mkdir($repodir); + } + $repodir .= '/' . $this->shortname; + if (!file_exists($repodir)) { + symlink($this->repopath, $repodir); + } else if (is_link($repodir) && readlink($repodir) != $this->repopath) { + unlink($repodir); + symlink($this->repopath, $repodir); + } + } + $CS->add("repo:" . $this->repoid . ":shortname", $old['shortname'], $this->shortname); + $CS->add("repo:" . $this->repoid . ":scmtype", $old['scmtype'], $this->scmtype); + $CS->add("repo:" . $this->repoid . ":repopath", $old['repopath'], $this->repopath); + $CS->add("repo:" . $this->repoid . ":browserurl", $old['browserurl'], $this->browserurl); + $CS->add("repo:" . $this->repoid . ":browsertype", $old['browsertype'], $this->browsertype); + $CS->add("repo:" . $this->repoid . ":description", $old['description'], $this->description); + $CS->add("repo:" . $this->repoid . ":parent", $old['parent'], $this->parent); + $CS->add("repo:" . $this->repoid . ":clonedfrom", $old['clonedfrom'], $this->clonedfrom); + $CS->add("repo:" . $this->repoid . ":serverurl", $old['serverurl'], $this->serverurl); + + foreach ($this->links_to_add as $link) { + MTrackDB::q('insert into project_repo_link (projid, repoid, repopathregex) values (?, ?, ?)', $link[0], $this->repoid, $link[1]); + } + foreach ($this->links_to_remove as $linkid) { + MTrackDB::q('delete from project_repo_link where repoid = ? and linkid = ?', $this->repoid, $linkid); + } + } + + function getLinks() + { + if ($this->links === null) { + $this->links = array(); + foreach (MTrackDB::q('select linkid, projid, repopathregex + from project_repo_link where repoid = ? order by repopathregex', + $this->repoid)->fetchAll() as $row) { + $this->links[$row[0]] = array($row[1], $row[2]); + } + } + return $this->links; + } + + function addLink($proj, $regex) + { + if ($proj instanceof MTrackProject) { + $this->links_to_add[] = array($proj->projid, $regex); + } else { + $this->links_to_add[] = array($proj, $regex); + } + } + + function removeLink($linkid) + { + $this->links_to_remove[$linkid] = $linkid; + } + + public function getBranches() {} + public function getTags() {} + public function readdir($path, $object = null, $ident = null) {} + public function file($path, $object = null, $ident = null) {} + public function history($path, $limit = null, $object = null, $ident = null){} + public function diff($path, $from = null, $to = null) {} + public function getRelatedChanges($revision) {} + + function projectFromPath($filename) { + static $links = array(); + if (!isset($links[$this->repoid]) || $links[$this->repoid] === null) { + $links[$this->repoid] = array(); + foreach (MTrackDB::q( + 'select projid, repopathregex from project_repo_link where repoid = ?', + $this->repoid) as $row) { + $re = str_replace('/', '\\/', $row[1]); + $links[$this->repoid][] = array($row[0], "/$re/"); + } + } + if (is_array($filename)) { + $proj_incidence = array(); + foreach ($filename as $file) { + $proj = $this->projectFromPath($file); + if ($proj === null) continue; + if (isset($proj_incidence[$proj])) { + $proj_incidence[$proj]++; + } else { + $proj_incidence[$proj] = 1; + } + } + $the_proj = null; + $the_proj_count = 0; + foreach ($proj_incidence as $proj => $count) { + if ($count > $the_proj_count) { + $the_proj_count = $count; + $the_proj = $proj; + } + } + return $the_proj; + } + + if ($filename instanceof MTrackSCMFileEvent) { + $filename = $filename->name; + } + + // walk through the regexes; take the longest match as definitive + $longest = null; + $longest_id = null; + if ($filename[0] != '/') { + $filename = '/' . $filename; + } + foreach ($links[$this->repoid] as $link) { + if (preg_match($link[1], $filename, $M)) { + if (strlen($M[0]) > strlen($longest)) { + $longest = $M[0]; + $longest_id = $link[0]; + } + } + } + return $longest_id; + } +} diff --git a/inc/scm/git.php b/inc/scm/git.php new file mode 100644 index 00000000..2e5cf6ba --- /dev/null +++ b/inc/scm/git.php @@ -0,0 +1,534 @@ +repo = $repo; + $this->name = $name; + $this->rev = $rev; + $this->is_dir = $is_dir; + } + + public function getChangeEvent() + { + list($ent) = $this->repo->history($this->name, 1, 'rev', $this->rev); + return $ent; + } + + function cat() + { + // There may be a better way... + // ls-tree to determine the hash of the file from this change: + $fp = $this->repo->git('ls-tree', $this->rev, $this->name); + $line = fgets($fp); + $fp = null; + list($mode, $type, $hash, $name) = preg_split("/\s+/", $line); + + // now we can cat that blob + return $this->repo->git('cat-file', 'blob', $hash); + } + + function annotate($include_line_content = false) + { + if ($this->repo->gitdir == $this->repo->repopath) { + // For bare repos, we can't run annotate, so we need to make a clone + // with a work tree. This relies on local clones being a cheap operation + $wc = new MTrackWCGit($this->repo); + $wc->push = false; + $fp = $wc->git('annotate', '-p', $this->name, $this->rev); + } else { + $fp = $this->repo->git('annotate', '-p', $this->name, $this->rev); + } + $i = 1; + $ann = array(); + $meta = array(); + while ($line = fgets($fp)) { +// echo htmlentities($line), "
\n"; + if (!strncmp($line, "\t", 1)) { + $A = new MTrackSCMAnnotation; + if (isset($meta['author-mail']) && + strpos($meta['author-mail'], '@')) { + $A->changeby = $meta['author'] . ' ' . $meta['author-mail']; + } else { + $A->changeby = $meta['author']; + } + $A->rev = $meta['rev']; + if ($include_line_content) { + $A->line = substr($line, 1); + } + $ann[$i++] = $A; + continue; + } + if (preg_match("/^([a-f0-9]+)\s[a-f0-9]+\s[a-f0-9]+\s[a-f0-9]+$/", + $line, $M)) { + $meta['rev'] = $M[1]; + } else if (preg_match("/^(\S+)\s*(.*)$/", $line, $M)) { + $name = $M[1]; + $value = $M[2]; + $meta[$name] = $value; + } + } + return $ann; + } +} + +class MTrackWCGit extends MTrackSCMWorkingCopy { + private $repo; + public $push = true; + + function __construct(MTrackRepo $repo) { + $this->dir = mtrack_make_temp_dir(); + $this->repo = $repo; + + mtrack_run_tool('git', 'string', + array('clone', $this->repo->repopath, $this->dir) + ); + } + + function __destruct() { + if ($this->push) { + echo stream_get_contents($this->git('push')); + } + mtrack_rmdir($this->dir); + } + + function getFile($path) + { + return $this->repo->file($path); + } + + function addFile($path) + { + $this->git('add', $path); + } + + function delFile($path) + { + $this->git('rm', '-f', $path); + } + + function commit(MTrackChangeset $CS) + { + if ($CS->when) { + $d = strtotime($CS->when); + putenv("GIT_AUTHOR_DATE=$d -0000"); + } else { + putenv("GIT_AUTHOR_DATE="); + } + $reason = trim($CS->reason); + if (!strlen($reason)) { + $reason = 'Changed'; + } + putenv("GIT_AUTHOR_NAME=$CS->who"); + putenv("GIT_AUTHOR_EMAIL=$CS->who"); + stream_get_contents($this->git('commit', '-a', + '-m', $reason + ) + ); + } + + function git() + { + $args = func_get_args(); + $a = array("--git-dir=$this->dir/.git", "--work-tree=$this->dir"); + foreach ($args as $arg) { + $a[] = $arg; + } + + return mtrack_run_tool('git', 'read', $a); + } +} + +class MTrackSCMGit extends MTrackRepo { + protected $branches = null; + protected $tags = null; + public $gitdir = null; + + public function getSCMMetaData() { + return array( + 'name' => 'Git', + 'tools' => array('git'), + ); + } + + function __construct($id = null) { + parent::__construct($id); + if ($id !== null) { + /* transparently handle bare vs. non bare repos */ + $this->gitdir = $this->repopath; + if (is_dir("$this->repopath/.git")) { + $this->gitdir .= "/.git"; + } + } + } + + function getServerURL() { + $url = parent::getServerURL(); + if ($url) return $url; + $url = MTrackConfig::get('repos', 'serverurl'); + if ($url) { + return "$url:" . $this->getBrowseRootName(); + } + return null; + } + + public function reconcileRepoSettings(MTrackSCM $r = null) { + if ($r == null) { + $r = $this; + } + + if (!is_dir($r->repopath)) { + $userdata = MTrackAuth::getUserData(MTrackAuth::whoami()); + $who = $userdata['email']; + putenv("GIT_AUTHOR_NAME=$who"); + putenv("GIT_AUTHOR_EMAIL=$who"); + + if ($r->clonedfrom) { + $S = MTrackRepo::loadById($r->clonedfrom); + + $stm = mtrack_run_tool('git', 'read', + array('clone', '--bare', $S->repopath, $r->repopath)); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git init failed: $out"); + } + + } else { + /* a little peculiar, but bear with it. + * We need to have a bare repo so that git doesn't mess around + * trying to deal with a checkout in the repo dir. + * So we need to create two repos; one bare, one not bare. + * We populate the non-bare repo with a dummy file just to have + * something to commit, then push non-bare -> bare, and remove non-bare. + */ + + $stm = mtrack_run_tool('git', 'read', + array('init', '--bare', $r->repopath)); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git init failed: $out"); + } + + $alt = "$r->repopath.MTRACKINIT"; + + $stm = mtrack_run_tool('git', 'read', + array('init', $alt)); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git init failed: $out"); + } + + $dir = getcwd(); + chdir($alt); + + file_put_contents("$alt/.gitignore", "#\n"); + $stm = mtrack_run_tool('git', 'read', + array('add', '.gitignore')); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git add .gitignore failed: $out"); + } + $stm = mtrack_run_tool('git', 'read', + array('commit', '-a', '-m', 'init')); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git commit failed: $out"); + } + $stm = mtrack_run_tool('git', 'read', + array('push', $r->repopath, 'master')); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("git push failed: $out"); + } + chdir($dir); + system("rm -rf $alt"); + } + + $php = MTrackConfig::get('tools', 'php'); + $hook = realpath(dirname(__FILE__) . '/../../bin/git-commit-hook'); + $conffile = realpath(MTrackConfig::getLocation()); + foreach (array('pre', 'post') as $step) { + $script = <<repopath"); + } + + function canFork() { + return true; + } + + + public function getBranches() + { + if ($this->branches !== null) { + return $this->branches; + } + $this->branches = array(); + $fp = $this->git('branch', '--no-color', '--verbose'); + while ($line = fgets($fp)) { + // * master 61e7e7d oneliner + $line = substr($line, 2); + list($branch, $rev) = preg_split('/\s+/', $line); + $this->branches[$branch] = $rev; + } + $fp = null; + return $this->branches; + } + + public function getTags() + { + if ($this->tags !== null) { + return $this->tags; + } + $this->tags = array(); + $fp = $this->git('tag'); + while ($line = fgets($fp)) { + $line = trim($line); + $this->tags[$line] = $line; + } + $fp = null; + return $this->tags; + } + + public function readdir($path, $object = null, $ident = null) + { + $res = array(); + + if ($object === null) { + $object = 'branch'; + $ident = 'master'; + } + $rev = $this->resolveRevision(null, $object, $ident); + + if (strlen($path)) { + $path = rtrim($path, '/') . '/'; + } + + $fp = $this->git('ls-tree', $rev, $path); + + $dirs = array(); + + while ($line = fgets($fp)) { + list($mode, $type, $hash, $name) = preg_split("/\s+/", $line); + + $res[] = new MTrackSCMFileGit($this, "$name", $rev, $type == 'tree'); + } + return $res; + } + + public function file($path, $object = null, $ident = null) + { + if ($object == null) { + $branches = $this->getBranches(); + if (isset($branches['master'])) { + $object = 'branch'; + $ident = 'master'; + } else { + // fresh/empty repo + return null; + } + } + $rev = $this->resolveRevision(null, $object, $ident); + return new MTrackSCMFileGit($this, $path, $rev); + } + + public function history($path, $limit = null, $object = null, $ident = null) + { + $res = array(); + + $args = array(); + if ($object !== null) { + $rev = $this->resolveRevision(null, $object, $ident); + $args[] = "$rev"; + } else { + $args[] = "master"; + } + if ($limit !== null) { + if (is_int($limit)) { + $args[] = "--max-count=$limit"; + } else { + $args[] = "--since=$limit"; + } + } + $args[] = "--no-color"; + $args[] = "--name-status"; + $args[] = "--date=rfc"; + + $path = ltrim($path, '/'); + + $fp = $this->git('log', $args, '--', $path); + + $commits = array(); + $commit = null; + while (true) { + $line = fgets($fp); + if ($line === false) { + if ($commit !== null) { + $commits[] = $commit; + } + break; + } + if (preg_match("/^commit/", $line)) { + if ($commit !== null) { + $commits[] = $commit; + } + $commit = $line; + continue; + } + $commit .= $line; + } + + foreach ($commits as $commit) { + $ent = new MTrackSCMEvent; + $lines = explode("\n", $commit); + $line = array_shift($lines); + + if (!preg_match("/^commit\s+(\S+)$/", $line, $M)) { + break; + } + $ent->rev = $M[1]; + + $ent->branches = array(); // FIXME + $ent->tags = array(); // FIXME + $ent->files = array(); + + while (count($lines)) { + $line = array_shift($lines); + if (!strlen($line)) { + break; + } + if (preg_match("/^(\S+):\s+(.*)\s*$/", $line, $M)) { + $k = $M[1]; + $v = $M[2]; + + switch ($k) { + case 'Author': + $ent->changeby = $v; + break; + case 'Date': + $ts = strtotime($v); + $ent->ctime = MTrackDB::unixtime($ts); + break; + } + } + } + + $ent->changelog = ""; + + if ($lines[0] == '') { + array_shift($lines); + } + + while (count($lines)) { + $line = array_shift($lines); + if (strncmp($line, ' ', 4)) { + array_unshift($lines, $line); + break; + } + $line = substr($line, 4); + $ent->changelog .= $line . "\n"; + } + + if ($lines[0] == '') { + array_shift($lines); + } + foreach ($lines as $line) { + if (preg_match("/^(.+)\s+(\S+)\s*$/", $line, $M)) { + $f = new MTrackSCMFileEvent; + $f->name = $M[2]; + $f->status = $M[1]; + $ent->files[] = $f; + } + } + + if (!count($ent->branches)) { + $ent->branches[] = 'master'; + } + + $res[] = $ent; + } + $fp = null; + return $res; + } + + public function diff($path, $from = null, $to = null) + { + if ($path instanceof MTrackSCMFile) { + if ($from === null) { + $from = $path->rev; + } + $path = $path->name; + } + if ($to !== null) { + return $this->git('diff', "$from..$to", '--', $path); + } + return $this->git('diff', "$from^..$from", '--', $path); + } + + public function getWorkingCopy() + { + return new MTrackWCGit($this); + } + + public function getRelatedChanges($revision) + { + $parents = array(); + $kids = array(); + + $fp = $this->git('rev-parse', "$revision^"); + while (($line = fgets($fp)) !== false) { + $parents[] = trim($line); + } + + // Ugh!: http://stackoverflow.com/questions/1761825/referencing-the-child-of-a-commit-in-git + $fp = $this->git('rev-list', '--all', '--parents'); + while (($line = fgets($fp)) !== false) { + $hashes = preg_split("/\s+/", $line); + $kid = array_shift($hashes); + if (in_array($revision, $hashes)) { + $kids[] = $kid; + } + } + + return array($parents, $kids); + } + + function git() + { + $args = func_get_args(); + $a = array( + "--git-dir=$this->gitdir" + ); + + if ($this->gitdir != $this->repopath) { + $a[] = "--work-tree=$this->repopath"; + } + foreach ($args as $arg) { + $a[] = $arg; + } + + return mtrack_run_tool('git', 'read', $a); + } +} + +MTrackRepo::registerSCM('git', 'MTrackSCMGit'); + diff --git a/inc/scm/hg.php b/inc/scm/hg.php new file mode 100644 index 00000000..2a1931ff --- /dev/null +++ b/inc/scm/hg.php @@ -0,0 +1,471 @@ +repo = $repo; + $this->name = $name; + $this->rev = $rev; + $this->is_dir = $is_dir; + } + + public function _determineFileChangeEvent($repoid, $filename, $rev) + { + $repo = MTrackRepo::loadById($repoid); + $ents = $repo->history($filename, 1, 'rev', "$rev:0"); + if (!count($ents)) { + throw new Exception("$filename is invalid"); + } + return $ents[0]; + } + + public function getChangeEvent() + { + return mtrack_cache( + array('MTrackSCMFileHg', '_determineFileChangeEvent'), + array($this->repo->repoid, $this->name, $this->rev), + 864000); + } + + function cat() + { + return $this->repo->hg('cat', '-r', $this->rev, $this->name); + } + + function annotate($include_line_content = false) + { + $i = 1; + $ann = array(); + $fp = $this->repo->hg('annotate', '-r', $this->rev, '-uvc', $this->name); + while ($line = fgets($fp)) { + preg_match("/^\s*([^:]*)\s+([0-9a-fA-F]+): (.*)$/", $line, $M); + $A = new MTrackSCMAnnotation; + $A->changeby = $M[1]; + $A->rev = $M[2]; + if ($include_line_content) { + $A->line = $M[3]; + } + $ann[$i++] = $A; + } + return $ann; + } +} + +class MTrackWCHg extends MTrackSCMWorkingCopy { + private $repo; + + function __construct(MTrackRepo $repo) { + $this->dir = mtrack_make_temp_dir(); + $this->repo = $repo; + + stream_get_contents($this->hg('init', $this->dir)); + stream_get_contents($this->hg('pull', $this->repo->repopath)); + stream_get_contents($this->hg('up')); + } + + function __destruct() { + + $a = array("-y", "--cwd", $this->dir, 'push', $this->repo->repopath); + + list($proc, $pipes) = mtrack_run_tool('hg', 'proc', $a); + + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + $st = proc_close($proc); + + if ($st) { + throw new Exception("push failed with status $st: $err $out"); + } + mtrack_rmdir($this->dir); + } + + function getFile($path) + { + return $this->repo->file($path); + } + + function addFile($path) + { + // nothing to do; we use --addremove + } + + function delFile($path) + { + // we use --addremove when we commit for this to take effect + unlink($this->dir . DIRECTORY_SEPARATOR . $path); + } + + function commit(MTrackChangeset $CS) + { + $hg_date = (int)strtotime($CS->when) . ' 0'; + $reason = trim($CS->reason); + if (!strlen($reason)) { + $reason = 'Changed'; + } + $out = $this->hg('ci', '--addremove', + '-m', $reason, + '-d', $hg_date, + '-u', $CS->who); + $data = stream_get_contents($out); + $st = pclose($out); + if ($st != 0) { + throw new Exception("commit failed $st $data"); + } + } + + function hg() + { + $args = func_get_args(); + $a = array("-y", "--cwd", $this->dir); + foreach ($args as $arg) { + $a[] = $arg; + } + + return mtrack_run_tool('hg', 'read', $a); + } +} + +class MTrackSCMHg extends MTrackRepo { + protected $hg = 'hg'; + protected $branches = null; + protected $tags = null; + + public function getSCMMetaData() { + return array( + 'name' => 'Mercurial', + 'tools' => array('hg'), + ); + } + + public function reconcileRepoSettings(MTrackSCM $r = null) { + if ($r == null) { + $r = $this; + } + $description = substr(preg_replace("/\r?\n/m", ' ', $r->description), 0, 64); + $description = trim($description); + if (!is_dir($r->repopath)) { + if ($r->clonedfrom) { + $S = MTrackRepo::loadById($r->clonedfrom); + $stm = mtrack_run_tool('hg', 'read', array( + 'clone', $S->repopath, $r->repopath)); + } else { + $stm = mtrack_run_tool('hg', 'read', array('init', $r->repopath)); + } + $out = stream_get_contents($stm); + $st = pclose($stm); + if ($st) { + throw new Exception("hg: failed $out"); + } + } + + $php = MTrackConfig::get('tools', 'php'); + $conffile = realpath(MTrackConfig::getLocation()); + + $install = realpath(dirname(__FILE__) . '/../../'); + + /* fixup config */ + $apply = array( + "hooks" => array( + "changegroup.mtrack" => + "$php $install/bin/hg-commit-hook changegroup $conffile", + "commit.mtrack" => + "$php $install/bin/hg-commit-hook commit $conffile", + "pretxncommit.mtrack" => + "$php $install/bin/hg-commit-hook pretxncommit $conffile", + "pretxnchangegroup.mtrack" => + "$php $install/bin/hg-commit-hook pretxnchangegroup $conffile", + ), + "web" => array( + "description" => $description, + ) + ); + + $cfg = @file_get_contents("$r->repopath/.hg/hgrc"); + $adds = array(); + + foreach ($apply as $sect => $opts) { + foreach ($opts as $name => $value) { + if (preg_match("/^$name\s*=/m", $cfg)) { + $cfg = preg_replace("/^$name\s*=.*$/m", "$name = $value", $cfg); + } else { + $adds[$sect][$name] = $value; + } + } + } + + foreach ($adds as $sect => $opts) { + $cfg .= "[$sect]\n"; + foreach ($opts as $name => $value) { + $cfg .= "$name = $value\n"; + } + } + file_put_contents("$r->repopath/.hg/hgrc", $cfg, LOCK_EX); + system("chmod -R 02777 $r->repopath"); + } + + function canFork() { + return true; + } + + function getServerURL() { + $url = parent::getServerURL(); + if ($url) return $url; + $url = MTrackConfig::get('repos', 'serverurl'); + if ($url) { + return "ssh://$url/" . $this->getBrowseRootName(); + } + return null; + } + + public function getBranches() + { + if ($this->branches !== null) { + return $this->branches; + } + $this->branches = array(); + $fp = $this->hg('branches'); + while ($line = fgets($fp)) { + list($branch, $revstr) = preg_split('/\s+/', $line); + list($num, $rev) = explode(':', $revstr, 2); + $this->branches[$branch] = $rev; + } + $fp = null; + return $this->branches; + } + + public function getTags() + { + if ($this->tags !== null) { + return $this->tags; + } + $this->tags = array(); + $fp = $this->hg('tags'); + while ($line = fgets($fp)) { + list($tag, $revstr) = preg_split('/\s+/', $line); + list($num, $rev) = explode(':', $revstr, 2); + $this->tags[$tag] = $rev; + } + $fp = null; + return $this->tags; + } + + public function readdir($path, $object = null, $ident = null) + { + $res = array(); + + if ($object === null) { + $object = 'branch'; + $ident = 'default'; + } + $rev = $this->resolveRevision(null, $object, $ident); + + $fp = $this->hg('manifest', '-r', $rev); + + if (strlen($path)) { + $path .= '/'; + } + $plen = strlen($path); + + $dirs = array(); + $exists = false; + + while ($line = fgets($fp)) { + $name = trim($line); + + if (!strncmp($name, $path, $plen)) { + $exists = true; + $ent = substr($name, $plen); + if (strpos($ent, '/') === false) { + $res[] = new MTrackSCMFileHg($this, "$path$ent", $rev); + } else { + list($d) = explode('/', $ent, 2); + if (!isset($dirs[$d])) { + $dirs[$d] = $d; + $res[] = new MTrackSCMFileHg($this, "$path$d", $rev, true); + } + } + } + } + + if (!$exists) { + throw new Exception("location $path does not exist"); + } + return $res; + } + + public function file($path, $object = null, $ident = null) + { + if ($object == null) { + $branches = $this->getBranches(); + if (isset($branches['default'])) { + $object = 'branch'; + $ident = 'default'; + } else { + // fresh/empty repo + $object = 'tag'; + $ident = 'tip'; + } + } + $rev = $this->resolveRevision(null, $object, $ident); + return new MTrackSCMFileHg($this, $path, $rev); + } + + public function history($path, $limit = null, $object = null, $ident = null) + { + $res = array(); + + $args = array(); + if ($object !== null) { + $rev = $this->resolveRevision(null, $object, $ident); + $args[] = '-r'; + $args[] = $rev; + } + if ($limit !== null) { + if (is_int($limit)) { + $args[] = '-l'; + $args[] = $limit; + } else { + $t = strtotime($limit); + $args[] = '-d'; + $args[] = ">$t 0"; + } + } + + $sep = uniqid(); + $fp = $this->hg('log', + '--template', $sep . '\n{node|short}\n{branches}\n{tags}\n{file_adds}\n{file_copies}\n{file_mods}\n{file_dels}\n{author|email}\n{date|hgdate}\n{desc}\n', $args, + $path); + + fgets($fp); # discard leading $sep + + // corresponds to the file_adds, file_copies, file_modes, file_dels + // in the template above + static $file_status_order = array('A', 'C', 'M', 'D'); + + while (true) { + $ent = new MTrackSCMEvent; + $ent->rev = trim(fgets($fp)); + if (!strlen($ent->rev)) { + break; + } + + $ent->branches = array(); + foreach (preg_split('/\s+/', trim(fgets($fp))) as $b) { + if (strlen($b)) { + $ent->branches[] = $b; + } + } + if (!count($ent->branches)) { + $ent->branches[] = 'default'; + } + + $ent->tags = array(); + foreach (preg_split('/\s+/', trim(fgets($fp))) as $t) { + if (strlen($t)) { + $ent->tags[] = $t; + } + } + + $ent->files = array(); + + foreach ($file_status_order as $status) { + foreach (preg_split('/\s+/', trim(fgets($fp))) as $t) { + if (strlen($t)) { + $f = new MTrackSCMFileEvent; + $f->name = $t; + $f->status = $status; + $ent->files[] = $f; + } + } + } + + $ent->changeby = trim(fgets($fp)); + list($ts) = preg_split('/\s+/', fgets($fp)); + $ent->ctime = MTrackDB::unixtime((int)$ts); + $changelog = array(); + while (($line = fgets($fp)) !== false) { + $line = rtrim($line, "\r\n"); + if ($line == $sep) { + break; + } + $changelog[] = $line; + } + $ent->changelog = join("\n", $changelog); + + $res[] = $ent; + + if ($line === false) { + break; + } + } + $fp = null; + return $res; + } + + public function diff($path, $from = null, $to = null) + { + if ($path instanceof MTrackSCMFile) { + if ($from === null) { + $from = $path->rev; + } + $path = $path->name; + } + if ($to !== null) { + return $this->hg('diff', '-r', $from, '-r', $to, + '--git', $path); + } + return $this->hg('diff', '-c', $from, '--git', $path); + } + + public function getWorkingCopy() + { + return new MTrackWCHg($this); + } + + public function getRelatedChanges($revision) + { + $parents = array(); + $kids = array(); + + foreach (preg_split('/\s+/', + stream_get_contents($this->hg('parents', '-r', $revision, + '--template', '{node|short}\n'))) as $p) { + if (strlen($p)) { + $parents[] = $p; + } + } + + foreach (preg_split('/\s+/', + stream_get_contents($this->hg('--config', + 'extensions.children=', + 'children', '-r', $revision, + '--template', '{node|short}\n'))) as $p) { + if (strlen($p)) { + $kids[] = $p; + } + } + return array($parents, $kids); + } + + function hg() + { + $args = func_get_args(); + $a = array("-y", "-R", $this->repopath, "--cwd", $this->repopath); + foreach ($args as $arg) { + $a[] = $arg; + } + + return mtrack_run_tool('hg', 'read', $a); + } +} + +MTrackRepo::registerSCM('hg', 'MTrackSCMHg'); + diff --git a/inc/scm/svn.php b/inc/scm/svn.php new file mode 100644 index 00000000..78536f12 --- /dev/null +++ b/inc/scm/svn.php @@ -0,0 +1,466 @@ +repo = $repo; + $this->name = $name; + $this->rev = $rev; + $this->is_dir = $is_dir; + } + + public function _determineFileChangeEvent($reponame, $filename, $rev) + { + $repo = MTrackRepo::loadByName($reponame); + list($ent) = $repo->history($filename, 1, 'rev', $rev); + return $ent; + } + + public function getChangeEvent() + { + return mtrack_cache( + array('MTrackSCMFileSVN', '_determineFileChangeEvent'), + array($this->repo->getBrowseRootName(), $this->name, $this->rev), + 864000); + } + + function cat() + { + return $this->repo->svn('cat', '-r', $this->rev, + 'file://' . $this->repo->repopath . '/' . $this->name . "@$this->rev"); + } + + function annotate($include_line_content = false) + { + $xml = stream_get_contents($this->repo->svn('annotate', '--xml', + 'file://' . $this->repo->repopath . '/' . $this->name . "@$this->rev")); + $ann = array(); + $xml = @simplexml_load_string($xml); + if (!is_object($xml)) { + return 'DELETED'; + } + if ($include_line_content) { + $cat = $this->cat(); + } + foreach ($xml->target->entry as $ent) { + $A = new MTrackSCMAnnotation; + $A->rev = (int)$ent->commit['revision']; + $A->changeby = (string)$ent->commit->author; + if ($include_line_content) { + $A->line = fgets($cat); + } + $ann[(int)$ent['line-number']] = $A; + } + return $ann; + + } +} + +class MTrackWCSVN extends MTrackSCMWorkingCopy { + public $repo; + + function __construct(MTrackRepo $repo) { + $this->dir = mtrack_make_temp_dir(); + $this->repo = $repo; + + stream_get_contents($this->repo->svn('checkout', + 'file://' . $this->repo->repopath . '/trunk', + $this->dir)); + } + + function getFile($path) + { + return $this->repo->file('trunk/' . $path); + } + + + function addFile($path) + { + stream_get_contents( + $this->repo->svn('add', $this->dir . DIRECTORY_SEPARATOR . $path)); + } + + function delFile($path) + { + stream_get_contents( + $this->repo->svn('rm', $this->dir . DIRECTORY_SEPARATOR . $path)); + } + + function commit(MTrackChangeset $CS) + { + list($proc, $pipes) = mtrack_run_tool('svn', 'proc', + array('ci', '--non-interactive', '--username', $CS->who, + '-m', $CS->reason, $this->dir)); +/* + $svn = MTrackConfig::get('tools', 'svn'); + if (!strlen($svn)) $svn = 'svn'; + $proc = proc_open( + "$svn ci --non-interactive " . + ' --username ' . escapeshellarg($CS->who) . + ' -m ' . escapeshellarg($CS->reason) . + ' ' . $this->dir, + array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ), $pipes, $this->dir); +*/ + $pipes[0] = null; + $output = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + + if (strlen($err)) { + throw new Exception($err); + } + + if (preg_match("/Committed revision (\d+)/", $output, $M)) { + $rev = $M[1]; + stream_get_contents( + $this->repo->svn('propset', 'svn:date', + '--revprop', + '-r', $rev, $CS->when, $this->dir + )); + } + } +} + +class MTrackSCMSVN extends MTrackRepo { + protected $svn = 'svn'; + static $debug = false; + + public function getSCMMetaData() { + return array( + 'name' => 'Subversion', + 'tools' => array('svn', 'svnlook', 'svnadmin'), + ); + } + + function getServerURL() { + $url = parent::getServerURL(); + if ($url) return $url; + $url = MTrackConfig::get('repos', 'serverurl'); + if ($url) { + return "svn+ssh://$url/" . $this->getBrowseRootName() . '/BRANCHNAME'; + } + return null; + } + + + public function reconcileRepoSettings(MTrackSCM $r = null) { + if ($r == null) { + $r = $this; + } + if (!is_dir($r->repopath)) { + $stm = mtrack_run_tool('svnadmin', 'read', array('create', $r->repopath)); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("failed to create repo: $out"); + } + file_put_contents("$r->repopath/hooks/pre-revprop-change", + "#!/bin/sh\nexit 0\n"); + chmod("$r->repopath/hooks/pre-revprop-change", 0755); + $me = mtrack_canon_username(MTrackAuth::whoami()); + $stm = mtrack_run_tool('svn', 'read', array('mkdir', '-m', 'init', + '--username', $me, "file://$r->repopath/trunk")); + $out = stream_get_contents($stm); + if (pclose($stm)) { + throw new Exception("failed to create trunk: $out"); + } + system("chmod -R 02777 $r->repopath/db $r->repopath/locks"); + + $authzname = MTrackConfig::get('core', 'vardir') . '/svn.authz'; + $svnserve = "[general]\nauthz-db = $authzname\n"; + file_put_contents("$r->repopath/conf/svnserve.conf", $svnserve); + } + } + + public function getDefaultRoot() { + return 'trunk/'; + } + + public function getBranches() + { + return null; + } + + public function getTags() + { + return null; + } + + public function readdir($path, $object = null, $ident = null) + { + $res = array(); + + if ($object === null) { + $object = 'rev'; + $ident = 'HEAD'; + } + $rev = $this->resolveRevision(null, $object, $ident); + + $rpath = $this->repopath; + if (strlen($path)) { + $rpath .= "/$path"; + } + + $fp = $this->svn('ls', '--xml', '-r', $rev, + "file://" . $rpath); + + $ls = stream_get_contents($fp); + $doc = simplexml_load_string($ls); + if (!is_object($doc)) { + echo '
', htmlentities($ls, ENT_QUOTES, 'utf-8'), '
'; + } + if (isset($doc->list)) foreach ($doc->list->entry as $le) { + $name = $path; + $name .= '/'; + $name .= $le->name; + if ($name[0] == '/') { + $name = substr($name, 1); + } + /* Use the revision passed in to readdir rather than the revision + * in the entry, as svn can return a revision number that pre-dates + * that of the containing tag, and this causes the subsequent + * lookup of commit data to fail */ + $res[] = new MTrackSCMFileSVN($this, $name, + //$le->commit['revision'], + $rev, + $le['kind'] == 'dir'); + } + return $res; + } + + public function file($path, $object = null, $ident = null) + { + if ($object == null) { + $object = 'rev'; + $ident = 'HEAD'; + } + $rev = $this->resolveRevision(null, $object, $ident); + return new MTrackSCMFileSVN($this, $path, $rev); + } + + public function history($path, $limit = null, $object = null, $ident = null) + { + $res = array(); + $args = array(); + $limit_date = null; + + if ($limit !== null) { + if (!is_int($limit)) { + $limit_date = strtotime($limit); + $limit = null; + $limit_date = date('c', $limit_date); + } + } + + $use_at_rev = false; + if ($object !== null) { + $rev = $this->resolveRevision(null, $object, $ident); + if ($limit_date != null) { + $args[] = '-r'; + $args[] = $rev . ':{' . $limit_date . '}'; + } else if ($rev == 'HEAD') { + $args[] = '-r'; + $args[] = "$rev:1"; + } else { + $use_at_rev = true; + } + } + if ($limit !== null) { + $args[] = '--limit'; + $args[] = $limit; + } else if ($limit_date !== null) { + $args[] = '-r'; + $args[] = '{' . $limit_date . '}:head'; + } + + $rpath = $this->repopath; + if (strlen($path)) { + if ($path[0] != '/') { + $rpath .= '/'; + } + $rpath .= $path; + } + $spath = $rpath; + + if ($use_at_rev) { + $spath .= "@$rev"; + } + + $fp = $this->svn('log', '--xml', '-v', $args, "file://$spath"); + + $xml = stream_get_contents($fp); + $doc = @simplexml_load_string($xml); + if (!is_object($doc)) { + /* try looking at the parent */ + $spath = dirname($spath); + if ($use_at_rev) { + $spath .= "@$rev"; + } + $fp = $this->svn('log', '--xml', '-v', $args, "file://$spath"); + $xml = stream_get_contents($fp); + $doc = @simplexml_load_string($xml); + } + + if (!is_object($doc)) { +// echo '
', htmlentities($xml, ENT_QUOTES, 'utf-8'), '
'; + return null; + } + if (self::$debug) { + if (php_sapi_name() == 'cli') { + echo $xml, "\n"; + } else { + echo htmlentities(var_export($xml, true)) . "
"; + } + } + $origpath = $path; + if ($origpath[0] != '/') { + $origpath = '/' . $origpath; + } + if ($doc->logentry) foreach ($doc->logentry as $le) { + $matched = false; + $ent = new MTrackSCMEvent; + $ent->rev = (int)$le['revision']; + $ent->branches = array(); + $ent->tags = array(); + + $ent->files = array(); + foreach ($le->paths->path as $path) { + if (strncmp($path, $origpath, strlen($origpath))) { + continue; + } + $matched = true; + $f = new MTrackSCMFileEvent; + $f->name = (string)$path; + $f->status = (string)$path['action']; + $ent->files[] = $f; + } + + if ($matched) { + $ent->changeby = (string)$le->author; + $ent->ctime = MTrackDB::unixtime(strtotime($le->date)); + $ent->changelog = (string)$le->msg; + + $res[] = $ent; + } + } + $fp = null; + if (count($res) == 0) { + return null; + } + return $res; + } + + function getCheckoutCommand() { + $url = $this->getServerURL(); + if (strlen($url)) { + return $this->scmtype . ' checkout ' . $this->getServerURL(); + } + return null; + } + + public function diff($path, $from = null, $to = null) + { + $is_file = null; + + if ($path instanceof MTrackSCMFile) { + $is_file = !$path->is_dir; + if ($from === null) { + $from = $path->rev; + } + $path = $path->name; + } elseif ($path instanceof MTrackSCMFileEvent) { + $is_file = true; + } else { + // http://subversion.tigris.org/issues/show_bug.cgi?id=2873 + // Essentially, if there are files added in a changeset, you cannot use + // diff to show the diff of those newly added files if you explicitly + // request the file itself. So we need to assess whether $path represents + // a file and dance around by diffing the parent path. + + $is_file = false; + $info = $this->svn('info', "file://$this->repopath$path", '-r', $from); + $lines = 0; + while (($line = fgets($info)) !== false) { + $lines++; + if (preg_match("/^Node Kind:\s+file/", $line)) { + $is_file = true; + break; + } + } + if ($lines == 0) { + // no data returned; path doesn't exist at that revision + if ($to === null) { + $to = $from; + $from--; + } + } + } + if ($is_file) { + $diffpath = dirname($path); + } else { + $diffpath = $path; + } + + if ($to !== null) { + $diff = $this->svn('diff', '-r', $from, '-r', $to, + "file://$this->repopath$diffpath"); + } else { + $diff = $this->svn('diff', '-c', $from, + "file://$this->repopath$diffpath"); + } + + if ($is_file) { + $dir = $diff; + $diff = tmpfile(); + $wanted = basename($path); + $in_wanted = false; + // search in the diffstream for the file that was originally requested + // and copy that through to the tmpfile we're using for the diff we're + // returning to the caller + while (($line = fgets($dir)) !== false) { + if (preg_match("/^Index: $wanted$/", $line)) { + $in_wanted = true; + fwrite($diff, $line); + continue; + } else if (preg_match("/^Index: /", $line)) { + if ($in_wanted) { + break; + } + } + if ($in_wanted) { + fwrite($diff, $line); + } + } + fseek($diff, 0); + } + return $diff; + } + + public function getWorkingCopy() + { + return new MTrackWCSVN($this); + } + + public function getRelatedChanges($revision) + { + return null; + } + + function svn() + { + $args = func_get_args(); + return mtrack_run_tool('svn', 'read', $args); + } +} + +MTrackRepo::registerSCM('svn', 'MTrackSCMSVN'); diff --git a/inc/search.php b/inc/search.php new file mode 100644 index 00000000..60be5345 --- /dev/null +++ b/inc/search.php @@ -0,0 +1,92 @@ +excerpt; + } +} + +interface IMTrackSearchEngine { + public function setBatchMode(); + public function commit($optimize = false); + public function add($object, $fields, $replace = false); + /** returns an array of MTrackSearchResult objects corresponding + * to matches to the supplied query string */ + public function search($query); +} + +class MTrackSearchDB { + static $index = null; + static $engine = null; + + static function getEngine() { + if (self::$engine === null) { + $name = MTrackConfig::get('core', 'search_engine'); + if (!$name) $name = 'MTrackSearchEngineLucene'; + self::$engine = new $name; + } + return self::$engine; + } + + /* functions that can perform indexing */ + static $funcs = array(); + + static function register_indexer($id, $func) + { + self::$funcs[$id] = $func; + } + + static function index_object($id) + { + $key = $id; + while (strlen($key)) { + if (isset(self::$funcs[$key])) { + break; + } + $new_key = preg_replace('/:[^:]+$/', '', $key); + if ($key == $new_key) { + break; + } + $key = $new_key; + } + + if (isset(self::$funcs[$key])) { + $func = self::$funcs[$key]; + return call_user_func($func, $id); + } + return false; + } + + static function get() { + return self::getEngine()->getIdx(); + } + + static function setBatchMode() { + self::getEngine()->setBatchMode(); + } + + static function commit($optimize = false) { + self::getEngine()->commit($optimize); + } + + static function add($object, $fields, $replace = false) { + self::getEngine()->add($object, $fields, $replace); + } + + static function search($query) { + return self::getEngine()->search($query); + } +} diff --git a/inc/search/lucene.php b/inc/search/lucene.php new file mode 100644 index 00000000..cd40bc5d --- /dev/null +++ b/inc/search/lucene.php @@ -0,0 +1,980 @@ + 1) { + self::replace($word, 'e', ''); + + } else if (self::m(substr($word, 0, -1)) == 1) { + + if (!self::cvc(substr($word, 0, -1))) { + self::replace($word, 'e', ''); + } + } + } + + // Part b + if (self::m($word) > 1 AND + self::doubleConsonant($word) AND substr($word, -1) == 'l') { + $word = substr($word, 0, -1); + } + + return $word; + } + + /** + * Replaces the first string with the second, at the end of the string. If third + * arg is given, then the preceding string must match that m count at least. + * + * @param string $str String to check + * @param string $check Ending to check for + * @param string $repl Replacement string + * @param int $m Optional minimum number of m() to meet + * @return bool Whether the $check string was at the end + * of the $str string. True does not necessarily mean + * that it was replaced. + */ + private static function replace(&$str, $check, $repl, $m = null) + { + $len = 0 - strlen($check); + + if (substr($str, $len) == $check) { + $substr = substr($str, 0, $len); + if (is_null($m) OR self::m($substr) > $m) { + $str = $substr . $repl; + } + + return true; + } + + return false; + } + + /** + * What, you mean it's not obvious from the name? + * + * m() measures the number of consonant sequences in $str. if c is + * a consonant sequence and v a vowel sequence, and <..> indicates arbitrary + * presence, + * + * gives 0 + * vc gives 1 + * vcvc gives 2 + * vcvcvc gives 3 + * + * @param string $str The string to return the m count for + * @return int The m count + */ + private static function m($str) + { + $c = self::$regex_consonant; + $v = self::$regex_vowel; + + $str = preg_replace("#^$c+#", '', $str); + $str = preg_replace("#$v+$#", '', $str); + + preg_match_all("#($v+$c+)#", $str, $matches); + + return count($matches[1]); + } + + + /** + * Returns true/false as to whether the given string contains two + * of the same consonant next to each other at the end of the string. + * + * @param string $str String to check + * @return bool Result + */ + private static function doubleConsonant($str) + { + $c = self::$regex_consonant; + + return preg_match("#$c{2}$#", $str, $matches) + AND $matches[0]{0} == $matches[0]{1}; + } + + + /** + * Checks for ending CVC sequence where second C is not W, X or Y + * + * @param string $str String to check + * @return bool Result + */ + private static function cvc($str) + { + $c = self::$regex_consonant; + $v = self::$regex_vowel; + + return preg_match("#($c$v$c)$#", $str, $matches) + AND strlen($matches[1]) == 3 + AND $matches[1]{2} != 'w' + AND $matches[1]{2} != 'x' + AND $matches[1]{2} != 'y'; + } +} + +class MTrackSearchStemmer extends + Zend_Search_Lucene_Analysis_TokenFilter { + + public function normalize(Zend_Search_Lucene_Analysis_Token $tok) + { + $text = $tok->getTermText(); + $text = PorterStemmer::Stem($text); + $ntok = new Zend_Search_Lucene_Analysis_Token($text, + $tok->getStartOffset(), + $tok->getEndOffset()); + $ntok->setPositionIncrement($tok->getPositionIncrement()); + return $tok; + } +} + +class MTrackSearchDateToken extends Zend_Search_Lucene_Analysis_Token { +} + +class MTrackSearchAnalyzer extends Zend_Search_Lucene_Analysis_Analyzer_Common +{ + private $_position; + private $_bytePosition; + private $_moreTokens = array(); + + function reset() + { + $this->_position = 0; + $this->_bytePosition = 0; + } + + function nextToken() + { + if (count($this->_moreTokens)) { + $tok = array_shift($this->_moreTokens); + return $tok; + } + if ($this->_input == null) { + return null; + } + + do { + /* first check for date fields */ + + $is_date = false; + // 2008-12-22T05:42:42.285445Z + if (preg_match('/\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d+)?Z?)?/u', + $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) { + $is_date = true; + } else if (!preg_match('/[\p{L}\p{N}_]+/u', + $this->_input, $match, PREG_OFFSET_CAPTURE, $this->_bytePosition)) { + return null; + } + if (!function_exists('mb_strtolower')) { + $matchedWord = strtolower($match[0][0]); + } else { + $matchedWord = mb_strtolower($match[0][0], 'UTF-8'); + } + $binStartPos = $match[0][1]; + $startPos = $this->_position + + iconv_strlen(substr($this->_input, $this->_bytePosition, + $binStartPos - $this->_bytePosition), + 'UTF-8'); + $endPos = $startPos + iconv_strlen($matchedWord, 'UTF-8'); + $this->_bytePosition = $binStartPos + strlen($matchedWord); + $this->_position = $endPos; + + if ($is_date) { +// $this->_moreTokens[] = new MTrackSearchDateToken($matchedWord, +// $startPos, $endPos); + + /* Seems very difficult to allow range searching on strings + * of the form "2009-10-10", so we just smush it together */ + $no_sep = str_replace(array('-', ':'), array('', ''), $matchedWord); + list($no_sep) = explode('.', $no_sep); + + /* full date and time */ +// $this->_moreTokens[] = new MTrackSearchDateToken( +// $no_sep, $startPos, $endPos); + + /* date only */ + $date = substr($no_sep, 0, 8); + $this->_moreTokens[] = new MTrackSearchDateToken( + $date, $startPos, $endPos); + } else { + $token = new Zend_Search_Lucene_Analysis_Token( + $matchedWord, $startPos, $endPos); + $token = $this->normalize($token); + if ($token !== null) { + $this->_moreTokens[] = $token; + } + } + if (!$is_date) { + /* split by underscores and add those tokens too */ + foreach (explode('_', $matchedWord) as $ele) { + $token = new Zend_Search_Lucene_Analysis_Token( + $ele, $startPos, $endPos); + $token = $this->normalize($token); + if ($token !== null) { + $this->_moreTokens[] = $token; + } + } + } + } while (count($this->_moreTokens) == 0); + return array_shift($this->_moreTokens); + } + + function normalize(Zend_Search_Lucene_Analysis_Token $tok) + { + if ($tok instanceof MTrackSearchDateToken) { + return $tok; + } + return parent::normalize($tok); + } +} + +class MTrackSearchQueryParser { + public $toks; + public $syntax; + public $query; + + function __construct($q) { + $this->toks = $this->tokenize($q); + $this->alltoks = $this->toks; +// echo '
', htmlentities(var_export($this->toks, true)), '
'; + + $this->query = $this->expression(); + } + + function tokenize($string) + { + $toks = array(); + while (strlen($string)) { + if (preg_match("/^\s+/", $string, $M)) { + $toks[] = array('white', $M[0]); + $string = substr($string, strlen($M[0])); + continue; + } + if (preg_match("/^[+!(){}^~*?:\\\[\]-]/", $string)) { + $toks[] = array($string[0]); + $string = substr($string, 1); + continue; + } + if (!strncmp($string, "&&", 2)) { + $toks[] = array("&&"); + $string = substr($string, 2); + continue; + } + if (preg_match("/^and\W/i", $string, $M)) { + $toks[] = array("&&", $M[0]); + $string = substr($string, 3); + continue; + } + if (preg_match("/^not\W/i", $string, $M)) { + $toks[] = array("!", $M[0]); + $string = substr($string, 3); + continue; + } + if (!strncmp($string, "||", 2)) { + $toks[] = array("||"); + $string = substr($string, 2); + continue; + } + if (preg_match("/^or\W/i", $string, $M)) { + $toks[] = array("||", $M[0]); + $string = substr($string, 2); + continue; + } + if (preg_match('/^"([^"]*)"/', $string, $M)) { + $toks[] = array('literal', $M[1]); + $string = substr($string, strlen($M[0])); + continue; + } + if (preg_match("/^[a-zA-Z0-9_][a-zA-Z0-9_.+-]*/", $string, $M)) { + $toks[] = array('literal', $M[0]); + $string = substr($string, strlen($M[0])); + continue; + } + $string = trim($string); + if (strlen($string)) { + echo "Invalid search string: " . htmlentities($string) . ""; + break; + } + } + return $toks; + } + + function get() + { + if (count($this->toks) == 0) { + return null; + } + $t = array_shift($this->toks); + $args = func_get_args(); + if (count($args)) { + $ok = false; + $expected = array(); + foreach ($args as $expect) { + if ($t[0] == $expect) { + $ok = true; + break; + } + $expected[] = $expect; + } + if (!$ok) { + $name = $t[0]; + $value = isset($t[1]) ? $t[1] : $t[0]; + $ntoks = count($this->alltoks); + $rtoks = count($this->toks); + $hint = ''; + for ($i = 0; $i < $rtoks; $i++) { + $hint .= htmlentities($this->alltoks[$i][1], ENT_QUOTES, 'utf-8'); + } + $hint .= "$value"; + foreach ($this->toks as $tok) { + $hint .= htmlentities($tok[1]); + } + throw new Exception( + "Unexpected token '$value' of type $name expected " . + join(', ', $expected) . "
$hint"); + } + } + return $t; + } + + function peek() + { + if (!count($this->toks)) { + return null; + } + $t = $this->toks[0]; + $args = func_get_args(); + if (count($args)) { + $ok = false; + foreach ($args as $expect) { + if ($t[0] == $expect) { + $ok = true; + break; + } + } + if (!$ok) { + return false; + } + } + return $t; + } + + function try_rule($name) { + $save = $this->toks; + try { + return $this->$name(); + } catch (Exception $e) { + $this->toks = $save; + return false; + } + } + + function _make_term($t, $field = null) + { + if (function_exists('mb_strtolower')) { + $t[1] = mb_strtolower($t[1], 'UTF-8'); + } else { + $t[1] = strtolower($t[1]); + } + if ($t[0] == 'literal') { + $bits = preg_split("/\s+/u", $t[1]); + + /* only treat it as a phrase if it is a phrase */ + if (count($bits) > 1) { + $q = new Zend_Search_Lucene_Search_Query_Phrase; + + foreach ($bits as $w) { + $t = new Zend_Search_Lucene_Index_Term($w, $field); + $q->addTerm($t); + } + return $q; + } + } + + /* underscores and periods! + * if we're searching for text delimited by underscores, we + * rewrite that as a phrase search also */ + $bits = preg_split("/[._]/", $t[1]); + if (count($bits) > 1) { + $q = new Zend_Search_Lucene_Search_Query_Phrase; + + foreach ($bits as $w) { + $t = new Zend_Search_Lucene_Index_Term($w, $field); + $q->addTerm($t); + } + return $q; + } + + return new Zend_Search_Lucene_Index_Term((string)$t[1], $field); + } + + function term() + { + if ($this->peek('literal')) { + $t = $this->get(); + if ($this->peek(':')) { + /* specific field */ + $field = $t[1]; + $this->get(); + + /* does it have a range? */ + if ($this->peek('[')) { + $this->get(); + + $this->skipwhite(); + + $from = $this->get('literal'); + $from = $this->_make_term($from, $field); + + $this->skipwhite(); + $t = $this->get('literal'); + if (strcasecmp($t[1], 'to')) { + throw new Exception("Expected 'to'"); + } + $this->skipwhite(); + + $to = $this->get('literal'); + $to = $this->_make_term($to, $field); + + $q = new Zend_Search_Lucene_Search_Query_Range( + $from, $to, true); + $this->skipwhite(); + + $this->get(']'); + + return $q; + } + + $t = $this->get('literal'); + + return $this->_make_term($t, $field); + } + } else { + $t = $this->get('literal'); + } + + if ($t) { + return $this->_make_term($t); + } + return null; + } + + function skipwhite() + { + while ($this->peek('white')) { + $this->get(); + } + } + + function expression() + { + $terms = array(); + + while (count($this->toks)) { + $modifier = null; + + $this->skipwhite(); + + if ($this->peek('+')) { + $this->get(); + $modifier = true; + } + if ($this->peek('-')) { + $this->get(); + $modifier = false; + } + if ($modifier === null) { + $modifier = true; + } + + $t = $this->term(); + if ($t) { + $terms[] = array($t, $modifier); + } else { + break; + } + } + + if (count($terms) == 0) { + return null; + } + + if (count($terms) == 1) { + if ($terms[0][0] instanceof Zend_Search_Lucene_Search_Query) { + if ($terms[0][1] === null) { + return $terms[0][0]; + } + } + } + + $q = new Zend_Search_Lucene_Search_Query_Boolean(); + foreach ($terms as $term) { + list($t, $mod) = $term; + + if ($t instanceof Zend_Search_Lucene_Search_Query) { + $q->addSubquery($t, $mod); + } else { + $sq = new Zend_Search_Lucene_Search_Query_MultiTerm; + $sq->addTerm($t); + $q->addSubquery($sq, $mod); + } + } + + return $q; + } +} + +/* the highlighter insists on using html document things, + * so we force in our own dummy so that we can present the + * same text we used initially */ +class MTrackSearchLuceneDummyDocument { + public $text; + function __construct($text) { + $this->text = $text; + } + function getFieldUtf8Value($name) { + return $this->text; + } +} + +class MTrackHLText + implements Zend_Search_Lucene_Search_Highlighter_Interface { + public $doc; + public $context = array(); + public $text; + public $matched = array(); + + function setDocument(Zend_Search_Lucene_Document_Html $doc) + { + /* sure, I'll get right on that... */ + } + + function getDocument() { + /* we just return our dummy doc instead */ + return $this->doc; + } + + function highlight($words) { + if (!is_array($words)) { + $words = array($words); + } + foreach ($words as $word) { + foreach ($this->text as $line) { + $x = strpos($line, $word); + if ($x !== false) { + if (isset($this->matched[$word])) { + $this->matched[$word]++; + } else { + $this->matched[$word] = 1; + } + if (isset($this->context[$line])) { + $this->context[$line]++; + } else { + $this->context[$line] = 1; + } + } + } + } + } + + function __construct($text, $query) + { + $this->doc = new MTrackSearchLuceneDummyDocument($text); + $text = wordwrap($text); + $this->text = preg_split("/\r?\n/", $text); + $query->htmlFragmenthighlightMatches($text, 'utf-8', $this); + } +} + +class MTrackSearchResultLucene extends MTrackSearchResult { + var $_query; + + function getExcerpt($text) { + $hl = new MTrackHLText($text, $this->_query); + $lines = array(); + foreach ($hl->context as $line => $count) { + $line = trim($line); + if (!strlen($line)) continue; + $line = htmlentities($line, ENT_QUOTES, 'utf-8'); + foreach ($hl->matched as $word => $wcount) { + $line = str_replace($word, "$word", $line); + } + $lines[] = $line; + if (count($lines) > 6) { + break; + } + } + $ex = join(" … ", $lines); + if (strlen($ex)) { + return "
$ex
"; + } + return ''; + } +} + +class MTrackSearchEngineLucene implements IMTrackSearchEngine +{ + var $idx = null; + + function getIdx() { + if ($this->idx) return $this->idx; + $ana = new MTrackSearchAnalyzer; + $ana->addFilter(new MTrackSearchStemmer); + Zend_Search_Lucene_Analysis_Analyzer::setDefault($ana); + + $p = MTrackConfig::get('core', 'searchdb'); + if (!is_dir($p)) { + $idx = Zend_Search_Lucene::create($p); + chmod($p, 0777); + } else { + $idx = Zend_Search_Lucene::open($p); + } + $this->index = $idx; + return $idx; + } + + public function setBatchMode() + { + $idx = $this->getIdx(); + $idx->setMaxBufferedDocs(64); + $idx->setMergeFactor(15); + } + + public function commit($optimize = false) + { + $idx = $this->getIdx(); + if ($optimize) { + $idx->optimize(); + } + $idx->commit(); + $this->idx = null; + } + + public function add($object, $fields, $replace = false) + { + $idx = $this->getIdx(); + + if ($replace) { + $term = new Zend_Search_Lucene_Index_Term($object, 'object'); + foreach ($idx->termDocs($term) as $id) { + $idx->delete($id); + } + } + + $doc = new Zend_Search_Lucene_Document(); + + $doc->addField(Zend_Search_Lucene_Field::Text('object', $object, 'utf-8')); + foreach ($fields as $key => $value) { + if (!strlen($value)) continue; + if (!strncmp($key, 'stored:', 7)) { + $key = substr($key, 7); + $F = Zend_Search_Lucene_Field::Text($key, $value, 'utf-8'); + } else { + $F = Zend_Search_Lucene_Field::UnStored($key, $value, 'utf-8'); + } + $doc->addField($F); + } + + $idx->addDocument($doc); + } + + public function search($query) { + Zend_Search_Lucene::setTermsPerQueryLimit(150); + Zend_Search_Lucene::setResultSetLimit(250); + + $p = new MTrackSearchQueryParser($query); + $q = $p->query; + $idx = $this->getIdx(); + $hits = $idx->find($q); + $result = array(); + foreach ($hits as $hit) { + $r = new MTrackSearchResultLucene; + $r->_query = $q; + $r->objectid = $hit->object; + $r->score = $hit->score; + $result[] = $r; + } + return $result; + } + + +} + + diff --git a/inc/search/solr.php b/inc/search/solr.php new file mode 100644 index 00000000..6e798e03 --- /dev/null +++ b/inc/search/solr.php @@ -0,0 +1,112 @@ +url = MTrackConfig::get('solr', 'url'); + } + + public function setBatchMode() { + } + + function post($xml) { + $params = array( + 'http' => array( + 'method' => 'POST', + 'content' => $xml, + 'header' => 'Content-Type: text/xml', + ), + ); + $ctx = stream_context_create($params); + for ($i = 0; $i < 10; $i++) { + $fp = fopen("$this->url/update", 'rb', false, $ctx); + if ($fp) { + fclose($fp); + return; + } + sleep(1); + } + throw new Exception("unable to update index; is Solr running?\n$xml\n"); + } + + public function commit($optimize = false) { + $this->post(''); + } + + public function add($object, $fields, $replace = false) { + $xml = "$object"; + foreach ($fields as $key => $value) { + if (!strlen($value)) continue; + if (!strncmp($key, 'stored:', 7)) { + $key = substr($key, 7); + } + + switch ($key) { + case 'date': + case 'created': + $t = strtotime($value); + $value = date('Y-m-d\\TH:i:s', $t) . 'Z'; + break; + } + // avoid: HTTP/1.1 400 Illegal_character_CTRLCHAR_code_12 + $value = str_replace("\x0c", " ", $value); + + $xml .= "" . + htmlspecialchars($value, ENT_QUOTES, 'utf-8') . + ""; + } + $xml .= ""; + + $this->post($xml); + } + + /** returns an array of MTrackSearchResult objects corresponding + * to matches to the supplied query string */ + public function search($query) { + $q = http_build_query(array( + 'q' => $query, + 'version' => '2.2', + 'hl' => 'on', + 'hl.fl' => '', + 'hl.usePhraseHighlighter' => 'on', + 'hl.simple.pre' => "", + 'hl.simple.post' => "", + 'fl' => 'id,score', + 'wt' => 'json', + 'rows' => 250, + )); + $json = file_get_contents("$this->url/select?$q"); + $doc = json_decode($json); + //echo htmlentities($json); + //var_dump($doc); + $result = array(); + + /* look for excerpt text */ + $hl = array(); + foreach ($doc->highlighting as $name => $arr) { + $hl[$name] = array(); + foreach ($arr as $fname => $v) { + foreach ($v as $a) { + $hl[$name][] = $a; + } + } + } + + foreach ($doc->response->docs as $doc) { + $r = new MTrackSearchResult; + $r->objectid = $doc->id; + $r->score = $doc->score; + $r->excerpt = null; + if (isset($hl[$r->objectid])) { + $r->excerpt = "
" . + join("\n", $hl[$r->objectid]) . + "
"; + } + $result[] = $r; + } + + return $result; + } +} diff --git a/inc/snippet.php b/inc/snippet.php new file mode 100644 index 00000000..5662c949 --- /dev/null +++ b/inc/snippet.php @@ -0,0 +1,73 @@ +fetchAll() as $row) { + return new self($row[0]); + } + return null; + } + + function __construct($id = null) + { + if ($id !== null) { + $this->snid = $id; + + list($row) = MTrackDB::q('select * from snippets where snid = ?', $id) + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($row as $k => $v) { + $this->$k = $v; + } + } + } + + function save(MTrackChangeset $CS) + { + $this->updated = $CS->cid; + + if ($this->snid === null) { + $this->created = $CS->cid; + + $this->snid = sha1( + $CS->who . ':' . + $CS->when . ':' . + $this->description . ':' . + $this->lang . ':' . + $this->snippet); + + MTrackDB::q('insert into snippets + (snid, created, updated, description, lang, snippet) + values (?, ?, ?, ?, ?, ?)', + $this->snid, + $this->created, + $this->updated, + $this->description, + $this->lang, + $this->snippet + ); + } else { + MTrackDB::q('update snippets set updated = ?, + description = ?, lang = ?, snippet = ? + WHERE snid = ?', + $this->updated, + $this->description, + $this->lang, + $this->snippet, + $this->snid + ); + } + } +} + +MTrackACL::registerAncestry('snippet', 'Snippets'); + diff --git a/inc/syntax.php b/inc/syntax.php new file mode 100644 index 00000000..f0429884 --- /dev/null +++ b/inc/syntax.php @@ -0,0 +1,131 @@ + 'No syntax highlighting', + 'wezterm' => "Wez's Terminal", + 'zenburn' => "Zenburn", + 'vibrant-ink' => "Vibrant Ink", + ); + static $lang_by_ext = array( + 'c' => 'cpp', + 'cc' => 'cpp', + 'cpp' => 'cpp', + 'h' => 'cpp', + 'hpp' => 'cpp', + 'icl' => 'cpp', + 'ipp' => 'cpp', + 'css' => 'css', + 'php' => 'php', + 'php3' => 'php', + 'php4' => 'php', + 'php5' => 'php', + 'phtml' => 'php', + 'pl' => 'perl', + 'pm' => 'perl', + 't' => 'perl', + 'bash' => 'shell', + 'sh' => 'shell', + 'js' => 'javascript', + 'json' => 'javascript', + 'vb' => 'vb', + 'xml' => 'xml', + 'xsl' => 'xml', + 'xslt' => 'xml', + 'xsd' => 'xml', + 'html' => 'xml', + 'diff' => 'diff', + 'patch' => 'diff', + 'wiki' => 'wiki', + ); + static $langs = array( + '' => 'No particular file type', + 'cpp' => 'C/C++', + 'css' => 'CSS (Cascading Style Sheet)', + 'php' => 'PHP', + 'perl' => 'Perl', + 'shell' => 'Shell script', + 'javascript' => 'Javascript', + 'vb' => 'Visual Basic', + 'xml' => 'HTML, XML, XSL', + 'wiki' => 'Wiki Markup', + 'diff' => 'Diff/Patch', + ); + + static function inferFileTypeFromContents($data) { + if (preg_match("/vim:.*ft=(\S+)/", $data, $M)) { + return $M[1]; + } + if (preg_match("/^#!.*env\s+(\S+)/", $data, $M)) { + return $M[1]; + } + if (preg_match("/^#!\s*(\S+)/", $data, $M)) { + return basename($M[1]); + } + return null; + } + + static function highlightSource($data, $type = null, $filename = null, $line_numbers = false) { + if ($type === null) { + $type = self::inferFileTypeFromContents($data); + if ($type === null && $filename !== null) { + if (preg_match("/\.([^.]+)$/", $filename, $M)) { + $ext = strtolower($M[1]); + if (isset(self::$lang_by_ext[$ext])) { + $type = self::$lang_by_ext[$ext]; + } + } + } + } + if ($type == 'diff') { + return mtrack_diff($data); + } + if (strlen($type) && isset(self::$langs[$type])) { + require_once dirname(__FILE__) . '/hyperlight/hyperlight.php'; + $hl = new Hyperlight($type); + $hdata = $hl->render($data); + } else { + $hdata = htmlentities($data); + } + if (!$line_numbers) { + return "$hdata"; + } + $lines = preg_split("/\r?\n/", $data); + $html = << + + line + code + +HTML; + $nlines = count($lines); + for ($i = 1; $i <= $nlines; $i++) { + $html .= "$i"; + if ($i == 1) { + $html .= "$hdata"; + } + $html .= "\n"; + } + return $html . "\n"; + } + + static function getSchemeSelect($selected = 'wezterm') { + $html = << +HTML; + foreach (self::$schemes as $k => $v) { + $sel = $selected == $k ? " selected" : ''; + $html .= "\n"; + } + return $html . ""; + } + + static function getLangSelect($name, $value) { + return mtrack_select_box($name, self::$langs, $value); + } + +} + diff --git a/inc/timeline.php b/inc/timeline.php new file mode 100644 index 00000000..7079a832 --- /dev/null +++ b/inc/timeline.php @@ -0,0 +1,240 @@ +fetchAll() as $r) { + $proj_by_id[$r[0]] = MTrackProject::loadById($r[0]); + } + $events = array(); + + foreach (MTrackDB::q('select repoid from repos')->fetchAll() as $row) { + list($repoid) = $row; + $repo = MTrackRepo::loadById($repoid); + $reponame = $repo->getBrowseRootName(); + if ($reponame == 'default/wiki') continue; + $checker = new MTrackCommitChecker($repo); + + $hist = $repo->history(null, $db_date_limit); + if (is_array($hist)) foreach ($hist as $e) { + if (is_array($filter_users)) { + $wanted_user = false; + foreach ($filter_users as $fuser) { + if (mtrack_canon_username($e->changeby) === $fuser) { + $wanted_user = true; + break; + } + } + if (!$wanted_user) { + continue; + } + } + /* we want to include changesets that do not reference tickets */ + $pid = $repo->projectFromPath($e->files); + if ($pid > 1) { + $proj = $proj_by_id[$pid]; + $e->changelog = $proj->adjust_links($e->changelog, true); + } + $actions = $checker->parseCommitMessage($e->changelog); + $tickets = array(); + foreach ($actions as $act) { + $tkt = $act[1]; + $tickets[$tkt] = $tkt; + $repo_changes_by_ticket[$tkt][$reponame][$e->rev] = $e->rev; + } + if (count($tickets) == 0) { + $events[] = array( + 'changedate' => $e->ctime, + 'who' => $e->changeby, + 'object' => "changeset:$reponame:$e->rev", + 'reason' => $e->changelog, + ); + } + } + } + foreach (MTrackDB::q("select + changedate, who, object, reason from changes + where changedate > ? + order by changedate desc + ", $db_date_limit)->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (is_array($filter_users)) { + $wanted_user = false; + foreach ($filter_users as $fuser) { + if (mtrack_canon_username($row['who']) === $fuser) { + $wanted_user = true; + break; + } + } + if (!$wanted_user) { + continue; + } + } + $events[] = $row; + } + + usort($events, 'mtrack_timeline_order_events_newest_first'); + return $events; +} + +function _mtrack_timeline_is_repo_visible($reponame) +{ + static $cache = array(); + $me = MTrackAuth::whoami(); + if (isset($cache[$me][$reponame])) { + return $cache[$me][$reponame]; + } + + if (ctype_digit($reponame)) { + $oid = "repo:$reponame"; + } else { + $repo = MTrackRepo::loadByName($reponame); + if ($repo) { + $oid = "repo:$repo->repoid"; + } else { + $oid = null; + } + } + if ($oid) { + $ok = MTrackACL::hasAnyRights($oid, array( + 'read', 'checkout')); + } else { + $ok = false; + } + $cache[$me][$reponame] = $ok; + return $ok; +} + +function mtrack_render_timeline($user = null) +{ + global $ABSWEB; + + $limit = 50; + $events = mtrack_cache('mtrack_get_timeline', + array('-2 weeks', $user, $limit), 300, array('Timeline', $user)); + + echo "
"; + $last_date = null; + foreach ($events as $row) { + if (--$limit == 0) { + break; + } + + $d = date_create($row['changedate'], new DateTimeZone('UTC')); + date_timezone_set($d, new DateTimeZone(date_default_timezone_get())); + $time = $d->format('H:i'); + $day = $d->format('D, M d Y'); + + if ($last_date != $day) { + echo "

$day

\n"; + $last_date = $day; + } + + // figure out an event type based on the object and the reason + if (strpos($row['object'], ':') !== false) { + list($object, $id) = explode(':', $row['object'], 3); + } else { + $id = 0; + $object = $row['object']; + } + $eventclass = ''; + $item = $row['object']; + switch ($object) { + case 'ticket': + if (!strncmp($row['reason'], 'created ', 8)) { + $eventclass = ' newticket'; + } elseif (!strncmp($row['reason'], 'closed ', 7)) { + $eventclass = ' closedticket'; + } else { + $eventclass = ' editticket'; + } + $item = "Ticket " . mtrack_ticket($id); + break; + case 'wiki': + $eventclass = ' editwiki'; + $item = "Wiki " . mtrack_wiki($id); + break; + case 'milestone': + $eventclass = ' editmilestone'; + $item = "Milestone $id"; + break; + case 'changeset': + $eventclass = ' newchangeset'; + preg_match("/^changeset:(.*):([^:]+)$/", $row['object'], $M); + $repo = $M[1]; + if (!_mtrack_timeline_is_repo_visible($repo)) { + continue 2; + } + $id = $M[2]; + $item = "$repo change " . mtrack_changeset($id, $repo); + break; + case 'snippet': + $item = "View Snippet"; + break; + case 'repo': + static $repos = null; + if ($repos === null) { + $repos = array(); + foreach (MTrackDB::q( + 'select repoid, shortname, parent from repos')->fetchAll() + as $r) { + $repos[$r[0]] = $r; + } + } + if (!_mtrack_timeline_is_repo_visible($id)) { + continue 2; + } + if (isset($repos[$id])) { + $name = MTrackRepo::makeDisplayName($repos[$id]); + $item = "$name"; + } else { + $item = "<item has been deleted>"; + } + break; + } + + $reason = MTrackWiki::format_to_oneliner($row['reason']); + + echo "
", + mtrack_username($row['who'], array( + 'no_name' => true, + 'size' => 48, + 'class' => 'timelineface' + )), + "
", + "
", + "$reason
\n", + "$time $item by ", + mtrack_username($row['who'], array('no_image' => true)), + "
\n"; + echo "
\n"; + } + echo "
\n"; +} + diff --git a/inc/watch.php b/inc/watch.php new file mode 100644 index 00000000..b93852e2 --- /dev/null +++ b/inc/watch.php @@ -0,0 +1,439 @@ + 'Email', +// 'timline' => 'Timeline' + ); + + static function registerEventTypes($objecttype, $events) { + self::$possible_event_types[$objecttype] = $events; + } + + static function getWatchUI($object, $id) { + ob_start(); + self::renderWatchUI($object, $id); + $res = ob_get_contents(); + ob_end_clean(); + return $res; + } + + static function renderWatchUI($object, $id) { + $me = mtrack_canon_username(MTrackAuth::whoami()); + if ($me == 'anonymous' || MTrackAuth::getUserClass() == 'anonymous') { + return; + } + + global $ABSWEB; + $url = $ABSWEB . 'admin/watch.php?' . + http_build_query(array('o' => $object, 'i' => $id)); + $evts = json_encode(self::$possible_event_types[$object]); + $media = json_encode(self::$media); + $val = new stdclass; + foreach (MTrackDB::q('select medium, event from watches where otype = ? and oid = ? and userid = ? and active = 1', $object, $id, $me)->fetchAll() as $row) + { + $val->{$row['medium']}->{$row['event']} = true; + } + $val = json_encode($val); + echo <<Watch + +HTML; + } + + /* Returns an array, keyed by watching entity, of objects that changed + * since the specified date. + * $watcher = null means all watchers, otherwise specifies the only watcher of interest. + * $medium specifies timeline or email (or some other medium) + */ + static function getWatchedItemsAndWatchers($since, $medium, $watcher = null) { + if ($watcher) { + $q = MTrackDB::q('select otype, oid, userid, event from watches where active = 1 and medium = ? and userid = ?', $medium, $watcher); + } else { + $q = MTrackDB::q('select otype, oid, userid, event from watches where active = 1 and medium = ?', $medium); + } + $watches = $q->fetchAll(PDO::FETCH_ASSOC); + + $last = strtotime($since); + $LATEST = $last; + + $db = MTrackDB::get(); + $changes = MTrackDB::q( + "select * from changes where changedate > ? order by changedate asc", + MTrackDB::unixtime($last))->fetchAll(PDO::FETCH_OBJ); + $cids = array(); + $cs_by_cid = array(); + $by_object = array(); + foreach ($changes as $CS) { + $cids[] = $CS->cid; + $cs_by_cid[$CS->cid] = $CS; + $t = strtotime($CS->changedate); + if ($t > $LATEST) { + $LATEST = $t; + } + + list($object, $id) = explode(':', $CS->object, 3); + $by_object[$object][$id][] = $CS->cid; + } + + $repo_by_id = array(); + $changesets_by_repo_and_rev = array(); + $related_projects = array(); + + foreach (MTrackDB::q('select repoid from repos') + ->fetchAll(PDO::FETCH_COLUMN, 0) as $repoid) { + $repo = MTrackRepo::loadById($repoid); + $repo_by_id[$repoid] = $repo; + + foreach ($repo->history(null, MTrackDB::unixtime($last)) as $e) { + /* SCM doesn't always respect our date range */ + $t = strtotime($e->ctime); + if ($t <= $last) { + continue; + } + if ($t > $LATEST) { + $LATEST = $t; + } + + $key = $repo->getBrowseRootName() . ',' . $e->rev; + $e->repo = $repo; + $changesets_by_repo_and_rev[$key] = $e; + + $e->_related = array(); + + /* relationships to projects based on path */ + $projid = $repo->projectFromPath($e->files); + if ($projid !== null) { + $e->_related[] = array('project', $projid); + $related_projects[$projid] = $projid; + } + } + } + + /* Ensure that changesets are sorted chronologically */ + uasort($changesets_by_repo_and_rev, array('MTrackWatch', '_compare_cs')); + + /* Look at the changed tickets: match the reason back to one of the + * above changesets */ + if (isset($by_object['ticket'])) { + foreach ($by_object['ticket'] as $tid => $cslist) { + foreach ($cslist as $cid) { + $CS = $cs_by_cid[$cid]; + if (!preg_match_all( + "/\(In \[changeset:(([^,]+),([a-zA-Z0-9]+))\]\)/sm", + $CS->reason, $CSM)) { + continue; + } + // $CSM[2] => repo + // $CSM[3] => changeset + foreach ($CSM[2] as $csm => $csm_repo) { + $csm_rev = $CSM[3][$csm]; + + /* Look for the repo changeset */ + $key = "$csm_repo,$csm_rev"; + if (isset($changesets_by_repo_and_rev[$key])) { + $e = $changesets_by_repo_and_rev[$key]; + $e->CS = $CS; + $CS->ent = $e; + } + } + } + } + } + + $tkt_list = array(); + $proj_by_tid = array(); + $emails_by_tid = array(); + $emails_by_pid = array(); + $owners_by_csid = array(); + $milestones_by_tid = array(); + $milestones_by_cid = array(); + + /* determine linked projects and group emails */ + if (count($related_projects)) { + $projlist = join(',', $related_projects); + foreach (MTrackDB::q( + "select projid, notifyemail from projects where + notifyemail is not null and projid in ($projlist)") + ->fetchAll(PDO::FETCH_NUM) as $row) { + $emails_by_pid[$row[0]] = $row[1]; + } + } + + if (isset($by_object['ticket'])) { + $tkt_owner_ids = array(); + $tkt_cid_list = array(); + $tkt_milestone_fields = array(); + + foreach ($by_object['ticket'] as $tid => $cidlist) { + $tkt_list[] = $db->quote($tid); + $tkt_owner_ids[] = $db->quote("ticket:$tid:owner"); + foreach ($cidlist as $cid) { + $tkt_cid_list[$cid] = $cid; + } + /* also want to include folks that were Cc'd */ + $tkt_owner_ids[] = $db->quote("ticket:$tid:cc"); + /* milestones */ + $tkt_milestone_fields[] = $db->quote("ticket:$tid:@milestones"); + } + $tkt_list = join(',', $tkt_list); + + foreach (MTrackDB::q( + "select t.tid, p.projid, notifyemail from tickets t left join ticket_components tc on t.tid = tc.tid left join components_by_project cbp on cbp.compid = tc.compid left join projects p on cbp.projid = p.projid where p.projid is not null and t.tid in ($tkt_list)")->fetchAll(PDO::FETCH_NUM) as $row) { + $proj_by_tid[$row[0]][$row[1]] = $row[1]; + if (isset($row[2]) && strlen($row[2])) { + $emails_by_tid[$row[0]] = $row[2]; + $emails_by_pid[$row[1]] = $row[2]; + } + } + + /* determine all changed owners in the affected period */ + $tkt_owner_ids = join(',', $tkt_owner_ids); + $tkt_cid_list = join(',', $tkt_cid_list); + foreach (MTrackDB::q( + "select cid, oldvalue, value from change_audit where cid in ($tkt_cid_list) and fieldname in ($tkt_owner_ids)")->fetchAll(PDO::FETCH_NUM) as $row) { + $cid = array_shift($row); + foreach ($row as $owner) { + if (!strlen($owner)) continue; + $owners_by_csid[$cid][$owner] = mtrack_canon_username($owner); + } + } + + /* determine all changed milestones in the affected period */ + $tkt_milestone_fields = join(',', $tkt_milestone_fields); + foreach (MTrackDB::q( + "select cid, oldvalue, value from change_audit where cid in ($tkt_cid_list) and fieldname in ($tkt_milestone_fields)")->fetchAll(PDO::FETCH_NUM) as $row) { + $cid = array_shift($row); + foreach ($row as $ms) { + $ms = split(',', $ms); + foreach ($ms as $mid) { + $mid = (int)$mid; + $milestones_by_cid[$cid][$mid] = $mid; + } + } + } + + foreach (MTrackDB::q( + "select tid, mid from ticket_milestones where tid in ($tkt_list)") + ->fetchAll(PDO::FETCH_NUM) as $row) { + $milestones_by_tid[$row[0]][$row[1]] = $row[1]; + } + } + + /* walk through list of objects and add related objects */ + if (isset($by_object['ticket'])) { + foreach ($by_object['ticket'] as $tid => $cslist) { + foreach ($cslist as $cid) { + $CS = $cs_by_cid[$cid]; + if (!isset($CS->_related)) { + $CS->_related = array(); + } + + if (isset($CS->ent)) { + $CS->_related[] = array('repo', $CS->ent->repo->repoid); + } + if (isset($proj_by_tid[$tid])) { + foreach ($proj_by_tid[$tid] as $pid) { + $CS->_related[] = array('project', $pid); + } + } + if (isset($milestones_by_tid[$tid])) { + foreach ($milestones_by_tid[$tid] as $mid) { + $CS->_related[] = array('milestone', $mid); + } + } + if (isset($milestones_by_cid[$cid])) { + foreach ($milestones_by_cid[$cid] as $mid) { + $CS->_related[] = array('milestone', $mid); + } + } + } + } + } + foreach ($changesets_by_repo_and_rev as $ent) { + $ent->_related[] = array('repo', $ent->repo->repoid); + } + + /* having determined all changed items, make a pass through to determine + * how to associate those with watchers. + * Watchers are one of: + * - an user with a matching watches entry + * - the group email address associated with a project associated with the + * changed object + * - the owner of a ticket + */ + + /* generate synthetic watcher entries for project group emails */ + foreach ($emails_by_pid as $pid => $email) { + $watches[] = array( + 'otype' => 'project', + 'oid' => $pid, + 'userid' => $email, + 'event' => '*', + ); + } + + foreach ($by_object as $otype => $objects) { + foreach ($objects as $oid => $cidlist) { + foreach ($cidlist as $cid) { + $CS = $cs_by_cid[$cid]; + if (isset($owners_by_csid[$cid])) { + /* add synthetic watcher for a past or current owner */ + foreach ($owners_by_csid[$cid] as $owner) { + $watches[] = array( + 'otype' => $otype, + 'oid' => $oid, + 'userid' => $owner, + 'event' => '*' + ); + } + } + self::_compute_watch($watches, $otype, $oid, $CS); + /* eliminate from the set if there are no watchers */ + if (!isset($CS->_watcher)) { + unset($cs_by_cid[$cid]); + } + } + } + } + foreach ($changesets_by_repo_and_rev as $key => $ent) { + self::_compute_watch($watches, 'changeset', $key, $ent); + /* eliminate from the set if there are no watchers */ + if (!isset($ent->_watcher)) { + unset($changesets_by_repo_and_rev[$key]); + } + } + + /* now collect the data by watcher */ + $by_watcher = array(); + foreach ($cs_by_cid as $CS) { + foreach ($CS->_watcher as $user) { + $by_watcher[$user][$CS->cid] = $CS; + } + } + foreach ($changesets_by_repo_and_rev as $key => $ent) { + foreach ($ent->_watcher as $user) { + /* don't add this if we have an associated CS */ + if (isset($ent->CS) && $by_watcher[$user][$ent->CS->cid]) { + continue; + } + $by_watcher[$user][$key] = $ent; + } + } + /* one last pass to group the data by object */ + foreach ($by_watcher as $user => $items) { + foreach ($items as $key => $obj) { + if ($obj instanceof MTrackSCMEvent) { + /* group by repo */ + $nkey = "repo:" . $obj->repo->repoid; + } else { + $nkey = $obj->object; + } + unset($by_watcher[$user][$key]); + $by_watcher[$user][$nkey][] = $obj; + } + } + + return $by_watcher; + } + + static function _compute_watch($watches, $otype, $oid, $obj, $event = null) { + foreach ($watches as $row) { + if ($row['otype'] != $otype) continue; + if ($row['oid'] != '*' && $row['oid'] != $oid) continue; + if ($event === null || $row['event'] == '*' || $row['event'] == $event) { + if (!isset($obj->_watcher)) { + $obj->_watcher = array(); + } + $obj->_watcher[$row['userid']] = $row['userid']; + } + } + if ($event === null && isset($obj->_related)) { + foreach ($obj->_related as $rel) { + self::_compute_watch($watches, $rel[0], $rel[1], $obj, $otype); + } + } + } + + static function _get_project($pid) { + static $projects = array(); + if (isset($projects[$pid])) { + return $projects[$pid]; + } + $projects[$pid] = MTrackProject::loadById($pid); + return $projects[$pid]; + } + + /* comparison function for MTrackSCMEvent objects that sorts in ascending + * chronological order */ + static function _compare_cs($A, $B) { + return strcmp($A->ctime, $B->ctime); + } +} + diff --git a/inc/web.php b/inc/web.php new file mode 100644 index 00000000..7b5d52f0 --- /dev/null +++ b/inc/web.php @@ -0,0 +1,1131 @@ + $value) { + if (isset($data[$i])) { + $return_vars[$name] = $data[$i]; + $i++; + } else { + $return_vars[$name] = $value; + } + } + return $return_vars; +} + +/* Pathinfo retrieval minus starting slash */ +function mtrack_get_pathinfo($no_strip = false) { + $pi = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : NULL; + if ($pi !== NULL && strlen($pi) && $no_strip == false) { + $pi = substr($pi, 1); + } + return $pi; +} + +function mtrack_calc_root() +{ + /* ABSWEB: the absolute URL to the base of the web app */ + global $ABSWEB; + + /* if they have one, use the weburl config value for this */ + $ABSWEB = MTrackConfig::get('core', 'weburl'); + if (strlen($ABSWEB)) { + return; + } + + /* otherwise, determine the root of the app. + * This is complicated because the DOCUMENT_ROOT may refer to an area that + * is completely unrelated to the actual root of the web application, for + * instance, in the case that the user has a public_html dir where they + * are running mtrack */ + + /* determine the root of the app */ + $sdir = dirname($_SERVER['SCRIPT_FILENAME']); + $idir = dirname(dirname(__FILE__)) . '/web'; + $diff = substr($sdir, strlen($idir)+1); + $rel = preg_replace('@[^/]+@', '..', $diff); + if (strlen($rel)) { + $rel .= '/'; + } + /* $rel is now the relative path to the root of the web app, from the current + * page */ + + if (isset($_SERVER['HTTP_HOST'])) { + $ABSWEB = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? + 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; + } else { + $ABSWEB = 'http://localhost'; + } + + $bits = explode('/', $rel); + $base = $_SERVER['SCRIPT_NAME']; + foreach ($bits as $b) { + $base = dirname($base); + } + if ($base == '/') { + $ABSWEB .= '/'; + } else { + $ABSWEB .= $base . '/'; + } +} +mtrack_calc_root(); + +function mtrack_head($title, $navbar = true) +{ + global $ABSWEB; + static $mtrack_did_head; + + $whoami = mtrack_username(MTrackAuth::whoami(), + array( + 'no_image' => true + ) + ); + + if ($mtrack_did_head) { + return; + } + $mtrack_did_head = true; + + $projectname = htmlentities(MTrackConfig::get('core', 'projectname'), + ENT_QUOTES, 'utf-8'); + $logo = MTrackConfig::get('core', 'projectlogo'); + if (strlen($logo)) { + $projectname = "$projectname"; + } + $fav = MTrackConfig::get('core', 'favicon'); + if (strlen($fav)) { + $fav = << + +HTML; + } else { + $fav = ''; + } + + $title = htmlentities($title, ENT_QUOTES, 'utf-8'); + + $userinfo = "Logged in as $whoami"; + MTrackNavigation::augmentUserInfo($userinfo); + + echo << + + + + +$title +$fav + + + + +HTML; + + if ($navbar) { + echo << +
+ $userinfo + +
+ +HTML; + + echo << +HTML; + + $nav = array(); + if (MTrackAuth::whoami() !== 'anonymous') { + $nav['/'] = 'Today'; + } + $navcandidates = array( + "/browse.php" => array("Browse", 'read', 'Browser'), + "/wiki.php" => array("Wiki", 'read', 'Wiki'), + "/timeline.php" => array("Timeline", 'read', 'Timeline'), + "/roadmap.php" => array("Roadmap", 'read', 'Roadmap'), + "/reports.php" => array("Reports", 'read', 'Reports'), + "/ticket.php/new" => array("New Ticket", 'create', 'Tickets'), + "/snippet.php" => array("Snippets", 'read', 'Snippets'), + "/admin/" => array("Administration", 'modify', 'Enumerations', 'Components', 'Projects', 'Browser'), + ); + foreach ($navcandidates as $url => $data) { + $label = array_shift($data); + $right = array_shift($data); + $ok = false; + foreach ($data as $object) { + if (MTrackACL::hasAllRights($object, $right)) { + $ok = true; + break; + } + } + if ($ok) { + $nav[$url] = $label; + } + } + + echo mtrack_nav('mainnav', $nav); + echo << +HTML; + } + if (MTrackConfig::get('core', 'admin_party') == 1 && + MTrackAuth::whoami() == 'adminparty' && + ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || + $_SERVER['REMOTE_ADDR'] == '::1')) { + echo << + + Welcome to the admin party! Authentication is not yet configured; + while it is in this state, any user connecting from the localhost + address is treated as having admin rights (that includes you, and this + is why you are seeing this message). All other users are treated + as anonymous users.
+ Click here to Configure Authentication + +HTML; + } elseif (!MTrackAuth::isAuthConfigured() && + MTrackConfig::get('core', 'admin_party') == 1) + { + $localaddr = preg_replace('@^(https?://)([^/]+)/(.*)$@', + "\\1localhost/\\3", $ABSWEB); + + echo << + + Authentication is not yet configured. If you are the admin, + you should use the localhost address + to reach the system and configure it. + +HTML; + } elseif (!MTrackAuth::isAuthConfigured()) { + echo << + + Authentication is not yet configured. If you are the admin, + you will need to edit the config.ini file to configure authentication. + +HTML; + } + + if (ini_get('magic_quotes_gpc') === true || + !strcasecmp(ini_get('magic_quotes_gpc'), 'on')) { + echo << + + magic_quotes_gpc is enabled. This causes mtrack not to work. + Please disable this setting in your server configuration. + +HTML; + + } + + echo << +
+HTML; +} + +function mtrack_foot($visible_markup = true) +{ + echo << +HTML; + if ($visible_markup) { + echo << + +
+ + + +HTML; + if (MTrackConfig::get('core', 'debug.footer')) { + global $FORKS; + + echo ""; + } + } +} + +interface IMTrackExtensionPage { + /** called to dispatch a page render */ + function dispatchRequest(); +} + +class MTrackExtensionPage { + static $locations = array(); + static function registerLocation($location, IMTrackExtensionPage $page) { + self::$locations[$location] = $page; + } + static function locationToURL($location) { + global $ABSWEB; + return $ABSWEB . 'ext.php/' . $location; + } + static function bindToPage($location) { + while (strlen($location)) { + if (isset(self::$locations[$location])) { + return self::$locations[$location]; + } + if (strpos($location, '/') === false) { + return null; + } + $location = dirname($location); + } + } +} + +interface IMTrackNavigationHelper { + /** called by mtrack_nav + * You may remove items from or add items to the items array by + * changing the $items array. + * Should you want to suppress the Wiki from navigation, you may + * do so like this: + * if ($id == 'mainnav') { + * unset($items['/wiki.php']); + * } + * If you want to add an item, the key is the URL and the value + * is the label. The label is raw HTML. + */ + function augmentNavigation($id, &$items); + + /** called by mtrack_head + * You may augment or override the "Logged in as user" text by + * changing the $content variable */ + function augmentUserInfo(&$content); +} + +class MTrackNavigation { + static $helpers = array(); + + static function registerHelper(IMTrackNavigationHelper $helper) + { + self::$helpers[] = $helper; + } + + static function augmentNavigation($id, &$items) + { + foreach (self::$helpers as $helper) { + $helper->augmentNavigation($id, $items); + } + } + + static function augmentUserInfo(&$content) + { + foreach (self::$helpers as $helper) { + $helper->augmentUserInfo($content); + } + } +} + +function mtrack_nav($id, $nav) { + global $ABSWEB; + + // Allow config file to manipulate the navigation bits + $cnav = MTrackConfig::getSection('nav:' . $id); + if (is_array($cnav)) { + foreach ($cnav as $loc => $label) { + if (!strlen($label)) { + unset($nav[$loc]); + } else { + $nav[$loc] = $label; + } + } + } + + MTrackNavigation::augmentNavigation($id, $nav); + + $elements = array(); + + $web = realpath(dirname(__FILE__) . '/../web'); + $where = substr($_SERVER['SCRIPT_FILENAME'], strlen($web)); + if (isset($_SERVER['PATH_INFO'])) { + $where .= $_SERVER['PATH_INFO']; + } + $active = null; + $tries = 0; + do { + foreach ($nav as $loc => $label) { + $cloc = $loc; + if (!strncmp($cloc, $ABSWEB, strlen($ABSWEB))) { + $cloc = substr($cloc, strlen($ABSWEB)-1); + } + if ($where == $cloc || $where == rtrim($cloc, '/')) { + $active = $loc; + break; + } + } + $where = dirname($where); + } while ($active === null && $tries++ < 100); + + foreach ($nav as $loc => $label) { + unset($nav[$loc]); + $class = array(); + if (!count($elements)) { + $class[] = "first"; + } + if (count($nav) == 0) { + $class[] = "last"; + } + if ($active == $loc) { + $class[] = 'active'; + } + if (count($class)) { + $class = " class=\"" . implode(' ', $class) . "\""; + } else { + $class = ''; + } + if ($loc[0] == '/') { + $url = substr($loc, 1); // trim off leading / + } else { + $url = $loc; + } + if (!preg_match('/^[a-z-]+:/', $url)) { + $url = $ABSWEB . $url; + } + $elements[] = "$label"; + } + return ""; +} + +function mtrack_date($tstring, $show_full = false) +{ + /* database time is always relative to UTC */ + $d = date_create($tstring, new DateTimeZone('UTC')); + if (!is_object($d)) { + throw new Exception("could not represent $tstring as a datetime object"); + } + $iso8601 = $d->format(DateTime::W3C); + /* but we want to render relative to user prefs */ + date_timezone_set($d, new DateTimeZone(date_default_timezone_get())); + $full = $d->format('D, M d Y H:i'); + + if (!$show_full) { + return "$full"; + } + + return "$full $full"; +} + +function mtrack_rmdir($dir) +{ + foreach (scandir($dir) as $ent) { + if ($ent == '.' || $ent == '..') { + continue; + } + $full = $dir . DIRECTORY_SEPARATOR . $ent; + if (is_dir($full)) { + mtrack_rmdir($full); + } else { + unlink($full); + } + } + rmdir($dir); +} + +function mtrack_make_temp_dir($do_make = true) +{ + $tempdir = sys_get_temp_dir(); + $base = $tempdir . DIRECTORY_SEPARATOR . "mtrack." . uniqid(); + for ($i = 0; $i < 1024; $i++) { + $candidate = $base . sprintf("%04x", $i); + if ($do_make) { + if (mkdir($candidate)) { + return $candidate; + } + } else { + /* racy */ + if (!file_exists($candidate) && !is_dir($candidate)) { + return $candidate; + } + } + } + throw new Exception("unable to make temp dir based on path $candidate"); +} + +function mtrack_diff_strings($before, $now) +{ + $tempdir = sys_get_temp_dir(); + $afile = tempnam($tempdir, "mtrack"); + $bfile = tempnam($tempdir, "mtrack"); + file_put_contents($afile, $before); + file_put_contents($bfile, $now); + $diff = MTrackConfig::get('tools', 'diff'); + if (PHP_OS == 'SunOS') { + // TODO: make an option to allow use of gnu diff on solaris + $diff = shell_exec("$diff -u $afile $bfile"); + $diff = str_replace($afile, 'before', $diff); + $diff = str_replace($bfile, 'now', $diff); + } else { + $diff = shell_exec("$diff --label before --label now -u $afile $bfile"); + } + unlink($afile); + unlink($bfile); + $diff = htmlentities($diff, ENT_COMPAT, 'utf-8'); + return $diff; +} + +function mtrack_last_chance_saloon($e) +{ + if ($e instanceof MTrackAuthorizationException) { + if (MTrackAuth::whoami() == 'anonymous') { + MTrackAuth::forceAuthenticate(); + } + mtrack_head('Insufficient Privilege'); + echo '

Insufficient Privilege

'; + $rights = is_array($e->rights) ? join(', ', $e->rights) : $e->rights; + echo "You do not have the required set of rights ($rights) to access this page
"; + mtrack_foot(); + exit; + } + + $msg = $e->getMessage(); + + try { + mtrack_head('Whoops: ' . $msg); + } catch (Exception $doublefault) { + } + + echo "

An error occurred!

"; + + echo htmlentities($msg, ENT_QUOTES, 'utf-8'); + + echo "
"; + + echo nl2br(htmlentities($e->getTraceAsString(), ENT_QUOTES, 'utf-8')); + + try { + mtrack_foot(); + } catch (Exception $doublefault) { + } +} + +function mtrack_canon_username($username) +{ + static $canon_map = null; + + if ($canon_map === null) { + $canon_map = array(); + foreach (MTrackDB::q('select alias, userid from useraliases union select email, userid from userinfo where email <> \'\'')->fetchAll() + as $row) { + $canon_map[$row[0]] = $row[1]; + } + } + + $runaway = 25; + do { + if (isset($canon_map[$username])) { + if ($username == $canon_map[$username]) { + break; + } + $username = $canon_map[$username]; + } elseif (preg_match('/<([a-z0-9_.+=-]+@[a-z0-9.-]+)>/', $username, $M)) { + // look at just the email address + $username = $M[1]; + if (!isset($canon_map[$username])) { + break; + } + } else { + break; + } + } while ($runaway-- > 0); + + return $username; +} + +function mtrack_username($username, $options = array()) +{ + $username = mtrack_canon_username($username); + $userdata = MTrackAuth::getUserData($username); + + if (isset($userdata['fullname']) && strlen($userdata['fullname'])) { + $title = " title='" . + htmlentities($userdata['fullname'], ENT_QUOTES, 'utf-8') . "' "; + } else { + $title = ''; + } + + global $ABSWEB; + + if (!isset($options['size'])) { + $options['size'] = 24; + } + if (isset($options['class'])) { + $extraclass = " $options[class]"; + } else { + $extraclass = ''; + } + + if (!ctype_alnum($username)) { + $target = "{$ABSWEB}user.php?user=" . urlencode($username); + if (isset($options['edit'])) { + $target .= '&edit=1'; + } + } else { + $target = "{$ABSWEB}user.php/$username"; + if (isset($options['edit'])) { + $target .= '?edit=1'; + } + } + $open_a = ""; + + $ret = ''; + if ((!isset($options['no_image']) || !$options['no_image'])) { + $ret .= $open_a . + mtrack_avatar($username, $options['size']) . + ' '; + } + if (!isset($options['no_name']) || !$options['no_name']) { + $dispuser = $username; + + if (strlen($dispuser) > 12) { + if (preg_match("/^([^+]*)(\+.*)?@(.*)$/", $dispuser, $M)) { + /* looks like an email address, try to shorten it in a reasonable way */ + $local = $M[1]; + $extra = $M[2]; + $domain = $M[3]; + + if (strlen($extra)) { + $local .= '...'; + } + + $dispuser = "$local@$domain"; + } + } + $ret .= "$open_a$dispuser"; + } + return $ret; +} + +function mtrack_avatar($username, $size = 24) +{ + global $ABSWEB; + + $id = urlencode($username); + + return ""; +} + +function mtrack_gravatar($email, $size = 24) +{ + // d=identicon + // d=monsterid + // d=wavatar + return ""; +} + +function mtrack_defrepo() +{ + static $defrepo = null; + if ($defrepo === null) { + $defrepo = MTrackConfig::get('core', 'default.repo'); + if ($defrepo === null) { + $defrepo = ''; + foreach (MTrackDB::q( + 'select parent, shortname from repos order by shortname') + ->fetchAll() as $row) { + $defrepo = MTrackSCM::makeDisplayName($row); + break; + } + } else if (strpos($defrepo, '/') === false) { + $defrepo = 'default/' . $defrepo; + } + } + return $defrepo; +} + +function mtrack_changeset_url($cs, $repo = null) +{ + global $ABSWEB; + if ($repo instanceof MTrackRepo) { + $p = $repo->getBrowseRootName() . '/'; + } elseif ($repo !== null) { + if (strpos($repo, '/') === false) { + $repo = "default/$repo"; + } + $p = $repo . '/'; + } else { + static $repos = null; + if ($repos === null) { + $repos = array(); + foreach (MTrackDB::q('select r.shortname as repo, p.shortname as proj from repos r left join project_repo_link l using (repoid) left join projects p using (projid) where parent is null or length(parent) = 0')->fetchAll(PDO::FETCH_ASSOC) as $row) { + $r = $row['repo']; + if ($row['proj']) { + $repos[$row['proj']] = $r; + } + $repos[$row['repo']] = $r; + } + } + $p = null; + foreach ($repos as $a => $b) { + if (!strncasecmp($cs, $a, strlen($a))) { + $p = 'default/' . $b; + $cs = substr($cs, strlen($a)); + break; + } + } + if ($p === null) { + $p = mtrack_defrepo(); + } + $p .= '/'; + } + return $ABSWEB . "changeset.php/$p$cs"; +} + +function mtrack_changeset($cs, $repo = null) +{ + $display = $cs; + if (strlen($display) > 12) { + $display = substr($display, 0, 12); + } + $url = mtrack_changeset_url($cs, $repo); + return "[$display]"; +} + +function mtrack_branch($branch, $repo = null) +{ + return "$branch"; +} + +function mtrack_wiki($pagename, $extras = array()) +{ + global $ABSWEB; + if ($pagename instanceof MTrackWikiItem) { + $wiki = $pagename; + } else if (is_string($pagename)) { + $wiki = null;//MTrackWikiItem::loadByPageName($pagename); + } else { + // FIXME: hinted data from reports + throw new Exception("FIXME: wiki"); + } + if ($wiki) { + $pagename = $wiki->pagename; + } + $html = ""; + if (isset($extras['display'])) { + $html .= htmlentities($extras['display'], ENT_QUOTES, 'utf-8'); + } else { + $html .= htmlentities($pagename, ENT_QUOTES, 'utf-8'); + } + $html .= ""; + return $html; +} + +function mtrack_ticket($no, $extras = array()) +{ + global $ABSWEB; + + if ($no instanceof MTrackIssue) { + $tkt = $no; + } else if (is_string($no) || is_int($no)) { + static $cache = array(); + + if ($no[0] == '#') { + $no = substr($no, 1); + } + + if (!isset($cache[$no])) { + $tkt = MTrackIssue::loadByNSIdent($no); + if (!$tkt) { + $tkt = MTrackIssue::loadById($no); + } + $cache[$no] = $tkt; + } else { + $tkt = $cache[$no]; + } + } else { + // FIXME: hinted data from reports + $tkt = new stdClass; + $tkt->tid = $no['ticket']; + $tkt->summary = $no['summary']; + if (isset($no['state'])) { + $tkt->status = $no['state']; + } elseif (isset($no['status'])) { + $tkt->status = $no['status']; + } elseif (isset($no['__status__'])) { + $tkt->status = $no['__status__']; + } else { + $tkt->status = ''; + } + } + if ($tkt == NULL) { + $tkt = new stdClass; + $tkt->tid = $no; + $tkt->summary = 'No such ticket'; + $tkt->status = 'No such ticket'; + } + $html = "nsident)) { + $ident = $tkt->nsident; + } else { + $ident = $tkt->tid; + } + if (isset($extras['#'])) { + $anchor = '#' . $extras['#']; + } else { + $anchor = ''; + } + $html .= "' href=\"{$ABSWEB}ticket.php/$ident$anchor\">"; + if (isset($extras['display'])) { + $html .= htmlentities($extras['display'], ENT_QUOTES, 'utf-8'); + } else { + $html .= '#' . htmlentities($ident, ENT_QUOTES, 'utf-8'); + } + $html .= ""; + return $html; +} + +function mtrack_tag($tag, $repo = null) +{ + return "$tag"; +} + +function mtrack_keyword($keyword) +{ + global $ABSWEB; + $kw = urlencode($keyword); + return "$keyword
"; +} + +function mtrack_multi_select_box($name, $title, $items, $values = null) +{ + $title = htmlentities($title, ENT_QUOTES, 'utf-8'); + $html = ""; +} + +function mtrack_select_box($name, $items, $value = null, $keyed = true) +{ + $html = ""; +} + +function mtrack_radio($name, $value, $curval) +{ + $checked = $curval == $value ? " checked='checked'": ''; + return ""; +} + +function mtrack_diff($diffstr) +{ + $nlines = 0; + + if (is_resource($diffstr)) { + $lines = array(); + while (($line = fgets($diffstr)) !== false) { + $lines[] = rtrim($line, "\r\n"); + } + $diffstr = $lines; + } + + if (is_string($diffstr)) { + $abase = md5($diffstr); + $diffstr = preg_split("/\r?\n/", $diffstr); + } else { + $abase = md5(join("\n", $diffstr)); + } + + /* we could use toggle() below, but it is much faster to determine + * if we are hiding or showing based on a single variable than evaluating + * that for each possible cell */ + $html = <<Toggle Diff Line Numbers +HTML; + $html .= ""; + //$html = "
";
+
+  while (true) {
+    if (!count($diffstr)) {
+      break;
+    }
+    $line = array_shift($diffstr);
+    $nlines++;
+    if (!strncmp($line, '@@ ', 3)) {
+      /* done with preamble */
+      break;
+    }
+    $line = htmlspecialchars($line, ENT_QUOTES, 'utf-8');
+    $line = "
"; + $html .= $line . "\n"; + } + + $lines = array(0, 0); + $first = false; + while (true) { + $class = 'unmod'; + + if (preg_match("/^@@\s+-(\pN+)(?:,\pN+)?\s+\+(\pN+)(?:,\pN+)?\s*@@/", + $line, $M)) { + $lines[0] = (int)$M[1] - 1; + $lines[1] = (int)$M[2] - 1; + $class = 'meta'; + $first = true; + } elseif (strlen($line)) { + if ($line[0] == '-') { + $lines[0]++; + $class = 'removed'; + } elseif ($line[0] == '+') { + $lines[1]++; + $class = 'added'; + } else { + $lines[0]++; + $lines[1]++; + } + } else { + $lines[0]++; + $lines[1]++; + } + $row = ""; + break; + case 'removed': + $row .= ""; + break; + default: + $row .= ""; + } + $anchor = $abase . '.' . $nlines; + $row .= ""; + + $line = htmlspecialchars($line, ENT_QUOTES, 'utf-8'); + $row .= "\n"; + $html .= $row; + + if (!count($diffstr)) { + break; + } + $line = array_shift($diffstr); + $nlines++; + } + + if ($nlines == 0) { + return null; + } + + $html .= "
$line
" . $lines[0] . "" . $lines[0] . "" . $lines[1] . "$line
"; + return $html; +} + +function mtrack_mime_detect($filename, $namehint = null) +{ + /* does config tell us how to decide mimetype */ + $detector = MTrackConfig::get('core', 'mimetype_detect'); + + /* if detector is blank, we'll try to figure out which one to use */ + if (empty($detector)) { + if (function_exists('finfo_open')) { + $detector = 'fileinfo'; + } elseif (function_exists('mime_content_type')) { + $detector = 'mime_magic'; + } else { + $detector = 'file'; + } + } + + /* use detector or all mimetypes will be blank */ + if ($detector === 'fileinfo') { + if (defined('FILEINFO_MIME_TYPE')) { + $fileinfo = finfo_open(FILEINFO_MIME_TYPE); + } else { + $magic = MTrackConfig::get('core', 'mime.magic'); + if (strlen($magic)) { + $fileinfo = finfo_open(FILEINFO_MIME, $magic); + } else { + $fileinfo = finfo_open(FILEINFO_MIME); + } + } + $mimetype = finfo_file($fileinfo, $filename); + finfo_close($fileinfo); + } elseif ($detector === 'mime_magic') { + $mimetype = mime_content_type($filename); + } elseif (PHP_OS != 'SunOS') { + $mimetype = shell_exec("file -b --mime " . escapeshellarg($filename)); + } else { + $mimetype = 'application/octet-stream'; + } + $mimetype = trim(preg_replace("/\s*;.*$/", '', $mimetype)); + if (empty($mimetype)) { + $mimetype = 'application/octet-stream'; + } + if ($mimetype == 'application/octet-stream') { + if ($namehint === null) { + $namehint = $filename; + } + $pi = pathinfo($namehint); + switch (strtolower($pi['extension'])) { + case 'bin': return 'application/octet-stream'; + case 'exe': return 'application/octet-stream'; + case 'dll': return 'application/octet-stream'; + case 'iso': return 'application/octet-stream'; + case 'so': return 'application/octet-stream'; + case 'a': return 'application/octet-stream'; + case 'lib': return 'application/octet-stream'; + case 'pdf': return 'application/pdf'; + case 'ps': return 'application/postscript'; + case 'ai': return 'application/postscript'; + case 'eps': return 'application/postscript'; + case 'ppt': return 'application/vnd.ms-powerpoint'; + case 'xls': return 'application/vnd.ms-excel'; + case 'tiff': return 'image/tiff'; + case 'tif': return 'image/tiff'; + case 'wbmp': return 'image/vnd.wap.wbmp'; + case 'png': return 'image/png'; + case 'gif': return 'image/gif'; + case 'jpg': return 'image/jpeg'; + case 'jpeg': return 'image/jpeg'; + case 'ico': return 'image/x-icon'; + case 'bmp': return 'image/bmp'; + case 'css': return 'text/css'; + case 'htm': return 'text/html'; + case 'html': return 'text/html'; + case 'txt': return 'text/plain'; + case 'xml': return 'text/xml'; + case 'eml': return 'message/rfc822'; + case 'asc': return 'text/plain'; + case 'rtf': return 'application/rtf'; + case 'wml': return 'text/vnd.wap.wml'; + case 'wmls': return 'text/vnd.wap.wmlscript'; + case 'gtar': return 'application/x-gtar'; + case 'gz': return 'application/x-gzip'; + case 'tgz': return 'application/x-gzip'; + case 'tar': return 'application/x-tar'; + case 'zip': return 'application/zip'; + case 'sql': return 'text/plain'; + } + // if the file is ascii, then treat it as text/plain + $fp = fopen($filename, 'rb'); + $mimetype = 'text/plain'; + do { + $x = fread($fp, 8192); + if (!strlen($x)) break; + if (preg_match('/([\x80-\xff])/', $x, $M)) { + $mimetype = 'application/octet-stream'; + break; + } + } while (true); + $fp = null; + } + return $mimetype; +} + +function mtrack_run_tool($toolname, $mode, $args = null) +{ + global $FORKS; + + $tool = MTrackConfig::get('tools', $toolname); + if (!strlen($tool)) { + $tool = $toolname; + } + if (PHP_OS == 'Windows' && strpos($tool, ' ') !== false) { + $tool = '"' . $tool . '"'; + } + $cmd = $tool; + if (is_array($args)) { + foreach ($args as $arg) { + if (is_array($arg)) { + foreach ($arg as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + } else { + $cmd .= ' ' . escapeshellarg($arg); + } + } + } + if (!isset($FORKS[$cmd])) { + $FORKS[$cmd] = 0; + } + $FORKS[$cmd]++; + if (false) { + if (php_sapi_name() == 'cli') { + echo $cmd, "\n"; + } else { + error_log($cmd); + echo htmlentities($cmd) . "
\n"; + } + } + + switch ($mode) { + case 'read': return popen($cmd, 'r'); + case 'write': return popen($cmd, 'w'); + case 'string': return stream_get_contents(popen($cmd, 'r')); + case 'proc': + $pipedef = array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + $proc = proc_open($cmd, $pipedef, $pipes); + return array($proc, $pipes); + } +} + +if (php_sapi_name() != 'cli') { + set_exception_handler('mtrack_last_chance_saloon'); + error_reporting(E_NOTICE|E_ERROR|E_WARNING); + ini_set('display_errors', false); + set_time_limit(300); +} + + diff --git a/inc/wiki-item.php b/inc/wiki-item.php new file mode 100644 index 00000000..58dc531b --- /dev/null +++ b/inc/wiki-item.php @@ -0,0 +1,176 @@ +content = stream_get_contents($this->file->cat()); + return $this->content; + } + } + + static function commitNow() { + /* force any delayed push to invoke right now */ + self::$wc = null; + putenv("MTRACK_WIKI_COMMIT="); + } + + static function loadByPageName($name) { + $w = new MTrackWikiItem($name); + if ($w->file) { + return $w; + } + return null; + } + + static function getWC() { + if (self::$wc === null) { + self::getRepoAndRoot($repo); + self::$wc = $repo->getWorkingCopy(); + } + return self::$wc; + } + + static function getRepoAndRoot(&$repo) { + $repo = MTrackRepo::loadByName('default/wiki'); + return $repo->getDefaultRoot(); + } + + function __construct($name, $version = null) { + $this->pagename = $name; + $this->filename = self::getRepoAndRoot($repo) . $name; + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + if ($suf) { + $this->filename .= $suf; + } + + if ($version !== null) { + $this->file = $repo->file($this->filename, 'rev', $version); + } else { + $this->file = $repo->file($this->filename); + } + if ($this->file && $repo->history($this->filename, 1)) { + $this->version = $this->file->rev; + } else { + $this->file = null; + } + } + + function save(MTrackChangeset $changeset) { + $wc = self::getWC(); + $lfilename = $this->pagename; + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + if ($suf) { + $lfilename .= $suf; + } + + if (!strlen(trim($this->content))) { + if ($wc->file_exists($lfilename)) { + // removing + $wc->delFile($lfilename); + } + } else { + if (!$wc->file_exists($lfilename)) { + // handle dirs + $elements = explode('/', $lfilename); + $accum = array(); + while (count($elements) > 1) { + $ent = array_shift($elements); + $accum[] = $ent; + $base = join(DIRECTORY_SEPARATOR, $accum); + if (!$wc->file_exists($base)) { + if (!mkdir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) { + throw new Exception( + "unable to mkdir(" . $wc->getDir() . + DIRECTORY_SEPARATOR . "$base)"); + } + $wc->addFile($base); + } else if (!is_dir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) { + throw new Exception("$base is not a dir; cannot create $lfilename"); + } + } + file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename, + $this->content); + $wc->addFile($lfilename); + } else { + file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename, + $this->content); + } + } + /* use an env var to signal to the commit hook that it does not + * need to make a changeset for this commit */ + putenv("MTRACK_WIKI_COMMIT=1"); + $wc->commit($changeset); + } + + static function index_item($object) + { + list($ignore, $ident) = explode(':', $object, 2); + $w = MTrackWikiItem::loadByPageName($ident); + + MTrackSearchDB::add("wiki:$w->pagename", array( + 'wiki' => $w->content, + 'who' => $w->who, + ), true); + } + static function _get_parent_for_acl($objectid) { + if (preg_match("/^(wiki:.*)\/([^\/]+)$/", $objectid, $M)) { + return $M[1]; + } + if (preg_match("/^wiki:.*$/", $objectid, $M)) { + return 'Wiki'; + } + return null; + } +} + +class MTrackWikiCommitListener implements IMTrackCommitListener { + function vetoCommit($msg, $files, $actions) { + return true; + } + + function postCommit($msg, $files, $actions) { + /* is this affecting the wiki? */ + $wiki = array(); + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + foreach ($files as $name) { + list($repo, $fname) = explode('/', $name, 2); + if ($repo == 'wiki') { + if ($suf && substr($fname, -strlen($suf)) == $suf) { + $fname = substr($fname, 0, -strlen($suf)); + } + $wiki[] = $fname; + } + } + /* MTRACK_WIKI_COMMIT is set by MTrackWikiItem when it commits, + * so we check for the absence of it to determine if mtrack has + * recorded a changeset record */ + if (count($wiki) && getenv("MTRACK_WIKI_COMMIT") != "1") { + /* wiki being changed outside of the MTrackWikiItem class, so + * let's create a changeset record for the search engine to + * pick up and index this change */ + foreach ($wiki as $name) { + $CS = MTrackChangeset::begin("wiki:$name", $msg); + $CS->commit(); + } + } + return true; + } + + static function register() { + $l = new MTrackWikiCommitListener; + MTrackCommitChecker::registerListener($l); + } + +}; + +MTrackSearchDB::register_indexer('wiki', array('MTrackWikiItem', 'index_item')); +MTrackWikiCommitListener::register(); +MTrackACL::registerAncestry('wiki', array('MTrackWikiItem', '_get_parent_for_acl')); + diff --git a/inc/wiki.php b/inc/wiki.php new file mode 100644 index 00000000..6ccf7ae1 --- /dev/null +++ b/inc/wiki.php @@ -0,0 +1,1231 @@ +\s])"; +const SHREF_TARGET_LAST = "[\w/=](?!?%s)", self::BOLDITALIC_TOKEN), + array("(?P!?%s)" , self::BOLD_TOKEN), + array("(?P!?%s)" , self::ITALIC_TOKEN), + array("(?P!?%s)" , self::UNDERLINE_TOKEN), + array("(?P!?%s)" , self::STRIKE_TOKEN), + array("(?P!?%s)" , self::SUBSCRIPT_TOKEN), + array("(?P!?%s)" , self::SUPERSCRIPT_TOKEN), + array("(?P!?%s(?P.*?)%s)" , + self::STARTBLOCK_TOKEN, self::ENDBLOCK_TOKEN), + array("(?P!?%s(?P.*?)%s)", + self::INLINE_TOKEN, self::INLINE_TOKEN), + ); + static $post_rules = array( + # WikiPageName + array("(?P!?(?!?\[\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+(?:@\d+)?(?:#%s)?(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z)\s+(?:%s|[^\]]+)\])", + self::UPPER, self::LOWER, self::UPPER, self::LOWER, self::LOWER, self::XML_NAME, self::QUOTED_STRING), + + # [21450] changeset + "(?P!?\[(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+)\])", + # #ticket + "(?P!?#(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+))", + # {report} + "(?P!?\{([^}]*)\})", + + # e-mails + array("(?P!?%s)" , self::EMAIL_LOOKALIKE_PATTERN), + # > ... + "(?P^(?P>(?: *>)*))", + # &, < and > to &, < and > + "(?P[&<>])", + # wiki:TracLinks + array( + "(?P!?((?P%s):(?P%s|%s(?:%s*%s)?)))", + self::LINK_SCHEME, self::QUOTED_STRING, + self::SHREF_TARGET_FIRST, self::SHREF_TARGET_MIDDLE, + self::SHREF_TARGET_LAST), + + # [wiki:TracLinks with optional label] or [/relative label] + array( + "(?P!?\[(?:(?P%s)|(?P%s):(?P%s|[^\]\s]*))(?:\s+(?P
%s', + $depth, $anchor, $anchor, $heading, $depth); + } + + function tag_open_p($tag) { + /* do we currently have any open tag with $tag as end-tag? */ + return in_array($tag, $this->open_tags); + } + + function open_tag($open_tag, $close_tag) { + $this->open_tags[] = array($open_tag, $close_tag); + } + + function simple_tag_handler($match, $open_tag, $close_tag) { + if ($this->tag_open_p(array($open_tag, $close_tag))) { + $this->out .= $this->close_tag($close_tag); + return; + } + $this->open_tag($open_tag, $close_tag); + $this->out .= $open_tag; + } + + function close_tag($tag) { + $tmp = ''; + /* walk backwards until we find the tag, closing out + * as we go */ + $keys = array_reverse(array_keys($this->open_tags)); + foreach ($keys as $k) { + $pair = $this->open_tags[$k]; + $tmp .= $pair[1]; + if ($pair[1] == $tag) { + unset($this->open_tags[$k]); + foreach ($this->open_tags as $k2 => $pair) { + if ($k2 == $k) { + break; + } + $tmp .= $pair[0]; + } + break; + } + } + return $tmp; + } + + function _bolditalic_formatter($match, $info) { + $italic = array('', ''); + $open = $this->tag_open_p($italic); + $tmp = ''; + if ($open) { + $this->out .= $italic[1]; + $this->close_tag($italic[1]); + } + $this->_bold_formatter($match, $info); + if (!$open) { + $this->out .= $italic[0]; + $this->open_tag($italic[0], $italic[1]); + } + } + + function _bold_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _italic_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _underline_formatter($match, $info) { + $this->simple_tag_handler($match, + '', ''); + } + function _strike_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _subscript_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + function _superscript_formatter($match, $info) { + $this->simple_tag_handler($match, '', ''); + } + + function _email_formatter($match, $info) { + $this->out .= "" . htmlspecialchars($match, ENT_COMPAT, 'utf-8') . ""; + } + + function _htmlspecialcharsape_formatter($match, $info) { + $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8'); + } + + function _make_link($ns, $target, $match, $label) { + global $ABSWEB; + $is_closed = false; + + if ($label[0] == '"' || $label[0] == "'") { + $label = substr($label, 1, -1); + } + if (preg_match('/^(.*)#(.*)$/', $target, $M)) { + $target = $M[1]; + $anchor = $M[2]; + } else { + $anchor = null; + } + + if (strlen($ns)) { + + /* special cases */ + if ($ns == 'ticket' && + (strpos($target, '-') !== false || strpos($target, ',') !== false)) { + /* ranged ticket query */ + $ns = 'query'; + $target = 'id=' . $target; + } + + switch ($ns) { + case 'ticket': + $this->out .= mtrack_ticket($target, array( + 'display' => $label, + '#' => $anchor, + )); + return; + + case 'changeset': + if (strpos($target, ',') !== false) { + list($repo, $cs) = explode(',', $target, 2); + $this->out .= mtrack_changeset($cs, $repo); + } else { + $this->out .= mtrack_changeset($target); + } + return; + + case 'milestone': + $label = htmlspecialchars(urldecode($target), ENT_QUOTES, 'utf-8'); + $target = $ABSWEB . "$ns.php/" . $target; + + $this->out .= "$label"; + return; + + case 'wiki': + $this->out .= mtrack_wiki($target, array( + '#' => $anchor, + 'display' => $label + )); + return; + + case 'help': + if (!empty($anchor)) { + $target .= "#$anchor"; + } + $this->out .= + "$label"; + return; + + case 'user': + $this->out .= mtrack_username($target); + return; + + case 'repo': + $target = $ABSWEB . "browse.php/$target"; + break; + + case 'log': + if ($target == '/') { + $target = mtrack_defrepo(); + } + $target = $ABSWEB . "$ns.php/$target"; + break; + + case 'query': + case 'report': + $target = $ABSWEB . "$ns.php/$target"; + break; + case 'source': + @list($file, $rev) = explode('#', $target, 2); + $file = ltrim($file, '/'); + /* some legacy handling here; there are three cases: + * owner/repo/path -> repo = owner/repo + * repo/path -> repo = default/repo + * path -> repo = config.ini default repo + */ + $bits = explode('/', $file); + $repo = null; + if (count($bits) > 2) { + /* maybe owner/repo */ + $repo = MTrackRepo::loadByName($bits[0] . '/' . $bits[1]); + if ($repo) { + $repo = $repo->getBrowseRootName(); + } + } + if ($repo === null && count($bits) > 1) { + $repo = MTrackRepo::loadByName('default/' . $bits[0]); + if ($repo) { + $repo = $repo->getBrowseRootName(); + array_unshift($bits, 'default'); + } + } + if ($repo === null) { + $defrep = mtrack_defrepo(); + if ($defrep) { + if (strpos($defrep, '/') === false) { + $defrep = "default/$defrep"; + } + $repo = MTrackRepo::loadByName($defrep); + if ($repo) { + $repo = $repo->getBrowseRootName(); + array_unshift($bits, $repo); + } + } + } + $file = join($bits, '/'); + + if ($rev) { + $target = $ABSWEB . "file.php/$file@$rev"; + } else { + $target = $ABSWEB . "file.php/$file"; + } + break; + case 'comment': + if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) { + $this->out .= mtrack_ticket($M[2], + array( + '#' => 'comment:' . $M[1], + 'display' => $label + ) + ); + return; + } else { + $target = "#comment:$target"; + } + break; + + default: + $target = "$ns:$target"; + if (strlen($anchor)) { + $target .= "#$anchor"; + } + break; + } + } + $label = htmlspecialchars($label, ENT_QUOTES, 'utf-8'); + if ($is_closed) { + $label = "$label"; + } + $link = "$label"; + $this->out .= $link; + } + + function _ticket_formatter($match, $info, $nmatch) { + $ticket = substr($match, 1); + $this->_make_link('ticket', $ticket, $ticket, $match); + } + + function _report_formatter($match, $info, $nmatch) { + $ticket = substr($match, 1, -1); + $this->_make_link('report', $ticket, $ticket, $match); + } + + function _svnchangeset_formatter($match, $info, $nmatch) { + $rev = substr($match, 1, -1); + $this->_make_link('changeset', $rev, $rev, $match); + } + + function _wikipagename_formatter($match, $info, $nmatch) { + $this->_make_link('wiki', $match, $match, $match); + } + function _wikipagenamewithlabel_formatter($match, $info, $nmatch) { + $match = substr($match, 1, -1); + list($page, $label) = explode(" ", $match, 2); + $label = $this->_unquote(trim($label)); + $this->_make_link('wiki', $page, $match, $label); + } + + + function _shref_formatter($match, $info, $nmatch) { + $ns = $info['sns'][$nmatch][0]; + $target = $this->_unquote($info['stgt'][$nmatch][0]); + $shref = $info['shref'][$nmatch][0]; + $this->_make_link($ns, $target, $match, $match); + } + + function _lhref_formatter($match, $info, $nmatch) { + $rel = $info['rel'][$nmatch][0]; + $ns = $info['lns'][$nmatch][0]; + $target = $info['ltgt'][$nmatch][0]; + $label = isset($info['label'][$nmatch][0]) ? $info['label'][$nmatch][0] : ''; + +// var_dump($rel, $ns, $target, $label); + + if (!strlen($label)) { + /* [http://target] or [wiki:target] */ + if (strlen($target)) { + if (!strncmp($target, "//", 2)) { + /* for [http://target], label is http://target */ + $label = "$ns:$target"; + } else { + /* for [wiki:target], label is target */ + $label = $target; + } + } else { + /* [search:] */ + $label = $ns; + } + } else { + $label = $this->_unquote($label); + } + if (strlen($rel)) { + list($path, $query, $frag) = $this->split_link($rel); + if (!strncmp($path, '//', 2)) { + $path = '/' . ltrim($path, '/'); + } elseif ($path[0] == '/') { + $path = $GLOBALS['ABSWEB'] . substr($path, 1); + } + $target = $path; + if (strlen($query)) { + $target .= "?$query"; + } + if (strlen($frag)) { + $target .= "#$frag"; + } + $this->out .= "$label"; + } else { + $this->_make_link($ns, $target, $match, $label); + } + } + + function _inlinecode_formatter($match, $info, $nmatch) { + $this->out .= "" . + nl2br(htmlspecialchars($info['inline'][$nmatch][0], + ENT_COMPAT, 'utf-8')) . + ""; + } + function _inlinecode2_formatter($match, $info, $nmatch) { + $this->out .= "" . + nl2br(htmlspecialchars($info['inline2'][$nmatch][0], + ENT_COMPAT, 'utf-8')) . + ""; + } + + function _macro_formatter($match, $info, $nmatch) { + $name = $info['macroname'][$nmatch][0]; + if (!strcasecmp($name, 'br')) { + $this->out .= "
"; + return; + } + if (isset(MTrackWiki::$macros[$name])) { + $args = explode(',', $info['macroargs'][$nmatch][0]); + $this->out .= call_user_func_array(MTrackWiki::$macros[$name], $args); + } else { + $this->out .= "" . + htmlspecialchars($match, ENT_QUOTES, 'utf-8') . ""; + } + } + + + function split_link($target) { + @list($query, $frag) = explode('#', $target, 2); + @list($target, $query) = explode('?', $query, 2); + return array($target, $query, $frag); + } + + function _unquote($text) { + return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text); + } + + function close_list() { + $this->_set_list_depth(0, null, null, null); + } + + private function _get_list_depth() { + // Return the space offset associated to the deepest opened list + if (count($this->list_stack)) { + $e = end($this->list_stack); + return $e[1]; + } + return 0; + } + + private function _open_list($depth, $new_type, $list_class, $start) { + $this->close_table(); + $this->close_paragraph(); + $this->close_indentation(); + $this->list_stack[] = array($new_type, $depth); + $this->_set_tab($depth); + if ($list_class) { + $list_class = "wikilist $list_class"; + } else { + $list_class = "wikilist"; + } + $class_attr = $list_class ? sprintf(' class="%s"', $list_class) : ''; + $start_attr = $start ? sprintf(' start="%s"', $start) : ''; + $this->out .= "<$new_type$class_attr$start_attr>
  • "; + } + private function _close_list($type) { + array_pop($this->list_stack); + $this->out .= "
  • "; + } + + private function _set_list_depth($depth, $new_type, $list_class, $start) { + if ($depth > $this->_get_list_depth()) { + $this->_open_list($depth, $new_type, $list_class, $start); + return; + } + while (count($this->list_stack)) { + list($deepest_type, $deepest_offset) = end($this->list_stack); + if ($depth >= $deepest_offset) { + break; + } + $this->_close_list($deepest_type); + } + if ($depth > 0) { + if (count($this->list_stack)) { + list($old_type, $old_offset) = end($this->list_stack); + if ($new_type && $new_type != $old_type) { + $this->_close_list($old_type); + $this->_open_list($depth, $new_type, $list_class, $start); + } else { + if ($old_offset != $depth) { + array_pop($this->list_stack); + $this->list_stack[] = array($old_type, $depth); + } + $this->out .= "
  • "; + } + } else { + $this->_open_list($depth, $new_type, $list_class, $start); + } + } + } + + function close_indentation() { + $this->_set_quote_depth(0); + } + + private function _get_quote_depth() { + // Return the space offset associated to the deepest opened quote + if (count($this->quote_stack)) { + $e = end($this->quote_stack); + return $e; + } + return 0; + } + + private function _open_one_quote($d, $citation) { + $this->quote_stack[] = $d; + $this->_set_tab($d); + $class_attr = $citation ? ' class="citation"' : ''; + $this->out .= "\n"; + } + + private function _open_quote($quote_depth, $depth, $citation) { + $this->close_table(); + $this->close_paragraph(); + $this->close_list(); + + if ($citation) { + for ($d = $quote_depth + 1; $d < $depth+1; $d++) { + $this->_open_one_quote($d, $citation); + } + } else { + $this->_open_one_quote($depth, $citation); + } + } + + private function _close_quote() { + $this->close_table(); + $this->close_paragraph(); + array_pop($this->quote_stack); + $this->out .= "\n"; + } + + private function _set_quote_depth($depth, $citation = false) { + $quote_depth = $this->_get_quote_depth(); + if ($depth > $quote_depth) { + $this->_set_tab($depth); + $tabstops = $this->tabstops; + + while (count($tabstops)) { + $tab = array_pop($tabstops); + if ($tab > $quote_depth) { + $this->_open_quote($quote_depth, $tab, $citation); + } + } + } else { + while ($this->quote_stack) { + $deepest_offset = end($this->quote_stack); + if ($depth >= $deepest_offset) { + break; + } + $this->_close_quote(); + } + if (!$citation && $depth > 0) { + if (count($this->quote_stack)) { + $old_offset = end($this->quote_stack); + if ($old_offset != $depth) { + array_pop($this->quote_stack); + $this->quote_stack[] = $depth; + } + } else { + $this->_open_quote($quote_depth, $depth, $citation); + } + } + } + if ($depth > 0) { + $this->in_quote = true; + } + } + + function open_paragraph() { + if (!$this->paragraph_open) { + $this->out .= "

    \n"; + $this->paragraph_open = true; + } + } + + function close_paragraph() { + if ($this->paragraph_open) { + while (count($this->open_tags)) { + $t = array_pop($this->open_tags); + $this->out .= $t[1]; + } + $this->out .= "

    \n"; + $this->paragraph_open = false; + } + } + + function _last_table_cell_formatter($match, $info, $nmatch) { + return; + } + + function _table_cell_formatter($match, $info, $nmatch) { + $this->open_table(); + $this->open_table_row(); + $tag = $this->table_row_count == 1 ? 'th' : 'td'; + if ($this->in_table_cell) { + $this->out .= "<$tag>"; + return; + } + $this->in_table_cell = 1; + $this->out .= "<$tag>"; + } + + + function open_table() { + if (!$this->in_table) { + $this->close_paragraph(); + $this->close_list(); + $this->close_def_list(); + $this->in_table = 1; + $this->table_row_count = 0; + $this->out .= "\n"; + } + } + + function open_table_row() { + if (!$this->in_table_row) { + $this->open_table(); + if ($this->table_row_count == 0) { + $this->out .= ""; + } else if ($this->table_row_count == 1) { + $this->out .= ""; + } else { + $this->out .= ""; + } + $this->in_table_row = 1; + $this->table_row_count++; + } + } + + function close_table_row() { + if ($this->in_table_row) { + $tag = $this->table_row_count == 1 ? 'th' : 'td'; + $this->in_table_row = 0; + if ($this->in_table_cell) { + $this->in_table_cell = 0; + $this->out .= ""; + } + if ($this->table_row_count == 1) { + $this->out .= ""; + } else { + $this->out .= ""; + } + } + } + + function close_table() { + if ($this->in_table) { + $this->close_table_row(); + if ($this->table_row_count == 1) { + $this->out .= "
    \n"; + } else { + $this->out .= "\n"; + } + $this->in_table = 0; + } + } + + function close_def_list() { + if ($this->in_def_list) { + $this->out .= "\n"; + } + $this->in_def_list = false; + } + + function handle_code_block($line) { + if (trim($line) == MTrackWikiParser::STARTBLOCK) { + $this->in_code_block++; + if ($this->in_code_block == 1) { + $this->code_buf = array(); + } else { + $this->code_buf[] = $line; + } + } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) { + $this->in_code_block--; + if ($this->in_code_block == 0) { + // FIXME process the code here + if (preg_match("/^#!(\S+)$/", $this->code_buf[0], $M) + && isset(MTrackWiki::$processors[$M[1]])) { + $func = MTrackWiki::$processors[$M[1]]; + array_shift($this->code_buf); + $this->out .= call_user_func($func, $M[1], $this->code_buf); + } else { + $this->out .= "
    " .
    +            htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
    +            "
    "; + } + } else { + $this->code_buf[] = $line; + } + } else { + $this->code_buf[] = $line; + } + } + + function close_code_blocks() { + while ($this->in_code_block) { + $this->handle_code_block(MTrackWikiParser::ENDBLOCK); + } + } + + function _set_tab($depth) { + /* Append a new tab if needed and truncate tabs deeper than `depth` + given: -*-----*--*---*-- + setting: * + results in: -*-----*-*------- + */ + $tabstops = array(); + foreach ($this->tabstops as $ts) { + if ($ts >= $depth) { + break; + } + $tabstops[] = $ts; + } + $tabstops[] = $depth; + $this->tabstops = $tabstops; + } + + function _list_formatter($match, $info, $nmatch) { + $ldepth = strlen($info['ldepth'][$nmatch][0]); + $listid = $match[$ldepth]; + $this->in_list_item = true; + $class = ''; + $start = ''; + if ($listid == '-' || $listid == '*') { + $type = 'ul'; + } else { + $type = 'ol'; + switch ($listid) { + case '1': break; + case '0': $class = 'arabiczero'; break; + case 'i': $class = 'lowerroman'; break; + case 'I': $class = 'upperroman'; break; + default: + if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) { + $start = (int)$d[1]; + } elseif (ctype_lower($listid)) { + $class = 'loweralpha'; + } elseif (ctype_upper($listid)) { + $class = 'upperalpha'; + } + } + } + $this->_set_list_depth($ldepth, $type, $class, $start); + } + + function _definition_formatter($match, $info, $nmatch) { + $tmp = $this->in_def_list ? '' : '
    '; + list($def) = explode('::', $match, 2); + $tmp .= sprintf("
    %s
    ", + MTrackWiki::format_to_oneliner(trim($def))); + $this->in_def_list = true; + $this->out .= $tmp; + } + + function _indent_formatter($match, $info, $nmatch) { + $idepth = strlen($info['idepth'][$nmatch][0]); + if (count($this->list_stack)) { + list($ltype, $ldepth) = end($this->list_stack); + if ($idepth < $ldepth) { + foreach ($this->list_stack as $pair) { + $ldepth = $pair[1]; + if ($idepth > $ldepth) { + $this->in_list_item = true; + $this->_set_list_depth($idepth, null, null, null); + return; + } + } + } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) { + $this->in_list_item = true; + return; + } + } + if (!$this->in_def_list) { + $this->_set_quote_depth($idepth); + } + } + + function _citation_formatter($match, $info, $nmatch) { + $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0])); + $this->_set_quote_depth($cdepth, true); + } + + +} + +class MTrackWikiOneLinerFormatter extends MTrackWikiHTMLFormatter { + function format($text, $escape_newlines = false) { + if (!strlen($text)) return; + $this->reset(); + $in_code_block = 0; + $num = 0; + foreach (preg_split("!\r?\n!", $text) as $line) { + if ($num++) $this->out .= ' '; + $result = ''; + if ($this->in_code_block || trim($line) == MTrackWikiParser::STARTBLOCK) { + $in_code_block++; + } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) { + if ($in_code_block) { + $in_code_block--; + if ($in_code_block == 0) { + $result .= " [...]\n"; + } + } + } elseif (!$in_code_block) { + $result .= "$line\n"; + } + + $result = $this->_apply_rules(rtrim($result, "\r\n")); + $this->out .= $result; + $this->close_tag(null); + } + } +} + +/* +#error_reporting(E_NOTICE); +$f = new MTrackWikiHTMLFormatter; +$f->format(file_get_contents("WikiFormatting")); +#$f->format("* '''wooot'''\noh '''yeah'''\n\n"); +#$f->format(" < wez@php.net http://foo.com/bar [https://baz.com/flib Flib] [/foo Shoe]\n"); +/* +$f->format(<<> foo +> bar + +all done +WIKI +); +*/ +/* +echo $f->out, "\n"; +print_r($f->missing); +echo "\ndone\n"; +*/ diff --git a/schema/0.xml b/schema/0.xml new file mode 100644 index 00000000..b238e68c --- /dev/null +++ b/schema/0.xml @@ -0,0 +1,391 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + +
    + + + + + + + + + if defined, mtrac will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + repoid + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid +
    + + + + eid + + + + + revised estimate + + tid +
    + + + nested set representation of a tree, see MTrackTree + + + + objectid + lseq + rseq +
    + + + access control list + + + + -- indicates whether the entry applies to this item or its children + -- sequence number allows explicit ordering for fine grained + -- permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + +
    + + + + alias + +
    + + + + + -- the object to which this is attached + -- sha1 hash of the contents of the attachment + -- (used to locate the underlying file) + + + + + + + + +AFTER DELETE ON attachments + BEGIN + select mtrack_cleanup_attachments(OLD.hash, + (select count(hash) from attachments)); + END; + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    +
    diff --git a/schema/1.php b/schema/1.php new file mode 100644 index 00000000..677c1ba7 --- /dev/null +++ b/schema/1.php @@ -0,0 +1,16 @@ +prepare('update attachments set payload = ? where hash = ?'); + +foreach ($db->query('select hash from attachments')->fetchAll() as $row) { + $path = MTrackAttachment::local_path($row['hash']); + $fp = fopen($path, 'rb'); + $q->bindValue(1, $fp, PDO::PARAM_LOB); + $q->bindValue(2, $row['hash']); + $q->execute(); + fclose($fp); + $fp = null; +} + diff --git a/schema/1.xml b/schema/1.xml new file mode 100644 index 00000000..8e91dd4f --- /dev/null +++ b/schema/1.xml @@ -0,0 +1,402 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + +
    + + + + + + + + + if defined, mtrac will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + repoid + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid +
    + + + + eid + + + + + revised estimate + + tid +
    + + + nested set representation of a tree, see MTrackTree + + + + objectid + lseq + rseq +
    + + + access control list + + + + -- indicates whether the entry applies to this item or its children + -- sequence number allows explicit ordering for fine grained + -- permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/2.xml b/schema/2.xml new file mode 100644 index 00000000..0dd764db --- /dev/null +++ b/schema/2.xml @@ -0,0 +1,432 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid +
    + + + + eid + + + + + revised estimate + + tid +
    + + + nested set representation of a tree, see MTrackTree + + + + objectid + lseq + rseq +
    + + + access control list + + + + -- indicates whether the entry applies to this item or its children + -- sequence number allows explicit ordering for fine grained + -- permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/3.xml b/schema/3.xml new file mode 100644 index 00000000..091a1a5f --- /dev/null +++ b/schema/3.xml @@ -0,0 +1,422 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/4-pre.php b/schema/4-pre.php new file mode 100644 index 00000000..1b5adab6 --- /dev/null +++ b/schema/4-pre.php @@ -0,0 +1,29 @@ +query('select compid, name from components')->fetchAll() as $row) +{ + $names[$row[1]][] = $row[0]; +} + +foreach ($names as $name => $ids) { + if (count($ids) == 1) continue; + echo "Fixing duplicate component: $name\n"; + sort($ids); + $id = array_shift($ids); + $change = join(',', $ids); + + $q = $db->prepare("update ticket_components set compid = ? where compid in ($change)"); + $q->execute(array($id)); + $q = $db->prepare("update components_by_project set compid = ? where compid in ($change)"); + $q->execute(array($id)); + $comps = array(); + foreach ($ids as $i) { + $comps[] = $db->quote("component:$i"); + } + $comps = join(',', $comps); + $db->exec("update changes set object = 'component:$id' where object in ($comps)"); + $db->exec("delete from components where compid in ($change)"); +} + diff --git a/schema/4.xml b/schema/4.xml new file mode 100644 index 00000000..0bc9731c --- /dev/null +++ b/schema/4.xml @@ -0,0 +1,432 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + + + name + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + + + name + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + name + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid + summary +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/5.xml b/schema/5.xml new file mode 100644 index 00000000..1673cdbd --- /dev/null +++ b/schema/5.xml @@ -0,0 +1,456 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + + + name + +
    + + + + + + name + project + +
    + + + + + + + groupname + project + username + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + + + name + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + name + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid + summary +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/6.php b/schema/6.php new file mode 100644 index 00000000..45caf2ea --- /dev/null +++ b/schema/6.php @@ -0,0 +1,9 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + + + name + +
    + + + + + + name + project + +
    + + + + + + + groupname + project + username + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + + + name + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + name + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid + summary +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + + diff --git a/schema/7.xml b/schema/7.xml new file mode 100644 index 00000000..27496622 --- /dev/null +++ b/schema/7.xml @@ -0,0 +1,502 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + + + name + +
    + + + + + + name + project + +
    + + + + + + + groupname + project + username + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + + + name + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + name + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + + + rid + summary +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + + Records things that are being watched by a given user + + + The type of object being watched: ticket, repo, user, project, + milestone, wiki + + + + + The id of the object being watched. + If '*', treated as a wildcard for objects of the specified + type. + + + + + The person doing the watching + + + + + all - interested in all events + tickets - ticket changes + changeset - repo changes + + + + otype + oid + userid + event + medium + + + + email - receive via email + feed - visible in RSS feed + timeline - show up in timeline by default + + + +
    + + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/schema/8.xml b/schema/8.xml new file mode 100644 index 00000000..0030df1f --- /dev/null +++ b/schema/8.xml @@ -0,0 +1,520 @@ + + + + + + used to order the project names + + + + + readable version of the name + + + + + shorter name + + + + + where email notifications are sent + + + + projid + + + name + +
    + + + + + + name + project + +
    + + + + + + + groupname + project + username + +
    + + + + + + + + + if defined, mtrack will use this as the base for links + to changesets and repo browsing, otherwise it will + handle it locally + + + + + + + The URL that SCM tools will use to checkout, + clone, push, pull or otherwise interact with + the repo. + + + + + If NULL, this is a global repo. Otherwise, parent is + a string like 'user:wez' to indicate that it is owned + by 'wez', or 'project:name' to indicate that it is owned + by the 'name' project. + + + + + If this was forked from another repo in the system, + then this field is set to its repoid + + + + repoid + + + shortname + parent + +
    + + + +Links a location within a repo to its "parent" project. +This allows multiple projects to exist within a repository +and also allows pre/post commit rules to determine whether +the location is a personal branch or scratch space, versus +a formal project branch. + + + + + + + + May replace this with a reference to a workflow or other kind + of ruleset to affect pre/post commit + + + + linkid + +
    + + + + + + + compid + + + name + +
    + + + + + projidcompid +
    + + + + + + priorityname +
    + + + + sevname + + +
    + + + + resname + + +
    + + + classname + + +
    + + + statename + + +
    + + + kid + keyword + +
    + + + + + + usually tablename:id + where id is a comma separated list of the primary key fields + of the object that was edited + + + + + + commit/changelog message + + + cid + object + changedate +
    + + + + + + + set, changed, deleted, added, removed. + set: filled in from a blank value + changed: changed existing value. value field has old value. + deleted: set value to blank, value field has old value + added: used for associated values (like keywords); the value field + lists out the primary keys of the added items, comma separated. + removed: used for associated values (like keywords); the value field + lists out the primary keys of the removed items, comma separated + + + + + +
    + + + + mid + + + name + + + + + + + + + + + parent milestone (for sprint support) + + +
    + + + + unique identifier (short form UUID) + + + + identifier assigned within a particular namespace + eg: when a ticket is accepted as a bug, will be assigned + a bug number for that project + + + + + + -- one line summary + -- problem description in detail + + + + + + + -- end-user (or customer) facing summary, suitable for use in + -- a release notes or ChangeLog format + + + + + + + + + + + + + + + + + + tid + nsident +
    + + + + +
    + + + + +
    + + + + +
    + + + + + + For distributed version control, we may push the same + changes into multiple repos that we maintain in the same + mtrack instance. We don't want to count any spent time + more than once, so we allow storing a hash with each + ticket. + + + + tid + hash + +
    + + + + + + + + rid + summary +
    + + + + eid + + + + + revised estimate + + tid +
    + + + access control list + + + + indicates whether the entry applies to this item or its children + sequence number allows explicit ordering for fine grained + permissions (exclude all members of a group, except a particular user) + + + + + user or group name + + + + -- activity or action name ("read", "write") + -- whether access is allowed + + + + + objectid + seq + cascade + + + role + +
    + + + + canonical user id + + userid + + + + + +
    + + + + alias + +
    + + + + + the object to which this is attached + sha1 hash of the contents of the attachment + + + + + + + +
    + + + last time that we procesed change notifications + + last_run +
    + + + last_run +
    + + + + snippet id + + + + + summary/blurb in wiki markup + + + what language? + + + and the snippet itself + + snid +
    + + + Records things that are being watched by a given user + + + The type of object being watched: ticket, repo, user, project, + milestone, wiki + + + + + The id of the object being watched. + If '*', treated as a wildcard for objects of the specified + type. + + + + + The person doing the watching + + + + + all - interested in all events + tickets - ticket changes + changeset - repo changes + + + + otype + oid + userid + event + medium + + + + email - receive via email + feed - visible in RSS feed + timeline - show up in timeline by default + + + +
    + + + +CREATE OR REPLACE FUNCTION _mtrack_group_concat(text, text) + RETURNS text as $$ +SELECT CASE + WHEN $2 IS NULL THEN $1 + WHEN $1 IS NULL THEN $2 +ELSE + $1 operator(pg_catalog.||) ',' operator(pg_catalog.||) $2 +END +$$ IMMUTABLE LANGUAGE SQL; + +-- requires postgres 8.2 and higher +DROP AGGREGATE IF EXISTS mtrack_group_concat(text); + +CREATE AGGREGATE mtrack_group_concat( + BASETYPE = text, + SFUNC = _mtrack_group_concat, + STYPE = text +); + + +
    diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 00000000..a0530282 --- /dev/null +++ b/web/.htaccess @@ -0,0 +1 @@ +php_value magic_quotes_gpc off diff --git a/web/admin/auth.php b/web/admin/auth.php new file mode 100644 index 00000000..e3189ea8 --- /dev/null +++ b/web/admin/auth.php @@ -0,0 +1,294 @@ + $role) { + if ($role == 'admin') { + if (preg_match('@^https?://@', $id)) { + $admins[] = $id; + } else { + $regadmins[$id] = $id; + } + } + } + if (count($regadmins)) { + /* look at aliases to see if there are any that look like OpenIDs */ + foreach (MTrackDB::q('select alias, userid from useraliases')->fetchAll() + as $row) { + if (!preg_match('@^https?://@', $row[0])) { + continue; + } + if (isset($regadmins[$row[1]])) { + $admins[] = $row[0]; + } + } + } + return $admins; +} + +function get_admins() +{ + $admins = array(); + foreach (MTrackConfig::getSection('user_classes') as $id => $role) { + if ($role == 'admin' && !preg_match('@^https?://@', $id)) { + $admins[] = $id; + } + } + return $admins; +} + +$message = null; + +if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if (isset($_POST['setuppublic'])) { + $admins = get_openid_admins(); + $add_admin = isset($_POST['adminopenid']) ? + trim($_POST['adminopenid']) : ''; + $localid = isset($_POST['adminuserid']) ? + trim($_POST['adminuserid']) : ''; + if (count($admins) == 0 && (!strlen($add_admin) || !strlen($localid))) { + $message = "You MUST add an OpenID for the administrator"; + } else { + if (strlen($localid)) { + MTrackConfig::set('user_classes', $localid, 'admin'); + } + $new = true; + foreach (MTrackDB::q('select userid from userinfo where userid = ?', + $localid)->fetchAll() as $row) { + $new = false; + break; + } + if ($new) { + MTrackDB::q('insert into userinfo (userid, active) values (?, 1)', $localid); + } + $new = true; + foreach (MTrackDB::q('select userid from useraliases where alias = ?', $add_admin)->fetchAll() as $row) { + if ($row[0] != $localid) { + throw new Exception("$add_admin is already associated with $row[0]"); + } + $new = false; + } + if ($new) { + MTrackDB::q('insert into useraliases (userid, alias) values (?,?)', + $localid, $add_admin); + } + + MTrackConfig::set('plugins', 'MTrackAuth_OpenID', ''); + if (isset($plugins['MTrackAuth_HTTP'])) { + MTrackConfig::remove('plugins', 'MTrackAuth_HTTP'); + // Reset anonymous for public access + MTrackConfig::remove('user_class_roles', 'anonymous'); + } + + MTrackConfig::save(); + header("Location: {$ABSWEB}admin/auth.php"); + exit; + } + } elseif (isset($_POST['setupprivate'])) { + $admins = get_admins(); + $add_admin = isset($_POST['adminuser']) ? + trim($_POST['adminuser']) : ''; + if (count($admins) == 0 && !strlen($add_admin)) { + $message = "You MUST add a user with admin rights"; + } else { + $vardir = MTrackConfig::get('core', 'vardir'); + $pfile = "$vardir/http.user"; + + if (strlen($add_admin)) { + if (!isset($_SERVER['REMOTE_USER'])) { + // validate the password + if ($_POST['adminpass1'] != $_POST['adminpass2']) { + $message = "Passwords don't match"; + } else { + $http_auth = new MTrackAuth_HTTP(null, "digest:$pfile"); + $http_auth->setUserPassword($add_admin, $_POST['adminpass1']); + } + } + MTrackConfig::set('user_classes', $add_admin, 'admin'); + } + if ($message == null) { + if (!isset($plugins['MTrackAuth_HTTP'])) { + MTrackConfig::set('plugins', 'MTrackAuth_HTTP', + "$vardir/http.group, digest:$pfile"); + } + if (isset($plugins['MTrackAuth_OpenID'])) { + MTrackConfig::remove('plugins', 'MTrackAuth_OpenID'); + // Set up the roles for private access + // Use default authenticated permissions + MTrackConfig::remove('user_class_roles', 'authenticated'); + // Make anonymous have no rights + MTrackConfig::set('user_class_roles', 'anonymous', ''); + } + MTrackConfig::save(); + header("Location: {$ABSWEB}admin/auth.php"); + exit; + } + } + } +} + +mtrack_head("Administration - Authentication"); + +$plugins = MTrackConfig::getSection('plugins'); +$http_configd = isset($plugins['MTrackAuth_HTTP']) ? " (Active)" : ''; +$openid_configd = isset($plugins['MTrackAuth_OpenID']) ? " (Active)" : ''; + + +?> +

    Authentication

    + + + $message + +HTML; +} + + +?> +

    +Select one of the following, depending +on which one best matches your intended mtrack deployment: +

    + +
    +
    +

    Private (HTTP authentication)

    +
    +

    + I want to strictly control who has access to mtrack, and prevent + anonymous users from having any rights. +

    + +

    + It looks like your web server is configured to use HTTP authentication + (you're authenticated as ) + mtrack will defer to your web server configuration for authentication. + Contact your system administrator to add or remove users, or to change + their passwords. You may still use the mtrack user management screens + to change rights assignments for the users. +

    + +

    + mtrack will use HTTP authentication and store the password and group + files in the vardir. +

    +Administrators"; +$admins = get_admins(); +if (count($admins)) { + echo "

    The following users are configured with admin rights:

    "; + echo "

    "; + foreach ($admins as $id) { + echo mtrack_username($id) . " "; + } + echo "

    "; +} else { + echo <<You MUST add at least one user as an administrator, +otherwise no one will be able to administer the system without editing +the config.ini file. +

    +HTML; + + echo << + +Add Admin User: + + +HTML; + + if (!isset($_SERVER['REMOTE_USER'])) { + echo << + Set Password: + + + + Confirm Password: + + + +HTML; + } else { + echo << +

    +You can't set the password here, because mtrack has no way to automatically +find out how to do that. Contact your system administrator to ensure that +you have a username and password configured for mtrack

    +HTML; + } +} +?> + + +
    +

    Public (OpenID)

    +
    +

    + I want to allow the public access to mtrack, but only allow people that + I trust to make certain kinds of changes. +

    +

    + mtrack will use OpenID to manage authentication. +

    +

    Administrators

    +The following OpenID users are configured with admin rights:

    "; + echo "

    "; + foreach ($admins as $id) { + echo mtrack_username($id) . " ($id) "; + } + echo "

    "; +} else { + echo <<You MUST add at least one OpenID as an administrator, +otherwise no one will be able to administer the system without editing +the config.ini file. +

    +HTML; +} +?> +Add Admin OpenID:
    +Local Username:
    + +
    +
    +
    + +name = $_POST['newcomponent']; + $comp->setProjects($_POST['newcomponentprojects']); + $comp->save($CS); + $CS->setObject("component:$comp->compid"); + $CS->commit(); + } + foreach ($_POST as $name => $value) { + if (preg_match("/^comp:(\d+):name$/", $name, $M)) { + $compid = (int)$M[1]; + $C = MTrackComponent::loadById($compid); + $changed = false; + + if ($C->name != $_POST["comp:$compid:name"]) { + $C->name = $_POST["comp:$compid:name"]; + $changed = true; + } + if (isset($_POST["comp:$compid:deleted"]) && + $_POST["comp:$compid:deleted"] == "on") { + $deleted = '1'; + } else { + $deleted = ''; + } + if ($C->deleted != $deleted) { + $C->deleted = $deleted; + $changed = true; + } + $plist = $_POST["comp:$compid:projects"]; + if (is_array($plist)) { + asort($plist); + } + if ($plist != $C->getProjects()) { + $C->setProjects($plist); + $changed = true; + } + if ($changed) { + $CS = MTrackChangeset::begin("component:$compid", + "Edit Component $C->name"); + + $C->save($CS); + $CS->commit(); + } + } + } + header("Location: ${ABSWEB}admin/"); + exit; +} + +mtrack_head("Administration - Components"); + +echo "
    "; +echo "
    Components
    \n"; +echo "\n"; + +$projects = array(); +foreach (MTrackDB::q('select projid, name, shortname from projects + order by name')->fetchAll() as $row) { + if ($row[1] != $row[2]) { + $projects[$row[0]] = $row[1] . " ($row[2])"; + } else { + $projects[$row[0]] = $row[1]; + } +} + +$p_by_c = array(); +foreach (MTrackDB::q('select compid, projid from components_by_project') + ->fetchAll() as $row) { + $p_by_c[$row[0]][$row[1]] = $row[1]; +} + +foreach (MTrackDB::q('select compid, name, deleted from components order by name')->fetchAll() as $row) { + $compid = (int)$row[0]; + $name = htmlentities($row[1], ENT_QUOTES, 'utf-8'); + $del = $row[2] ? ' checked="checked" ' : ''; + echo "" . + "" . + "" . + "" . + "\n"; +} + +echo "" . + "\n"; + +echo "
    NameProjectsDeleted
    " . mtrack_multi_select_box("comp:$compid:projects", + "(select to add)", $projects, $p_by_c[$compid]) . + "
    " . mtrack_multi_select_box('newcomponentprojects', + "(select to add)", $projects) . + "Add a new Component
    \n"; + +echo "
    "; + +mtrack_foot(); + diff --git a/web/admin/customfield.php b/web/admin/customfield.php new file mode 100644 index 00000000..6c5db508 --- /dev/null +++ b/web/admin/customfield.php @@ -0,0 +1,199 @@ +field_types[$type])) { + throw new Exception("invalid type $type"); + } + + $name = MTrackTicket_CustomField::canonName($name); + if (!preg_match("/^x_[a-z_]+$/", $name)) { + throw new Exception("invalid field name $name"); + } + + $field = $C->fieldByName($name, true); + + if (isset($_POST['delete'])) { + $C->deleteField($field); + } else { + $field->group = $group; + $field->label = $label; + $field->type = $type; + $field->order = $order; + $field->options = $options; + $field->default = $default; + } + + $C->save(); + MTrackConfig::save(); + + header("Location: ${ABSWEB}admin/customfield.php"); + exit; +} + +mtrack_head("Administration - Custom Fields"); +echo "

    Custom Fields

    "; + + +$field = null; +if (isset($_GET['add'])) { + $field = new MTrackTicket_CustomField; + $field->type = 'text'; + $field->name = 'x_fieldname'; + $field->label = 'The Label'; + $field->group = 'Custom Fields'; +} else if (isset($_GET['field'])) { + $field = $C->fieldByName($_GET['field']); + if ($field === null) { + throw new Exception("No such field " . $_GET['field']); + } +} + +if ($field) { + $type = mtrack_select_box('type', $C->field_types, $field->type); + $name = htmlentities($field->name, ENT_QUOTES, 'utf-8'); + $label = htmlentities($field->label, ENT_QUOTES, 'utf-8'); + $group = htmlentities($field->group, ENT_QUOTES, 'utf-8'); + $options = htmlentities($field->options, ENT_QUOTES, 'utf-8'); + $default = htmlentities($field->default, ENT_QUOTES, 'utf-8'); + $order = $field->order; +?> +
    +
    + Edit Custom Field + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + The field name to use in the database. Must have a prefix + of 'x_' and must only contain characters a-z or underscore. + You cannot rename a field; once it is created, it stays + in the database. You can use the label field below if you + want to change the presentation.

    + The label to display on the ticket screen

    + Fields with the same group are grouped together on the ticket + editing screen

    + Enter the default value for this field

    + For Select and Multi-Select types, enter a list of possible + choices here, separated by a pipe character |
    +
    + Lower means show first. If two or more fields have same 'order', + then they are ordered by name +
    + + + +
    +
    + + + +getGroupedFields(); + +foreach ($grouped as $groupname => $group) { + $groupname = htmlentities($groupname, ENT_QUOTES, 'utf-8'); + echo "Group: $groupname
    \n\n"; + foreach ($group as $field) { + $type = $field->type; + $label = htmlentities($field->label, ENT_QUOTES, 'utf-8'); + $name = $field->name; + $name = "$name"; + echo "\n"; + } + echo "
    $name$type$label
    \n"; +} + +?> +
    + + +
    +shortname"); + $S->deleteRepo($CS); + $CS->commit(); +} + +header("Location: ${ABSWEB}browse.php"); +exit; + diff --git a/web/admin/enum.php b/web/admin/enum.php new file mode 100644 index 00000000..77580b49 --- /dev/null +++ b/web/admin/enum.php @@ -0,0 +1,88 @@ +name = $_POST["$ename:name:"]; + $obj->value = $_POST["$ename:value:"]; + $CS = MTrackChangeset::begin("enum:$obj->tablename:$obj->name", + "added $ename $obj->name"); + $obj->save($CS); + $CS->commit(); + } + + foreach ($_POST as $name => $value) { + if (preg_match("/^$ename:value:(.+)$/", $name, $M)) { + $n = $M[1]; + $obj = new $cls($n); + $changed = false; + + if ($obj->value != $value) { + $obj->value = $value; + $changed = true; + } + if (isset($_POST["$ename:deleted:$n"]) && + $_POST["$ename:deleted:$n"] == "on") { + $deleted = '1'; + } else { + $deleted = ''; + } + if ($obj->deleted != $deleted) { + $obj->deleted = $deleted; + $changed = true; + } + + if ($changed) { + $CS = MTrackChangeset::begin("enum:$obj->tablename:$obj->name", + "changed $ename $obj->name"); + $obj->save($CS); + $CS->commit(); + } + } + } + header("Location: ${ABSWEB}admin/"); + exit; +} + +mtrack_head("Administration - $ename"); + +echo "
    "; + +$cls = 'MTrack' . $ename; +$obj = new $cls; +echo "
    $ename values
    \n"; +$vals = $obj->enumerate(true); +echo "\n"; +foreach ($vals as $V) { + $n = htmlentities($V['name'], ENT_QUOTES, 'utf-8'); + $v = htmlentities($V['value'], ENT_QUOTES, 'utf-8'); + $del = $V['deleted'] ? ' checked="checked" ' : ''; + echo "" . + "" . + "" . + "" . + "\n"; +} +echo "" . + "" . + "" . + "" . + "\n"; +echo "
    NameValueDeleted
    $n
    Add a new $ename
    \n"; + +echo "
    "; + +mtrack_foot(); + diff --git a/web/admin/forkrepo.php b/web/admin/forkrepo.php new file mode 100644 index 00000000..c06b724b --- /dev/null +++ b/web/admin/forkrepo.php @@ -0,0 +1,53 @@ +canFork()) { + throw new Exception("cannot fork this repo"); + } + $P = new MTrackRepo; + $P->shortname = $name; + if (isset($_POST['repo:parent'])) { + // FIXME: ACL check to see if we're allowed to create under the specified + // parent + $P->parent = $_POST['repo:parent']; + } else { + $P->parent = "user:$owner"; + } + + $P->scmtype = $S->scmtype; + $P->description = $S->description; + $P->clonedfrom = $S->repoid; + + $CS = MTrackChangeset::begin("repo:X", + "Clone repo $S->shortname as $P->shortname"); + $P->save($CS); + $CS->setObject("repo:$P->repoid"); + $CS->commit(); + $name = $P->getBrowseRootName(); + header("Location: ${ABSWEB}browse.php/$name"); + exit; +} + +header("Location: ${ABSWEB}browse.php"); +exit; + diff --git a/web/admin/group.php b/web/admin/group.php new file mode 100644 index 00000000..2ad19b8f --- /dev/null +++ b/web/admin/group.php @@ -0,0 +1,87 @@ +commit(); + header("Location: {$ABSWEB}admin/project.php?edit=$pid"); + exit; +} + +mtrack_head($group ? "$P->name - $group" : "$P->name - New Group"); + +echo "
    "; +if ($group) { + echo "

    " . htmlentities("$P->name - $group", ENT_QUOTES, 'utf-8') . "

    "; + echo ""; +} else { + echo "

    " . htmlentities("$P->name - New Group", ENT_QUOTES, 'utf-8') . "

    "; + echo "Group: "; + echo ""; +} + +$users = array(); +foreach (MTrackDB::q('select userid, fullname from userinfo where active = 1 order by userid') + ->fetchAll() as $row) { + if (strlen($row[1])) { + $disp = "$row[0] - $row[1]"; + } else { + $disp = $row[0]; + } + $users[$row[0]] = $disp; +} +$members = array(); +foreach (MTrackDB::q('select username from group_membership where project = ? and groupname = ?', $pid, $group)->fetchAll(PDO::FETCH_COLUMN, 0) as $name) { + $members[$name] = $name; +} +echo mtrack_multi_select_box('members', "Members", $users, $members); + +echo ""; + +echo "
    "; + +mtrack_foot(); + diff --git a/web/admin/importcsv.php b/web/admin/importcsv.php new file mode 100644 index 00000000..a79651aa --- /dev/null +++ b/web/admin/importcsv.php @@ -0,0 +1,325 @@ + 'status', + 'pri' => 'priority', + 'id' => 'ticket', + 'type' => 'classification', +); +$supported_fields = array( + 'classification', + 'ticket', + 'milestone', + '-milestone', + '+milestone', + 'summary', + 'status', + 'priority', + 'owner', + 'type', + 'component', + '-component', + '+component', + 'description' +); +foreach ($supported_fields as $i => $f) { + unset($supported_fields[$i]); + $supported_fields[$f] = $f; +} + +$C = MTrackTicket_CustomFields::getInstance(); +foreach ($C->fields as $f) { + $name = substr($f->name, 2); + $supported_fields[$f->name] = $f->name; + if (!isset($field_aliases[$name])) { + $field_aliases[$name] = $f->name; + } +} + +if ($_SERVER['REQUEST_METHOD'] == 'POST') { + + if (isset($_FILES['csvfile']) && $_FILES['csvfile']['error'] == 0 + && is_uploaded_file($_FILES['csvfile']['tmp_name'])) { + ini_set('auto_detect_line_endings', true); + $fp = fopen($_FILES['csvfile']['tmp_name'], 'r'); + $header = fgetcsv($fp); + $err = array(); + $output = array(); + foreach ($header as $i => $name) { + $name = strtolower($name); + if (isset($field_aliases[$name])) { + $name = $field_aliases[$name]; + } + if (!isset($supported_fields[$name])) { + $err[] = "Unsupported field: $name"; + } + $header[$i] = $name; + } + $db = MTrackDB::get(); + $db->beginTransaction(); + MTrackChangeset::$use_txn = false; + $todo = array(); + do { + $line = fgetcsv($fp); + if ($line === false) break; + + $item = array(); + foreach ($header as $i => $name) { + $item[$name] = $line[$i]; + } + + if (isset($item['ticket'])) { + $id = $item['ticket']; + if ($id[0] == '#') { + $id = substr($id, 1); + } + try { + $tkt = MTrackIssue::loadByNSIdent($id); + if ($tkt == null) { + $err[] = "No such ticket $id"; + continue; + } + } catch (Exception $e) { + $err[] = $e->getMessage(); + continue; + } + $output[] = "Updating ticket $tkt->nsident
    \n"; + } else { + $tkt = new MTrackIssue; + $tkt->priority = 'normal'; + list($tkt->nsident) = MTrackDB::q( + 'select max(cast(nsident as integer)) + 1 from tickets') + ->fetchAll(PDO::FETCH_COLUMN, 0); + if ($tkt->nsident === null) { + $tkt->nsident = 1; + } + $output[] = "Creating ticket $tkt->nsident
    \n"; + } + $CS = MTrackChangeset::begin("ticket:X", $_POST['comment']); + if (strlen(trim($_POST['comment']))) { + $tkt->addComment($_POST['comment']); + } + foreach ($item as $name => $value) { + if ($name == 'ticket') { + continue; + } + $output[] = "$name => $value
    \n"; + try { + switch ($name) { + case 'summary': + case 'description': + case 'classification': + case 'priority': + case 'severity': + case 'changelog': + case 'owner': + case 'cc': + $tkt->$name = strlen($value) ? $value : null; + break; + case 'milestone': + if (strlen($value)) { + foreach ($tkt->getMilestones() as $mid) { + $tkt->dissocMilestone($mid); + } + $tkt->assocMilestone($value); + } + break; + case '+milestone': + if (strlen($value)) { + $tkt->assocMilestone($value); + } + break; + case '-milestone': + if (strlen($value)) { + $tkt->dissocMilestone($value); + } + break; + case 'component': + if (strlen($value)) { + foreach ($tkt->getComponents() as $mid) { + $tkt->dissocComponent($mid); + } + $tkt->assocComponent($value); + } + break; + case '+component': + if (strlen($value)) { + $tkt->assocComponent($value); + } + break; + case '-component': + if (strlen($value)) { + $tkt->dissocComponent($value); + } + break; + default: + if (!strncmp($name, 'x_', 2)) { + $tkt->{$name} = $value; + } + break; + } + } catch (Exception $e) { + $err[] = $e->getMessage(); + } + } + $tkt->save($CS); + $CS->setObject("ticket:" . $tkt->tid); + + } while (true); + $_SESSION['admin.import.result'] = array($err, $output); + if (count($err)) { + $db->rollback(); + } else { + $db->commit(); + } + } + header("Location: {$ABSWEB}admin/importcsv.php"); + exit; +} + +if (isset($_SESSION['admin.import.result'])) { + list($err, $info) = $_SESSION['admin.import.result']; + unset($_SESSION['admin.import.result']); + + mtrack_head(count($err) ? 'Import Failed' : 'Import Complete'); + + foreach ($info as $line) { + echo $line; + } + + if (count($err)) { + echo "The following errors were encountered:
    \n"; + foreach ($err as $msg) { + echo htmlentities($msg) . "
    \n"; + } + echo "
    No changes were committed
    \n"; + } else { + echo "
    Done!\n"; + } + + mtrack_foot(); + exit; +} + +mtrack_head('Import'); + +?> +

    Import/Update via CSV

    + +

    +You may use this facility to change ticket properties en-masse by uploading +a CSV file. +

    + +
      +
    • If a ticket column is present and non-empty, + that ticket will be updated
    • +
    • If there is no ticket column, or the ticket column is empty, + then a ticket will be created
    • +
    • If any errors are detected, none of the changes from the CSV file + will be applied
    • +
    + +

    +The input file must be a CSV file with the field names on the first line. +

    + +

    +The following fields are supported: +

    + +
    +
    ticket
    +
    The ticket number
    + +
    milestone
    +
    The value to use for the milestone. If updating an existing ticket, + this field will remove any other milestones in the ticket and set it to + only this value. +
    + +
    -milestone
    +
    Removes a milestone; if the ticket is associated with the named milestone, + it will be removed from that milestone. +
    + +
    +milestone
    +
    Associates the ticket with the named milestone, preserving any other + milestones currently associated with the ticket. +
    + +
    summary
    +
    Sets the summary for the ticket
    + +
    status or state
    +
    Sets the state of the ticket; can be one of the configured ticket states +
    + +
    priority
    +
    Sets the priority; can be one of the configured priorities
    + +
    owner
    +
    Sets the owner
    + +
    type
    +
    Sets the ticket type
    + +
    component
    +
    Sets the component, replacing all other component associations
    + +
    -component
    +
    Removes association with the named component
    + +
    +component
    +
    Associates with the named component, preserving existing associations
    + +
    description
    +
    Sets the description of the ticket
    + +fields as $f) { + $name = substr($f->name, 2); + if (!isset($field_aliases[$name]) || $field_aliases[$name] != $f->name) { + $name = $f->name; + echo "
    $name
    \n"; + } else { + echo "
    $name
    \n"; + echo "
    $f->name
    \n"; + } + echo "
    " . htmlentities($f->label, ENT_QUOTES, 'utf-8') . "\n"; + + if ($f->type == 'select') { + echo "
    Value may be one of:
    "; + $data = $f->ticketData(); + foreach ($data['options'] as $opt) { + echo " " . htmlentities($opt, ENT_QUOTES, 'utf-8') . "
    "; + } + } + + echo "
    \n"; +} + +?> + +
    + +

    Import

    + +

    Enter a comment in the box below; it will be added as a comment to +all affected tickets

    + +
    + + + +
    + + 'Configure Tickets', + 'projects' => 'Configure Projects & Notifications', + 'repo' => 'Configure Repositories', + 'user' => 'User Administration & Authentication', + 'logs' => 'Review Logs', +); + +$by_cat = array(); + +function add_cat($url) { + global $by_cat; + $cats = func_get_args(); + array_shift($cats); + foreach ($cats as $cat) { + $by_cat[$cat][] = $url; + } +} + +if (MTrackACL::hasAnyRights('Projects', 'modify')) { + add_cat("Projects and their notification settings", 'projects'); +} + +if (MTrackACL::hasAnyRights('Enumerations', 'modify')) { + $eurl = $ABSWEB . 'admin/enum.php'; + add_cat("Priority, TicketState, Severity, Resolution and Classification fields used in tickets", 'tickets'); + add_cat("Custom Fields", 'tickets'); +} + +if (MTrackACL::hasAnyRights('Components', 'modify')) { + add_cat("Components and their associations with Projects", 'tickets', 'projects'); +} + +if (MTrackACL::hasAnyRights('Tickets', 'create')) { + add_cat("Import Tickets from a CSV file", 'tickets'); +} + +if (MTrackACL::hasAnyRights('Browser', 'modify')) { + add_cat("Configure Repositories and their links to Projects", 'repo'); +} + +if (MTrackACL::hasAllRights('User', 'modify')) { + add_cat("Administer Authentication", 'user'); + add_cat("Administer Users", 'user'); +} + +if (MTrackACL::hasAllRights('Browser', 'modify')) { + add_cat("Indexer logs", 'logs'); +} + +foreach ($cat_titles as $cat => $title) { + $links = $by_cat[$cat]; + if (count($links) == 0) { + continue; + } + echo "

    $title

    "; + foreach ($links as $link) { + echo $link, "
    \n"; + } +} + +mtrack_foot(); + diff --git a/web/admin/logs.php b/web/admin/logs.php new file mode 100644 index 00000000..f94080de --- /dev/null +++ b/web/admin/logs.php @@ -0,0 +1,62 @@ +Indexer Log\n"; +echo "$filename
    \n"; +$mtime = filemtime($filename); +if ($mtime) { + echo "Modified: " . mtrack_date("@$mtime", true) . "
    "; +} + +$last = null; +foreach (MTrackDB::q('select last_run from search_engine_state')->fetchAll() + as $row) { + $last = $row[0]; +} +if ($last === null) { + echo "No objects have been indexed yet\n"; +} else { + echo "Last Indexed Object: " . mtrack_date($last, true) . "
    \n"; +} + +if ($mtime) { + $fp = fopen($filename, 'r'); + $lines = array(); + while (($line = fgets($fp)) !== false) { + $lines[] = htmlentities($line, ENT_QUOTES, 'utf-8'); + if (count($lines) > 100) { + array_shift($lines); + } + } + echo "
    ";
    +  foreach ($lines as $line) {
    +    echo $line;
    +  }
    +  echo "
    "; +} +?> +
    + +
    +name = $_POST["name"]; + $P->shortname = $_POST["shortname"]; + $P->ordinal = $_POST["ordinal"]; + $P->notifyemail = $_POST["email"]; + $CS = MTrackChangeset::begin("project:X", + $pid == 'new' ? + "added project $P->name" : + "edit project $P->name"); + $P->save($CS); + + if (MTrackACL::hasAnyRights('Components', 'modify')) { + MTrackDB::q('delete from components_by_project where projid = ?', $P->projid); + if (isset($_POST['components'])) { + $comps = $_POST['components']; + foreach ($comps as $cid) { + MTrackDB::q( + 'insert into components_by_project (compid, projid) values (?, ?)', + $cid, $P->projid); + } + } + } + + $CS->setObject("project:$P->projid"); + if (isset($_POST['perms'])) { + MTrackACL::setACL("project:$P->projid", 0, json_decode($_POST['perms'])); + } + $CS->commit(); + + header("Location: ${ABSWEB}admin/project.php"); + exit; +} + +mtrack_head("Administration - Projects"); + +?> +

    Projects

    +

    +Projects can be created to track development on a per-project or per-product +basis. Components may be associated with a project, as well as a default +email distribution address. +

    + 'new', + 'name' => 'My New Project', + 'shortname' => 'newproject', + 'ordinal' => 5, + 'notifyemail' => null + ); + } + echo "
    "; + + echo ""; + $name = htmlentities($p['name'], ENT_QUOTES, 'utf-8'); + $sname = htmlentities($p['shortname'], ENT_QUOTES, 'utf-8'); + $ord = htmlentities($p['ordinal'], ENT_QUOTES, 'utf-8'); + $email = htmlentities($p['notifyemail'], ENT_QUOTES, 'utf-8'); + echo "", + ""; + echo "", + ""; + echo "", + ""; + echo "", + ""; + echo "
    Name
    Short Name
    Sorting
    Group Email Address
    "; + + if (MTrackACL::hasAnyRights('Components', 'modify')) { + $components = array(); + foreach (MTrackDB::q( + 'select compid, name, deleted from components order by name') + ->fetchAll() as $row) { + if ($row[2]) { + $row[1] .= " (deleted)"; + } + $components[$row[0]] = $row[1]; + } + $p_by_c = array(); + if ($pid != 'new') { + foreach (MTrackDB::q( + 'select compid from components_by_project where projid = ?', $pid) + ->fetchAll() as $row) { + $p_by_c[$row[0]] = $row[0]; + } + } + echo "

    Components

    "; + echo "

    Associate component(s) with this project

    "; + echo mtrack_multi_select_box('components', "(select to add)", + $components, $p_by_c); + } + + $repos = array(); + foreach (MTrackDB::q('select distinct r.repoid, shortname from project_repo_link p left join repos r on p.repoid = r.repoid where projid = ?', (int)$pid) as $row) { + $repos[$row[0]] = $row[1]; + } + foreach (MTrackDB::q("select repoid, shortname from repos where parent = 'project:' || ?", $p['shortname']) as $row) { + $repos[$row[0]] = $row[1]; + } + + if ($pid != 'new') { + echo "

    Groups

    "; + echo "

    The following groups are associated with this project. You may assign permissions to groups to make it easier to manage groups of users.

    "; + + foreach (MTrackDB::q('select name from groups where project = ?', $pid) + as $row) { + echo "" + . htmlentities($row[0], ENT_QUOTES, 'utf-8') . '
    '; + } + + echo "New Group"; + } + + echo "

    Linked Repositories

    "; + if (count($repos)) { + echo "\n"; + } else { + echo "No linked repositories\n"; + } + echo "

    \n"; + + if (MTrackACL::hasAnyRights("project:$pid", 'modify')) { + $action_map = array( + 'Admin' => array( + 'modify' => 'Administer via web UI', + ), + ); + + MTrackACL::renderACLForm('perms', "project:$pid", $action_map); + } + + echo ""; + echo ""; + + echo "
    "; +} else { +?> +

    +Select a project below to edit it, or click the "Add" button to create +a new project. +

    +\n"; + foreach (MTrackDB::q( + 'select projid, name, shortname, ordinal, notifyemail + from projects order by ordinal') as $row) { + + $pid = $row[0]; + $name = htmlentities($row[1], ENT_QUOTES, 'utf-8'); + $sname = htmlentities($row[2], ENT_QUOTES, 'utf-8'); + if ($sname != $name) { + $sname = " ($sname)"; + } else { + $sname = ''; + } + $email = htmlentities($row[4], ENT_QUOTES, 'utf-8'); + + echo "", + "$name$sname", + "$email", + "\n"; + + } + echo "
    "; + + echo "
    "; + echo ""; + echo "
    "; +} + +mtrack_foot(); + diff --git a/web/admin/repo.php b/web/admin/repo.php new file mode 100644 index 00000000..44d6d995 --- /dev/null +++ b/web/admin/repo.php @@ -0,0 +1,282 @@ +getLinks(); + $plinks = array(); + + foreach ($_POST as $name => $value) { + if (preg_match("/^link:(\d+|new):project$/", $name, $M)) { + $lid = $M[1]; + $plinks[$lid] = array( + (int)$_POST["link:$lid:project"], + trim($_POST["link:$lid:regex"])); + } + } + if (isset($plinks['new'])) { + $n = $plinks['new']; + unset($plinks['new']); + if (strlen($n[1])) { + $P->addLink($n[0], $n[1]); + } + } + foreach ($plinks as $lid => $n) { + if (isset($links[$lid])) { + if ($n != $links[$lid] || !strlen($n[1])) { + $P->removeLink($lid); + if (strlen($n[1])) { + $P->addLink($n[0], $n[1]); + } + } + } else if (strlen($n[1])) { + $P->addLink($n[0], $n[1]); + } + } + + $restricted = !MTrackACL::hasAnyRights('Browser', 'create'); + if ($rid == 'new') { + if (isset($_POST['repo:name'])) { + $P->shortname = $_POST["repo:name"]; + } + if (isset($_POST['repo:type'])) { + $P->scmtype = $_POST["repo:type"]; + } + if (isset($_POST['repo:path'])) { + if ($restricted) throw new Exception("cannot set the repo path"); + $P->repopath = $_POST["repo:path"]; + } + if (isset($_POST['repo:parent']) && strlen($_POST['repo:parent'])) { + $P->parent = $_POST["repo:parent"]; + } + } else { + $editable = !strlen($P->parent); + + if (isset($_POST['repo:name']) && $_POST['repo:name'] != $P->shortname) { + if (!$editable) throw new Exception("cannot change the repo name"); + $P->shortname = $_POST["repo:name"]; + } + if (isset($_POST['repo:type']) && $_POST['repo:type'] != $P->scmtype) { + if (!$editable) throw new Exception("cannot change the repo type"); + $P->scmtype = $_POST["repo:type"]; + } + if (isset($_POST['repo:path']) && $_POST['repo:path'] != $P->repopath) { + if (!$editable) throw new Exception("cannot change the repo path"); + $P->repopath = $_POST["repo:path"]; + } + if (isset($_POST['repo:parent']) && $_POST['repo:parent'] != $P->parent) { + if (!$editable) throw new Exception("cannot change the repo parent"); + $P->parent = $_POST["repo:parent"]; + } + } + if (isset($_POST["repo:description"])) { + $P->description = $_POST["repo:description"]; + } + + $CS = MTrackChangeset::begin("repo:$rid", "Edit repo $P->shortname"); + $P->save($CS); + $CS->setObject("repo:$P->repoid"); + + if (isset($_POST['perms'])) { + $perms = json_decode($_POST['perms']); + MTrackACL::setACL("repo:$P->repoid", 0, $perms); + } + + $CS->commit(); + header("Location: ${ABSWEB}browse.php/" . $P->getBrowseRootName()); + exit; +} + +mtrack_head("Administration - Repositories"); +if (!strlen($rid)) { + MTrackACL::requireAnyRights('Browser', 'modify'); +?> +

    Repositories

    + +

    +Repositories are version controlled folders that remember your files and +folders at various points in time. Mtrack has support for multiple different +Software Configuration Management systems (also known as Version Control +Systems; SCM and VCS are the common acronyms). +

    +

    +Listed below are the repositories that mtrack is configured to use. +The wiki repository is treated specially by mtrack; it stores the +wiki pages. Click on the repository name to edit it, or click on the "Add" +button to tell mtrack to use another repository. +

    +
      +$name\n"; + } + } + echo "
    "; + if (MTrackACL::hasAnyRights('Browser', 'create')) { + echo "Add new repo
    \n"; + } + mtrack_foot(); + exit; +} + +$repotypes = array(); +foreach (MTrackRepo::getAvailableSCMs() as $t => $r) { + $d = $r->getSCMMetaData(); + $repotypes[$t] = $d['name']; +} + +echo "
    "; + +if ($rid == 'new') { + MTrackACL::requireAnyRights('Browser', 'create'); +?> +

    Add new or existing Repository

    +

    + Use the form below to tell mtrack where to find an existing + repository and add it to its list. Leave the "Path" field + blank to create a new repository. +

    + +" . + "" . + ""; + echo "" . + "\n"; + echo "" . + "" . + "\n"; + echo "\n"; + echo "
    Name
    Type" . + mtrack_select_box("repo:type", $repotypes, null, true) . + "
    Path
    Description
    You may use WikiFormatting
    \n"; + echo "
    "; +} else { + $P = MTrackRepo::loadById($rid); + MTrackACL::requireAnyRights("repo:$P->repoid", 'modify'); + + $name = htmlentities($P->shortname, ENT_QUOTES, 'utf-8'); + $type = htmlentities($P->scmtype, ENT_QUOTES, 'utf-8'); + $path = htmlentities($P->repopath, ENT_QUOTES, 'utf-8'); + $desc = htmlentities($P->description, ENT_QUOTES, 'utf-8'); + + echo "

    Repository: $name

    \n"; + echo "\n"; + + if (!$P->parent) { + /* not created/managed by us; some fields are editable */ + $name = ""; + $type = mtrack_select_box("repo:type", $repotypes, $type); + $path = ""; + } else { + $name = htmlentities($P->getBrowseRootName(), ENT_QUOTES, 'utf-8'); + } + + echo ""; + echo "\n"; + echo "\n"; + echo "\n"; + + echo "\n"; + echo "
    Name$name
    Type$type
    Path$path
    Description
    You may use WikiFormatting
    \n"; + echo "
    \n"; + + $action_map = array( + 'Web' => array( + 'read' => 'Browse via web UI', + 'modify' => 'Administer via web UI', + 'delete' => 'Delete repo via web UI', + ), + 'SSH' => array( + 'checkout' => 'Check-out repo via SSH', + 'commit' => 'Commit changes to repo via SSH', + ), + ); + + MTrackACL::renderACLForm('perms', "repo:$P->repoid", $action_map); + + echo "
    "; +} + +$projects = array(); +foreach (MTrackDB::q('select projid, name, shortname from projects + order by name')->fetchAll() as $row) { + if ($row[1] != $row[2]) { + $projects[$row[0]] = $row[1] . " ($row[2])"; + } else { + $projects[$row[0]] = $row[1]; + } +} + +if (count($projects)) { + + echo <<Linked Projects +

    +Project links help associate code changes made in a repository with a project, +and this in turn helps mtrack decide who to notify about the change. +

    +

    +When assessing a change, mtrack will try each regex listed below and then take +the project that corresponds with the longest match--not the longest pattern; +the longest actual match. +

    +

    +The regex should just be the bare regex string--you must not enclose it in +regex delimiters. +

    +

    +You can remove a link by setting the regex to the empty string. +

    +HTML; + + echo ""; + echo "\n"; + + if ($rid != 'new') { + foreach ($P->getLinks() as $lid => $n) { + list($pid, $regex) = $n; + + $regex = htmlentities($regex, ENT_QUOTES, 'utf-8'); + echo "". + "\n"; + } + } + + if ($rid == 'new') { + $newre = '/'; + } else { + $newre = ''; + } + + echo "". + "\n"; + + echo "
    RegexProject
    " . + "" . mtrack_select_box("link:$lid:project", $projects, $pid) . + "
    " . + "" . mtrack_select_box("link:new:project", $projects) . + "Add new link
    "; +} + +echo "
    "; + +mtrack_foot(); + diff --git a/web/admin/user.php b/web/admin/user.php new file mode 100644 index 00000000..3000cce7 --- /dev/null +++ b/web/admin/user.php @@ -0,0 +1,76 @@ + +

    Users

    +"; + $find = htmlentities(trim($_GET['find']), ENT_QUOTES, 'utf-8'); +?> +

    To find an user, enter their name, userid or email address in the box +below and click search; matches will be shown in the list below. +

    + + + +

    +Select a user below to edit them, or click the "Add" button to create +a new user. +

    + +\n"; +foreach (MTrackDB::q($sql) as $row) { + + $uid = $row[0]; + $name = htmlentities($row[1], ENT_QUOTES, 'utf-8'); + $email = htmlentities($row[2], ENT_QUOTES, 'utf-8'); + $class = $row[3] == '1' ? 'activeuser' : 'inactiveuser'; + + echo "", + "" . mtrack_username($uid, array('edit' => 1)) . "" . + "$name", + "$email", + "\n"; + +} +echo "
    "; +if ($offset > 0) { + echo "Previous "; +} +echo "Next"; +echo "

    "; + +echo "

    Add User

    "; +echo "
    "; +?> + +

    +To create a new user, enter the userid (typically the "short" login name) that +you want to use in the box below, and click "Create". +

    + + +
    +beginTransaction(); + MTrackDB::q('delete from watches where otype = ? and oid = ? and userid = ?', + $object, $id, $me); + + foreach ($value as $medium => $events) { + foreach ($events as $evt => $value) { + MTrackDB::q('insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)', + $object, $id, $me, $medium, $evt); + } + } + + $db->commit(); +} + diff --git a/web/attachment.php b/web/attachment.php new file mode 100644 index 00000000..82a22264 --- /dev/null +++ b/web/attachment.php @@ -0,0 +1,42 @@ +fetchAll() as $row) +{ + $filename = basename($filename); + header("Pragma: public"); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Cache-Control: private', false); + $path = MTrackAttachment::local_path($row['hash']); + $mimetype = mtrack_mime_detect($path, $filename); + header("Content-Type: $mimetype"); + + list($major) = explode('/', $mimetype, 2); + if ($major == 'image' || $major == 'text') { + $disp = 'inline'; + } else { + $disp = 'attachment'; + } + header("Content-Disposition: $disp; filename=\"$filename\""); + header('Content-Transfer-Encoding: binary'); + header("Content-Length: $row[size]"); + readfile($path); + exit; +} + +mtrack_header('Not found'); +mtrack_foot(); diff --git a/web/avatar.php b/web/avatar.php new file mode 100644 index 00000000..52271f5c --- /dev/null +++ b/web/avatar.php @@ -0,0 +1,213 @@ +]*>(.*)@smi', $data, $M)) { + $data = "" . $M[1] . ""; + } + $doc = new DomDocument; + if (!@$doc->loadHTML($data)) { + return null; + } + $xpath = new DomXPath($doc); + $links = $xpath->query( + '/html/head/link[@rel="shortcut icon" or @rel="icon"]'); + + if (substr($relurl, -1) != '/') { + $relurl .= '/'; + } + + foreach ($links as $link) { + $url = $link->getAttribute('href'); + if ($url !== null) { + break; + } + } + + if ($url === null) { + return $relurl . 'favicon.ico'; + } + + if (!preg_match('@^([a-zA-Z]+)://@', $url)) { + /* fixup relative links */ + if ($url[0] == '/') { + $url = substr($url, 1); + } + foreach ($xpath->query('/html/head/base') as $base) { + $url = $base->getAttribute('href') . $url; + } + if (!preg_match('@^([a-zA-Z]+)://@', $url)) { + $url = $relurl . $url; + } + } + return $url; + } + + list($head, $link) = cache_get_url_and_operate( + $username, 'extract_favatar_link', $username); + + $source = $link; +} + +function logit($msg) +{ +# echo "$msg
    "; +// error_log($msg); +} + +/** + * Fetches the contents of the URL $source using a cache. + * Optionally runs a callback specified by $funcname on the + * data while it is under a lock (to ensure a consistent view). + * $funcname is passed the local cache filename as its first parameter. + * Any additional parameters passed to this function will be passed + * to $funcname as parameters after the cache filename. + * + * returns an array( + * 0 => data from the url + * 1 => return value of optional funcname + * ) + */ +function cache_get_url_and_operate($source, $funcname = null /* args */) +{ + global $loc; + global $cache_duration; + + $args = func_get_args(); + if (count($args) > 2) { + array_shift($args); + array_shift($args); + } else { + $args = array(); + } + $cache = $loc . "/" . md5($source); + array_unshift($args, $cache); + + // cache file population, avoiding thundering herd and maintaining + // consistency under concurrency. + + $dat = null; + $tosend = null; + + $tries = 20; + while ($tries-- > 0) { + logit("tries=$tries"); + // Can we open the file for read? + $fp = @fopen($cache, 'r+b'); + if (!$fp) { + $fp = @fopen($cache, 'x+'); + } + if ($fp) { + // Yes; get a lock for consistency + flock($fp, LOCK_SH); + logit("got shared lock"); + // What do we need to do? + $st = fstat($fp); + if ($st['size'] == 0) { + // No data in the file, let's see if we can do something about that + logit("zero size; getting ex lock"); + flock($fp, LOCK_EX); + $st = fstat($fp); + if ($st['size'] == 0) { + // We get to fix it + logit("zero sized; we're fixing it, reading from $source"); + $tosend = file_get_contents($source); + fwrite($fp, $tosend); + + if ($funcname !== null) { + $dat = call_user_func_array($funcname, $args); + } + break; + } + // Someone else fixed it + logit("Someone else fixed it, size is now $st[size]"); + } else if (time() - $st['mtime'] > $cache_duration) { + // Someone needs to re-fetch the data + logit("Past cache period, getting ex lock"); + flock($fp, LOCK_EX); + $st = fstat($fp); + if (time() - $st['mtime'] > $cache_duration) { + // We get to fix it + logit("cache expired; reading from $source, truncating"); + ftruncate($fp, 0); + rewind($fp); + $tosend = file_get_contents($source); + logit("read " . strlen($tosend) . " from $source"); + $x = fwrite($fp, $tosend); + logit("wrote $x to local cache file"); + if ($funcname !== null) { + $dat = call_user_func_array($funcname, $args); + } + break; + } + // Someone else fixed it + logit("Someone fixed it, mtime now $st[mtime]"); + } + // Good to read through + logit("Reading through cache"); + $tosend = stream_get_contents($fp); + if ($funcname !== null) { + $dat = call_user_func_array($funcname, $args); + } + break; + } + logit("Couldn't get data, sleeping and retrying"); + usleep(100); + } + if ($fp) { + flock($fp, LOCK_UN); + fclose($fp); + } + return array($tosend, $dat); +} + +if ($source) { + $hint = basename($source); + list($tosend, $mime) = cache_get_url_and_operate( + $source, 'mtrack_mime_detect', $hint); + if ($mime) { + logit("All is good, sending data"); + } else { + logit("Unable to get data"); + } + if ($mime) { + header("Content-Type: $mime"); + header("Content-Disposition: inline; filename=\"$hint\""); + echo $tosend; + exit; + } +} + +$cache = dirname(__FILE__) . "/images/default_avatar.png"; +$mime = mtrack_mime_detect($cache, $cache); +header("Content-Type: $mime"); +header("Content-Disposition: inline"); +readfile($cache); +exit; + diff --git a/web/browse.php b/web/browse.php new file mode 100644 index 00000000..0c64a8a9 --- /dev/null +++ b/web/browse.php @@ -0,0 +1,518 @@ + 2) { + $repo = MTrackSCM::factory($pi); +} else { + $repo = null; +} + +if (!isset($_GET['_'])) { + $AJAX = false; +} else { + $AJAX = true; +} + +function one_line_cl($changelog) +{ + list($one) = explode("\n", $changelog); + return rtrim($one, " \r\n"); +} + +function get_browse_data($repo, $pi, $object, $ident) +{ + global $ABSWEB; + + $data = new stdclass; + $data->dirs = array(); + $data->files = array(); + $data->jumps = array(); + + if (!$repo) { + return $data; + } + $branches = $repo->getBranches(); + $tags = $repo->getTags(); + if (count($branches) + count($tags)) { + $jumps = array("" => "- Select Branch / Tag - "); + if (is_array($branches)) { + foreach ($branches as $name => $notcare) { + $jumps["branch:$name"] = "Branch: $name"; + } + } + if (is_array($tags)) { + foreach ($tags as $name => $notcare) { + $jumps["tag:$name"] = "Tag: $name"; + } + } + $data->jumps = $jumps; + } + $files = array(); + $dirs = array(); + + if ($repo) { + try { + $ents = $repo->readdir($pi, $object, $ident); + } catch (Exception $e) { + // Typically a freshly created repo + $ents = array(); + $data->err = $e->getMessage(); + } + foreach ($ents as $file) { + $basename = basename($file->name); + if ($file->is_dir) { + $dirs[$basename] = $file; + } else { + $files[$basename] = $file; + } + } + } + uksort($files, 'strnatcmp'); + uksort($dirs, 'strnatcmp'); + + $data->files = array(); + $data->dirs = array(); + + $urlbase = $ABSWEB . 'browse.php'; + $pathbase = '/' . $repo->getBrowseRootName(); + $urlbase .= $pathbase; + + foreach ($dirs as $basename => $file) { + $ent = $file->getChangeEvent(); + $url = $urlbase . '/' . $file->name; + $d = new stdclass; + $d->url = $url; + $d->basename = $basename; + $d->rev = $ent->rev; + $d->ctime = $ent->ctime; + $d->changeby = $ent->changeby; + $d->changelog = one_line_cl($ent->changelog); + + $data->dirs[] = $d; + } + foreach ($files as $basename => $file) { + $ent = $file->getChangeEvent(); + $url = $ABSWEB . 'file.php' . $pathbase . + '/' . $file->name . '?rev=' . $ent->rev; + $d = new stdclass; + $d->url = $url; + $d->basename = $basename; + $d->rev = $ent->rev; + $d->ctime = $ent->ctime; + $d->changeby = $ent->changeby; + $d->changelog = one_line_cl($ent->changelog); + + $data->files[] = $d; + } + + return $data; +} + +if (isset($_GET['jump']) && strlen($_GET['jump'])) { + list($object, $ident) = explode(':', $_GET['jump'], 2); +} else { + $object = null; + $ident = null; +} + +if ($USE_AJAX && !$AJAX) { + mtrack_head("Browse $pi"); + + // Since big dirs can take a while to gather the browse data, + // We want to show *something* to the user while we wait for + // the data to come in + $g = $_GET; + $g['_'] = '_'; + $url = $_SERVER['REQUEST_URI'] . '?' . http_build_query($g); + echo << +

    Loading browse data, please wait

    + + +HTML; + mtrack_foot(); +} else { + if (!$USE_AJAX) { + mtrack_head("Browse $pi"); + } + +$bdata = mtrack_cache('get_browse_data', + array($repo, $pi, $object, $ident)); + +if (isset($bdata->err) && strlen($pi) > 1) { + throw new Exception($bdata->err); +} + +/* Render a bread-crumb enabled location indicator */ +echo "
    Location: "; +$location = null; +foreach ($crumbs as $path) { + if (!strlen($path)) { + $path = '[root]'; + } else { + $location .= '/' . urlencode($path); + } + $path = htmlentities($path, ENT_QUOTES, 'utf-8'); + echo "$path / "; +} + +if (count($bdata->jumps)) { + echo "
    "; + echo mtrack_select_box("jump", $bdata->jumps, + isset($_GET['jump']) ? $_GET['jump'] : null); + echo "
    \n"; +} + +echo "
    "; + +$me = mtrack_canon_username(MTrackAuth::whoami()); +if (MTrackACL::hasAllRights('Browser', 'create')) { + /* some users may have rights to create repos that belong to projects. + * Determine that list of projects here, because we need it for both + * the fork and new repo cases */ + $owners = array("user:$me" => $me); + + foreach (MTrackDB::q( + 'select projid, shortname, name from projects order by ordinal') + as $row) + { + if (MTrackACL::hasAllRights("project:$row[0]", 'modify')) { + $owners["project:$row[1]"] = $row[1]; + } + } + if (count($owners) > 1) { + $owners = mtrack_select_box('repo:parent', $owners, null, true); + } else { + $owners = ''; + } +} + +if ($repo) { + MTrackACL::requireAllRights("repo:$repo->repoid", 'read'); + + $description = MTrackWiki::format_to_html($repo->description); + $url = $repo->getCheckoutCommand(); + + echo "
    $description
    "; + if (strlen($url)) { + echo "
    \n"; + echo "Use the following command to obtain a working copy:
    "; + echo "
    \$ $url
    "; + echo "
    \n"; + } + + + if ($repo->canFork() && MTrackACL::hasAllRights('Browser', 'fork') + && MTrackConfig::get('repos', 'allow_user_repo_creation')) { + $forkname = "$me/$repo->shortname"; + if ($forkname == $repo->getBrowseRootName()) { + /* if this is mine already, make a "more unique" name for my new fork */ + $forkname = $repo->shortname . '2'; + } else { + $forkname = $repo->shortname; + } + $forkname = htmlentities($forkname, ENT_QUOTES, 'utf-8'); + echo <<
  • ").data("item.autocomplete", +c).append(""+c.label+"").appendTo(a)},_move:function(a,c){if(this.menu.element.is(":visible"))if(this.menu.first()&&/^previous/.test(a)||this.menu.last()&&/^next/.test(a)){this.element.val(this.term);this.menu.deactivate()}else this.menu[a](c);else this.search(null,c)},widget:function(){return this.menu.element}});e.extend(e.ui.autocomplete,{escapeRegex:function(a){return a.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi,"\\$1")},filter:function(a,c){var d=new RegExp(e.ui.autocomplete.escapeRegex(c), +"i");return e.grep(a,function(b){return d.test(b.label||b.value||b)})}})})(jQuery); +(function(e){e.widget("ui.menu",{_create:function(){var a=this;this.element.addClass("ui-menu ui-widget ui-widget-content ui-corner-all").attr({role:"listbox","aria-activedescendant":"ui-active-menuitem"}).click(function(c){if(e(c.target).closest(".ui-menu-item a").length){c.preventDefault();a.select(c)}});this.refresh()},refresh:function(){var a=this;this.element.children("li:not(.ui-menu-item):has(a)").addClass("ui-menu-item").attr("role","menuitem").children("a").addClass("ui-corner-all").attr("tabindex", +-1).mouseenter(function(c){a.activate(c,e(this).parent())}).mouseleave(function(){a.deactivate()})},activate:function(a,c){this.deactivate();if(this.hasScroll()){var d=c.offset().top-this.element.offset().top,b=this.element.attr("scrollTop"),f=this.element.height();if(d<0)this.element.attr("scrollTop",b+d);else d>f&&this.element.attr("scrollTop",b+d-f+c.height())}this.active=c.eq(0).children("a").addClass("ui-state-hover").attr("id","ui-active-menuitem").end();this._trigger("focus",a,{item:c})},deactivate:function(){if(this.active){this.active.children("a").removeClass("ui-state-hover").removeAttr("id"); +this._trigger("blur");this.active=null}},next:function(a){this.move("next",".ui-menu-item:first",a)},previous:function(a){this.move("prev",".ui-menu-item:last",a)},first:function(){return this.active&&!this.active.prev().length},last:function(){return this.active&&!this.active.next().length},move:function(a,c,d){if(this.active){a=this.active[a+"All"](".ui-menu-item").eq(0);a.length?this.activate(d,a):this.activate(d,this.element.children(c))}else this.activate(d,this.element.children(c))},nextPage:function(a){if(this.hasScroll())if(!this.active|| +this.last())this.activate(a,this.element.children(":first"));else{var c=this.active.offset().top,d=this.element.height(),b=this.element.children("li").filter(function(){var f=e(this).offset().top-c-d+e(this).height();return f<10&&f>-10});b.length||(b=this.element.children(":last"));this.activate(a,b)}else this.activate(a,this.element.children(!this.active||this.last()?":first":":last"))},previousPage:function(a){if(this.hasScroll())if(!this.active||this.first())this.activate(a,this.element.children(":last")); +else{var c=this.active.offset().top,d=this.element.height();result=this.element.children("li").filter(function(){var b=e(this).offset().top-c+d-e(this).height();return b<10&&b>-10});result.length||(result=this.element.children(":first"));this.activate(a,result)}else this.activate(a,this.element.children(!this.active||this.first()?":last":":first"))},hasScroll:function(){return this.element.height()").addClass("ui-button-text").html(this.options.label).appendTo(b.empty()).text(),d=this.options.icons,e=d.primary&&d.secondary;if(d.primary||d.secondary){b.addClass("ui-button-text-icon"+(e?"s":""));d.primary&&b.prepend("");d.secondary&&b.append("");if(!this.options.text){b.addClass(e?"ui-button-icons-only":"ui-button-icon-only").removeClass("ui-button-text-icons ui-button-text-icon"); +this.hasTitle||b.attr("title",c)}}else b.addClass("ui-button-text-only")}}});a.widget("ui.buttonset",{_create:function(){this.element.addClass("ui-buttonset");this._init()},_init:function(){this.refresh()},_setOption:function(b,c){b==="disabled"&&this.buttons.button("option",b,c);a.Widget.prototype._setOption.apply(this,arguments)},refresh:function(){this.buttons=this.element.find(":button, :submit, :reset, :checkbox, :radio, a, :data(button)").filter(":ui-button").button("refresh").end().not(":ui-button").button().end().map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-all ui-corner-left ui-corner-right").filter(":first").addClass("ui-corner-left").end().filter(":last").addClass("ui-corner-right").end().end()}, +destroy:function(){this.element.removeClass("ui-buttonset");this.buttons.map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-left ui-corner-right").end().button("destroy");a.Widget.prototype.destroy.call(this)}})})(jQuery); +;/* + * jQuery UI Dialog 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Dialog + * + * Depends: + * jquery.ui.core.js + * jquery.ui.widget.js + * jquery.ui.button.js + * jquery.ui.draggable.js + * jquery.ui.mouse.js + * jquery.ui.position.js + * jquery.ui.resizable.js + */ +(function(c){c.widget("ui.dialog",{options:{autoOpen:true,buttons:{},closeOnEscape:true,closeText:"close",dialogClass:"",draggable:true,hide:null,height:"auto",maxHeight:false,maxWidth:false,minHeight:150,minWidth:150,modal:false,position:"center",resizable:true,show:null,stack:true,title:"",width:300,zIndex:1E3},_create:function(){this.originalTitle=this.element.attr("title");var a=this,b=a.options,d=b.title||a.originalTitle||" ",e=c.ui.dialog.getTitleId(a.element),g=(a.uiDialog=c("
    ")).appendTo(document.body).hide().addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+ +b.dialogClass).css({zIndex:b.zIndex}).attr("tabIndex",-1).css("outline",0).keydown(function(i){if(b.closeOnEscape&&i.keyCode&&i.keyCode===c.ui.keyCode.ESCAPE){a.close(i);i.preventDefault()}}).attr({role:"dialog","aria-labelledby":e}).mousedown(function(i){a.moveToTop(false,i)});a.element.show().removeAttr("title").addClass("ui-dialog-content ui-widget-content").appendTo(g);var f=(a.uiDialogTitlebar=c("
    ")).addClass("ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix").prependTo(g), +h=c('').addClass("ui-dialog-titlebar-close ui-corner-all").attr("role","button").hover(function(){h.addClass("ui-state-hover")},function(){h.removeClass("ui-state-hover")}).focus(function(){h.addClass("ui-state-focus")}).blur(function(){h.removeClass("ui-state-focus")}).click(function(i){a.close(i);return false}).appendTo(f);(a.uiDialogTitlebarCloseText=c("")).addClass("ui-icon ui-icon-closethick").text(b.closeText).appendTo(h);c("").addClass("ui-dialog-title").attr("id", +e).html(d).prependTo(f);if(c.isFunction(b.beforeclose)&&!c.isFunction(b.beforeClose))b.beforeClose=b.beforeclose;f.find("*").add(f).disableSelection();b.draggable&&c.fn.draggable&&a._makeDraggable();b.resizable&&c.fn.resizable&&a._makeResizable();a._createButtons(b.buttons);a._isOpen=false;c.fn.bgiframe&&g.bgiframe()},_init:function(){this.options.autoOpen&&this.open()},destroy:function(){var a=this;a.overlay&&a.overlay.destroy();a.uiDialog.hide();a.element.unbind(".dialog").removeData("dialog").removeClass("ui-dialog-content ui-widget-content").hide().appendTo("body"); +a.uiDialog.remove();a.originalTitle&&a.element.attr("title",a.originalTitle);return a},widget:function(){return this.uiDialog},close:function(a){var b=this,d;if(false!==b._trigger("beforeClose",a)){b.overlay&&b.overlay.destroy();b.uiDialog.unbind("keypress.ui-dialog");b._isOpen=false;if(b.options.hide)b.uiDialog.hide(b.options.hide,function(){b._trigger("close",a)});else{b.uiDialog.hide();b._trigger("close",a)}c.ui.dialog.overlay.resize();if(b.options.modal){d=0;c(".ui-dialog").each(function(){if(this!== +b.uiDialog[0])d=Math.max(d,c(this).css("z-index"))});c.ui.dialog.maxZ=d}return b}},isOpen:function(){return this._isOpen},moveToTop:function(a,b){var d=this,e=d.options;if(e.modal&&!a||!e.stack&&!e.modal)return d._trigger("focus",b);if(e.zIndex>c.ui.dialog.maxZ)c.ui.dialog.maxZ=e.zIndex;if(d.overlay){c.ui.dialog.maxZ+=1;d.overlay.$el.css("z-index",c.ui.dialog.overlay.maxZ=c.ui.dialog.maxZ)}a={scrollTop:d.element.attr("scrollTop"),scrollLeft:d.element.attr("scrollLeft")};c.ui.dialog.maxZ+=1;d.uiDialog.css("z-index", +c.ui.dialog.maxZ);d.element.attr(a);d._trigger("focus",b);return d},open:function(){if(!this._isOpen){var a=this,b=a.options,d=a.uiDialog;a.overlay=b.modal?new c.ui.dialog.overlay(a):null;d.next().length&&d.appendTo("body");a._size();a._position(b.position);d.show(b.show);a.moveToTop(true);b.modal&&d.bind("keypress.ui-dialog",function(e){if(e.keyCode===c.ui.keyCode.TAB){var g=c(":tabbable",this),f=g.filter(":first");g=g.filter(":last");if(e.target===g[0]&&!e.shiftKey){f.focus(1);return false}else if(e.target=== +f[0]&&e.shiftKey){g.focus(1);return false}}});c([]).add(d.find(".ui-dialog-content :tabbable:first")).add(d.find(".ui-dialog-buttonpane :tabbable:first")).add(d).filter(":first").focus();a._trigger("open");a._isOpen=true;return a}},_createButtons:function(a){var b=this,d=false,e=c("
    ").addClass("ui-dialog-buttonpane ui-widget-content ui-helper-clearfix");b.uiDialog.find(".ui-dialog-buttonpane").remove();typeof a==="object"&&a!==null&&c.each(a,function(){return!(d=true)});if(d){c.each(a, +function(g,f){g=c('').text(g).click(function(){f.apply(b.element[0],arguments)}).appendTo(e);c.fn.button&&g.button()});e.appendTo(b.uiDialog)}},_makeDraggable:function(){function a(f){return{position:f.position,offset:f.offset}}var b=this,d=b.options,e=c(document),g;b.uiDialog.draggable({cancel:".ui-dialog-content, .ui-dialog-titlebar-close",handle:".ui-dialog-titlebar",containment:"document",start:function(f,h){g=d.height==="auto"?"auto":c(this).height();c(this).height(c(this).height()).addClass("ui-dialog-dragging"); +b._trigger("dragStart",f,a(h))},drag:function(f,h){b._trigger("drag",f,a(h))},stop:function(f,h){d.position=[h.position.left-e.scrollLeft(),h.position.top-e.scrollTop()];c(this).removeClass("ui-dialog-dragging").height(g);b._trigger("dragStop",f,a(h));c.ui.dialog.overlay.resize()}})},_makeResizable:function(a){function b(f){return{originalPosition:f.originalPosition,originalSize:f.originalSize,position:f.position,size:f.size}}a=a===undefined?this.options.resizable:a;var d=this,e=d.options,g=d.uiDialog.css("position"); +a=typeof a==="string"?a:"n,e,s,w,se,sw,ne,nw";d.uiDialog.resizable({cancel:".ui-dialog-content",containment:"document",alsoResize:d.element,maxWidth:e.maxWidth,maxHeight:e.maxHeight,minWidth:e.minWidth,minHeight:d._minHeight(),handles:a,start:function(f,h){c(this).addClass("ui-dialog-resizing");d._trigger("resizeStart",f,b(h))},resize:function(f,h){d._trigger("resize",f,b(h))},stop:function(f,h){c(this).removeClass("ui-dialog-resizing");e.height=c(this).height();e.width=c(this).width();d._trigger("resizeStop", +f,b(h));c.ui.dialog.overlay.resize()}}).css("position",g).find(".ui-resizable-se").addClass("ui-icon ui-icon-grip-diagonal-se")},_minHeight:function(){var a=this.options;return a.height==="auto"?a.minHeight:Math.min(a.minHeight,a.height)},_position:function(a){var b=[],d=[0,0];a=a||c.ui.dialog.prototype.options.position;if(typeof a==="string"||typeof a==="object"&&"0"in a){b=a.split?a.split(" "):[a[0],a[1]];if(b.length===1)b[1]=b[0];c.each(["left","top"],function(e,g){if(+b[e]===b[e]){d[e]=b[e];b[e]= +g}})}else if(typeof a==="object"){if("left"in a){b[0]="left";d[0]=a.left}else if("right"in a){b[0]="right";d[0]=-a.right}if("top"in a){b[1]="top";d[1]=a.top}else if("bottom"in a){b[1]="bottom";d[1]=-a.bottom}}(a=this.uiDialog.is(":visible"))||this.uiDialog.show();this.uiDialog.css({top:0,left:0}).position({my:b.join(" "),at:b.join(" "),offset:d.join(" "),of:window,collision:"fit",using:function(e){var g=c(this).css(e).offset().top;g<0&&c(this).css("top",e.top-g)}});a||this.uiDialog.hide()},_setOption:function(a, +b){var d=this,e=d.uiDialog,g=e.is(":data(resizable)"),f=false;switch(a){case "beforeclose":a="beforeClose";break;case "buttons":d._createButtons(b);break;case "closeText":d.uiDialogTitlebarCloseText.text(""+b);break;case "dialogClass":e.removeClass(d.options.dialogClass).addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+b);break;case "disabled":b?e.addClass("ui-dialog-disabled"):e.removeClass("ui-dialog-disabled");break;case "draggable":b?d._makeDraggable():e.draggable("destroy");break; +case "height":f=true;break;case "maxHeight":g&&e.resizable("option","maxHeight",b);f=true;break;case "maxWidth":g&&e.resizable("option","maxWidth",b);f=true;break;case "minHeight":g&&e.resizable("option","minHeight",b);f=true;break;case "minWidth":g&&e.resizable("option","minWidth",b);f=true;break;case "position":d._position(b);break;case "resizable":g&&!b&&e.resizable("destroy");g&&typeof b==="string"&&e.resizable("option","handles",b);!g&&b!==false&&d._makeResizable(b);break;case "title":c(".ui-dialog-title", +d.uiDialogTitlebar).html(""+(b||" "));break;case "width":f=true;break}c.Widget.prototype._setOption.apply(d,arguments);f&&d._size()},_size:function(){var a=this.options,b;this.element.css({width:"auto",minHeight:0,height:0});b=this.uiDialog.css({height:"auto",width:a.width}).height();this.element.css(a.height==="auto"?{minHeight:Math.max(a.minHeight-b,0),height:"auto"}:{minHeight:0,height:Math.max(a.height-b,0)}).show();this.uiDialog.is(":data(resizable)")&&this.uiDialog.resizable("option","minHeight", +this._minHeight())}});c.extend(c.ui.dialog,{version:"1.8.2",uuid:0,maxZ:0,getTitleId:function(a){a=a.attr("id");if(!a){this.uuid+=1;a=this.uuid}return"ui-dialog-title-"+a},overlay:function(a){this.$el=c.ui.dialog.overlay.create(a)}});c.extend(c.ui.dialog.overlay,{instances:[],oldInstances:[],maxZ:0,events:c.map("focus,mousedown,mouseup,keydown,keypress,click".split(","),function(a){return a+".dialog-overlay"}).join(" "),create:function(a){if(this.instances.length===0){setTimeout(function(){c.ui.dialog.overlay.instances.length&& +c(document).bind(c.ui.dialog.overlay.events,function(d){return c(d.target).zIndex()>=c.ui.dialog.overlay.maxZ})},1);c(document).bind("keydown.dialog-overlay",function(d){if(a.options.closeOnEscape&&d.keyCode&&d.keyCode===c.ui.keyCode.ESCAPE){a.close(d);d.preventDefault()}});c(window).bind("resize.dialog-overlay",c.ui.dialog.overlay.resize)}var b=(this.oldInstances.pop()||c("
    ").addClass("ui-widget-overlay")).appendTo(document.body).css({width:this.width(),height:this.height()});c.fn.bgiframe&& +b.bgiframe();this.instances.push(b);return b},destroy:function(a){this.oldInstances.push(this.instances.splice(c.inArray(a,this.instances),1)[0]);this.instances.length===0&&c([document,window]).unbind(".dialog-overlay");a.remove();var b=0;c.each(this.instances,function(){b=Math.max(b,this.css("z-index"))});this.maxZ=b},height:function(){var a,b;if(c.browser.msie&&c.browser.version<7){a=Math.max(document.documentElement.scrollHeight,document.body.scrollHeight);b=Math.max(document.documentElement.offsetHeight, +document.body.offsetHeight);return a");if(!b.values)b.values=[this._valueMin(),this._valueMin()];if(b.values.length&&b.values.length!==2)b.values=[b.values[0],b.values[0]]}else this.range=d("
    ");this.range.appendTo(this.element).addClass("ui-slider-range");if(b.range==="min"||b.range==="max")this.range.addClass("ui-slider-range-"+b.range);this.range.addClass("ui-widget-header")}d(".ui-slider-handle",this.element).length===0&&d("").appendTo(this.element).addClass("ui-slider-handle"); +if(b.values&&b.values.length)for(;d(".ui-slider-handle",this.element).length").appendTo(this.element).addClass("ui-slider-handle");this.handles=d(".ui-slider-handle",this.element).addClass("ui-state-default ui-corner-all");this.handle=this.handles.eq(0);this.handles.add(this.range).filter("a").click(function(c){c.preventDefault()}).hover(function(){b.disabled||d(this).addClass("ui-state-hover")},function(){d(this).removeClass("ui-state-hover")}).focus(function(){if(b.disabled)d(this).blur(); +else{d(".ui-slider .ui-state-focus").removeClass("ui-state-focus");d(this).addClass("ui-state-focus")}}).blur(function(){d(this).removeClass("ui-state-focus")});this.handles.each(function(c){d(this).data("index.ui-slider-handle",c)});this.handles.keydown(function(c){var e=true,f=d(this).data("index.ui-slider-handle"),g,h,i;if(!a.options.disabled){switch(c.keyCode){case d.ui.keyCode.HOME:case d.ui.keyCode.END:case d.ui.keyCode.PAGE_UP:case d.ui.keyCode.PAGE_DOWN:case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:e= +false;if(!a._keySliding){a._keySliding=true;d(this).addClass("ui-state-active");g=a._start(c,f);if(g===false)return}break}i=a.options.step;g=a.options.values&&a.options.values.length?(h=a.values(f)):(h=a.value());switch(c.keyCode){case d.ui.keyCode.HOME:h=a._valueMin();break;case d.ui.keyCode.END:h=a._valueMax();break;case d.ui.keyCode.PAGE_UP:h=a._trimAlignValue(g+(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.PAGE_DOWN:h=a._trimAlignValue(g-(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:if(g=== +a._valueMax())return;h=a._trimAlignValue(g+i);break;case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:if(g===a._valueMin())return;h=a._trimAlignValue(g-i);break}a._slide(c,f,h);return e}}).keyup(function(c){var e=d(this).data("index.ui-slider-handle");if(a._keySliding){a._keySliding=false;a._stop(c,e);a._change(c,e);d(this).removeClass("ui-state-active")}});this._refreshValue();this._animateOff=false},destroy:function(){this.handles.remove();this.range.remove();this.element.removeClass("ui-slider ui-slider-horizontal ui-slider-vertical ui-slider-disabled ui-widget ui-widget-content ui-corner-all").removeData("slider").unbind(".slider"); +this._mouseDestroy();return this},_mouseCapture:function(a){var b=this.options,c,e,f,g,h,i;if(b.disabled)return false;this.elementSize={width:this.element.outerWidth(),height:this.element.outerHeight()};this.elementOffset=this.element.offset();c={x:a.pageX,y:a.pageY};e=this._normValueFromMouse(c);f=this._valueMax()-this._valueMin()+1;h=this;this.handles.each(function(j){var k=Math.abs(e-h.values(j));if(f>k){f=k;g=d(this);i=j}});if(b.range===true&&this.values(1)===b.min){i+=1;g=d(this.handles[i])}if(this._start(a, +i)===false)return false;this._mouseSliding=true;h._handleIndex=i;g.addClass("ui-state-active").focus();b=g.offset();this._clickOffset=!d(a.target).parents().andSelf().is(".ui-slider-handle")?{left:0,top:0}:{left:a.pageX-b.left-g.width()/2,top:a.pageY-b.top-g.height()/2-(parseInt(g.css("borderTopWidth"),10)||0)-(parseInt(g.css("borderBottomWidth"),10)||0)+(parseInt(g.css("marginTop"),10)||0)};e=this._normValueFromMouse(c);this._slide(a,i,e);return this._animateOff=true},_mouseStart:function(){return true}, +_mouseDrag:function(a){var b=this._normValueFromMouse({x:a.pageX,y:a.pageY});this._slide(a,this._handleIndex,b);return false},_mouseStop:function(a){this.handles.removeClass("ui-state-active");this._mouseSliding=false;this._stop(a,this._handleIndex);this._change(a,this._handleIndex);this._clickOffset=this._handleIndex=null;return this._animateOff=false},_detectOrientation:function(){this.orientation=this.options.orientation==="vertical"?"vertical":"horizontal"},_normValueFromMouse:function(a){var b; +if(this.orientation==="horizontal"){b=this.elementSize.width;a=a.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)}else{b=this.elementSize.height;a=a.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)}b=a/b;if(b>1)b=1;if(b<0)b=0;if(this.orientation==="vertical")b=1-b;a=this._valueMax()-this._valueMin();return this._trimAlignValue(this._valueMin()+b*a)},_start:function(a,b){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value= +this.values(b);c.values=this.values()}return this._trigger("start",a,c)},_slide:function(a,b,c){var e;if(this.options.values&&this.options.values.length){e=this.values(b?0:1);if(this.options.values.length===2&&this.options.range===true&&(b===0&&c>e||b===1&&c1){this.options.values[a]=this._trimAlignValue(b);this._refreshValue();this._change(null,a)}if(arguments.length)if(d.isArray(arguments[0])){c=this.options.values;e=arguments[0];for(f=0;fthis._valueMax())return this._valueMax();var b=this.options.step>0?this.options.step:1,c=a%b;a=a-c;if(Math.abs(c)*2>=b)a+=c>0?b:-b;return parseFloat(a.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},_refreshValue:function(){var a= +this.options.range,b=this.options,c=this,e=!this._animateOff?b.animate:false,f,g={},h,i,j,k;if(this.options.values&&this.options.values.length)this.handles.each(function(l){f=(c.values(l)-c._valueMin())/(c._valueMax()-c._valueMin())*100;g[c.orientation==="horizontal"?"left":"bottom"]=f+"%";d(this).stop(1,1)[e?"animate":"css"](g,b.animate);if(c.options.range===true)if(c.orientation==="horizontal"){if(l===0)c.range.stop(1,1)[e?"animate":"css"]({left:f+"%"},b.animate);if(l===1)c.range[e?"animate":"css"]({width:f- +h+"%"},{queue:false,duration:b.animate})}else{if(l===0)c.range.stop(1,1)[e?"animate":"css"]({bottom:f+"%"},b.animate);if(l===1)c.range[e?"animate":"css"]({height:f-h+"%"},{queue:false,duration:b.animate})}h=f});else{i=this.value();j=this._valueMin();k=this._valueMax();f=k!==j?(i-j)/(k-j)*100:0;g[c.orientation==="horizontal"?"left":"bottom"]=f+"%";this.handle.stop(1,1)[e?"animate":"css"](g,b.animate);if(a==="min"&&this.orientation==="horizontal")this.range.stop(1,1)[e?"animate":"css"]({width:f+"%"}, +b.animate);if(a==="max"&&this.orientation==="horizontal")this.range[e?"animate":"css"]({width:100-f+"%"},{queue:false,duration:b.animate});if(a==="min"&&this.orientation==="vertical")this.range.stop(1,1)[e?"animate":"css"]({height:f+"%"},b.animate);if(a==="max"&&this.orientation==="vertical")this.range[e?"animate":"css"]({height:100-f+"%"},{queue:false,duration:b.animate})}}});d.extend(d.ui.slider,{version:"1.8.2"})})(jQuery); +;/* + * jQuery UI Tabs 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Tabs + * + * Depends: + * jquery.ui.core.js + * jquery.ui.widget.js + */ +(function(d){function s(){return++u}function v(){return++w}var u=0,w=0;d.widget("ui.tabs",{options:{add:null,ajaxOptions:null,cache:false,cookie:null,collapsible:false,disable:null,disabled:[],enable:null,event:"click",fx:null,idPrefix:"ui-tabs-",load:null,panelTemplate:"
    ",remove:null,select:null,show:null,spinner:"Loading…",tabTemplate:'
  • #{label}
  • '},_create:function(){this._tabify(true)},_setOption:function(c,e){if(c=="selected")this.options.collapsible&& +e==this.options.selected||this.select(e);else{this.options[c]=e;this._tabify()}},_tabId:function(c){return c.title&&c.title.replace(/\s/g,"_").replace(/[^A-Za-z0-9\-_:\.]/g,"")||this.options.idPrefix+s()},_sanitizeSelector:function(c){return c.replace(/:/g,"\\:")},_cookie:function(){var c=this.cookie||(this.cookie=this.options.cookie.name||"ui-tabs-"+v());return d.cookie.apply(null,[c].concat(d.makeArray(arguments)))},_ui:function(c,e){return{tab:c,panel:e,index:this.anchors.index(c)}},_cleanup:function(){this.lis.filter(".ui-state-processing").removeClass("ui-state-processing").find("span:data(label.tabs)").each(function(){var c= +d(this);c.html(c.data("label.tabs")).removeData("label.tabs")})},_tabify:function(c){function e(g,f){g.css({display:""});!d.support.opacity&&f.opacity&&g[0].style.removeAttribute("filter")}this.list=this.element.find("ol,ul").eq(0);this.lis=d("li:has(a[href])",this.list);this.anchors=this.lis.map(function(){return d("a",this)[0]});this.panels=d([]);var a=this,b=this.options,h=/^#.+/;this.anchors.each(function(g,f){var j=d(f).attr("href"),l=j.split("#")[0],p;if(l&&(l===location.toString().split("#")[0]|| +(p=d("base")[0])&&l===p.href)){j=f.hash;f.href=j}if(h.test(j))a.panels=a.panels.add(a._sanitizeSelector(j));else if(j!="#"){d.data(f,"href.tabs",j);d.data(f,"load.tabs",j.replace(/#.*$/,""));j=a._tabId(f);f.href="#"+j;f=d("#"+j);if(!f.length){f=d(b.panelTemplate).attr("id",j).addClass("ui-tabs-panel ui-widget-content ui-corner-bottom").insertAfter(a.panels[g-1]||a.list);f.data("destroy.tabs",true)}a.panels=a.panels.add(f)}else b.disabled.push(g)});if(c){this.element.addClass("ui-tabs ui-widget ui-widget-content ui-corner-all"); +this.list.addClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.lis.addClass("ui-state-default ui-corner-top");this.panels.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom");if(b.selected===undefined){location.hash&&this.anchors.each(function(g,f){if(f.hash==location.hash){b.selected=g;return false}});if(typeof b.selected!="number"&&b.cookie)b.selected=parseInt(a._cookie(),10);if(typeof b.selected!="number"&&this.lis.filter(".ui-tabs-selected").length)b.selected= +this.lis.index(this.lis.filter(".ui-tabs-selected"));b.selected=b.selected||(this.lis.length?0:-1)}else if(b.selected===null)b.selected=-1;b.selected=b.selected>=0&&this.anchors[b.selected]||b.selected<0?b.selected:0;b.disabled=d.unique(b.disabled.concat(d.map(this.lis.filter(".ui-state-disabled"),function(g){return a.lis.index(g)}))).sort();d.inArray(b.selected,b.disabled)!=-1&&b.disabled.splice(d.inArray(b.selected,b.disabled),1);this.panels.addClass("ui-tabs-hide");this.lis.removeClass("ui-tabs-selected ui-state-active"); +if(b.selected>=0&&this.anchors.length){this.panels.eq(b.selected).removeClass("ui-tabs-hide");this.lis.eq(b.selected).addClass("ui-tabs-selected ui-state-active");a.element.queue("tabs",function(){a._trigger("show",null,a._ui(a.anchors[b.selected],a.panels[b.selected]))});this.load(b.selected)}d(window).bind("unload",function(){a.lis.add(a.anchors).unbind(".tabs");a.lis=a.anchors=a.panels=null})}else b.selected=this.lis.index(this.lis.filter(".ui-tabs-selected"));this.element[b.collapsible?"addClass": +"removeClass"]("ui-tabs-collapsible");b.cookie&&this._cookie(b.selected,b.cookie);c=0;for(var i;i=this.lis[c];c++)d(i)[d.inArray(c,b.disabled)!=-1&&!d(i).hasClass("ui-tabs-selected")?"addClass":"removeClass"]("ui-state-disabled");b.cache===false&&this.anchors.removeData("cache.tabs");this.lis.add(this.anchors).unbind(".tabs");if(b.event!="mouseover"){var k=function(g,f){f.is(":not(.ui-state-disabled)")&&f.addClass("ui-state-"+g)},n=function(g,f){f.removeClass("ui-state-"+g)};this.lis.bind("mouseover.tabs", +function(){k("hover",d(this))});this.lis.bind("mouseout.tabs",function(){n("hover",d(this))});this.anchors.bind("focus.tabs",function(){k("focus",d(this).closest("li"))});this.anchors.bind("blur.tabs",function(){n("focus",d(this).closest("li"))})}var m,o;if(b.fx)if(d.isArray(b.fx)){m=b.fx[0];o=b.fx[1]}else m=o=b.fx;var q=o?function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.hide().removeClass("ui-tabs-hide").animate(o,o.duration||"normal",function(){e(f,o);a._trigger("show", +null,a._ui(g,f[0]))})}:function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.removeClass("ui-tabs-hide");a._trigger("show",null,a._ui(g,f[0]))},r=m?function(g,f){f.animate(m,m.duration||"normal",function(){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");e(f,m);a.element.dequeue("tabs")})}:function(g,f){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");a.element.dequeue("tabs")};this.anchors.bind(b.event+".tabs", +function(){var g=this,f=d(this).closest("li"),j=a.panels.filter(":not(.ui-tabs-hide)"),l=d(a._sanitizeSelector(this.hash));if(f.hasClass("ui-tabs-selected")&&!b.collapsible||f.hasClass("ui-state-disabled")||f.hasClass("ui-state-processing")||a._trigger("select",null,a._ui(this,l[0]))===false){this.blur();return false}b.selected=a.anchors.index(this);a.abort();if(b.collapsible)if(f.hasClass("ui-tabs-selected")){b.selected=-1;b.cookie&&a._cookie(b.selected,b.cookie);a.element.queue("tabs",function(){r(g, +j)}).dequeue("tabs");this.blur();return false}else if(!j.length){b.cookie&&a._cookie(b.selected,b.cookie);a.element.queue("tabs",function(){q(g,l)});a.load(a.anchors.index(this));this.blur();return false}b.cookie&&a._cookie(b.selected,b.cookie);if(l.length){j.length&&a.element.queue("tabs",function(){r(g,j)});a.element.queue("tabs",function(){q(g,l)});a.load(a.anchors.index(this))}else throw"jQuery UI Tabs: Mismatching fragment identifier.";d.browser.msie&&this.blur()});this.anchors.bind("click.tabs", +function(){return false})},destroy:function(){var c=this.options;this.abort();this.element.unbind(".tabs").removeClass("ui-tabs ui-widget ui-widget-content ui-corner-all ui-tabs-collapsible").removeData("tabs");this.list.removeClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.anchors.each(function(){var e=d.data(this,"href.tabs");if(e)this.href=e;var a=d(this).unbind(".tabs");d.each(["href","load","cache"],function(b,h){a.removeData(h+".tabs")})});this.lis.unbind(".tabs").add(this.panels).each(function(){d.data(this, +"destroy.tabs")?d(this).remove():d(this).removeClass("ui-state-default ui-corner-top ui-tabs-selected ui-state-active ui-state-hover ui-state-focus ui-state-disabled ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide")});c.cookie&&this._cookie(null,c.cookie);return this},add:function(c,e,a){if(a===undefined)a=this.anchors.length;var b=this,h=this.options;e=d(h.tabTemplate.replace(/#\{href\}/g,c).replace(/#\{label\}/g,e));c=!c.indexOf("#")?c.replace("#",""):this._tabId(d("a",e)[0]);e.addClass("ui-state-default ui-corner-top").data("destroy.tabs", +true);var i=d("#"+c);i.length||(i=d(h.panelTemplate).attr("id",c).data("destroy.tabs",true));i.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide");if(a>=this.lis.length){e.appendTo(this.list);i.appendTo(this.list[0].parentNode)}else{e.insertBefore(this.lis[a]);i.insertBefore(this.panels[a])}h.disabled=d.map(h.disabled,function(k){return k>=a?++k:k});this._tabify();if(this.anchors.length==1){h.selected=0;e.addClass("ui-tabs-selected ui-state-active");i.removeClass("ui-tabs-hide"); +this.element.queue("tabs",function(){b._trigger("show",null,b._ui(b.anchors[0],b.panels[0]))});this.load(0)}this._trigger("add",null,this._ui(this.anchors[a],this.panels[a]));return this},remove:function(c){var e=this.options,a=this.lis.eq(c).remove(),b=this.panels.eq(c).remove();if(a.hasClass("ui-tabs-selected")&&this.anchors.length>1)this.select(c+(c+1=c?--h:h});this._tabify();this._trigger("remove", +null,this._ui(a.find("a")[0],b[0]));return this},enable:function(c){var e=this.options;if(d.inArray(c,e.disabled)!=-1){this.lis.eq(c).removeClass("ui-state-disabled");e.disabled=d.grep(e.disabled,function(a){return a!=c});this._trigger("enable",null,this._ui(this.anchors[c],this.panels[c]));return this}},disable:function(c){var e=this.options;if(c!=e.selected){this.lis.eq(c).addClass("ui-state-disabled");e.disabled.push(c);e.disabled.sort();this._trigger("disable",null,this._ui(this.anchors[c],this.panels[c]))}return this}, +select:function(c){if(typeof c=="string")c=this.anchors.index(this.anchors.filter("[href$="+c+"]"));else if(c===null)c=-1;if(c==-1&&this.options.collapsible)c=this.options.selected;this.anchors.eq(c).trigger(this.options.event+".tabs");return this},load:function(c){var e=this,a=this.options,b=this.anchors.eq(c)[0],h=d.data(b,"load.tabs");this.abort();if(!h||this.element.queue("tabs").length!==0&&d.data(b,"cache.tabs"))this.element.dequeue("tabs");else{this.lis.eq(c).addClass("ui-state-processing"); +if(a.spinner){var i=d("span",b);i.data("label.tabs",i.html()).html(a.spinner)}this.xhr=d.ajax(d.extend({},a.ajaxOptions,{url:h,success:function(k,n){d(e._sanitizeSelector(b.hash)).html(k);e._cleanup();a.cache&&d.data(b,"cache.tabs",true);e._trigger("load",null,e._ui(e.anchors[c],e.panels[c]));try{a.ajaxOptions.success(k,n)}catch(m){}},error:function(k,n){e._cleanup();e._trigger("load",null,e._ui(e.anchors[c],e.panels[c]));try{a.ajaxOptions.error(k,n,c,b)}catch(m){}}}));e.element.dequeue("tabs");return this}}, +abort:function(){this.element.queue([]);this.panels.stop(false,true);this.element.queue("tabs",this.element.queue("tabs").splice(-2,2));if(this.xhr){this.xhr.abort();delete this.xhr}this._cleanup();return this},url:function(c,e){this.anchors.eq(c).removeData("cache.tabs").data("load.tabs",e);return this},length:function(){return this.anchors.length}});d.extend(d.ui.tabs,{version:"1.8.2"});d.extend(d.ui.tabs.prototype,{rotation:null,rotate:function(c,e){var a=this,b=this.options,h=a._rotate||(a._rotate= +function(i){clearTimeout(a.rotation);a.rotation=setTimeout(function(){var k=b.selected;a.select(++k')}function E(a,b){d.extend(a, +b);for(var c in b)if(b[c]==null||b[c]==undefined)a[c]=b[c];return a}d.extend(d.ui,{datepicker:{version:"1.8.2"}});var y=(new Date).getTime();d.extend(J.prototype,{markerClassName:"hasDatepicker",log:function(){this.debug&&console.log.apply("",arguments)},_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(a){E(this._defaults,a||{});return this},_attachDatepicker:function(a,b){var c=null;for(var e in this._defaults){var f=a.getAttribute("date:"+e);if(f){c=c||{};try{c[e]=eval(f)}catch(h){c[e]= +f}}}e=a.nodeName.toLowerCase();f=e=="div"||e=="span";if(!a.id){this.uuid+=1;a.id="dp"+this.uuid}var i=this._newInst(d(a),f);i.settings=d.extend({},b||{},c||{});if(e=="input")this._connectDatepicker(a,i);else f&&this._inlineDatepicker(a,i)},_newInst:function(a,b){return{id:a[0].id.replace(/([^A-Za-z0-9_])/g,"\\\\$1"),input:a,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:b,dpDiv:!b?this.dpDiv:d('
    ')}}, +_connectDatepicker:function(a,b){var c=d(a);b.append=d([]);b.trigger=d([]);if(!c.hasClass(this.markerClassName)){this._attachments(c,b);c.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});this._autoSize(b);d.data(a,"datepicker",b)}},_attachments:function(a,b){var c=this._get(b,"appendText"),e=this._get(b,"isRTL");b.append&& +b.append.remove();if(c){b.append=d(''+c+"");a[e?"before":"after"](b.append)}a.unbind("focus",this._showDatepicker);b.trigger&&b.trigger.remove();c=this._get(b,"showOn");if(c=="focus"||c=="both")a.focus(this._showDatepicker);if(c=="button"||c=="both"){c=this._get(b,"buttonText");var f=this._get(b,"buttonImage");b.trigger=d(this._get(b,"buttonImageOnly")?d("").addClass(this._triggerClass).attr({src:f,alt:c,title:c}):d('').addClass(this._triggerClass).html(f== +""?c:d("").attr({src:f,alt:c,title:c})));a[e?"before":"after"](b.trigger);b.trigger.click(function(){d.datepicker._datepickerShowing&&d.datepicker._lastInput==a[0]?d.datepicker._hideDatepicker():d.datepicker._showDatepicker(a[0]);return false})}},_autoSize:function(a){if(this._get(a,"autoSize")&&!a.inline){var b=new Date(2009,11,20),c=this._get(a,"dateFormat");if(c.match(/[DM]/)){var e=function(f){for(var h=0,i=0,g=0;gh){h=f[g].length;i=g}return i};b.setMonth(e(this._get(a, +c.match(/MM/)?"monthNames":"monthNamesShort")));b.setDate(e(this._get(a,c.match(/DD/)?"dayNames":"dayNamesShort"))+20-b.getDay())}a.input.attr("size",this._formatDate(a,b).length)}},_inlineDatepicker:function(a,b){var c=d(a);if(!c.hasClass(this.markerClassName)){c.addClass(this.markerClassName).append(b.dpDiv).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});d.data(a,"datepicker",b);this._setDate(b,this._getDefaultDate(b), +true);this._updateDatepicker(b);this._updateAlternate(b)}},_dialogDatepicker:function(a,b,c,e,f){a=this._dialogInst;if(!a){this.uuid+=1;this._dialogInput=d('');this._dialogInput.keydown(this._doKeyDown);d("body").append(this._dialogInput);a=this._dialogInst=this._newInst(this._dialogInput,false);a.settings={};d.data(this._dialogInput[0],"datepicker",a)}E(a.settings,e||{});b=b&&b.constructor== +Date?this._formatDate(a,b):b;this._dialogInput.val(b);this._pos=f?f.length?f:[f.pageX,f.pageY]:null;if(!this._pos)this._pos=[document.documentElement.clientWidth/2-100+(document.documentElement.scrollLeft||document.body.scrollLeft),document.documentElement.clientHeight/2-150+(document.documentElement.scrollTop||document.body.scrollTop)];this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px");a.settings.onSelect=c;this._inDialog=true;this.dpDiv.addClass(this._dialogClass);this._showDatepicker(this._dialogInput[0]); +d.blockUI&&d.blockUI(this.dpDiv);d.data(this._dialogInput[0],"datepicker",a);return this},_destroyDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();d.removeData(a,"datepicker");if(e=="input"){c.append.remove();c.trigger.remove();b.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",this._doKeyUp)}else if(e=="div"||e=="span")b.removeClass(this.markerClassName).empty()}}, +_enableDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=false;c.trigger.filter("button").each(function(){this.disabled=false}).end().filter("img").css({opacity:"1.0",cursor:""})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().removeClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,function(f){return f==a?null:f})}},_disableDatepicker:function(a){var b= +d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=true;c.trigger.filter("button").each(function(){this.disabled=true}).end().filter("img").css({opacity:"0.5",cursor:"default"})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().addClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,function(f){return f==a?null:f});this._disabledInputs[this._disabledInputs.length]=a}},_isDisabledDatepicker:function(a){if(!a)return false; +for(var b=0;b-1}},_doKeyUp:function(a){a=d.datepicker._getInst(a.target);if(a.input.val()!=a.lastVal)try{if(d.datepicker.parseDate(d.datepicker._get(a,"dateFormat"),a.input?a.input.val():null,d.datepicker._getFormatConfig(a))){d.datepicker._setDateFromField(a);d.datepicker._updateAlternate(a);d.datepicker._updateDatepicker(a)}}catch(b){d.datepicker.log(b)}return true},_showDatepicker:function(a){a=a.target|| +a;if(a.nodeName.toLowerCase()!="input")a=d("input",a.parentNode)[0];if(!(d.datepicker._isDisabledDatepicker(a)||d.datepicker._lastInput==a)){var b=d.datepicker._getInst(a);d.datepicker._curInst&&d.datepicker._curInst!=b&&d.datepicker._curInst.dpDiv.stop(true,true);var c=d.datepicker._get(b,"beforeShow");E(b.settings,c?c.apply(a,[a,b]):{});b.lastVal=null;d.datepicker._lastInput=a;d.datepicker._setDateFromField(b);if(d.datepicker._inDialog)a.value="";if(!d.datepicker._pos){d.datepicker._pos=d.datepicker._findPos(a); +d.datepicker._pos[1]+=a.offsetHeight}var e=false;d(a).parents().each(function(){e|=d(this).css("position")=="fixed";return!e});if(e&&d.browser.opera){d.datepicker._pos[0]-=document.documentElement.scrollLeft;d.datepicker._pos[1]-=document.documentElement.scrollTop}c={left:d.datepicker._pos[0],top:d.datepicker._pos[1]};d.datepicker._pos=null;b.dpDiv.css({position:"absolute",display:"block",top:"-1000px"});d.datepicker._updateDatepicker(b);c=d.datepicker._checkOffset(b,c,e);b.dpDiv.css({position:d.datepicker._inDialog&& +d.blockUI?"static":e?"fixed":"absolute",display:"none",left:c.left+"px",top:c.top+"px"});if(!b.inline){c=d.datepicker._get(b,"showAnim");var f=d.datepicker._get(b,"duration"),h=function(){d.datepicker._datepickerShowing=true;var i=d.datepicker._getBorders(b.dpDiv);b.dpDiv.find("iframe.ui-datepicker-cover").css({left:-i[0],top:-i[1],width:b.dpDiv.outerWidth(),height:b.dpDiv.outerHeight()})};b.dpDiv.zIndex(d(a).zIndex()+1);d.effects&&d.effects[c]?b.dpDiv.show(c,d.datepicker._get(b,"showOptions"),f, +h):b.dpDiv[c||"show"](c?f:null,h);if(!c||!f)h();b.input.is(":visible")&&!b.input.is(":disabled")&&b.input.focus();d.datepicker._curInst=b}}},_updateDatepicker:function(a){var b=this,c=d.datepicker._getBorders(a.dpDiv);a.dpDiv.empty().append(this._generateHTML(a)).find("iframe.ui-datepicker-cover").css({left:-c[0],top:-c[1],width:a.dpDiv.outerWidth(),height:a.dpDiv.outerHeight()}).end().find("button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a").bind("mouseout",function(){d(this).removeClass("ui-state-hover"); +this.className.indexOf("ui-datepicker-prev")!=-1&&d(this).removeClass("ui-datepicker-prev-hover");this.className.indexOf("ui-datepicker-next")!=-1&&d(this).removeClass("ui-datepicker-next-hover")}).bind("mouseover",function(){if(!b._isDisabledDatepicker(a.inline?a.dpDiv.parent()[0]:a.input[0])){d(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover");d(this).addClass("ui-state-hover");this.className.indexOf("ui-datepicker-prev")!=-1&&d(this).addClass("ui-datepicker-prev-hover"); +this.className.indexOf("ui-datepicker-next")!=-1&&d(this).addClass("ui-datepicker-next-hover")}}).end().find("."+this._dayOverClass+" a").trigger("mouseover").end();c=this._getNumberOfMonths(a);var e=c[1];e>1?a.dpDiv.addClass("ui-datepicker-multi-"+e).css("width",17*e+"em"):a.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width("");a.dpDiv[(c[0]!=1||c[1]!=1?"add":"remove")+"Class"]("ui-datepicker-multi");a.dpDiv[(this._get(a,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl"); +a==d.datepicker._curInst&&d.datepicker._datepickerShowing&&a.input&&a.input.is(":visible")&&!a.input.is(":disabled")&&a.input.focus()},_getBorders:function(a){var b=function(c){return{thin:1,medium:2,thick:3}[c]||c};return[parseFloat(b(a.css("border-left-width"))),parseFloat(b(a.css("border-top-width")))]},_checkOffset:function(a,b,c){var e=a.dpDiv.outerWidth(),f=a.dpDiv.outerHeight(),h=a.input?a.input.outerWidth():0,i=a.input?a.input.outerHeight():0,g=document.documentElement.clientWidth+d(document).scrollLeft(), +k=document.documentElement.clientHeight+d(document).scrollTop();b.left-=this._get(a,"isRTL")?e-h:0;b.left-=c&&b.left==a.input.offset().left?d(document).scrollLeft():0;b.top-=c&&b.top==a.input.offset().top+i?d(document).scrollTop():0;b.left-=Math.min(b.left,b.left+e>g&&g>e?Math.abs(b.left+e-g):0);b.top-=Math.min(b.top,b.top+f>k&&k>f?Math.abs(f+i):0);return b},_findPos:function(a){for(var b=this._get(this._getInst(a),"isRTL");a&&(a.type=="hidden"||a.nodeType!=1);)a=a[b?"previousSibling":"nextSibling"]; +a=d(a).offset();return[a.left,a.top]},_hideDatepicker:function(a){var b=this._curInst;if(!(!b||a&&b!=d.data(a,"datepicker")))if(this._datepickerShowing){a=this._get(b,"showAnim");var c=this._get(b,"duration"),e=function(){d.datepicker._tidyDialog(b);this._curInst=null};d.effects&&d.effects[a]?b.dpDiv.hide(a,d.datepicker._get(b,"showOptions"),c,e):b.dpDiv[a=="slideDown"?"slideUp":a=="fadeIn"?"fadeOut":"hide"](a?c:null,e);a||e();if(a=this._get(b,"onClose"))a.apply(b.input?b.input[0]:null,[b.input?b.input.val(): +"",b]);this._datepickerShowing=false;this._lastInput=null;if(this._inDialog){this._dialogInput.css({position:"absolute",left:"0",top:"-100px"});if(d.blockUI){d.unblockUI();d("body").append(this.dpDiv)}}this._inDialog=false}},_tidyDialog:function(a){a.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},_checkExternalClick:function(a){if(d.datepicker._curInst){a=d(a.target);a[0].id!=d.datepicker._mainDivId&&a.parents("#"+d.datepicker._mainDivId).length==0&&!a.hasClass(d.datepicker.markerClassName)&& +!a.hasClass(d.datepicker._triggerClass)&&d.datepicker._datepickerShowing&&!(d.datepicker._inDialog&&d.blockUI)&&d.datepicker._hideDatepicker()}},_adjustDate:function(a,b,c){a=d(a);var e=this._getInst(a[0]);if(!this._isDisabledDatepicker(a[0])){this._adjustInstDate(e,b+(c=="M"?this._get(e,"showCurrentAtPos"):0),c);this._updateDatepicker(e)}},_gotoToday:function(a){a=d(a);var b=this._getInst(a[0]);if(this._get(b,"gotoCurrent")&&b.currentDay){b.selectedDay=b.currentDay;b.drawMonth=b.selectedMonth=b.currentMonth; +b.drawYear=b.selectedYear=b.currentYear}else{var c=new Date;b.selectedDay=c.getDate();b.drawMonth=b.selectedMonth=c.getMonth();b.drawYear=b.selectedYear=c.getFullYear()}this._notifyChange(b);this._adjustDate(a)},_selectMonthYear:function(a,b,c){a=d(a);var e=this._getInst(a[0]);e._selectingMonthYear=false;e["selected"+(c=="M"?"Month":"Year")]=e["draw"+(c=="M"?"Month":"Year")]=parseInt(b.options[b.selectedIndex].value,10);this._notifyChange(e);this._adjustDate(a)},_clickMonthYear:function(a){a=this._getInst(d(a)[0]); +a.input&&a._selectingMonthYear&&!d.browser.msie&&a.input.focus();a._selectingMonthYear=!a._selectingMonthYear},_selectDay:function(a,b,c,e){var f=d(a);if(!(d(e).hasClass(this._unselectableClass)||this._isDisabledDatepicker(f[0]))){f=this._getInst(f[0]);f.selectedDay=f.currentDay=d("a",e).html();f.selectedMonth=f.currentMonth=b;f.selectedYear=f.currentYear=c;this._selectDate(a,this._formatDate(f,f.currentDay,f.currentMonth,f.currentYear))}},_clearDate:function(a){a=d(a);this._getInst(a[0]);this._selectDate(a, +"")},_selectDate:function(a,b){a=this._getInst(d(a)[0]);b=b!=null?b:this._formatDate(a);a.input&&a.input.val(b);this._updateAlternate(a);var c=this._get(a,"onSelect");if(c)c.apply(a.input?a.input[0]:null,[b,a]);else a.input&&a.input.trigger("change");if(a.inline)this._updateDatepicker(a);else{this._hideDatepicker();this._lastInput=a.input[0];typeof a.input[0]!="object"&&a.input.focus();this._lastInput=null}},_updateAlternate:function(a){var b=this._get(a,"altField");if(b){var c=this._get(a,"altFormat")|| +this._get(a,"dateFormat"),e=this._getDate(a),f=this.formatDate(c,e,this._getFormatConfig(a));d(b).each(function(){d(this).val(f)})}},noWeekends:function(a){a=a.getDay();return[a>0&&a<6,""]},iso8601Week:function(a){a=new Date(a.getTime());a.setDate(a.getDate()+4-(a.getDay()||7));var b=a.getTime();a.setMonth(0);a.setDate(1);return Math.floor(Math.round((b-a)/864E5)/7)+1},parseDate:function(a,b,c){if(a==null||b==null)throw"Invalid arguments";b=typeof b=="object"?b.toString():b+"";if(b=="")return null; +for(var e=(c?c.shortYearCutoff:null)||this._defaults.shortYearCutoff,f=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,h=(c?c.dayNames:null)||this._defaults.dayNames,i=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort,g=(c?c.monthNames:null)||this._defaults.monthNames,k=c=-1,l=-1,u=-1,j=false,o=function(p){(p=z+1-1){k=1;l=u;do{e=this._getDaysInMonth(c,k-1);if(l<=e)break;k++;l-=e}while(1)}v=this._daylightSavingAdjust(new Date(c, +k-1,l));if(v.getFullYear()!=c||v.getMonth()+1!=k||v.getDate()!=l)throw"Invalid date";return v},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925))*24*60*60*1E7,formatDate:function(a,b,c){if(!b)return"";var e=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,f=(c? +c.dayNames:null)||this._defaults.dayNames,h=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort;c=(c?c.monthNames:null)||this._defaults.monthNames;var i=function(o){(o=j+112?a.getHours()+2:0);return a},_setDate:function(a,b,c){var e=!b,f=a.selectedMonth,h=a.selectedYear;b=this._restrictMinMax(a,this._determineDate(a,b,new Date));a.selectedDay=a.currentDay=b.getDate();a.drawMonth=a.selectedMonth=a.currentMonth=b.getMonth();a.drawYear=a.selectedYear=a.currentYear=b.getFullYear();if((f!=a.selectedMonth||h!=a.selectedYear)&&!c)this._notifyChange(a);this._adjustInstDate(a);if(a.input)a.input.val(e?"":this._formatDate(a))},_getDate:function(a){return!a.currentYear|| +a.input&&a.input.val()==""?null:this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay))},_generateHTML:function(a){var b=new Date;b=this._daylightSavingAdjust(new Date(b.getFullYear(),b.getMonth(),b.getDate()));var c=this._get(a,"isRTL"),e=this._get(a,"showButtonPanel"),f=this._get(a,"hideIfNoPrevNext"),h=this._get(a,"navigationAsDateFormat"),i=this._getNumberOfMonths(a),g=this._get(a,"showCurrentAtPos"),k=this._get(a,"stepMonths"),l=i[0]!=1||i[1]!=1,u=this._daylightSavingAdjust(!a.currentDay? +new Date(9999,9,9):new Date(a.currentYear,a.currentMonth,a.currentDay)),j=this._getMinMaxDate(a,"min"),o=this._getMinMaxDate(a,"max");g=a.drawMonth-g;var m=a.drawYear;if(g<0){g+=12;m--}if(o){var n=this._daylightSavingAdjust(new Date(o.getFullYear(),o.getMonth()-i[0]*i[1]+1,o.getDate()));for(n=j&&nn;){g--;if(g<0){g=11;m--}}}a.drawMonth=g;a.drawYear=m;n=this._get(a,"prevText");n=!h?n:this.formatDate(n,this._daylightSavingAdjust(new Date(m,g-k,1)),this._getFormatConfig(a)); +n=this._canAdjustMonth(a,-1,m,g)?''+n+"":f?"":''+n+"";var r=this._get(a,"nextText");r=!h?r:this.formatDate(r,this._daylightSavingAdjust(new Date(m, +g+k,1)),this._getFormatConfig(a));f=this._canAdjustMonth(a,+1,m,g)?''+r+"":f?"":''+r+"";k=this._get(a,"currentText");r=this._get(a,"gotoCurrent")&& +a.currentDay?u:b;k=!h?k:this.formatDate(k,r,this._getFormatConfig(a));h=!a.inline?'":"";e=e?'
    '+(c?h:"")+(this._isInRange(a,r)?'":"")+(c?"":h)+"
    ":"";h=parseInt(this._get(a,"firstDay"),10);h=isNaN(h)?0:h;k=this._get(a,"showWeek");r=this._get(a,"dayNames");this._get(a,"dayNamesShort");var s=this._get(a,"dayNamesMin"),z=this._get(a,"monthNames"),v=this._get(a,"monthNamesShort"),p=this._get(a,"beforeShowDay"),w=this._get(a,"showOtherMonths"),G=this._get(a,"selectOtherMonths");this._get(a,"calculateWeek");for(var K=this._getDefaultDate(a),H="",C=0;C1)switch(D){case 0:x+=" ui-datepicker-group-first";t=" ui-corner-"+(c?"right":"left");break;case i[1]-1:x+=" ui-datepicker-group-last";t=" ui-corner-"+(c?"left":"right");break;default:x+=" ui-datepicker-group-middle";t="";break}x+='">'}x+='
    '+(/all|left/.test(t)&&C==0?c? +f:n:"")+(/all|right/.test(t)&&C==0?c?n:f:"")+this._generateMonthYearHeader(a,g,m,j,o,C>0||D>0,z,v)+'
    ';var A=k?'":"";for(t=0;t<7;t++){var q=(t+h)%7;A+="=5?' class="ui-datepicker-week-end"':"")+'>'+s[q]+""}x+=A+"";A=this._getDaysInMonth(m,g);if(m==a.selectedYear&&g==a.selectedMonth)a.selectedDay=Math.min(a.selectedDay, +A);t=(this._getFirstDayOfMonth(m,g)-h+7)%7;A=l?6:Math.ceil((t+A)/7);q=this._daylightSavingAdjust(new Date(m,g,1-t));for(var N=0;N";var O=!k?"":'";for(t=0;t<7;t++){var F=p?p.apply(a.input?a.input[0]:null,[q]):[true,""],B=q.getMonth()!=g,I=B&&!G||!F[0]||j&&qo;O+='";q.setDate(q.getDate()+1);q=this._daylightSavingAdjust(q)}x+=O+""}g++;if(g>11){g=0;m++}x+="
    '+this._get(a,"weekHeader")+"
    '+this._get(a,"calculateWeek")(q)+""+(B&&!w?" ":I?''+q.getDate()+ +"":''+q.getDate()+"")+"
    "+(l?""+(i[0]>0&&D==i[1]-1?'
    ':""):"");L+=x}H+=L}H+=e+(d.browser.msie&&parseInt(d.browser.version,10)<7&&!a.inline?'': +"");a._keyEvent=false;return H},_generateMonthYearHeader:function(a,b,c,e,f,h,i,g){var k=this._get(a,"changeMonth"),l=this._get(a,"changeYear"),u=this._get(a,"showMonthAfterYear"),j='
    ',o="";if(h||!k)o+=''+i[b]+"";else{i=e&&e.getFullYear()==c;var m=f&&f.getFullYear()==c;o+='"}u||(j+=o+(h||!(k&&l)?" ":""));if(h||!l)j+=''+c+"";else{g=this._get(a,"yearRange").split(":");var r=(new Date).getFullYear();i=function(s){s=s.match(/c[+-].*/)?c+parseInt(s.substring(1),10):s.match(/[+-].*/)?r+parseInt(s,10):parseInt(s,10);return isNaN(s)?r:s};b=i(g[0]);g=Math.max(b, +i(g[1]||""));b=e?Math.max(b,e.getFullYear()):b;g=f?Math.min(g,f.getFullYear()):g;for(j+='"}j+=this._get(a,"yearSuffix");if(u)j+=(h||!(k&&l)?" ":"")+o;j+="
    ";return j},_adjustInstDate:function(a,b,c){var e= +a.drawYear+(c=="Y"?b:0),f=a.drawMonth+(c=="M"?b:0);b=Math.min(a.selectedDay,this._getDaysInMonth(e,f))+(c=="D"?b:0);e=this._restrictMinMax(a,this._daylightSavingAdjust(new Date(e,f,b)));a.selectedDay=e.getDate();a.drawMonth=a.selectedMonth=e.getMonth();a.drawYear=a.selectedYear=e.getFullYear();if(c=="M"||c=="Y")this._notifyChange(a)},_restrictMinMax:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");b=c&&ba?a:b},_notifyChange:function(a){var b=this._get(a, +"onChangeMonthYear");if(b)b.apply(a.input?a.input[0]:null,[a.selectedYear,a.selectedMonth+1,a])},_getNumberOfMonths:function(a){a=this._get(a,"numberOfMonths");return a==null?[1,1]:typeof a=="number"?[1,a]:a},_getMinMaxDate:function(a,b){return this._determineDate(a,this._get(a,b+"Date"),null)},_getDaysInMonth:function(a,b){return 32-(new Date(a,b,32)).getDate()},_getFirstDayOfMonth:function(a,b){return(new Date(a,b,1)).getDay()},_canAdjustMonth:function(a,b,c,e){var f=this._getNumberOfMonths(a); +c=this._daylightSavingAdjust(new Date(c,e+(b<0?b:f[0]*f[1]),1));b<0&&c.setDate(this._getDaysInMonth(c.getFullYear(),c.getMonth()));return this._isInRange(a,c)},_isInRange:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");return(!c||b.getTime()>=c.getTime())&&(!a||b.getTime()<=a.getTime())},_getFormatConfig:function(a){var b=this._get(a,"shortYearCutoff");b=typeof b!="string"?b:(new Date).getFullYear()%100+parseInt(b,10);return{shortYearCutoff:b,dayNamesShort:this._get(a, +"dayNamesShort"),dayNames:this._get(a,"dayNames"),monthNamesShort:this._get(a,"monthNamesShort"),monthNames:this._get(a,"monthNames")}},_formatDate:function(a,b,c,e){if(!b){a.currentDay=a.selectedDay;a.currentMonth=a.selectedMonth;a.currentYear=a.selectedYear}b=b?typeof b=="object"?b:this._daylightSavingAdjust(new Date(e,c,b)):this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay));return this.formatDate(this._get(a,"dateFormat"),b,this._getFormatConfig(a))}});d.fn.datepicker= +function(a){if(!d.datepicker.initialized){d(document).mousedown(d.datepicker._checkExternalClick).find("body").append(d.datepicker.dpDiv);d.datepicker.initialized=true}var b=Array.prototype.slice.call(arguments,1);if(typeof a=="string"&&(a=="isDisabled"||a=="getDate"||a=="widget"))return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));if(a=="option"&&arguments.length==2&&typeof arguments[1]=="string")return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b)); +return this.each(function(){typeof a=="string"?d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this].concat(b)):d.datepicker._attachDatepicker(this,a)})};d.datepicker=new J;d.datepicker.initialized=false;d.datepicker.uuid=(new Date).getTime();d.datepicker.version="1.8.2";window["DP_jQuery_"+y]=d})(jQuery); +;/* + * jQuery UI Progressbar 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Progressbar + * + * Depends: + * jquery.ui.core.js + * jquery.ui.widget.js + */ +(function(b){b.widget("ui.progressbar",{options:{value:0},_create:function(){this.element.addClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").attr({role:"progressbar","aria-valuemin":this._valueMin(),"aria-valuemax":this._valueMax(),"aria-valuenow":this._value()});this.valueDiv=b("
    ").appendTo(this.element);this._refreshValue()},destroy:function(){this.element.removeClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").removeAttr("role").removeAttr("aria-valuemin").removeAttr("aria-valuemax").removeAttr("aria-valuenow"); +this.valueDiv.remove();b.Widget.prototype.destroy.apply(this,arguments)},value:function(a){if(a===undefined)return this._value();this._setOption("value",a);return this},_setOption:function(a,c){switch(a){case "value":this.options.value=c;this._refreshValue();this._trigger("change");break}b.Widget.prototype._setOption.apply(this,arguments)},_value:function(){var a=this.options.value;if(typeof a!=="number")a=0;if(athis._valueMax())a=this._valueMax();return a}, +_valueMin:function(){return 0},_valueMax:function(){return 100},_refreshValue:function(){var a=this.value();this.valueDiv[a===this._valueMax()?"addClass":"removeClass"]("ui-corner-right").width(a+"%");this.element.attr("aria-valuenow",a)}});b.extend(b.ui.progressbar,{version:"1.8.2"})})(jQuery); +;/* + * jQuery UI Effects 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Effects/ + */ +jQuery.effects||function(f){function k(c){var a;if(c&&c.constructor==Array&&c.length==3)return c;if(a=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(c))return[parseInt(a[1],10),parseInt(a[2],10),parseInt(a[3],10)];if(a=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(c))return[parseFloat(a[1])*2.55,parseFloat(a[2])*2.55,parseFloat(a[3])*2.55];if(a=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(c))return[parseInt(a[1], +16),parseInt(a[2],16),parseInt(a[3],16)];if(a=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(c))return[parseInt(a[1]+a[1],16),parseInt(a[2]+a[2],16),parseInt(a[3]+a[3],16)];if(/rgba\(0, 0, 0, 0\)/.exec(c))return l.transparent;return l[f.trim(c).toLowerCase()]}function q(c,a){var b;do{b=f.curCSS(c,a);if(b!=""&&b!="transparent"||f.nodeName(c,"body"))break;a="backgroundColor"}while(c=c.parentNode);return k(b)}function m(){var c=document.defaultView?document.defaultView.getComputedStyle(this,null):this.currentStyle, +a={},b,d;if(c&&c.length&&c[0]&&c[c[0]])for(var e=c.length;e--;){b=c[e];if(typeof c[b]=="string"){d=b.replace(/\-(\w)/g,function(g,h){return h.toUpperCase()});a[d]=c[b]}}else for(b in c)if(typeof c[b]==="string")a[b]=c[b];return a}function n(c){var a,b;for(a in c){b=c[a];if(b==null||f.isFunction(b)||a in r||/scrollbar/.test(a)||!/color/i.test(a)&&isNaN(parseFloat(b)))delete c[a]}return c}function s(c,a){var b={_:0},d;for(d in a)if(c[d]!=a[d])b[d]=a[d];return b}function j(c,a,b,d){if(typeof c=="object"){d= +a;b=null;a=c;c=a.effect}if(f.isFunction(a)){d=a;b=null;a={}}if(f.isFunction(b)){d=b;b=null}if(typeof a=="number"||f.fx.speeds[a]){d=b;b=a;a={}}a=a||{};b=b||a.duration;b=f.fx.off?0:typeof b=="number"?b:f.fx.speeds[b]||f.fx.speeds._default;d=d||a.complete;return[c,a,b,d]}f.effects={};f.each(["backgroundColor","borderBottomColor","borderLeftColor","borderRightColor","borderTopColor","color","outlineColor"],function(c,a){f.fx.step[a]=function(b){if(!b.colorInit){b.start=q(b.elem,a);b.end=k(b.end);b.colorInit= +true}b.elem.style[a]="rgb("+Math.max(Math.min(parseInt(b.pos*(b.end[0]-b.start[0])+b.start[0],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[1]-b.start[1])+b.start[1],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[2]-b.start[2])+b.start[2],10),255),0)+")"}});var l={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189, +183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255, +165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0],transparent:[255,255,255]},o=["add","remove","toggle"],r={border:1,borderBottom:1,borderColor:1,borderLeft:1,borderRight:1,borderTop:1,borderWidth:1,margin:1,padding:1};f.effects.animateClass=function(c,a,b,d){if(f.isFunction(b)){d=b;b=null}return this.each(function(){var e=f(this),g=e.attr("style")||" ",h=n(m.call(this)),p,t=e.attr("className");f.each(o,function(u, +i){c[i]&&e[i+"Class"](c[i])});p=n(m.call(this));e.attr("className",t);e.animate(s(h,p),a,b,function(){f.each(o,function(u,i){c[i]&&e[i+"Class"](c[i])});if(typeof e.attr("style")=="object"){e.attr("style").cssText="";e.attr("style").cssText=g}else e.attr("style",g);d&&d.apply(this,arguments)})})};f.fn.extend({_addClass:f.fn.addClass,addClass:function(c,a,b,d){return a?f.effects.animateClass.apply(this,[{add:c},a,b,d]):this._addClass(c)},_removeClass:f.fn.removeClass,removeClass:function(c,a,b,d){return a? +f.effects.animateClass.apply(this,[{remove:c},a,b,d]):this._removeClass(c)},_toggleClass:f.fn.toggleClass,toggleClass:function(c,a,b,d,e){return typeof a=="boolean"||a===undefined?b?f.effects.animateClass.apply(this,[a?{add:c}:{remove:c},b,d,e]):this._toggleClass(c,a):f.effects.animateClass.apply(this,[{toggle:c},a,b,d])},switchClass:function(c,a,b,d,e){return f.effects.animateClass.apply(this,[{add:a,remove:c},b,d,e])}});f.extend(f.effects,{version:"1.8.2",save:function(c,a){for(var b=0;b").addClass("ui-effects-wrapper").css({fontSize:"100%",background:"transparent",border:"none",margin:0,padding:0});c.wrap(b);b=c.parent();if(c.css("position")=="static"){b.css({position:"relative"});c.css({position:"relative"})}else{f.extend(a,{position:c.css("position"),zIndex:c.css("z-index")});f.each(["top","left","bottom","right"],function(d,e){a[e]=c.css(e);if(isNaN(parseInt(a[e],10)))a[e]="auto"}); +c.css({position:"relative",top:0,left:0})}return b.css(a).show()},removeWrapper:function(c){if(c.parent().is(".ui-effects-wrapper"))return c.parent().replaceWith(c);return c},setTransition:function(c,a,b,d){d=d||{};f.each(a,function(e,g){unit=c.cssUnit(g);if(unit[0]>0)d[g]=unit[0]*b+unit[1]});return d}});f.fn.extend({effect:function(c){var a=j.apply(this,arguments);a={options:a[1],duration:a[2],callback:a[3]};var b=f.effects[c];return b&&!f.fx.off?b.call(this,a):this},_show:f.fn.show,show:function(c){if(!c|| +typeof c=="number"||f.fx.speeds[c])return this._show.apply(this,arguments);else{var a=j.apply(this,arguments);a[1].mode="show";return this.effect.apply(this,a)}},_hide:f.fn.hide,hide:function(c){if(!c||typeof c=="number"||f.fx.speeds[c])return this._hide.apply(this,arguments);else{var a=j.apply(this,arguments);a[1].mode="hide";return this.effect.apply(this,a)}},__toggle:f.fn.toggle,toggle:function(c){if(!c||typeof c=="number"||f.fx.speeds[c]||typeof c=="boolean"||f.isFunction(c))return this.__toggle.apply(this, +arguments);else{var a=j.apply(this,arguments);a[1].mode="toggle";return this.effect.apply(this,a)}},cssUnit:function(c){var a=this.css(c),b=[];f.each(["em","px","%","pt"],function(d,e){if(a.indexOf(e)>0)b=[parseFloat(a),e]});return b}});f.easing.jswing=f.easing.swing;f.extend(f.easing,{def:"easeOutQuad",swing:function(c,a,b,d,e){return f.easing[f.easing.def](c,a,b,d,e)},easeInQuad:function(c,a,b,d,e){return d*(a/=e)*a+b},easeOutQuad:function(c,a,b,d,e){return-d*(a/=e)*(a-2)+b},easeInOutQuad:function(c, +a,b,d,e){if((a/=e/2)<1)return d/2*a*a+b;return-d/2*(--a*(a-2)-1)+b},easeInCubic:function(c,a,b,d,e){return d*(a/=e)*a*a+b},easeOutCubic:function(c,a,b,d,e){return d*((a=a/e-1)*a*a+1)+b},easeInOutCubic:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a+b;return d/2*((a-=2)*a*a+2)+b},easeInQuart:function(c,a,b,d,e){return d*(a/=e)*a*a*a+b},easeOutQuart:function(c,a,b,d,e){return-d*((a=a/e-1)*a*a*a-1)+b},easeInOutQuart:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a+b;return-d/2*((a-=2)*a*a*a-2)+ +b},easeInQuint:function(c,a,b,d,e){return d*(a/=e)*a*a*a*a+b},easeOutQuint:function(c,a,b,d,e){return d*((a=a/e-1)*a*a*a*a+1)+b},easeInOutQuint:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a*a+b;return d/2*((a-=2)*a*a*a*a+2)+b},easeInSine:function(c,a,b,d,e){return-d*Math.cos(a/e*(Math.PI/2))+d+b},easeOutSine:function(c,a,b,d,e){return d*Math.sin(a/e*(Math.PI/2))+b},easeInOutSine:function(c,a,b,d,e){return-d/2*(Math.cos(Math.PI*a/e)-1)+b},easeInExpo:function(c,a,b,d,e){return a==0?b:d*Math.pow(2, +10*(a/e-1))+b},easeOutExpo:function(c,a,b,d,e){return a==e?b+d:d*(-Math.pow(2,-10*a/e)+1)+b},easeInOutExpo:function(c,a,b,d,e){if(a==0)return b;if(a==e)return b+d;if((a/=e/2)<1)return d/2*Math.pow(2,10*(a-1))+b;return d/2*(-Math.pow(2,-10*--a)+2)+b},easeInCirc:function(c,a,b,d,e){return-d*(Math.sqrt(1-(a/=e)*a)-1)+b},easeOutCirc:function(c,a,b,d,e){return d*Math.sqrt(1-(a=a/e-1)*a)+b},easeInOutCirc:function(c,a,b,d,e){if((a/=e/2)<1)return-d/2*(Math.sqrt(1-a*a)-1)+b;return d/2*(Math.sqrt(1-(a-=2)* +a)+1)+b},easeInElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e)==1)return b+d;g||(g=e*0.3);if(h").css({position:"absolute",visibility:"visible",left:-f*(h/d),top:-e*(i/c)}).parent().addClass("ui-effects-explode").css({position:"absolute",overflow:"hidden",width:h/d,height:i/c,left:g.left+f*(h/d)+(a.options.mode=="show"?(f-Math.floor(d/2))*(h/d):0),top:g.top+e*(i/c)+(a.options.mode=="show"?(e-Math.floor(c/2))*(i/c):0),opacity:a.options.mode=="show"?0:1}).animate({left:g.left+f*(h/d)+(a.options.mode=="show"?0:(f-Math.floor(d/2))*(h/d)),top:g.top+ +e*(i/c)+(a.options.mode=="show"?0:(e-Math.floor(c/2))*(i/c)),opacity:a.options.mode=="show"?1:0},a.duration||500);setTimeout(function(){a.options.mode=="show"?b.css({visibility:"visible"}):b.css({visibility:"visible"}).hide();a.callback&&a.callback.apply(b[0]);b.dequeue();j("div.ui-effects-explode").remove()},a.duration||500)})}})(jQuery); +;/* + * jQuery UI Effects Fold 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Effects/Fold + * + * Depends: + * jquery.effects.core.js + */ +(function(c){c.effects.fold=function(a){return this.queue(function(){var b=c(this),j=["position","top","left"],d=c.effects.setMode(b,a.options.mode||"hide"),g=a.options.size||15,h=!!a.options.horizFirst,k=a.duration?a.duration/2:c.fx.speeds._default/2;c.effects.save(b,j);b.show();var e=c.effects.createWrapper(b).css({overflow:"hidden"}),f=d=="show"!=h,l=f?["width","height"]:["height","width"];f=f?[e.width(),e.height()]:[e.height(),e.width()];var i=/([0-9]+)%/.exec(g);if(i)g=parseInt(i[1],10)/100* +f[d=="hide"?0:1];if(d=="show")e.css(h?{height:0,width:g}:{height:g,width:0});h={};i={};h[l[0]]=d=="show"?f[0]:g;i[l[1]]=d=="show"?f[1]:0;e.animate(h,k,a.options.easing).animate(i,k,a.options.easing,function(){d=="hide"&&b.hide();c.effects.restore(b,j);c.effects.removeWrapper(b);a.callback&&a.callback.apply(b[0],arguments);b.dequeue()})})}})(jQuery); +;/* + * jQuery UI Effects Highlight 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Effects/Highlight + * + * Depends: + * jquery.effects.core.js + */ +(function(b){b.effects.highlight=function(c){return this.queue(function(){var a=b(this),e=["backgroundImage","backgroundColor","opacity"],d=b.effects.setMode(a,c.options.mode||"show"),f={backgroundColor:a.css("backgroundColor")};if(d=="hide")f.opacity=0;b.effects.save(a,e);a.show().css({backgroundImage:"none",backgroundColor:c.options.color||"#ffff99"}).animate(f,{queue:false,duration:c.duration,easing:c.options.easing,complete:function(){d=="hide"&&a.hide();b.effects.restore(a,e);d=="show"&&!b.support.opacity&& +this.style.removeAttribute("filter");c.callback&&c.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery); +;/* + * jQuery UI Effects Pulsate 1.8.2 + * + * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://docs.jquery.com/UI/Effects/Pulsate + * + * Depends: + * jquery.effects.core.js + */ +(function(d){d.effects.pulsate=function(a){return this.queue(function(){var b=d(this),c=d.effects.setMode(b,a.options.mode||"show");times=(a.options.times||5)*2-1;duration=a.duration?a.duration/2:d.fx.speeds._default/2;isVisible=b.is(":visible");animateTo=0;if(!isVisible){b.css("opacity",0).show();animateTo=1}if(c=="hide"&&isVisible||c=="show"&&!isVisible)times--;for(c=0;c').appendTo(document.body).addClass(a.options.className).css({top:d.top,left:d.left,height:b.innerHeight(),width:b.innerWidth(),position:"absolute"}).animate(c,a.duration,a.options.easing,function(){f.remove();a.callback&&a.callback.apply(b[0],arguments); +b.dequeue()})})}})(jQuery); +; \ No newline at end of file diff --git a/web/js/jquery.MultiFile.pack.js b/web/js/jquery.MultiFile.pack.js new file mode 100755 index 00000000..2ef968af --- /dev/null +++ b/web/js/jquery.MultiFile.pack.js @@ -0,0 +1,11 @@ +/* + ### jQuery Multiple File Upload Plugin v1.46 - 2009-05-12 ### + * Home: http://www.fyneworks.com/jquery/multiple-file-upload/ + * Code: http://code.google.com/p/jquery-multifile-plugin/ + * + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + ### +*/ +eval(function(p,a,c,k,e,r){e=function(c){return(c35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)r[e(c)]=k[c]||e(c);k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}(';3(U.1u)(6($){$.7.2=6(h){3(5.V==0)8 5;3(T S[0]==\'19\'){3(5.V>1){m i=S;8 5.M(6(){$.7.2.13($(5),i)})};$.7.2[S[0]].13(5,$.1N(S).27(1)||[]);8 5};m h=$.N({},$.7.2.F,h||{});$(\'2d\').1B(\'2-R\').Q(\'2-R\').1n($.7.2.Z);3($.7.2.F.15){$.7.2.1M($.7.2.F.15);$.7.2.F.15=10};5.1B(\'.2-1e\').Q(\'2-1e\').M(6(){U.2=(U.2||0)+1;m e=U.2;m g={e:5,E:$(5),L:$(5).L()};3(T h==\'21\')h={l:h};m o=$.N({},$.7.2.F,h||{},($.1m?g.E.1m():($.1S?g.E.17():10))||{},{});3(!(o.l>0)){o.l=g.E.D(\'28\');3(!(o.l>0)){o.l=(u(g.e.1D.B(/\\b(l|23)\\-([0-9]+)\\b/q)||[\'\']).B(/[0-9]+/q)||[\'\'])[0];3(!(o.l>0))o.l=-1;2b o.l=u(o.l).B(/[0-9]+/q)[0]}};o.l=18 2f(o.l);o.j=o.j||g.E.D(\'j\')||\'\';3(!o.j){o.j=(g.e.1D.B(/\\b(j\\-[\\w\\|]+)\\b/q))||\'\';o.j=18 u(o.j).t(/^(j|1d)\\-/i,\'\')};$.N(g,o||{});g.A=$.N({},$.7.2.F.A,g.A);$.N(g,{n:0,J:[],2c:[],1c:g.e.I||\'2\'+u(e),1i:6(z){8 g.1c+(z>0?\'1Z\'+u(z):\'\')},G:6(a,b){m c=g[a],k=$(b).D(\'k\');3(c){m d=c(b,k,g);3(d!=10)8 d}8 1a}});3(u(g.j).V>1){g.j=g.j.t(/\\W+/g,\'|\').t(/^\\W|\\W$/g,\'\');g.1k=18 2t(\'\\\\.(\'+(g.j?g.j:\'\')+\')$\',\'q\')};g.O=g.1c+\'1P\';g.E.1l(\'

    \');g.1q=$(\'#\'+g.O+\'\');g.e.H=g.e.H||\'p\'+e+\'[]\';3(!g.K){g.1q.1g(\'

    \');g.K=$(\'#\'+g.O+\'1F\')};g.K=$(g.K);g.16=6(c,d){g.n++;c.2=g;3(d>0)c.I=c.H=\'\';3(d>0)c.I=g.1i(d);c.H=u(g.1j.t(/\\$H/q,$(g.L).D(\'H\')).t(/\\$I/q,$(g.L).D(\'I\')).t(/\\$g/q,e).t(/\\$i/q,d));3((g.l>0)&&((g.n-1)>(g.l)))c.14=1a;g.Y=g.J[d]=c;c=$(c);c.1b(\'\').D(\'k\',\'\')[0].k=\'\';c.Q(\'2-1e\');c.1V(6(){$(5).1X();3(!g.G(\'1Y\',5,g))8 y;m a=\'\',v=u(5.k||\'\');3(g.j&&v&&!v.B(g.1k))a=g.A.1o.t(\'$1d\',u(v.B(/\\.\\w{1,4}$/q)));1p(m f 2a g.J)3(g.J[f]&&g.J[f]!=5)3(g.J[f].k==v)a=g.A.1r.t(\'$p\',v.B(/[^\\/\\\\]+$/q));m b=$(g.L).L();b.Q(\'2\');3(a!=\'\'){g.1s(a);g.n--;g.16(b[0],d);c.1t().2e(b);c.C();8 y};$(5).1v({1w:\'1O\',1x:\'-1Q\'});c.1R(b);g.1y(5,d);g.16(b[0],d+1);3(!g.G(\'1T\',5,g))8 y});$(c).17(\'2\',g)};g.1y=6(c,d){3(!g.G(\'1U\',c,g))8 y;m r=$(\'

    \'),v=u(c.k||\'\'),a=$(\'<1z X="2-1A" 1A="\'+g.A.12.t(\'$p\',v)+\'">\'+g.A.p.t(\'$p\',v.B(/[^\\/\\\\]+$/q)[0])+\'\'),b=$(\'\'+g.A.C+\'\');g.K.1g(r.1g(b,\' \',a));b.1C(6(){3(!g.G(\'22\',c,g))8 y;g.n--;g.Y.14=y;g.J[d]=10;$(c).C();$(5).1t().C();$(g.Y).1v({1w:\'\',1x:\'\'});$(g.Y).11().1b(\'\').D(\'k\',\'\')[0].k=\'\';3(!g.G(\'24\',c,g))8 y;8 y});3(!g.G(\'25\',c,g))8 y};3(!g.2)g.16(g.e,0);g.n++;g.E.17(\'2\',g)})};$.N($.7.2,{11:6(){m a=$(5).17(\'2\');3(a)a.K.26(\'a.2-C\').1C();8 $(5)},Z:6(a){a=(T(a)==\'19\'?a:\'\')||\'1E\';m o=[];$(\'1h:p.2\').M(6(){3($(5).1b()==\'\')o[o.V]=5});8 $(o).M(6(){5.14=1a}).Q(a)},1f:6(a){a=(T(a)==\'19\'?a:\'\')||\'1E\';8 $(\'1h:p.\'+a).29(a).M(6(){5.14=y})},R:{},1M:6(b,c,d){m e,k;d=d||[];3(d.1G.1H().1I("1J")<0)d=[d];3(T(b)==\'6\'){$.7.2.Z();k=b.13(c||U,d);1K(6(){$.7.2.1f()},1L);8 k};3(b.1G.1H().1I("1J")<0)b=[b];1p(m i=0;i + optionDisabledClass: 'asmOptionDisabled', // Class for items that are already selected / disabled + listClass: 'asmList', // Class for the list ($ol) + listSortableClass: 'asmListSortable', // Another class given to the list when it is sortable + listItemClass: 'asmListItem', // Class for the
  • list items + listItemLabelClass: 'asmListItemLabel', // Class for the label text that appears in list items + removeClass: 'asmListItemRemove', // Class given to the "remove" link + highlightClass: 'asmHighlight' // Class given to the highlight + + }; + + $.extend(options, customOptions); + + return this.each(function(index) { + + var $original = $(this); // the original select multiple + var $container; // a container that is wrapped around our widget + var $select; // the new select we have created + var $ol; // the list that we are manipulating + var buildingSelect = false; // is the new select being constructed right now? + var ieClick = false; // in IE, has a click event occurred? ignore if not + var ignoreOriginalChangeEvent = false; // originalChangeEvent bypassed when this is true + + function init() { + + // initialize the alternate select multiple + + // this loop ensures uniqueness, in case of existing asmSelects placed by ajax (1.0.3) + while($("#" + options.containerClass + index).size() > 0) index++; + + $select = $("") + .addClass(options.selectClass) + .attr('name', options.selectClass + index) + .attr('id', options.selectClass + index); + + $selectRemoved = $(""); + + $ol = $("<" + options.listType + ">") + .addClass(options.listClass) + .attr('id', options.listClass + index); + + $container = $("
    ") + .addClass(options.containerClass) + .attr('id', options.containerClass + index); + + buildSelect(); + + $select.change(selectChangeEvent) + .click(selectClickEvent); + + $original.change(originalChangeEvent) + .wrap($container).before($select).before($ol); + + if(options.sortable) makeSortable(); + + if($.browser.msie && $.browser.version < 8) $ol.css('display', 'inline-block'); // Thanks Matthew Hutton + } + + function makeSortable() { + + // make any items in the selected list sortable + // requires jQuery UI sortables, draggables, droppables + + $ol.sortable({ + items: 'li.' + options.listItemClass, + handle: '.' + options.listItemLabelClass, + axis: 'y', + update: function(e, data) { + + var updatedOptionId; + + $(this).children("li").each(function(n) { + + $option = $('#' + $(this).attr('rel')); + + if($(this).is(".ui-sortable-helper")) { + updatedOptionId = $option.attr('id'); + return; + } + + $original.append($option); + }); + + if(updatedOptionId) triggerOriginalChange(updatedOptionId, 'sort'); + } + + }).addClass(options.listSortableClass); + } + + function selectChangeEvent(e) { + + // an item has been selected on the regular select we created + // check to make sure it's not an IE screwup, and add it to the list + + if($.browser.msie && $.browser.version < 7 && !ieClick) return; + var id = $(this).children("option:selected").slice(0,1).attr('rel'); + addListItem(id); + ieClick = false; + triggerOriginalChange(id, 'add'); // for use by user-defined callbacks + } + + function selectClickEvent() { + + // IE6 lets you scroll around in a select without it being pulled down + // making sure a click preceded the change() event reduces the chance + // if unintended items being added. there may be a better solution? + + ieClick = true; + } + + function originalChangeEvent(e) { + + // select or option change event manually triggered + // on the original + // used only by buildSelect() + + if(disabled == undefined) var disabled = false; + + var $O = $('#' + optionId); + var $option = $("") + .val($O.val()) + .attr('rel', optionId); + + if(disabled) disableSelectOption($option); + + $select.append($option); + } + + function selectFirstItem() { + + // select the firm item from the regular select that we created + + $select.children(":eq(0)").attr("selected", true); + } + + function disableSelectOption($option) { + + // make an option disabled, indicating that it's already been selected + // because safari is the only browser that makes disabled items look 'disabled' + // we apply a class that reproduces the disabled look in other browsers + + $option.addClass(options.optionDisabledClass) + .attr("selected", false) + .attr("disabled", true); + + if(options.hideWhenAdded) $option.hide(); + if($.browser.msie) $select.hide().show(); // this forces IE to update display + } + + function enableSelectOption($option) { + + // given an already disabled select option, enable it + + $option.removeClass(options.optionDisabledClass) + .attr("disabled", false); + + if(options.hideWhenAdded) $option.show(); + if($.browser.msie) $select.hide().show(); // this forces IE to update display + } + + function addListItem(optionId) { + + // add a new item to the html list + + var $O = $('#' + optionId); + + if(!$O) return; // this is the first item, selectLabel + + var $removeLink = $("") + .attr("href", "#") + .addClass(options.removeClass) + .prepend(options.removeLabel) + .click(function() { + dropListItem($(this).parent('li').attr('rel')); + return false; + }); + + var $itemLabel = $("") + .addClass(options.listItemLabelClass) + .html($O.html()); + + var $item = $("
  • ") + .attr('rel', optionId) + .addClass(options.listItemClass) + .append($itemLabel) + .append($removeLink) + .hide(); + + if(!buildingSelect) { + if($O.is(":selected")) return; // already have it + $O.attr('selected', true); + } + + if(options.addItemTarget == 'top' && !buildingSelect) { + $ol.prepend($item); + if(options.sortable) $original.prepend($O); + } else { + $ol.append($item); + if(options.sortable) $original.append($O); + } + + addListItemShow($item); + + disableSelectOption($("[rel=" + optionId + "]", $select)); + + if(!buildingSelect) { + setHighlight($item, options.highlightAddedLabel); + selectFirstItem(); + if(options.sortable) $ol.sortable("refresh"); + } + + } + + function addListItemShow($item) { + + // reveal the currently hidden item with optional animation + // used only by addListItem() + + if(options.animate && !buildingSelect) { + $item.animate({ + opacity: "show", + height: "show" + }, 100, "swing", function() { + $item.animate({ + height: "+=2px" + }, 50, "swing", function() { + $item.animate({ + height: "-=2px" + }, 25, "swing"); + }); + }); + } else { + $item.show(); + } + } + + function dropListItem(optionId, highlightItem) { + + // remove an item from the html list + + if(highlightItem == undefined) var highlightItem = true; + var $O = $('#' + optionId); + + $O.attr('selected', false); + $item = $ol.children("li[rel=" + optionId + "]"); + + dropListItemHide($item); + enableSelectOption($("[rel=" + optionId + "]", options.removeWhenAdded ? $selectRemoved : $select)); + + if(highlightItem) setHighlight($item, options.highlightRemovedLabel); + + triggerOriginalChange(optionId, 'drop'); + + } + + function dropListItemHide($item) { + + // remove the currently visible item with optional animation + // used only by dropListItem() + + if(options.animate && !buildingSelect) { + + $prevItem = $item.prev("li"); + + $item.animate({ + opacity: "hide", + height: "hide" + }, 100, "linear", function() { + $prevItem.animate({ + height: "-=2px" + }, 50, "swing", function() { + $prevItem.animate({ + height: "+=2px" + }, 100, "swing"); + }); + $item.remove(); + }); + + } else { + $item.remove(); + } + } + + function setHighlight($item, label) { + + // set the contents of the highlight area that appears + // directly after the +
    +
    + +
    +HTML; + + $kids = MTrackDB::q('select name from milestones where pmid = ?', $ms->mid)->fetchAll(PDO::FETCH_COLUMN, 0); + if (count($kids)) { + + echo << + Effort expended against the following milestones is also counted towards the burndown of this milestone
    +HTML; + + foreach ($kids as $name) { + echo "$name
    \n"; + } + + echo "\n"; + + } else { + + $parents = array(); + foreach (MTrackDB::q('select mid, name from milestones where + pmid is null and ((deleted != 1 and mid != ? and completed is null) + or (mid = ?)) + order by name', + $ms->mid, $ms->pmid)->fetchAll(PDO::FETCH_ASSOC) as $row) { + $parents[$row['mid']] = $row['name']; + } + $parents[''] = '(none)'; + $parent = mtrack_select_box('pmid', $parents, $ms->pmid); + + + echo << + Effort expended against a milestone is also counted towards the burndown of its parent
    + $parent + +HTML; + } + + $open_milestones = MTrackMilestone::enumMilestones(); + $open_milestones[''] = '(none)'; + + $compmilestone = mtrack_select_box('compmilestone', $open_milestones); + + echo << + Schedule +
    + +
    +
    + +
    +
    +
    +
    + Re-target open tickets to milestone: $compmilestone +
    +HTML; + + if (count($kids) == 0 && !$ms->pmid) { + echo << +
    + +
    +HTML; + } + + echo << +
    +
    +
    + By default, the milestone summary will display a burndown chart + as though you had added [[BurnDown(milestone=name,width=50%,height=150)]] into the description field below.
    + If you wish to change the size and position of the chart, explicitly + enter the burndown macro in the description field.
    + To turn off the burndown for this milestone, enter [[BurnDown()]] in the description field. +
    + +
    +
    +
    + + +
    + + +HTML; +} else if (strlen($pi)) { + + mtrack_head($pi); +echo << + + +HTML; + + echo MTrackMilestone::macro_MilestoneSummary($pi); + + $kids = MTrackDB::q('select name from milestones where pmid = + (select mid from milestones where name = ?)', $pi) + ->fetchAll(PDO::FETCH_ASSOC); + if (count($kids)) { + echo "

    Related milestones:

    "; + foreach ($kids as $row) { + echo MTrackMilestone::macro_MilestoneSummary($row['name']); + } + } + +} else { + throw new Exception("no such milestone $pi"); +} + +mtrack_foot(); diff --git a/web/mtrack.css b/web/mtrack.css new file mode 100644 index 00000000..e36a0a23 --- /dev/null +++ b/web/mtrack.css @@ -0,0 +1,1377 @@ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, font, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td +{ + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + vertical-align: baseline; + background: transparent; +} + +td, th { + padding-left: 0.25em; + padding-right: 0.25em; + text-align: left; +} + +body { + line-height: 1; +} +ol { + list-style: decimal; +} +ul { + list-style: disc; +} +li { + /*list-style-position: inside;*/ + margin-left: 2.5em; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +/* remember to highlight inserts somehow! */ +ins { + text-decoration: none; +} +del { + text-decoration: line-through; +} + +/* tables still need 'cellspacing="0"' in the markup +table { + border-collapse: collapse; + border-spacing: 0; +} +*/ + +body { + background: #fff; + color: #000; + margin: 0; + padding: 0; +} + +body, th, td { + font: normal 10pt + /* Cambria, */ + 'Lucida Grande', 'Lucida Sans Unicode', + Verdana, Arial, 'Bitstream Vera Sans', + Helvetica, sans-serif; +} + +h1, h2, h3, h4, h5, h6 { + font-family: 'Trebuchet MS', verdana, sans-serif; + font-weight: bold; + margin: 0.75em 1em 0.75em 0; + letter-spacing: -0.018em; + page-break-after: avoid; +} + +h1 { + font-size: 18pt; +} + +h2 { font-size: 14pt; } +h3 { font-size: 12pt; } + +hr { + border: none; + border-top: 1px solid #ccb; + margin: 2em 0; +} + +address { + font-style: normal; +} +img { + border: none; +} + +ol.wikilist li, ul.wikilist li { + width: 50em; +} + +.underline { text-decoration: underline; } +ol.loweralpha { list-style-type: lower-alpha; } +ol.upperalpha { list-style-type: upper-alpha; } +ol.lowerroman { list-style-type: lower-roman; } +ol.upperroman { list-style-type: upper-roman; } +ol.arabic { list-style-type: decimal; } + +:link, :visited { + text-decoration: none; + /* color: #b00; */ + color: rgb(43, 84, 125); + border-bottom: 1px dotted #bbb; +} + +:link:hover, :visited:hover { + background-color: #eee; + color: #555; +} + +h1 :link, h1 :visited, +h2 :link, h2 :visited, +h3 :link, h3 :visited, +h4 :link, h4 :visited, +h5 :link, h5 :visited, +h6 :link, h6 :visited { + color: inherit; +} + +.anchor:link, .anchor:visited { + border: none; + color: #d7d7d7; + font-size: 0.8em; + vertical-align: text-top; +} + +* > .anchor:link, * > .anchor:visited { + visibility: hidden; +} + +h1:hover .anchor, +h2:hover .anchor, +h3:hover .anchor, +h4:hover .anchor, +h5:hover .anchor, +h6:hover .anchor { + visibility: visible; +} + +.nav h2, .nav hr { + display: none; +} + +.nav ul { + font-size: 9pt; + list-style: none; + margin: 0; + text-align: right; +} +.nav li { + border-right: 1px solid #d7d7d7; + display: inline; + padding: 0 0.75em; + white-space: nowrap; +} +.nav li.last { + border-right: none; +} + +#wikinav { + float: right; + clear: both; +} + +#wikinav ul { + text-align: left; +} + +#wikinav li { + display: block; + padding: 0.25em 0px; + margin-left: 0.5em; + border: none; +} + +#mainnav { + font-size: 9pt; + margin: 1em 0 0.33em; + padding: 0.2em 0; +} + +#mainnav li { + padding: 0.25em 0px; + margin-left: 0.5em; + border: none; +} +#mainnav li a { + background: #eee; +} + +#mainnav :link, #mainnav :visited { + color: #000; + padding: 0.2em 1em; + border: 1px solid lightGrey; + border-radius: 8px; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; +} + +#mainnav :link:hover, #mainnav :visited:hover { + background-color: #ccc; +} + +#mainnav .active :link, #mainnav .active :visited { + border: 1px solid #777; + background: #333; + color: #eee; + font-weight: bold; +} + +#content { + margin: 1em; +} +#content p { + width: 50em; + margin-top: 1em; +} + +#content td p:first-child { + margin-top: 0; +} + +input, textarea, select { + margin: 2px; +} +input, select { + vertical-align: middle; +} +.button, input[type=button], input[type=submit], input[type=reset] { + background: #eee; + color: #222; + border: 1px outset #ccc; + padding: 0.1em 0.5em; +} +.button:hover, input[type=button]:hover, input[type=submit]:hover, input[type=reset]:hover { + background: #ccb; +} +.button[disabled], input[type=button][disabled], input[type=submit][disabled], +input[type=reset][disabled] { + background: #f6f6f6; + border-style: solid; + color: #999; +} +input[type=text], input.textwidget, textarea { border: 1px solid #d7d7d7 } +input[type=text], input.textwidget { padding: .25em .5em } +input[type=text]:focus, input.textwidget:focus, textarea:focus { + border: 1px solid #886; +} +option { border-bottom: 1px dotted #d7d7d7 } +fieldset { border: 1px solid #d7d7d7; padding: .5em; margin: 1em 0 } +form p.hint, form span.hint { color: #666; font-size: 85%; font-style: italic; margin: .5em 0; + padding-left: 1em; +} +fieldset.iefix { + background: transparent; + border: none; + padding: 0; + margin: 0; +} +* html fieldset.iefix { width: 98% } +fieldset.iefix p { margin: 0 } +legend { color: #999; padding: 0 .25em; font-size: 90%; font-weight: bold } +label.disabled { color: #d7d7d7 } +.buttons { margin: .5em .5em .5em 0 } +.buttons form, .buttons form div { display: inline } +.buttons input { margin: 1em .5em .1em 0 } +.inlinebuttons input { + font-size: 70%; + border-width: 1px; + border-style: dotted; + margin: 0 .1em; + padding: 0.1em; + background: none; +} + +div.wikipreview { + background-color: #eee; + margin-bottom: 2em; +} + +div.error, textarea.error, input.error { + border: solid 1px red; +} + +div#motd { + float: right; +} + +table.history { + border-collapse: collapse; +} + +table.history tr { + vertical-align: text-top; +} +table.history tr th { + text-align: left; +} + +table.history tr td.diff { + border-bottom: solid 1px #bbb; + padding: 1em 4em; +} + +table.codeann { + border-collapse: collapse; + border: solid 1px #bbb; +} + +tt, pre, td.code { + font-family: 'Consolas', 'Bitstream Vera Sans Mono', + 'Monaco', 'Courier New', 'Courier', monospace; +} + +pre, td.code { + border: solid 1px #bbb; + padding: 0.2em; + margin: 0.4em; + background-color: #eee; + overflow-x: auto; + word-wrap: break-word; +} + +td.code { + overflow-x: auto; + word-wrap: break-word; + border-top: none; + border-bottom: none; + border-left: solid 1px #bbb; + border-right: solid 1px #bbb; + font-size: 9pt; +} + +td.source-code { + overflow-x: auto; + white-space: pre; + font-family: 'Consolas', 'Bitstream Vera Sans Mono', + 'Monaco', 'Courier New', 'Courier', monospace; + word-wrap: break-word; + line-height: 1.6em; + border: solid 1px #bbb; + background-color: #eee; +} + +table.codeann { + width: 100%; +} + +table.codeann tr th { + border-bottom: solid 1px #bbb; +} +table.codeann tr th.code { + text-align: left; +} +table.codeann tr td { + font-family: 'Consolas', 'Bitstream Vera Sans Mono', + 'Monaco', 'Courier New', 'Courier', monospace; + font-size: 10pt +} + +table.codeann tr th, table.codeann tr td { + padding-left: 0.5em; + padding-right: 0.5em; +} + +table.codeann tr th.line, table.codeann tr td.line, + table.codeann tr th.changeset, table.codeann tr td.changeset { + text-align: right; + line-height: 1.6em; +} + +table.codeann tr td.line a:link { + text-decoration: none; + border: none; + padding: none; + margin: none; +} + +table.codeann tr th.user, table.codeann tr td.user { + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* by default, when showing an annotated file, hide annotations, but show + * the line numbers */ +table.codeann tr th.changeset, table.codeann tr td.changeset, +table.codeann tr th.user, table.codeann tr td.user { + display: none; +} + +pre { + font-size: 9pt; +} + +#ticketinfo label { + font-weight: bold; + white-space: nowrap; +} + +#ticketinfo fieldset { + width: 57em; +} + +#ticketinfo fieldset#readonly-tkt-properties { + float: left; + min-height: 6em; + margin-right: 1em; + width: 35em; +} + +#ticketinfo fieldset#readonly-tkt-resources { + min-height: 6em; + width: 20em; +} + +div#readonly-tkt-description { + clear: both; + border: solid 1px #ccc; + min-width: 57em; + background-color: #ffc; + padding: 0.25em 0.5em 0.5em 0.5em; +} + +div.ticketevent { + margin-top: 1.5em; + margin-bottom: 0.5em; + border-bottom: solid 1px #bbb; + color: #999; +} + +blockquote.citation { + border-left: solid 2px #b00; + padding-left: 0.5em; + margin-left: inherit; + font-style: italic; + color: #444; +} + +a.pmark { + color: inherit; +} + +table.wiki { + border-collapse: collapse; +} + +table.wiki tr td { + border: solid 1px #bbb; + padding: 0.2em 0.5em; +} + +table.report { + border-collapse: collapse; + width: 100%; + clear: both; +} + +table.report tr th { + border: solid 1px #bbb; + padding: 0.2em 0.5em; + font-weight: bold; + text-align: left; +} + +table.report tr td { + border: solid 1px #bbb; + padding: 0.2em 0.5em; +} + +h2.reportgroup { +} + +table.progress { + width: 20em; + height: 1em; + border: solid 1px green; + border-collapse: collapse; +} + +table.progress tr td.closed { + background-color: #8b8; + padding: 0; +} + +table.esthours { + border: solid 1px blue; +} + +table.esthours tr td.closed { + background-color: #88b; +} + +div.milestone { + margin-bottom: 3em; +} + +dt { + font-weight: bold; +} + +dd { + margin-bottom: 1.2em; +} + +textarea.code { + font-family: monospace; + font-size: 1em; +} + +textarea.wiki { + width: 100%; + font-family: monospace; + font-size: 1em; +} + +#banner-back { + min-height: 3em; + background-image: url(images/gradient-header.png); + background-repeat: repeat-x; + background-position: center bottom; + background-color: rgb(229,229,229); + border-bottom: 1px solid #eee; + padding: 0.5em 1em 0.5em 1em; +} + +#banner { + display: inline; + font-family: Calibri, Arial, Verdana, 'Bitstream Vera Sans', + Helvetica, sans-serif; + font-weight: bolder; + font-size: 1.1em; + clear: both; +} + +#header { + clear: both; +} + +#mainsearch { + float: right; + margin: 0; +} + +#mainsearch input.search { + margin-left: 1em; + width: 30em; +} + +/* approximate Safari input type=search */ +input.roundsearch { + border-radius: 12px; + -moz-border-radius: 12px; + border-style: inset; + border-width: 1px; + border-color: #777; + color: black; + padding-left: 1em; + padding-right: 1em; +} + +input.watermark { + color: #999; +} + + +div.excerpt span.hl { + background-color: yellow; +} + +table.searchresults tr td { + padding-bottom: 0.8em; +} + +table.searchresults tr { + vertical-align: top; +} + +div.flotgraph div { +} + +h1.timelineday { + font-size: 1.2em; + margin-top: 1.5em; + margin-bottom: 0.5em; + border-bottom: solid 1px #bbb; + color: #999; +} + +div.timelineevent { + padding-bottom: 0.25em; + margin-top: 0.5em; + width: 100%; + overflow: hidden; + border-bottom: solid 1px #eee; +} + +div.timelineevent a.userlink.timelineface { + float: left; + margin: 0 1em 1em 0; +/* padding: 0 1em 1em 0; */ + border-bottom-style: none; +} + +div.timelineevent span.time { + color: #999; + font-size: 0.9em; +} + +div.timelinetext { + padding-left: 64px; + width: 50em; + padding-bottom: 0.5em; +} + +div.timelinereason { + color: rgb(84,84,84); + font-size: 0.8em; + margin-bottom: 1em; +} + + + +.newticket { + background: url(images/newticket.png) no-repeat; +} +.editticket { + background: url(images/editedticket.png) no-repeat; +} +.closedticket { + background: url(images/closedticket.png) no-repeat; +} +.editwiki { + background: url(images/wiki.png) no-repeat; +} +.editmilestone { + background: url(images/milestone.png) no-repeat; +} +.newchangeset { + background: url(images/changeset.png) no-repeat; +} + +a.changesetlink { + background: rgb(170, 255, 170); + border-bottom: 1px solid rgb(0, 204, 51); + border-left: 1px solid rgb(204, 255, 204); + border-right: 1px solid rgb(0, 204, 51); + border-top: 1px solid rgb(204, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; + font-size: 0.9em; + border-radius: 8px; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + + background-image: url(images/changeset.png); + background-repeat: no-repeat; + background-position-y: 2; + padding-left: 14px; +} + +table.codeann tr td.changeset a.changesetlink { + background-image: none; + padding-left: 2px; + font-size: 0.8em; +} + +a.ticketlink { + background: rgb(255, 255, 170); + border-bottom: 1px solid rgb(255, 238, 0); + border-left: 1px solid rgb(255, 255, 204); + border-right: 1px solid rgb(255, 238, 0); + border-top: 1px solid rgb(255, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; + font-size: 0.9em; + + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; +} + +a.wikilink { + background: rgb(170, 255, 170); + border-bottom: 1px solid rgb(0, 204, 51); + border-left: 1px solid rgb(204, 255, 204); + border-right: 1px solid rgb(0, 204, 51); + border-top: 1px solid rgb(204, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; + font-size: 0.9em; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; + + background-image: url(images/wiki.png); + background-repeat: no-repeat; + background-position-y: 2; + background-position-x: 2; + padding-left: 16px; +} +/* Styles for tabular listings such as those used for displaying directory + contents and report results. */ +table.listing { + clear: both; + border-bottom: 1px solid #d7d7d7; + border-collapse: collapse; + border-spacing: 0; + margin-top: 1em; + width: 100%; +} +table.listing th { text-align: left; padding: 0 1em .1em 0; font-size: 12px } +table.listing thead { background: #f7f7f0 } +table.listing thead th { + border: 1px solid #d7d7d7; + border-bottom-color: #999; + font-size: 11px; + font-weight: bold; + padding: 2px .5em; + vertical-align: bottom; +} +table.listing thead th :link:hover, table.listing thead th :visited:hover { + background-color: transparent; +} +table.listing thead th a { border: none; padding-right: 12px } +table.listing th.asc a, table.listing th.desc a { font-weight: bold } +table.listing th.asc a, table.listing th.desc a { + background-position: 100% 50%; + background-repeat: no-repeat; +} +table.listing th.asc a { } +table.listing th.desc a { } +table.listing tbody td, table.listing tbody th { + border: 1px dotted #ddd; + padding: .33em .5em; + vertical-align: top; +} +table.listing tbody td a:hover, table.listing tbody th a:hover { + background-color: transparent; +} +table.listing tbody tr { border-top: 1px solid #ddd } +table.listing tbody tr.even { background-color: #fcfcfc } +table.listing tbody tr.odd { background-color: #f7f7f7 } +table.listing tbody tr:hover { background: #eed !important } + + +/* Styles for the directory entries table + (extends the styles for "table.listing") */ +#dirlist { margin-top: 0 } +#dirlist td.rev, #dirlist td.age, #dirlist td.change, #dirlist td.size { + color: #888; + white-space: nowrap; +} +#dirlist td.size { text-align: right; } +/* #dirlist td.name { width: 30% } */ +#dirlist td.name a, #dirlist td.name span { + background-position: 0% 50%; + background-repeat: no-repeat; + padding-left: 20px; +} +#dirlist td.name a.parent { background-image: url(images/parent.png) } +#dirlist td.name a.dir { background-image: url(images/treeview/folder-closed.gif) } +#dirlist td.name span.dir { background-image: url(images/treeview/folder-closed.gif) } +#dirlist td.name a.file { background-image: url(images/file.png) } +#dirlist td.name span.file { background-image: url(images/filedeny.png) } +#dirlist td.name a, #dirlist td.rev a { border-bottom: none; display: block } +#dirlist td.change { + white-space: normal; +} + +table.code.diff { + border-collapse: collapse; + border: 1px solid #d3d3d0; + width: 100%; + margin-bottom: 3em; +} + +table.code.diff tr.removed { + background-color: rgb(255, 221, 221); +} +table.code.diff tr.added { + background-color: rgb(221, 255, 221); +} + +table.code.diff tr td { + white-space: pre; + font-family: 'Consolas', 'Bitstream Vera Sans Mono', + 'Monaco', 'Courier New', 'Courier', monospace; + color: black; + line-height: 1.5em; + overflow-x: auto; + word-wrap: break-word; +} + +table.code.diff tr td.line { + padding-left: 1.5em; + border-left: 1px solid #d3d3d0; +} +table.code.diff tr.first { + border-top: 1px solid #d3d3d0; +} + +table.code.diff tr td.lineno { + text-align: right; + padding-right: 1em; + width: 1%; +} + +table.code.diff tr.meta td.line { + border: none; +} + +pre.code.diff { + border: 1px solid #d3d3d0; + color: #939399; +} + +.code.diff code { + width: 100%; + margin: 0; + padding: 0; + color: black; + line-height: 1.5em; + display: inline-block; + overflow-x: auto; + word-wrap: break-word; +} + +/* +.code.diff code.odd { + background-color: #f7f7f7; +} +.code.diff code.even { + background-color: #fcfcfc; +} +.code.diff code.removed { + background-color: rgb(255, 221, 221); +} +.code.diff code.added { + background-color: rgb(221, 255, 221); +} +*/ + +ol.code { + text-align: left; + border: 1px solid #d3d3d0; + color: #939399; +} + +ol.code li { + white-space: nowrap; + list-style-type: none; + margin: 0; + margin-left: 2em; + padding: 0 0 0 0em; + text-align: left; + border-left: 1px solid #d3d3d0; +} + +ol.code li.even { background-color: #fcfcfc } +ol.code li.odd { background-color: #f7f7f7 } +ol.code li code { + white-space: pre; + padding-left: 1em; + line-height: 1.5em; + color: black; +} +ol.code { + overflow-x: auto; + word-wrap: break-word; +} +div.changesets { + background-color: #ddd; +} + +div.changeset, div.changesetodd { + margin: 0; + padding: 0; +} + +div.changesetodd { + background-color: #eee; +} + +div.changesets img.gravatar, div.revinfo img.gravatar +{ + float: left; + margin: 0 0.5em 0.5em 0; +} + +div.changelog { + padding: 0.5em; + padding-left: 1em; +} + +div.changeinfo { + border-bottom: solid 1px #bbb; + margin: 0; + margin-top: 1em; + padding: 0; + padding-bottom: 1.5em; + padding-left: 1em; +} + +div.changesetday { + font-size: 1.2em; + background-color: #ccc; + border-bottom: solid 1px #bbb; + margin: 0; + padding: 0; + padding-bottom: 0.3em; + padding-top: 0.3em; + padding-left: 1em; + color: #777; +} + +span.branchname, span.milestone { + background: rgb(170, 255, 170); + border-bottom: 1px solid rgb(0, 204, 51); + border-left: 1px solid rgb(204, 255, 204); + border-right: 1px solid rgb(0, 204, 51); + border-top: 1px solid rgb(204, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; + font-size: 0.9em; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; +} + +a.keyword { + background: rgb(170, 255, 170); + border-bottom: 1px solid rgb(0, 204, 51); + border-left: 1px solid rgb(204, 255, 204); + border-right: 1px solid rgb(0, 204, 51); + border-top: 1px solid rgb(204, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 4px; + padding-right: 4px; + margin-right: 0.5em; + font-size: 0.9em; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; +} + +span.milestone { + background-image: url(images/milestone.png); + background-repeat: no-repeat; + background-position-x: 2px; + padding-left: 16px; +} + +span.tagname { + background: rgb(255, 255, 170); + border-bottom: 1px solid rgb(255, 238, 0); + border-left: 1px solid rgb(255, 255, 204); + border-right: 1px solid rgb(255, 238, 0); + border-top: 1px solid rgb(255, 255, 204); + color: black; + white-space: normal; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; + font-size: 0.9em; +} + +.completed { + text-decoration: line-through; +} + +ol.code li code.removed { + background-color: rgb(255, 221, 221); + display: block; + padding-right: 0; +} + +ol.code li code.added { + background-color: rgb(221, 255, 221); + display: block; + padding-right: 0; +} + +div.difffiles { + margin-top: 2em; + margin-bottom: 1em; +} + +table.fields { + width: 45em; +} + +input.summaryedit { + font-size: 1.1em; + font-weight: bold; +} + +select.asmSelect { + display: inline; +} + +div.asmContainer { + border: 1px solid #d7d7d7; + padding: .5em; +} + +/* disabled options in the asmSelect */ +select.asmSelect option.asmOptionDisabled { + color: #999; +} + +ol.asmList, ul.asmList { + /* html list constructed around selected items */ + margin: 0.25em 0 1em 1em; + position: relative; + display: block; + padding: 0.4em 0 0 0; +} + +li.asmListItem { + margin-left: 0.5em; + line-height: 1.6em; + list-style: disc; +} + +a.asmListItemRemove { + padding: 0.2em 0.2em 0.2em 0.5em; + border: none; +} + +div.ticketchangeinfo img.gravatar { + float: right; + margin: 0 0em 1em 1em; +} +div.timelinereason img.gravatar { + float: left; + margin: 0 1em 1em 0em; +} +div.ticketevent, div.timelineevent, h1.timelineday { + clear: both; +} + +div.userinfo img.gravatar { + float: right; +} + +div.ui-state-highlight, div.ui-state-error { + padding: 0.4em; +} +.ui-widget { + font-size: 1em; +} +.ui-widget p { + margin-bottom: 1.2em; +} + +span.ui-icon-info, span.ui-icon-alert { + margin-right: 0.6em; + float: left; +} + +#changelog-container { + margin-left: 2em; +} + +div.attachment-list { + clear: both; + margin-top: 1em; + margin-bottom: 1em; + padding: 0; +} + +/* sortable report tables */ + +table.report thead tr th { + cursor: pointer; +/* font-size: 0.7em;*/ +} + +table.report thead tr .header { + background-image: url(images/sort/bg.gif); + background-repeat: no-repeat; + background-position: center right; + padding-right: 16px; +} + +table.report thead tr .headerSortUp { + background-image: url(images/sort/asc.gif); +} + +table.report thead tr .headerSortDown { + background-image: url(images/sort/desc.gif); +} + +table.report tbody tr.statusclosed td { + background-color: #eee !important; + color: #777; +} + +table.report tbody tr.statusclosed td.summary, + table.report tbody tr.statusclosed td.ticket { + text-decoration: line-through; +} + +table.report tbody tr.color1 td { + background-color: rgb(255, 221, 204); + color: rgb(170, 34, 34); +} +table.report tbody tr.color2 td { + background-color: rgb(255, 255, 187); + color: rgb(136, 136, 0); +} +table.report tbody tr.color4 td { + background-color: rgb(221, 255, 255); + color: rgb(0, 153, 153); +} + +table.report tbody tr.color5 td { + background-color: rgb(221, 231, 255); + color: rgb(68, 102, 153); +} + +table.report tbody tr td a { + border-bottom-style: none; + color: inherit; +} + +table.report tbody tr td { + white-space: nowrap; + /* font-size: 0.75em; */ +} + +table.report tbody tr td.summary { + white-space: normal; +} + +.treeview, .treeview ul { + padding: 0; + margin: 0; + list-style: none; +} + +.treeview ul { + margin-top: 4px; +} + +.treeview .hitarea { +/* background: url(images/treeview/treeview-gray.gif) -64px -25px no-repeat; */ + height: 16px; + width: 16px; + margin-left: -16px; + float: left; + cursor: pointer; +} + +/* fix for IE6 */ +* html .hitarea { + display: inline; + float: none; +} +.treeview li { + margin: 0; + padding: 3px 0pt 3px 20px; +} + +.treeview li { + background-image: none; + /* url(images/treeview/treeview-gray-line.gif) 0 0 no-repeat; */ +} +.treeview li.collapsable { + background: url(images/treeview/folder.gif) no-repeat; +} +.treeview li.expandable { + background: url(images/treeview/folder-closed.gif) no-repeat; +} + +#wikilastchange { + float: right; + padding: 0.5em; + margin: 1em 0 1.5em 1.5em; + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + border-radius: 8px; + border: solid 1px #bbb; + background-color: #eee; + font-size: 0.8em; + width: 15em; +} + +#wikilastchange a.userlink.wikilastchange { + float: left; + padding: 0 0.5em 0.5em 0; + border: none; +} + +#footer { + clear: both; + background-image: url(images/gradient-footer.png); + background-position: center top; + background-repeat: repeat-x; + background-color: rgb(229,229,229); + border-top: 1px solid #eee; + width: 100%; + margin-top: 1em; +} + +div.navfoot { + color: #999; + font-size: 0.8em; + padding-left: 1em; + padding-top: 1em; + height: 2.5em; +} + +#openid_identifier { + font-size: 2em; +} + +#openid-sign-in { + font-size: 2em; +} + +#openidlink { + border: none; +} + +@media print { + #header, #banner, #mainsearch, div.navfoot { + display: none; + } + #qform { + margin-bottom: 0em; + } + #qtable tr td, #qtable tr td input { + font-size: 0.5em; + } + #qtable tr td select { + font-size: 1em; + } + input { + border: none; + } + input[type=submit], button, #customqryaddfilter { + display: none; + } + h1 { + font-size: 1.2em; + } + #wikinav, #wikilastchange { + display: none; + } +} + +span.fulldate { + color: #999; + padding-left: 1.5em; + padding-right: 1.5em; +} + +div.ticketevent abbr.timeinterval { + color: #777; +} + +tr.inactiveuser { + text-decoration: line-through; +} + +.button, button, input[type=button], input[type=submit], input[type=file] { + border-radius: 4px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + background: #e6e6e6 + url(css/smoothness/images/ui-bg_glass_75_e6e6e6_1x400.png); + border: 1px solid lightGrey; + color: #555; + cursor: pointer; +} + +.button:hover, button:hover, input[type=button]:hover, input[type=submit]:hover, input[type=file]:hover { + background: #dadada + url(css/smoothness/images/ui-bg_glass_75_dadada_1x400.png); + border: 1px solid #999; + color: #212121; +} + +.button[disabled], button[disabled], input[type=button][disabled], input[type=submit][disabled], input[type=file][disabled] { + background: #fff; + border: 1px solid #ddd; + color: #999; +} + +textarea.shortwiki.markItUpEditor { + height: 5em; +} + +#snippetmug { + float: right; +} + +#recentsnippets { + font-size: smaller; + width: 10em; + vertical-align: top; +} + +form.snippetform, +div.snippetview { +} + +div.snippetsummary { + margin-top: 1em; +} + +#newsnippet { + margin-bottom: 1em; +} + +div.permissioneditor th { + font-weight: bold; +} + +div.permissioneditor tr.inheritedacl { + color: #999; +} + +div.button-float { + width: 100%; + background-color: white; + padding: 0.5em; +} + +div.button-float-floating { + border: solid 1px #ccc; + left: -1em; + padding-left: 2.3em; +} + +/* vim:ts=2:sw=2:et: + */ diff --git a/web/openid.php b/web/openid.php new file mode 100644 index 00000000..7b0345da --- /dev/null +++ b/web/openid.php @@ -0,0 +1,212 @@ +begin($id); + if (!$req) { + $message = "not a valid OpenID"; + } + } + if ($req) { + $sreg = Auth_OpenID_SRegRequest::build( + array('nickname', 'fullname', 'email') + ); + $req->addExtension($sreg); + + if ($req->shouldSendRedirect()) { + $rurl = $req->redirectURL( + $ABSWEB, $ABSWEB . 'openid.php/callback'); + if (Auth_OpenID::isFailure($rurl)) { + $message = "Unable to redirect to server: " . $rurl->message; + } else { + header("Location: $rurl"); + exit; + } + } else { + $html = $req->htmlMarkup($ABSWEB, $ABSWEB . 'openid.php/callback', + false, array('id' => 'openid_message')); + if (Auth_OpenID::isFailure($html)) { + $message = "Unable to redirect to server: " . $html->message; + } else { + echo $html; + } + } + } +} else if ($pi == 'callback') { + $res = $consumer->complete($ABSWEB . 'openid.php/callback'); + + if ($res->status == Auth_OpenID_CANCEL) { + $message = 'Verification cancelled'; + } else if ($res->status == Auth_OpenID_FAILURE) { + $message = 'OpenID authentication failed: ' . $res->message; + } else if ($res->status == Auth_OpenID_SUCCESS) { + $id = $res->getDisplayIdentifier(); + $sreg = Auth_OpenID_SRegResponse::fromSuccessResponse($res)->contents(); + + if (!empty($sreg['nickname'])) { + $name = $sreg['nickname']; + } else if (!empty($sreg['fullname'])) { + $name = $sreg['fullname']; + } else { + $name = $id; + } + $message = 'Authenticated as ' . $name; + + $_SESSION['openid.id'] = $id; + unset($_SESSION['openid.userid']); + $_SESSION['openid.name'] = $name; + if (!empty($sreg['email'])) { + $_SESSION['openid.email'] = $sreg['email']; + } + /* See if we can find a canonical identity for the user */ + foreach (MTrackDB::q('select userid from useraliases where alias = ?', + $id)->fetchAll() as $row) { + $_SESSION['openid.userid'] = $row[0]; + break; + } + + if (!isset($_SESSION['openid.userid'])) { + /* no alias; is there a direct userinfo entry? */ + foreach (MTrackDB::q('select userid from userinfo where userid = ?', + $id)->fetchAll() as $row) { + $_SERVER['openid.userid'] = $row[0]; + break; + } + } + + if (!isset($_SESSION['openid.userid'])) { + /* prompt the user to fill out some basic details so that we can create + * a local identity and associate their OpenID with it */ + header("Location: {$ABSWEB}openid.php/register?" . + http_build_query($sreg)); + } else { + header("Location: " . $ABSWEB); + } + exit; + } else { + $message = 'An error occurred while talking to your OpenID provider'; + } +} else if ($pi == 'signout') { + session_destroy(); + header('Location: ' . $ABSWEB); + exit; +} else if ($pi == 'register') { + + if (!isset($_SESSION['openid.id'])) { + header("Location: " . $ABSWEB); + exit; + } + + $userid = isset($_REQUEST['nickname']) ? $_REQUEST['nickname'] : ''; + $email = isset($_REQUEST['email']) ? $_REQUEST['email'] : ''; + $message = null; + + /* See if we can find a canonical identity for the user */ + foreach (MTrackDB::q('select userid from useraliases where alias = ?', + $_SESSION['openid.id'])->fetchAll() as $row) { + header("Location: " . $ABSWEB); + exit; + } + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if (!strlen($userid)) { + $message = 'You must enter a userid'; + } else { + /* is the requested id available? */ + $avail = true; + foreach (MTrackDB::q('select userid from userinfo where userid = ?', + $userid)->fetchAll() as $row) { + $avail = false; + $message = "Your selected user ID is not available"; + } + if ($avail) { + MTrackDB::q('insert into userinfo (userid, email, active) values (?, ?, 1)', $userid, $email); + /* we know the alias doesn't already exist, because we double-checked + * for it above */ + MTrackDB::q('insert into useraliases (userid, alias) values (?,?)', + $userid, $_SESSION['openid.id']); + header("Location: {$ABSWEB}user.php?user=$userid&edit=1"); + exit; + } + } + } + + mtrack_head('Register'); + + $userid = htmlentities($userid, ENT_QUOTES, 'utf-8'); + $email = htmlentities($email, ENT_QUOTES, 'utf-8'); + + if ($message) { + $message = htmlentities($message, ENT_QUOTES, 'utf-8'); + echo << + + $message + +HTML; + } + + echo <<Set up your local account +
    + User ID:
    + Email:
    + +
    + + +HTML; + mtrack_foot(); + exit; +} + +mtrack_head('Authentication Required'); +echo "

    Please sign in with your OpenID

    \n"; +echo "
    "; +echo ""; +echo " "; + +if ($message) { + $message = htmlentities($message, ENT_QUOTES, 'utf-8'); + echo << + + $message + +HTML; +} + +echo "
    "; + + +mtrack_foot(); + diff --git a/web/query.php b/web/query.php new file mode 100644 index 00000000..1f05a730 --- /dev/null +++ b/web/query.php @@ -0,0 +1,328 @@ +Custom Query\n"; + +/* This logic matches up to equivalent logic in the macro_RunReport + * function in inc/report */ + +$params = array(); + +if (strlen($_SERVER['QUERY_STRING'])) { + $qs = $_SERVER['QUERY_STRING']; +} else { + $qs = mtrack_get_pathinfo(); +} + +list ($params, $mparams) = MTrackReport::parseQuery($qs); + +echo "
    "; +echo "
    "; + +$params = json_encode($params); + +$milestones = json_encode(array_values(MTrackMilestone::enumMilestones(true))); +echo << +Add Filter: "; + echo " "; +} +$all_cols = array(); + +// Add in the selected columns in order first +foreach ($mparams['col'] as $col) { + $field = $C->fieldByName($col); + if ($field) { + $all_cols[$field->name] = $field->label; + } else { + $all_cols[$col] = ucfirst($col); + } +} +// Add in other possible fields +foreach ($fields as $field) { + $all_cols[$field] = ucfirst($field); +} +$possible_fields = array( + 'severity', 'remaining' +); +foreach ($possible_fields as $name) { + $all_cols[$name] = ucfirst($name); +} +foreach ($C->fields as $f) { + $all_cols[$f->name] = $f->label; +} + +foreach ($all_cols as $name => $label) { + add_col($name, $label); +} + +echo << + + + +
    + +HTML; + +if (strlen(trim($qs))) { + echo MTrackReport::macro_TicketQuery($qs); +} + +mtrack_foot(); + diff --git a/web/report.php b/web/report.php new file mode 100644 index 00000000..95baee0d --- /dev/null +++ b/web/report.php @@ -0,0 +1,126 @@ +rid, $edit ? 'modify' : 'read'); +} else { + $rep = MTrackReport::loadBySummary($pi); + MTrackACL::requireAllRights("report:" . $rep->rid, $edit ? 'modify' : 'read'); +} + +if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $rep->summary = $_POST['name']; + $rep->description = $_POST['description']; + $rep->query = $_POST['query']; + + if (isset($_POST['cancel'])) { + header("Location: {$ABSWEB}reports.php"); + exit; + } + + if (isset($_POST['save'])) { + try { + $cs = MTrackChangeset::begin( + "report:" . $rep->summary, $_POST['comment']); + $rep->save($cs); + $cs->commit(); + header("Location: {$ABSWEB}report.php/$rep->rid"); + exit; + } catch (Exception $e) { + $message = $e->getMessage(); + } + } +} + +if (isset($_GET['format'])) { + // targeted report format; omit decoration + $params = $_GET; + unset($params['format']); + switch ($_GET['format']) { + case 'tab': + header('Content-Type: text/plain'); + break; + } + echo $rep->renderReport($rep->query, $params, $_GET['format']); + exit; +} + +if ($rep->rid) { + if ($edit) { + mtrack_head('{' . $rep->rid . '} ' . $rep->summary . " (edit)"); + } else { + mtrack_head('{' . $rep->rid . '} ' . $rep->summary); + } +} else { + mtrack_head("Create Report"); +} + +if (!empty($message)) { + echo "
    " . htmlentities($message, ENT_COMPAT, 'utf-8') . "
    \n"; +} + +if (!$edit || isset($_POST['preview'])) { + echo "

    " . htmlentities($rep->summary, ENT_COMPAT, 'utf-8') . "

    "; + echo MTrackWiki::format_to_html($rep->description); + echo $rep->renderReport($rep->query); + + if ($edit) { + echo "
    "; + } else if (MTrackACL::hasAllRights("report:" . $rep->rid, 'modify')) { + echo << + + +HTML; + } +} + + +if ($edit) { + echo << + +HTML; + + if ($rep->rid) { + echo "\n"; + echo '{' . $rep->rid . '} '; + } + + $name = htmlentities($rep->summary, ENT_QUOTES, 'utf-8'); + $desc = htmlentities($rep->description, ENT_QUOTES, 'utf-8'); + $query = htmlentities($rep->query, ENT_QUOTES, 'utf-8'); + + echo <<Name:
    +
    + +
    + + +
    + Reason for change: + + + +HTML; + +} +mtrack_foot(); diff --git a/web/reports.php b/web/reports.php new file mode 100644 index 00000000..8aa5d277 --- /dev/null +++ b/web/reports.php @@ -0,0 +1,45 @@ + +

    Available Reports

    + +

    + The reports below are constructed using SQL. You may also + use the Custom Query + page to create a report on the fly. +

    + + + + + + +fetchAll(PDO::FETCH_ASSOC) as $row) +{ + $url = "${ABSWEB}report.php/$row[rid]"; + $t = "{" . $row['rid'] . "}"; + $s = htmlentities($row['summary'], ENT_COMPAT, 'utf-8'); + $s = "$s"; + + echo << +HTML; +} +?> +
    ReportTitle
    $t$s
    + +
    + +
    +Roadmap +
    +
    + +
    + +
    +
    +
    + + + +
    +HTML; +$db = MTrackDB::get(); + +if (!empty($_GET['completed'])) { + $comp = ""; +} else { + $comp = " AND completed IS NULL "; +} + +if ($watched == 'checked') { + $me = $db->quote(mtrack_canon_username(MTrackAuth::whoami())); + if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') { + $oid = 'w.oid::integer'; + } else { + $oid = 'w.oid'; + } + $sql = <<query($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) { + echo MTrackMilestone::macro_MilestoneSummary($row['name']); + $i++; +} + +if ($i == 0) { + $milestones = $watched == 'checked' ? 'watched milestones' : 'milestones'; + echo <<No $milestones were found.

    +HTML; +} + +mtrack_foot(); + diff --git a/web/search.php b/web/search.php new file mode 100644 index 00000000..1b333731 --- /dev/null +++ b/web/search.php @@ -0,0 +1,250 @@ + +

    Search results

    + +
    + + + Read more about Searching. + + + +
    + + + + +

    Searching for + +:


    \n"; + +$hits_by_object = array(); +$objects = array(); +/* aggregate results by canonical object; since + * we index comments separately from the top level + * item, we need to adjust for that here */ +foreach ($hits as $hit) { + /* get canonical object */ + list($item, $id) = explode(':', $hit->objectid, 3); + + $object = "$item:$id"; + if (isset($hits_by_object[$object])) { + if ($hit->score > $hits_by_object[$object]) { + $hits_by_object[$object] = $hit->score; + $objects[$object] = $hit; + } + } else { + $hits_by_object[$object] = $hit->score; + $objects[$object] = $hit; + } +} +arsort($hits_by_object); +?> + + $score) { + list($item, $id) = explode(':', $object, 2); + $obj = $objects[$object]; + $score = (int)($score * 100); + + $html = "\n"; + echo $html; +} +echo "
    $score%"; + + switch ($item) { + case 'ticket': + $tkt = MTrackIssue::loadByNSIdent($id); + if ($tkt === null) { + $tkt = MTrackIssue::loadById($id); + } + $aclid = "ticket:" . $tkt->tid; + $html .= mtrack_ticket($tkt); + if ($tkt->nsident) { + $url = "{$ABSWEB}ticket.php/$tkt->nsident"; + } else { + $url = "{$ABSWEB}ticket.php/$id"; + } + $html .= " "; + $html .= htmlentities($tkt->summary, ENT_QUOTES, 'utf-8'); + $html .= ""; + $html .= $obj->getExcerpt($tkt->description); + + break; + case 'wiki': + $wiki = MTrackWikiItem::loadByPageName($id); + $aclid = "wiki:$id"; + $url = "{$ABSWEB}wiki.php/$id"; + $html .= "". + htmlentities($id, ENT_QUOTES, 'utf-8'). + ""; + $html .= $obj->getExcerpt($wiki->content); + break; + default: + $aclid = $object; + $html .= $object; + } + + if (!MTrackACL::hasAnyRights($aclid, 'read')) { + $denied++; + continue; + } + + $html .= "
    \n"; + +if (!count($hits_by_object)) { + echo "No matches"; +} else { + echo "" . count($hits_by_object) . " results in $elapsed\n"; +} +if ($denied) { + echo "
    Denied access to $denied items
    \n"; +} + +mtrack_foot(); diff --git a/web/snippet.php b/web/snippet.php new file mode 100644 index 00000000..f9600a67 --- /dev/null +++ b/web/snippet.php @@ -0,0 +1,141 @@ +snippet = $_POST['code']; + $snip->description = $_POST['description']; + $snip->lang = $_POST['lang']; + $cs = MTrackChangeset::begin("snippet:?", $snip->description); + $snip->save($cs); + $cs->setObject("snippet:$snip->snid"); + $cs->commit(); + header("Location: {$ABSWEB}snippet.php/$snip->snid"); + exit; +} + +$pi = mtrack_get_pathinfo(); + +if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $snip = new MTrackSnippet; + $snip->description = $_POST['description']; + $snip->lang = $_POST['lang']; + $snip->snippet = $_POST['code']; +} elseif (strlen($pi)) { + $snip = MTrackSnippet::loadById($pi); + if (!$snip) { + throw new Exception("Invalid snippet ID"); + } +} else { + $snip = null; +} + +if ($snip) { + $lang = $snip->lang; + $code = $snip->snippet; + $desc = $snip->description; + mtrack_head("Snippet $pi"); +} else { + $lang = ''; + $code = 'Enter your snippet here'; + $desc = 'Enter a descriptive message here; you may use wiki syntax'; + mtrack_head("New Snippet"); +} + +echo ""; + + +/* collect recent snippets */ +$recent = MTrackDB::q('select snid, description, who, changedate +from snippets + left join changes on snippets.updated = changes.cid + order by changes.changedate desc + limit 10')->fetchAll(PDO::FETCH_OBJ); + +echo << +Snippets are a way to share text or code fragments

    +HTML; + +if (MTrackACL::hasAllRights('Snippets', 'create')) { + echo <<New Snippet
    + +HTML; +} + +echo <<Recent Snippets +HTML; + +foreach ($recent as $s) { + $url = "{$ABSWEB}snippet.php/$s->snid"; + $sum = MTrackWiki::format_to_oneliner($s->description); + $who = mtrack_username($s->who, array('no_image' => true)); + $when = mtrack_date($s->changedate); + echo << + $sum
    + $when by $who
    + view snippet + +HTML; +} +echo "
    "; + +if (MTrackACL::hasAllRights('Snippets', 'create') && + (!$snip || $_SERVER['REQUEST_METHOD'] == 'POST')) { + echo "
    "; + echo "\n"; + echo MTrackSyntaxHighlight::getLangSelect('lang', $lang); + echo "

    "; + echo "\n"; + echo "\n"; + echo "
    "; +} + +if ($snip) { + echo "
    "; + echo "

    Snippet

    "; + if ($snip->created) { + $created = MTrackChangeset::get($snip->created); + } else { + $created = new stdclass; + } + + echo "", + mtrack_username($created->who, array('no_name' => true, 'size' => 48)), + ""; + echo "Created: ", + mtrack_date($created->when), + " by ", + mtrack_username($created->who, array('no_image' => true)), + "
    \n"; + echo "Link to this snippet
    "; + + echo MTrackWiki::format_to_html($snip->description); + echo "

    "; + echo MTrackSyntaxHighlight::getSchemeSelect(); + echo MTrackSyntaxHighlight::highlightSource($code, $lang, null, true); + echo "
    "; +} else if (!MTrackACL::hasAllRights('Snippets', 'create')) { + echo "

    You do not have rights to create snippets

    "; +} + +echo "
    "; + +mtrack_foot(); diff --git a/web/ticket.php b/web/ticket.php new file mode 100644 index 00000000..7ee22cc1 --- /dev/null +++ b/web/ticket.php @@ -0,0 +1,1377 @@ +priority = 'normal'; +} else { + if (strlen($id) == 32) { + $issue = MTrackIssue::loadById($id); + } else { + $issue = MTrackIssue::loadByNSIdent($id); + } + if (!$issue) { + throw new Exception("Invalid ticket $id"); + } +} + +$FIELDSET = array( + array( + "description" => array( + "label" => "Full description", + "ownrow" => true, + "type" => "wiki", + "rows" => 10, + "cols" => 78, + "editonly" => true, + ), + ), + "Properties" => array( + "milestone" => array( + "label" => "Milestone", + "type" => "multiselect", + ), + "component" => array( + "label" => "Component", + "type" => "multiselect", + ), + "classification" => array( + "label" => "Classification", + "type" => "select", + ), + "priority" => array( + "label" => "Priority", + "type" => "select", + ), + "severity" => array( + "label" => "Severity", + "type" => "select", + ), + "keywords" => array( + "label" => "Keywords", + "type" => "text", + ), + "changelog" => array( + "label" => "ChangeLog (customer visible)", + "type" => "multi", + "ownrow" => true, + "rows" => 5, + "cols" => 78, + # "condition" => $issue->status == 'closed' + ), + ), + "Resources" => array( + "owner" => array( + "label" => "Responsible", + "type" => "select" + ), + "estimated" => array( + "label" => "Estimated Hours", + "type" => "text" + ), + "spent" => array( + "label" => "Spent Hours", + "type" => "text", + "readonly" => true, + ), + "cc" => array( + "label" => "Cc", + "type" => "text" + ), + ), + ); +$issue->augmentFormFields($FIELDSET); + + +$preview = false; +$error = array(); + +if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if (isset($_POST['cancel'])) { + header("Location: {$ABSWEB}ticket.php/$issue->nsident"); + exit; + } + if (!MTrackCaptcha::check('ticket')) { + $error[] = "CAPTCHA failed, please try again"; + } + $preview = isset($_POST['preview']) ? true : false; + + $comment = ''; + try { + if ($id == 'new') { + MTrackACL::requireAllRights("Tickets", 'create'); + } else { + MTrackACL::requireAllRights("ticket:" . $issue->tid, 'modify'); + } + } catch (Exception $e) { + $error[] = $e->getMessage(); + } + if ($id == 'new') { + $comment = $_POST['comment']; + } + if (!strlen($comment)) { + $comment = $_POST['summary']; + } + try { + $CS = MTrackChangeset::begin("ticket:X", $comment); + } catch (Exception $e) { + $error[] = $e->getMessage(); + $CS = null; + } + if ($id == 'new') { + // compute next id number. + // We don't use auto-number, because we allow for importing multiple + // projects with their own ticket sequence. + // During "normal" user-driven operation, we do want plain old id numbers + // so we compute it here, under a transaction + $db = MTrackDB::get(); + if ($db->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') { + // Some versions of postgres don't like that we have "abc123" for + // identifiers, so match on the bigest number nsident fields only + $max = "select max(cast(nsident as integer)) + 1 from tickets where nsident ~ '^\\\\d+$'"; + } else { + $max = 'select max(cast(nsident as integer)) + 1 from tickets'; + } + list($issue->nsident) = MTrackDB::q($max)->fetchAll(PDO::FETCH_COLUMN, 0); + if ($issue->nsident === null) { + $issue->nsident = 1; + } + } + + if (isset($_POST['action']) && !$preview) { + switch ($_POST['action']) { + case 'leave': + break; + case 'reopen': + $issue->reOpen(); + break; + case 'fixed': + $issue->resolution = 'fixed'; + $issue->close(); + $_POST['estimated'] = $issue->estimated; + break; + case 'resolve': + $issue->resolution = $_POST['resolution']; + $issue->close(); + $_POST['estimated'] = $issue->estimated; + break; + case 'accept': + // will be applied to the issue further down + $_POST['owner'] = MTrackAuth::whoami(); + if ($issue->status == 'new') { + $issue->status = 'open'; + } + break; + case 'changestatus': + $issue->status = $_POST['status']; + break; + } + } + + $fields = array( + 'summary', + 'description', + 'classification', + 'priority', + 'severity', + 'changelog', + 'owner', + 'cc', + ); + + $issue->applyPOSTData($_POST); + + foreach ($fields as $fieldname) { + if (isset($_POST[$fieldname]) && strlen($_POST[$fieldname])) { + $issue->$fieldname = $_POST[$fieldname]; + } else { + $issue->$fieldname = null; + } + } + + $kw = $issue->getKeywords(); + $kill = array_values($kw); + foreach (preg_split('/[ \t,]+/', $_POST['keywords']) as $w) { + if (!strlen($w)) { + continue; + } + $x = array_search($w, $kw); + if ($x === false) { + $k = MTrackKeyword::loadByWord($w); + if ($k === null) { + $k = new MTrackKeyword; + $k->keyword = $w; + $k->save($CS); + } + $issue->assocKeyword($k); + } else { + $w = array_search($w, $kill); + if ($w !== false) { + unset($kill[$w]); + } + } + } + foreach ($kill as $w) { + $issue->dissocKeyword($w); + } + + $ms = $issue->getMilestones(); + $kill = $ms; + if (isset($_POST['milestone']) && is_array($_POST['milestone'])) { + foreach ($_POST['milestone'] as $mid) { + $issue->assocMilestone($mid); + unset($kill[$mid]); + } + } + foreach ($kill as $mid) { + $issue->dissocMilestone($mid); + } + + $ms = $issue->getComponents(); + $kill = $ms; + if (isset($_POST['component']) && is_array($_POST['component'])) { + foreach ($_POST['component'] as $mid) { + $issue->assocComponent($mid); + unset($kill[$mid]); + } + } + foreach ($kill as $mid) { + $issue->dissocComponent($mid); + } + + $issue->addComment($_POST['comment']); + $issue->addEffort($_POST['spent'], $_POST['estimated']); + + if (!count($error)) { + try { + $issue->save($CS); + $CS->setObject("ticket:" . $issue->tid); + } catch (Exception $e) { + $error[] = $e->getMessage(); + } + } + + if (!count($error)) { + if (isset($_FILES['attachments']) && is_array($_FILES['attachments'])) { + foreach ($_FILES['attachments']['name'] as $fileid => $name) { + MTrackAttachment::add("ticket:$issue->tid", + $_FILES['attachments']['tmp_name'][$fileid], + $_FILES['attachments']['name'][$fileid], + $CS); + } + } + } + if (!count($error) && $id != 'new') { + MTrackAttachment::process_delete("ticket:$issue->tid", $CS); + } + + if (isset($_POST['apply']) && !count($error)) { + $CS->commit(); + header("Location: {$ABSWEB}ticket.php/$issue->nsident"); + exit; + } +} + +if ($id == 'new') { + MTrackACL::requireAllRights("Tickets", 'create'); + mtrack_head("New ticket"); +} else { + MTrackACL::requireAllRights("ticket:" . $issue->tid, 'read'); + if ($issue->nsident) { + mtrack_head("#$issue->nsident " . $issue->summary); + } else { + mtrack_head("#$id " . $issue->summary); + } +} + +echo "
    \n"; +/* now to render the edit controls, if suitably privileged */ +if ($id == 'new') { + $editable = MTrackACL::hasAllRights("Tickets", 'create'); +} else { + $editable = MTrackACL::hasAllRights("ticket:" . $issue->tid, 'modify'); +} + +echo << +HTML; + +if ($preview) { + echo << + + This is a preview of your pending changes. It does not show + changes to the resolution; those will be applied when you submit. + + +HTML; +} +if (count($error)) { + foreach ($error as $e) { + echo << + +HTML; + echo htmlentities($e, ENT_QUOTES, 'utf-8') . "\n\n"; + } +} + +if ($id != 'new') { + echo "

    "; + if (!$issue->isOpen()) { + echo ""; + } + if ($issue->nsident) { + echo "#$issue->nsident "; + } else { + echo "#$id "; + } + + echo htmlentities($issue->summary, ENT_QUOTES, 'utf-8'); + + if (!$issue->isOpen()) { + echo ""; + } + echo "

    \n"; +} + +if ($id == 'new') { + $created = new stdClass; + $created->when = MTrackDB::unixtime(time()); + $created->who = MTrackAuth::whoami(); +} else { + $created = MTrackChangeset::get($issue->created); +} + +$opened = mtrack_date($created->when); +echo << +HTML; + +$pseudo_fields = array(); + +if ($id != 'new') { + echo "\n"; + if ($issue->updated != $issue->created) { + $updated = MTrackChangeset::get($issue->updated); + echo ""; + } + echo "
    :", + mtrack_date($created->when), + "", + mtrack_username($created->who, array('no_image' => true)), + "
    :", + mtrack_date($updated->when), + "", + mtrack_username($updated->who, array('no_image' => true)), + "
    "; + + $v = get_components_list(join(',', array_keys($issue->getComponents()))); + $pseudo_fields['@components'] = $v; + + $v = get_milestones_list(join(',', array_keys($issue->getMilestones()))); + $pseudo_fields['@milestones'] = $v; + + $v = get_keywords_list(join(',', array_keys($issue->getKeywords()))); + $pseudo_fields['@keywords'] = $v; + + $ROFIELDSET = $FIELDSET; + $ROFIELDSET['Properties']['resolution'] = array( + 'label' => 'Resolution', + 'type' => 'text', + ); + + foreach ($ROFIELDSET as $fsid => $fieldset) { + $emited = false; + foreach ($fieldset as $propname => $info) { + if (isset($info['editonly'])) { + continue; + } + $value = null; + switch ($propname) { + case 'keywords': + $value = array(); + foreach ($issue->getKeywords() as $kw) { + $value[] = mtrack_keyword($kw); + } + $value = join(' ', $value); + break; + case 'milestone': + $value = $pseudo_fields['@milestones']; + break; + case 'component': + $value = $pseudo_fields['@components']; + break; + default: + $value = $issue->$propname; + } + + if (strlen($value)) { + if (!$emited) { + $rfsid = 'readonly-tkt-' . + preg_replace('/[^a-z]+/', '', strtolower($fsid)); + echo "
    $fsid\n"; + $emited = true; + } + + switch ($info['type']) { + case 'wiki': + $value = MTrackWiki::format_to_html($value); + break; + case 'multi': + $value = nl2br(htmlentities($value, ENT_QUOTES, 'utf-8')); + break; + } + + if (isset($info['ownrow']) && $info['ownrow'] == 'true') { + echo ""; + echo "\n"; + } else { + echo "\n"; + } + } + } + if ($emited) { + echo "
    :
    $value
    :$value
    \n"; + } + } +} +echo "\n"; + +if ($issue->tid !== null) { + echo MTrackAttachment::renderList("ticket:$issue->tid"); +} + +if ($id != 'new') { + echo "
    "; + echo MTrackWiki::format_to_html($issue->description); + echo "
    "; +} + +if ($editable && $id != 'new' && !$preview) { + echo "
    "; + echo ""; + echo " "; + MTrackWatch::renderWatchUI('ticket', $issue->tid); + echo "
    "; +} +echo ""; # issue-desc + +$hide_unless_preview = ($preview || $_SERVER['REQUEST_METHOD'] == 'POST') ? + '' : + ' style="display:none" '; + +if ($editable && $id != 'new') { + echo << +HTML; +} + +if ($editable) { + + echo " "; + + echo renderEditForm($issue); +} + +if ($editable && $id != 'new') { + echo ""; +} + + +function get_components_list($value) +{ + $res = array(); + if (strlen($value)) foreach (MTrackDB::q( + "select name, deleted from components where compid in ($value)") + ->fetchAll() as $row) { + $c = $row['deleted'] ? '' : ''; + $c .= htmlentities($row['name'], ENT_QUOTES, 'utf-8'); + $c .= $row['deleted'] ? '' : ''; + $res[] = $c; + } + return join(", ", $res); +} + +function get_milestones_list($value) +{ + global $ABSWEB; + + $res = array(); + if (strlen($value)) foreach (MTrackDB::q( + "select name, completed, deleted from milestones where mid in ($value)") + ->fetchAll() as $row) { + if (strlen($row['completed'])) { + $row['deleted'] = 1; + } + $c = "'; + $c .= htmlentities($row['name'], ENT_QUOTES, 'utf-8'); + $c .= ""; + $res[] = $c; + } + return join(", ", $res); +} + +function get_keywords_list($value) +{ + $res = array(); + if (strlen($value)) foreach (MTrackDB::q( + "select keyword from keywords where kid in ($value)") + ->fetchAll() as $row) { + $res[] = htmlentities($row['keyword'], ENT_QUOTES, 'utf-8'); + } + return join(", ", $res); +} + +if ($id == 'new') { + $changes = array(); +} else { + $changes = array(); + $cids = array(); + foreach (MTrackDB::q( + 'select * from changes where object = ? + order by changedate asc', + "ticket:$issue->tid")->fetchAll(PDO::FETCH_OBJ) as $CS) { + $changes[$CS->cid] = $CS; + $cids[] = $CS->cid; + } + $cidlist = join(',', $cids); + + $change_audit = array(); + foreach (MTrackDB::q("select * from change_audit where cid in ($cidlist)") + ->fetchAll(PDO::FETCH_ASSOC) as $citem) { + $change_audit[$citem['cid']][] = $citem; + } + + /* also need to include cases where the ticket was modified as a side-effect + * of other manipulations (such as milestones being closed and tickets being + * re-targeted. Such manipulations do not directly reference this ticket, + * and so do not need to be included in the effort_audit array that is + * populated below. */ + + $tid = $issue->tid; + foreach (MTrackDB::q( + "select c.cid as cid, c.who as who, c.object as object, c.changedate as changedate, c.reason as reason, ca.fieldname as fieldname, ca.action as action, ca.oldvalue as oldvalue, ca.value as value from change_audit ca left join changes c on (ca.cid = c.cid) where ca.cid not in ($cidlist) and ca.fieldname like 'ticket:$tid:%'") + ->fetchAll(PDO::FETCH_OBJ) as $CS) { + if (!isset($changes[$CS->cid])) { + $changes[$CS->cid] = $CS; + } + $change_audit[$CS->cid][] = array( + 'cid' => $CS->cid, + 'fieldname' => $CS->fieldname, + 'action' => $CS->action, + 'oldvalue' => $CS->oldvalue, + 'value' => $CS->value + ); + } + + $effort_audit = array(); + foreach (MTrackDB::q( + "select * from effort where cid in ($cidlist) and tid=?", $issue->tid) + ->fetchAll(PDO::FETCH_ASSOC) as $eff) { + $effort_audit[$eff['cid']][] = $eff; + } +} +ksort($changes); + +$idno = 0; +$events = array(); + +function collapse_diff($diff) +{ + static $idnum = 1; + $id = 'diff_' . $idnum++; + return "
    " . + "". + ""; +} + +foreach ($changes as $CS) { + $preamble = 0; + if ($idno == 0) { + $cid = "top"; + } else { + $cid = "comment:$idno"; + } + $idno++; + + $who = $CS->who; + $timestamp = mtrack_date($CS->changedate, true); + + $html = "
    # $timestamp " . + mtrack_username($who, array('no_image' => true)) . + "
    \n"; + + $html .= "
    "; + $html .= mtrack_username($who, array('no_name' => true, 'size' => 48)); + + if ($CS->cid == $issue->created) { + $html .= "Opened
    \n"; + } + + $comments = array(); + + if (is_array($change_audit[$CS->cid])) + foreach ($change_audit[$CS->cid] as $citem) { + list($tbl,,$field) = explode(':', $citem['fieldname'], 3); + + if ($tbl != 'ticket') { + // can get here if we created a new keyword, for example + //var_dump($citem); + continue; + } + if ($field == '@comment') { + $comments[] = $citem['value']; + continue; + } + + if ($field == '@components') { + $citem['value'] = get_components_list($citem['value']); + } + if ($field == '@milestones') { + $citem['value'] = get_milestones_list($citem['value']); + } + if ($field == '@keywords') { + $citem['value'] = get_keywords_list($citem['value']); + } + if ($field == 'spent') { + continue; + } + if ($field == 'estimated') { + if ($citem['value'] !== null) { + $citem['value'] += 0; + } + if ($citem['oldvalue'] !== null) { + $citem['oldvalue'] += 0; + } + } + + if ($field[0] == '@') { + $main = isset($pseudo_fields[$field]) ? $pseudo_fields[$field] : ''; + $field = substr($field, 1, -1); + } else { + $main = $issue->$field; + } + + $f = MTrackTicket_CustomFields::getInstance()->fieldByName($field); + if ($f) { + $label = htmlentities($f->label, ENT_QUOTES, 'utf-8'); + } else { + if ($field == 'attachment' && strlen($citem['oldvalue'])) { + $label = "Attachment: $citem[oldvalue]"; + } else { + $label = ucfirst($field); + } + } + + if ($citem['oldvalue'] == null) { + /* don't bother printing out a set if this is the initial thing + * and if the field values are currently the same */ + + if ($main != $citem['value'] || $cid != 'top') { + + /* Special case for description; since it is multi-line and often + * very large, render it as a diff against the current ticket + * description field */ + if ($field == 'description') { + if ($issue->description == $citem['value']) { + $html .= "Description: no longer empty; see above
    "; + continue; + } + + $initial_lines = count(explode("\n", $issue->description)); + $diff = mtrack_diff_strings($issue->description, $citem['value']); + $diff_add = 0; + $diff_rem = 0; + foreach (explode("\n", $diff) as $line) { + if (!strlen($line)) continue; + if ($line[0] == '-') { + $diff_rem++; + } else if ($line[0] == '+') { + $diff_add++; + } + } + if (abs($diff_add - $diff_rem) > $initial_lines / 2) { + $html .= "initial $label
    " . + MTrackWiki::format_to_html($citem['value']); + } else { + $diff = collapse_diff($diff); + $html .= "initial $label (diff to above):
    $diff\n"; + } + } else { + $html .= "$label $citem[value]
    \n"; + } + } + } elseif ($citem['action'] == 'changed') { + $lines = explode("\n", $citem['value'], 3); + if (count($lines) >= 2) { + $diff = mtrack_diff_strings($citem['oldvalue'], $citem['value']); + $diff = collapse_diff($diff); + $html .= "$label $citem[action]\n$diff\n"; + } else { + $html .= "$label $citem[action] to $citem[value]
    \n"; + } + } else { + $html .= "$label $citem[action]
    \n"; + } + } + + if (isset($effort_audit[$CS->cid]) && is_array($effort_audit[$CS->cid])) { + foreach ($effort_audit[$CS->cid] as $eff) { + $exp = (float)$eff['expended']; + if ($eff['expended'] != 0) { + $html .= "spent $exp hours
    \n"; + $preamble++; + } + } + } + + if (count($comments)) { + if ($preamble) { + $html .= "
    \n"; + $preamble = 0; + } + + foreach ($comments as $text) { + $html .= MTrackWiki::format_to_html($text); + } + } + + $html .= "
    "; # ticketchangeinfo + + $events[] = $html; +} +if (count($events) > 80 && !isset($_GET['all'])) { + $num_hidden = count($events) - 20; + $turl = $ABSWEB . 'ticket.php/' . $issue->nsident . '?all=1'; + echo << +
    + + There are $num_hidden older comments that are not shown. + Show hidden comments +
    +HTML; +} else if (count($events) > 20 && !isset($_GET['all'])) { + $num_hidden = count($events) - 20; + echo << +
    + + There are $num_hidden older comments that are not shown. + +
    +"; + + return $html; +} diff --git a/web/timeline.php b/web/timeline.php new file mode 100644 index 00000000..100a778e --- /dev/null +++ b/web/timeline.php @@ -0,0 +1,12 @@ +fetchAll(PDO::FETCH_ASSOC); + if (isset($data[0])) { + // Updating + if (MTrackACL::hasAllRights('User', 'modify')) { + if (isset($_POST['active'])) { + $active = $_POST['active'] == 'on' ? '1' : '0'; + } else { + $active = '0'; + } + MTrackDB::q('update userinfo set fullname = ?, email = ?, timezone = ?, active = ?, sshkeys = ? where userid = ?', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $active, $_POST['keys'], $user); + } else { + MTrackDB::q('update userinfo set fullname = ?, email = ?, timezone = ?, sshkeys = ? where userid = ?', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $_POST['keys'], $user); + } + } else { + MTrackDB::q('insert into userinfo (active, fullname, email, timezone, sshkeys, userid) values (1, ?, ?, ?, ?, ?)', $_POST['fullname'], $_POST['email'], $_POST['timezone'], $_POST['keys'], $user); + } + + if (MTrackACL::hasAllRights('User', 'modify')) { + MTrackDB::q('delete from useraliases where userid = ?', $user); + foreach (preg_split("/\r?\n/", $_POST['aliases']) as $alias) { + if (!strlen(trim($alias))) { + continue; + } + MTrackDB::q('insert into useraliases (userid, alias) values (?, ?)', + $user, $alias); + } + + $user_class = MTrackAuth::getUserClass($user); + if (isset($_POST['user_role']) && $_POST['user_role'] != $user_class) { + MTrackConfig::set('user_classes', $user, $_POST['user_role']); + MTrackConfig::save(); + } + } + $http_auth = MTrackAuth::getMech('MTrackAuth_HTTP'); + if ($http_auth && !isset($_SERVER['REMOTE_USER'])) { + // Allow changing their password + $http_auth->setUserPassword($user, $_POST['passwd1']); + } + header("Location: {$ABSWEB}user.php?user=" . urlencode($user)); + exit; + } + +} else { + MTrackACL::requireAllRights('User', 'read'); +} + +mtrack_head("User $user"); + +$data = MTrackDB::q('select * from userinfo where userid = ?', $user)->fetchAll(PDO::FETCH_ASSOC); +if (isset($data[0])) { + $data = $data[0]; +} else { + $data = null; +} + +$display = $user; + +if (strlen($data['fullname'])) { + $display .= " - " . $data['fullname']; +} + +echo "

    ", htmlentities($display, ENT_QUOTES, 'utf-8'), "

    "; +echo "
    "; +echo mtrack_username($user, array( + 'no_name' => true, + 'size' => 128 +)); +echo "$data[email]
    \n"; + +if (empty($_GET['edit'])) { + $aliases = MTrackDB::q('select alias from useraliases where userid = ? order by alias', $user)->fetchAll(PDO::FETCH_COLUMN, 0); + if (count($aliases)) { + echo "

    Aliases

      \n"; + foreach ($aliases as $alias) { + echo "
    • ", htmlentities($alias, ENT_QUOTES, 'utf-8'), "
    • \n"; + } + echo "
    \n"; + } +} + +echo "
    "; + +if (empty($_GET['edit'])) { + $me = mtrack_canon_username(MTrackAuth::whoami()); + if ($me != 'anonymous' && $me === $user) { + $label = 'Edit my details'; + } else if (MTrackACL::hasAnyRights('User', 'modify')) { + $label = 'Edit user details'; + } else { + $label = null; + } + if ($label !== null) { + echo "
    " . + "" . + "" . + "
    "; + } + + if (MTrackACL::hasAnyRights('Timeline', 'read')) { + echo "

    Recent Activity

    \n"; + mtrack_render_timeline($user); + } +} else { + + echo "
    \n"; + + $fullname = htmlentities( + isset($data['fullname']) ? $data['fullname'] : '', + ENT_QUOTES, 'utf-8'); + $email = htmlentities( + isset($data['email']) ? $data['email'] : '', + ENT_QUOTES, 'utf-8'); + $timezone = htmlentities( + isset($data['timezone']) ? $data['timezone'] : '', + ENT_QUOTES, 'utf-8'); + + echo << + +
    + User Information + + + + + + + + + + + + + +HTML; + if (MTrackACL::hasAllRights('User', 'modify')) { + if (isset($data['active'])) { + $active = (int)$data['active']; + } else { + $active = 0; + } + if ($active) { + $active = " checked='checked'"; + } + echo << + + + +HTML; + + $user_class = MTrackAuth::getUserClass($user); + $user_class_roles = array(); + foreach (MTrackConfig::getSection('user_class_roles') as $role => $rights) { + $user_class_roles[$role] = $role; + } + $role_select = mtrack_select_box('user_role', $user_class_roles, + $user_class); + echo << + + + +HTML; + + } + + $http_auth = MTrackAuth::getMech('MTrackAuth_HTTP'); + if ($http_auth && !isset($_SERVER['REMOTE_USER'])) { + + if ($me == $user) { + $your = "your"; + } else { + $your = "this users"; + } + + echo << + + + + + + + +HTML; + + } + + echo << + +HTML; + + $groups = MTrackAuth::getGroups($user); + echo << + Groups + This user is a member of the following groups +
      +HTML; + foreach ($groups as $group) { + echo "
    • " . htmlentities($group, ENT_QUOTES, 'utf-8') . "
    • \n"; + } + echo << + +HTML; + + if (MTrackACL::hasAllRights('User', 'modify')) { + + $aliases = MTrackDB::q('select alias from useraliases where userid = ? order by alias', $user)->fetchAll(PDO::FETCH_COLUMN, 0); + $atext = ''; + foreach ($aliases as $alias) { + $atext .= htmlentities($alias, ENT_QUOTES, 'utf-8') . "\n"; + } + + echo << + Aliases + This user is also known by the following identities (one per line) when + assessing changes in the various repositories
      + + + +HTML; + + } + + echo << + +HTML; + + $keytext = htmlentities($data['sshkeys'], ENT_QUOTES, 'utf-8'); + echo << + SSH Keys + The repositories created and managed by mtrack are served over SSH. + Access is enabled only based on public SSH keys, not passwords. + In order to check code in or out, you must provide one or more + keys. Paste in the public key(s) you want to use below, one per line. +
      + + + +HTML; + + echo <<Save Changes + +HTML; +} + +mtrack_foot(); diff --git a/web/wiki.php b/web/wiki.php new file mode 100644 index 00000000..70e96cfd --- /dev/null +++ b/web/wiki.php @@ -0,0 +1,429 @@ +pagename", + $edit ? 'modify' : 'read'); + } else { + MTrackACL::requireAnyRights("wiki:$pi", + $edit ? 'modify' : 'read'); + } + if ($doc === null && $edit) { + $doc = new MTrackWikiItem($pi); + $doc->content = " = $pi =\n"; + } + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + + if (isset($_POST['cancel'])) { + header("Location: ${ABSWEB}wiki.php/$pi"); + exit; + } + if (!MTrackCaptcha::check('wiki')) { + $message = 'CAPTCHA failed, please try again'; + } else if (isset($_POST['save'])) { + /* to avoid annoying "you lose because someone else edited" errors, + * we compute the diff from the original content we had, and apply + * that to the current content of the object */ + + $saved = false; + + $orig = base64_decode($_POST['orig']); + $content = $_POST['content']; + + /* for consistency, we always want a newline at the end, otherwise + * we can end up with some weird output from diff3 */ + $orig = normalize_text($orig); + $content = normalize_text($content); + $doc->content = normalize_text($doc->content); + $conflicted = is_content_conflicted($content); + $tempdir = sys_get_temp_dir(); + + if (!$conflicted) { + $ofile = tempnam($tempdir, "mtrack"); + $nfile = tempnam($tempdir, "mtrack"); + $tfile = tempnam($tempdir, "mtrack"); + $pfile = tempnam($tempdir, "mtrack"); + $diff3 = MTrackConfig::get('tools', 'diff3'); + if (empty($diff3)) { + $diff3 = 'diff3'; + } + + file_put_contents($ofile, $orig); + file_put_contents($nfile, $content); + file_put_contents($tfile, $doc->content); + + if (PHP_OS == 'SunOS') { + exec("($diff3 -X $nfile $ofile $tfile ; echo '1,\$p') | ed - $nfile > $pfile", + $output = array(), $retval = 0); + } else { + exec("$diff3 -mX --label mine $nfile --label original $ofile --label theirs $tfile > $pfile", + $output = array(), $retval = 0); + } + + if ($retval == 0) { + /* see if there were merge conflicts */ + $content = ''; + $mine = preg_quote($nfile, '/'); + $theirs = preg_quote($tfile, '/'); + $orig = preg_quote($ofile, '/'); + $content = file_get_contents($pfile); + + if (PHP_OS == 'SunOS') { + $content = str_replace($nfile, 'mine', $content); + $content = str_replace($ofile, 'original', $content); + $content = str_replace($tfile, 'theirs', $content); + } + } + unlink($ofile); + unlink($nfile); + unlink($tfile); + unlink($pfile); + + $conflicted = is_content_conflicted($content); + } + + /* keep the merged version for editing purposes */ + $_POST['content'] = $content; + /* our concept of the the original content is now what + * is currently saved */ + $_POST['orig'] = base64_encode($doc->content); + + if ($conflicted) { + $message = "Conflicting edits were detected; please correct them before saving"; + } else { + $doc->content = $content; + try { + $cs = MTrackChangeset::begin("wiki:$pi", $_POST['comment']); + $doc->save($cs); + if (is_array($_FILES['attachments'])) { + foreach ($_FILES['attachments']['name'] as $fileid => $name) { + $do_attach = false; + switch ($_FILES['attachments']['error'][$fileid]) { + case UPLOAD_ERR_OK: + $do_attach = true; + break; + case UPLOAD_ERR_NO_FILE: + break; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $message = "Attachment(s) exceed the upload file size limit"; + break; + case UPLOAD_ERR_PARTIAL: + case UPLOAD_ERR_CANT_WRITE: + $message = "Attachment file upload failed"; + break; + case UPLOAD_ERR_NO_TMP_DIR: + $message = "Server configuration prevents upload due to missing temporary dir"; + break; + case UPLOAD_ERR_EXTENSION: + $message = "An extension prevented an upload from running"; + } + if ($message !== null) { + throw new Exception($message); + } + if ($do_attach) { + MTrackAttachment::add("wiki:$pi", + $_FILES['attachments']['tmp_name'][$fileid], + $_FILES['attachments']['name'][$fileid], + $cs); + } + } + } + MTrackAttachment::process_delete("wiki:$pi", $cs); + $cs->commit(); + MTrackWikiItem::commitNow(); + $saved = true; + } catch (Exception $e) { + $message = $e->getMessage(); + } + } + + if ($saved) { + /* we're good; go back to view mode */ + header("Location: ${ABSWEB}wiki.php/$pi"); + exit; + } + } + } +} + +/* now just render */ + +$title = $pi; +if ($edit) { + $title .= " (edit)"; +} +mtrack_head($title); +$ppi = htmlentities($pi, ENT_QUOTES, 'utf-8'); +$editurl = $ABSWEB . "wiki.php/$pi"; + +$nav = array(); + +if (!$edit && MTrackACL::hasAnyRights("wiki:$pi", 'modify')) { + $nav["$editurl?edit=1"] = 'Edit this Page'; +} + +if ($doc) { + $nav["/log.php/default/wiki/$doc->filename"] = "Page History"; +} + +$nav["/wiki.php?action=list"] = "Help & Title Index"; +$nav["/wiki.php?action=recent"] = "Recent Changes"; + +if ($doc && $doc->file) { + $evt = $doc->file->getChangeEvent(); + $reason = $evt->changelog; + if (!strlen($evt->changelog)) { + $reason = 'Changed'; + } + $reason = htmlentities($reason, ENT_QUOTES, 'utf-8'); + echo "
      ", + mtrack_username($evt->changeby, array('no_name' => true, + 'class' => 'wikilastchange')), + "$reason by ", + mtrack_username($evt->changeby, array('no_image' => true)), " ", + mtrack_date($evt->ctime), + "
      \n"; +} + +echo mtrack_nav("wikinav", $nav); + +if (strlen($message)) { + echo "
      " . + "\n" . + htmlentities($message, ENT_QUOTES, 'utf-8') . + "
      "; +} + +if (count($_GET) == 0 && ($doc === null || strlen($doc->content) == 0)) { + if (MTrackACL::hasAnyRights("wiki:$pi", 'create')) { + echo "Wiki page $ppi doesn't exist, would you like to create it?
      "; + + echo << + + + +HTML; + } else { + echo "Wiki page $ppi doesn't exist.
      "; + } + +} elseif ($edit) { + echo "

      Editing $ppi

      "; + echo "Wiki Formatting (opens in a new window)
      \n"; + + $orig_content = isset($_POST['orig']) ? $_POST['orig'] + : base64_encode($doc->content); + $content = isset($_POST['content']) ? $_POST['content'] : $doc->content; + $comment = isset($_POST['comment']) ? $_POST['comment'] : ''; + $comment = htmlentities($comment, ENT_QUOTES, 'utf-8'); + + if (isset($_POST['preview'])) { + echo "
      " . + MTrackWiki::format_to_html($content) . "
      "; + } + + echo << + + +HTML; + + if ($conflicted) { + echo ""; + } + + echo <<$content +
      + Attachments +HTML; + echo MTrackAttachment::renderDeleteList("wiki:$pi"); + echo <<Select file(s) to be attached + +
      +
      + Change Information +
      +HTML; + echo MTrackCaptcha::emit('wiki'); + echo << + + + + + + +HTML; + +} else { + $action = isset($_GET['action']) ? $_GET['action'] : 'view'; + + switch ($action) { + case 'view': + echo MTrackWiki::format_to_html($doc->content); + echo MTrackAttachment::renderList("wiki:$pi"); + if (MTrackACL::hasAnyRights("wiki:$doc->pagename", 'modify')) { + echo << + + + +HTML; + } + break; + + case 'list': + echo "

      Help topics by Title

      \n"; + $htree = array(); + + function build_help_tree(&$tree, $dir) { + foreach (scandir($dir) as $ent) { + if ($ent[0] == '.') { + continue; + } + $full = $dir . DIRECTORY_SEPARATOR . $ent; + if (is_dir($full)) { + $kid = array(); + build_help_tree($kid, $full); + $tree[$ent] = $kid; + } else { + $tree[$ent] = array(); + } + } + } + function emit_tree($root, $parent, $phppage) + { + global $ABSWEB; + + if (strlen($parent)) { + echo "
        \n"; + } else { + echo "
          \n"; + } + $knames = array_keys($root); + usort($knames, 'strnatcasecmp'); + foreach ($knames as $key) { + $kids = $root[$key]; + $n = htmlentities($key, ENT_QUOTES, 'utf-8'); + echo "
        • "; + if (count($kids)) { + echo $n; + emit_tree($kids, "$parent$key/", $phppage); + } else { + echo "$n"; + } + echo "
        • \n"; + } + echo "
        \n"; + } + + build_help_tree($htree, dirname(__FILE__) . '/../defaults/help'); + emit_tree($htree, '', 'help.php'); + + echo "

        Wiki pages by Title

        \n"; + /* get the page names into a tree format */ + $tree = array(); + $root = MTrackWikiItem::getRepoAndRoot($repo); + $suf = MTrackConfig::get('core', 'wikifilenamesuffix'); + + function build_tree(&$tree, $repo, $dir, $suf) { + $items = $repo->readdir($dir); + foreach ($items as $file) { + $label = basename($file->name); + if ($file->is_dir) { + $kid = array(); + build_tree($kid, $repo, $file->name, $suf); + $tree[$label] = $kid; + } else { + if ($suf && substr($label, -strlen($suf)) == $suf) { + $label = substr($label, 0, strlen($label) - strlen($suf)); + } + $tree[$label] = array(); + } + } + } + + build_tree($tree, $repo, $root, $suf); + + emit_tree($tree, '', 'wiki.php'); + + echo << +$(document).ready(function(){ + $('ul.wikitree').treeview({ + collapsed: true, + persist: "location" + }); +}); + +HTML; + + break; + + case 'recent': + echo <<Recently Edited Wiki Pages +
    + + + +
    + + +
    + We use this with Gravatar + to obtain your avatar image throughout mtrack +
    + + +
    + We use this to show times in your preferred timezone +
    + + +
    + Active users are shown in the Responsible users list when editing tickets +
    + + + $role_select
    + The role defines which actions this user can carry out in mtrack +
    + + +
    + Enter $your new password +
    + + +
    + Confirm $your new password +
    + + + + + + +HTML; + $root = MTrackWikiItem::getRepoAndRoot($repo); + foreach ($repo->history(null, 100) as $e) { + $d = mtrack_date($e->ctime); + list($page) = $e->files; + if (strlen($root)) { + $page = substr($page, strlen($root)+1); + } + $author = mtrack_username($e->changeby); + $reason = htmlentities($e->changelog, ENT_QUOTES, 'utf-8'); + + echo "\n"; + } + + echo << +HTML; + + break; + + } +} +mtrack_foot();
    PageDateWhoReason
    $page$d$author$reason