29 Commits

Author SHA1 Message Date
Patrick Georgi
cdb8dbafe2 Write-protect git repository over HTTP!
I totally misunderstood the access control mechanism (but it's logical
that it behaves the way it does), and so on git projects with "open"
source access, repos were write-for-all.

This should fix it by enforcing member-or-owner auth for writes.

Signed-off-by: Patrick Georgi <patrick@georgi-clan.de>
2011-09-03 19:30:34 +02:00
Patrick Georgi
33b22f95ab Use idf_exec_prefix when git tools are called 2011-08-25 23:36:27 +02:00
Patrick Georgi
39f77886db Make HTTP auth cover all popular FastCGI workarounds 2011-08-25 15:28:02 +02:00
Patrick Georgi
f7470e4a7a Use REDIRECT_* for FastCGI/PHP Authorization handling 2011-08-25 14:50:21 +02:00
Patrick Georgi
c10c002ee3 Document HTTP repository access for git 2011-08-25 14:45:50 +02:00
Patrick Georgi
a47ec0df0a If necessary, create git repository on first http access 2011-08-21 23:54:42 +02:00
Patrick Georgi
be95050a4b Use new semantics for adding an stdin stream to PassThru 2011-08-21 08:39:41 +02:00
Patrick Georgi
5043c4e845 Stylistic fixes 2011-08-21 07:53:21 +02:00
Patrick Georgi
dacbf0707b Move http repository access to /r/$project
It's a shorter URL and also helps git derive the right name
for the clone.
2011-08-21 07:44:19 +02:00
Patrick Georgi
34c9d04a35 Provide http access to git repositories
/p/$project/source/repo for git repos now exposes both
"dumb" and "smart" http protocol access.
2011-08-20 20:07:26 +02:00
Patrick Georgi
aa2868eb17 Add basic framework for web based repository access
/p/$project/source/repo/ is assigned to a method that
takes care of providing repository access.
For now, this results in an exception on all SCMs.
2011-08-19 22:05:15 +02:00
Thomas Keller
a2c832a130 Improve the 'parents' parsing for git and ignore any empty parts; also
react gracefully if we could not parse the parents for some weird reason.
2011-08-17 20:17:14 +02:00
Thomas Keller
b17de014ec Reworked the option / argument handling in the SVN interface to
have less code duplication.
2011-08-13 02:28:15 +02:00
Thomas Keller
b1b72190e1 Dropped a few more not needed files. 2011-08-13 01:19:58 +02:00
Thomas Keller
2ff2f888bc - Make the SVN test case work without specific test configuration.
- Rename the test repo to match the test function so we can create
  more test repositories for other tests at a later stage.
- Remove useless hooks and configs from the repo (they are not used
  for our specific test and just need memory).
- Note the fix for issue 721 in NEWS.mdtext.
2011-08-13 01:11:00 +02:00
Patrick Georgi
57c2389aae Make SVN backend more robust
The SVN backend failed when trying to access historical information on deleted files.

There's also an initial test case for the SVN backend, testing this issue
and issue 364, which is about a similar problem for renamed files.
Reverting any of these fixes breaks the test.
2011-08-12 20:53:26 +02:00
Thomas Keller
d54c86f813 Note the change from issue 716. 2011-07-27 20:06:56 +02:00
Thomas Keller
05a9697007 Merge branch 'feature.content-md5' into develop 2011-07-27 19:59:18 +02:00
Patrick Georgi
945429abf0 Provide MD5 value of downloads to HTTP client
Content-MD5 is a HTTP header to provide end-to-end integrity checks
(see RFC2616, 14.15). This doesn't protect against malicious
modifications, but against transmissions errors and storage errors
on the server.

The change also removes one redirect when downloading files.
2011-07-24 22:12:36 +02:00
William MARTIN
a016bcb51b Merge branch 'develop' of projects.ceondo.com:indefero into develop 2011-07-05 11:31:05 +02:00
William MARTIN
f2b1ce795c Fix issue 247 : cron overwrites authorized_keys during cron run 2011-07-05 11:30:23 +02:00
Thomas Keller
3a8c56acc4 Postgres needs a VARCHAR cast, which MySQL doesn't understand, of
course. *sigh*
2011-07-01 13:35:43 +02:00
Thomas Keller
7b2552f940 Postgres (and probably others as well) needs an explicit char cast. 2011-06-30 00:25:29 +02:00
Thomas Keller
324b202215 Fix the rendering of issue changes in a mail template and the issue feed fragment. 2011-06-29 17:41:18 +02:00
Loïc d'Anterroches
2c2da6082a Fixed stupid missing semicolon. 2011-06-29 14:41:57 +02:00
Loïc d'Anterroches
dd3fbbd7e4 Fixes to support older PHP versions. 2011-06-29 14:30:17 +02:00
William MARTIN
9bbcd571ec Merge branch 'feature.issue-summary' into develop 2011-06-20 11:37:26 +02:00
Thomas Keller
d1bcdcda20 Fix the mtn getChanges() test. 2011-06-17 23:50:35 +02:00
Thomas Keller
4879d64989 If git's author name does not contain valid utf-8 bytes, skip the author
lookup in the database, which would otherwise only bring up errors.
2011-06-15 13:50:02 +02:00
35 changed files with 499 additions and 118 deletions

View File

@@ -13,10 +13,11 @@ or newer to properly run this version of Indefero!
- Display monotone file and directory attributes in the tree and file view - Display monotone file and directory attributes in the tree and file view
(needs a monotone with an interface version of 13.1 or newer) (needs a monotone with an interface version of 13.1 or newer)
- The context area is now kept in view when a page scrolls down several pages - The context area is now kept in view when a page scrolls down several pages
- git repositories are now available under /r/projectname/
## Bugfixes ## Bugfixes
- The SVN interface acts more robust if an underlying repository has been restructured (issue 364) - The SVN interface acts more robust if an underlying repository has been restructured (issues 364 and 721)
- monotone zip archive entries now all carry the revision date as mtime (issue 645) - monotone zip archive entries now all carry the revision date as mtime (issue 645)
- Timeline only displays filter options for items a user has actually access to (issue 655) - Timeline only displays filter options for items a user has actually access to (issue 655)
- The log, tags and branches parsers for Mercurial are more robust now (issue 663) - The log, tags and branches parsers for Mercurial are more robust now (issue 663)
@@ -29,10 +30,16 @@ or newer to properly run this version of Indefero!
- Prevent a timeout from popping up when Usher is restarted (issue 695) - Prevent a timeout from popping up when Usher is restarted (issue 695)
- The SyncMonotone plugin now cleans up partial artifacts it created during the addition of - The SyncMonotone plugin now cleans up partial artifacts it created during the addition of
a new project or monotone key, in case an error popped up in the middle (issue 697) a new project or monotone key, in case an error popped up in the middle (issue 697)
- Indefero now sends the MD5 checksum as HTTP header when downloading a file from the
download area. Additionally, a unneeded redirect has been removed. (issue 716)
- Better error detection and reporting in the SyncMonotone plugin - Better error detection and reporting in the SyncMonotone plugin
ATTENTION: This needs Pluf 46b7f251 or newer! ATTENTION: This needs Pluf 46b7f251 or newer!
- Fix the branch links users of the Subversion frontend get when they enter a wrong revision - Fix the branch links users of the Subversion frontend get when they enter a wrong revision
and only display this list if there are any branches available for all SCMs and only display this list if there are any branches available for all SCMs
- If git's author name is not encoded in an UTF-8 compatible encoding, skip the author lookup,
as we have no information what the author string is actually encoded in
- Indefero no longer displays an empty parents paragraph in the commit view for root revisions of
a git repository
## Documentation ## Documentation

57
doc/httprepos-git.mdtext Normal file
View File

@@ -0,0 +1,57 @@
# Accessing git repositories over HTTP
Starting with indefero 1.2, git repositories are provided via http,
featuring read-only and read-write access with full integration with
indefero's access control mechanisms.
## Access git repositories
The repositories are available under http://YOURHOST/BASEPATH/r/PROJECT.
For authentication, use the "extra password" which you can find on your
profile page.
## Setup
The main thing to setup is git_repositories and git_remote_url in
src/IDF/conf/idf.php
git_remote_url should match http://YOURHOST/BASEPATH/r/%s, while
git_repositories points to the local directory supposed to contain the
repositories like /PATH/TO/REPOSITORIES/%s.git.
## Setup requirements when using PHP over FastCGI
There are a couple of things to setup when using PHP over FastCGI, as compared
to mod_php or similar integrated mechanisms:
- You will need to setup a RewriteRule for mod_rewrite (in case of Apache,
analogous mechanisms might need to be setup for other http daemons), which
passes through the Authorization HTTP Header of a request.
In case of mod_rewrite, the necessary line is
one of (depending on server configuration):
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
RewriteRule .* - [E=REMOTE_USER:%{HTTP:Authorization},L]
- The FastCGI adaptor must allow large requests to be handled by PHP,
otherwise push might fail.
For mod_fcgid, this is the FcgidMaxRequestLen option, or MaxRequestLen in
older versions. Set this option to some large enough value - there is no
issue with RAM use, as another option defines the limit after which the
request is backed on disk, which can remain small.
## When migrating from syncgit
HTTP access can be used in parallel to syncgit.
If you want to disable syncgit, just undo the changes detailled
in doc/syncgit.mdtext:
- In src/IDF/conf/idf.php keep git_repositories
- In src/IDF/conf/idf.php adapt git_remote_url to http://host/basepath/r/%s
- In src/IDF/conf/idf.php remove idf_plugin_syncgit*
- Remove the cronjob that called gitcron.php
- Disable the git daemon (eg. /etc/event.d/local-git-daemon)
- You can remove the git user and group, if you also adapt the git repositories
to be owned by the www user.

View File

@@ -44,7 +44,7 @@ class IDF_Middleware
function process_request(&$request) function process_request(&$request)
{ {
$match = array(); $match = array();
if (preg_match('#^/(?:api/p|p)/([\-\w]+)/?#', $request->query, $match)) { if (preg_match('#^/(?:api/p|p|r)/([\-\w]+)/?#', $request->query, $match)) {
try { try {
$request->project = IDF_Project::getOr404($match[1]); $request->project = IDF_Project::getOr404($match[1]);
} catch (Pluf_HTTP_Error404 $e) { } catch (Pluf_HTTP_Error404 $e) {

View File

@@ -59,6 +59,16 @@ class IDF_Plugin_SyncGit_Cron
$out .= sprintf($template, $cmd, $key->login, $content)."\n"; $out .= sprintf($template, $cmd, $key->login, $content)."\n";
} }
} }
$out = "# indefero start" . PHP_EOL . $out . "# indefero end" . PHP_EOL;
// We update only the part of the file between IDF_START / IDF_END comment
$original_keys = file_get_contents($authorized_keys);
if (strstr($original_keys, "# indefero start") && strstr($original_keys, "# indefero end")) {
$out = preg_replace('/(#\sindefero\sstart).+(#\sindefero\send\s\s?)/isU',
$out, $original_keys);
} else {
$out .= $original_keys;
}
file_put_contents($authorized_keys, $out, LOCK_EX); file_put_contents($authorized_keys, $out, LOCK_EX);
} }

View File

@@ -152,15 +152,14 @@ class IDF_Project extends Pluf_Model
break; break;
} }
$sqlIssueTable = Pluf::factory('IDF_Issue')->getSqlTable(); $sqlIssueTable = Pluf::factory('IDF_Issue')->getSqlTable();
$query = <<<"QUERY" $query = "SELECT uid AS id,COUNT(uid) AS nb
SELECT uid AS id,COUNT(uid) AS nb
FROM ( FROM (
SELECT COALESCE(owner, -1) AS uid SELECT COALESCE(owner, -1) AS uid
FROM $sqlIssueTable FROM $sqlIssueTable
WHERE status IN ($tags) WHERE status IN ($tags)
) AS ff ) AS ff
GROUP BY uid GROUP BY uid";
QUERY;
$db = Pluf::db(); $db = Pluf::db();
$dbData = $db->select($query); $dbData = $db->select($query);
$ownerStatistics = array(); $ownerStatistics = array();

View File

@@ -497,5 +497,10 @@ class IDF_Scm
{ {
return 0; return 0;
} }
public function repository($request, $match)
{
throw new Exception('This repository does not support web based repository access');
}
} }

View File

@@ -346,6 +346,14 @@ class IDF_Scm_Git extends IDF_Scm
if (!preg_match('/<(.*)>/', $author, $match)) { if (!preg_match('/<(.*)>/', $author, $match)) {
return null; return null;
} }
// FIXME: newer git versions know a i18n.commitencoding setting which
// leads to another header, "encoding", with which we _could_ try to
// decode the string into utf8. Unfortunately this does not always
// work, especially not in older repos, so we would then still have
// to supply some fallback.
if (!mb_check_encoding($match[1], 'UTF-8')) {
return null;
}
$sql = new Pluf_SQL('login=%s', array($match[1])); $sql = new Pluf_SQL('login=%s', array($match[1]));
$users = Pluf::factory('Pluf_User')->getList(array('filter'=>$sql->gen())); $users = Pluf::factory('Pluf_User')->getList(array('filter'=>$sql->gen()));
if ($users->count() > 0) { if ($users->count() > 0) {
@@ -638,7 +646,11 @@ class IDF_Scm_Git extends IDF_Scm
$c['full_message'] = IDF_Commit::toUTF8($c['full_message']); $c['full_message'] = IDF_Commit::toUTF8($c['full_message']);
$c['title'] = IDF_Commit::toUTF8($c['title']); $c['title'] = IDF_Commit::toUTF8($c['title']);
if (isset($c['parents'])) { if (isset($c['parents'])) {
$c['parents'] = explode(' ', trim($c['parents'])); $c['parents'] = preg_split('/ /', trim($c['parents']), -1, PREG_SPLIT_NO_EMPTY);
} else {
// this is actually an error state because we should _always_
// be able to parse the parents line with every git version
$c['parents'] = null;
} }
$res[] = (object) $c; $res[] = (object) $c;
return $res; return $res;
@@ -912,4 +924,113 @@ class IDF_Scm_Git extends IDF_Scm
} }
return false; return false;
} }
public function repository($request, $match)
{
// handle a couple of workarounds for authenticating with FastCGI/PHP
if (!empty($_SERVER['HTTP_AUTHORIZATION']))
list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':' , base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)));
elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION']))
list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':' , base64_decode(substr($_SERVER['REDIRECT_HTTP_AUTHORIZATION'], 6)));
elseif (!empty($_SERVER['REDIRECT_REMOTE_USER']))
list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':' , base64_decode(substr($_SERVER['REDIRECT_REMOTE_USER'], 6)));
// authenticate: authenticate connection through "extra" password
if (!empty($_SERVER['PHP_AUTH_USER'])) {
$sql = new Pluf_SQL('login=%s', array($_SERVER['PHP_AUTH_USER']));
$users = Pluf::factory('Pluf_User')->getList(array('filter'=>$sql->gen()));
if (count($users) == 1 && $users[0]->active) {
$user = $users[0];
$realkey = substr(sha1($user->password.Pluf::f('secret_key')), 0, 8);
if ($_SERVER['PHP_AUTH_PW'] == $realkey) {
$request->user = $user;
}
}
}
if (IDF_Precondition::accessSource($request) !== true) {
$response = new Pluf_HTTP_Response("");
$response->status_code = 401;
$response->headers['WWW-Authenticate']='Basic realm="git for '.$this->project.'"';
return $response;
}
$path = $match[2];
if (!file_exists($this->repo)) {
mkdir($this->repo, 0750, true);
$out = array();
$res = 0;
exec(sprintf(Pluf::f('idf_exec_cmd_prefix', '').
Pluf::f('git_path', 'git').' --git-dir=%s init', escapeshellarg($this->repo)),
$out, $res);
if ($res != 0) {
Pluf_Log::error(array('IDF_Scm_Git::repository', $res, $this->repo));
throw new Exception(sprintf('Init repository error, exit status %d.', $res));
}
}
// update files before delivering them
if (($path == 'objects/info/pack') || ($path == 'info/refs')) {
$cmd = sprintf(Pluf::f('idf_exec_cmd_prefix', '').
'GIT_DIR=%s '.Pluf::f('git_path', 'git').' update-server-info -f',
escapeshellarg($this->repo));
self::shell_exec('IDF_Scm_Git::repository', $cmd);
}
// smart HTTP discovery
if ($path == 'info/refs' && !empty($request->GET['service'])){
$service = $request->GET['service'];
switch ($service) {
case 'git-receive-pack':
if (IDF_Precondition::projectMemberOrOwner($request) !== true) {
$response = new Pluf_HTTP_Response("");
$response->status_code = 401;
$response->headers['WWW-Authenticate']='Basic realm="git for '.$this->project.'"';
return $response;
}
case 'git-upload-pack':
$content = sprintf('%04x',strlen($service)+15).
'# service='.$service."\n0000";
$content .= self::shell_exec('IDF_Scm_Git::repository',
Pluf::f('idf_exec_cmd_prefix', '').
$service.' --stateless-rpc --advertise-refs '.
$this->repo);
$response = new Pluf_HTTP_Response($content,
'application/x-'.$service.'-advertisement');
return $response;
default:
throw new Exception('unknown service: '.$service);
}
}
switch($path) {
// smart HTTP RPC
case 'git-receive-pack':
if (IDF_Precondition::projectMemberOrOwner($request) !== true) {
$response = new Pluf_HTTP_Response("");
$response->status_code = 401;
$response->headers['WWW-Authenticate']='Basic realm="git for '.$this->project.'"';
return $response;
}
case 'git-upload-pack':
$response = new Pluf_HTTP_Response_CommandPassThru(
Pluf::f('idf_exec_cmd_prefix', '').$path.
' --stateless-rpc '.$this->repo,
'application/x-'.$path.'-result');
$response->setStdin(fopen('php://input', 'rb'));
return $response;
// regular file
default:
// make sure we're inside the repo hierarchy (ie. no break-out)
if (is_file($this->repo.'/'.$path) &&
strpos(realpath($this->repo.'/'.$path), $this->repo.'/') == 0) {
return new Pluf_HTTP_Response_File($this->repo.'/'.$path,
'application/octet-stream');
} else {
return new Pluf_HTTP_Response_NotFound($request);
}
}
}
} }

View File

@@ -33,7 +33,6 @@
*/ */
class IDF_Scm_Svn extends IDF_Scm class IDF_Scm_Svn extends IDF_Scm
{ {
public $username = ''; public $username = '';
public $password = ''; public $password = '';
private $assoc = array('dir' => 'tree', private $assoc = array('dir' => 'tree',
@@ -48,11 +47,7 @@ class IDF_Scm_Svn extends IDF_Scm
public function isAvailable() public function isAvailable()
{ {
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --xml --username=%s --password=%s %s', $cmd = $this->svnCmd(array('info', '--xml'), $this->repo);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlInfo = self::shell_exec('IDF_Scm_Svn::isAvailable', $cmd); $xmlInfo = self::shell_exec('IDF_Scm_Svn::isAvailable', $cmd);
try { try {
@@ -163,12 +158,7 @@ class IDF_Scm_Svn extends IDF_Scm
return IDF_Scm::REVISION_VALID; return IDF_Scm::REVISION_VALID;
} }
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('info'), $this->repo, $rev);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
self::exec('IDF_Scm_Svn::validateRevision', $cmd, $out, $ret); self::exec('IDF_Scm_Svn::validateRevision', $cmd, $out, $ret);
if ($ret == 0) if ($ret == 0)
@@ -190,12 +180,9 @@ class IDF_Scm_Svn extends IDF_Scm
} }
// Else, test the path on revision // Else, test the path on revision
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --xml --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('info', '--xml'),
escapeshellarg($this->username), $this->repo.'/'.self::smartEncode($path),
escapeshellarg($this->password), $rev);
escapeshellarg($rev),
escapeshellarg($this->repo.'/'.self::smartEncode($path)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlInfo = self::shell_exec('IDF_Scm_Svn::testHash', $cmd); $xmlInfo = self::shell_exec('IDF_Scm_Svn::testHash', $cmd);
// If exception is thrown, return false // If exception is thrown, return false
@@ -217,12 +204,9 @@ class IDF_Scm_Svn extends IDF_Scm
public function getTree($commit, $folder='/', $branch=null) public function getTree($commit, $folder='/', $branch=null)
{ {
$cmd = sprintf(Pluf::f('svn_path', 'svn').' ls --no-auth-cache --xml --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('ls', '--xml'),
escapeshellarg($this->username), $this->repo.'/'.self::smartEncode($folder),
escapeshellarg($this->password), $commit);
escapeshellarg($commit),
escapeshellarg($this->repo.'/'.self::smartEncode($folder)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getTree', $cmd)); $xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getTree', $cmd));
$res = array(); $res = array();
$folder = (strlen($folder) and ($folder != '/')) ? $folder.'/' : ''; $folder = (strlen($folder) and ($folder != '/')) ? $folder.'/' : '';
@@ -258,12 +242,7 @@ class IDF_Scm_Svn extends IDF_Scm
if (isset($this->cache['commitmess'][$rev])) { if (isset($this->cache['commitmess'][$rev])) {
return $this->cache['commitmess'][$rev]; return $this->cache['commitmess'][$rev];
} }
$cmd = sprintf(Pluf::f('svn_path', 'svn').' log --no-auth-cache --xml --limit 1 --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('log', '--xml', '--limit', '1'), $this->repo, $rev);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
try { try {
$xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getCommitMessage', $cmd)); $xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getCommitMessage', $cmd));
$this->cache['commitmess'][$rev] = (string) $xml->logentry->msg; $this->cache['commitmess'][$rev] = (string) $xml->logentry->msg;
@@ -279,12 +258,8 @@ class IDF_Scm_Svn extends IDF_Scm
if ($rev == null) { if ($rev == null) {
$rev = 'HEAD'; $rev = 'HEAD';
} }
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --xml --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('info', '--xml'),
escapeshellarg($this->username), $this->repo.'/'.self::smartEncode($filename), $rev);
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo.'/'.self::smartEncode($filename)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getPathInfo', $cmd)); $xml = simplexml_load_string(self::shell_exec('IDF_Scm_Svn::getPathInfo', $cmd));
if (!isset($xml->entry)) { if (!isset($xml->entry)) {
return false; return false;
@@ -306,12 +281,9 @@ class IDF_Scm_Svn extends IDF_Scm
public function getFile($def, $cmd_only=false) public function getFile($def, $cmd_only=false)
{ {
$cmd = sprintf(Pluf::f('svn_path', 'svn').' cat --no-auth-cache --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('cat'),
escapeshellarg($this->username), $this->repo.'/'.self::smartEncode($def->fullpath),
escapeshellarg($this->password), $def->rev);
escapeshellarg($def->rev),
escapeshellarg($this->repo.'/'.self::smartEncode($def->fullpath)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
return ($cmd_only) ? return ($cmd_only) ?
$cmd : self::shell_exec('IDF_Scm_Svn::getFile', $cmd); $cmd : self::shell_exec('IDF_Scm_Svn::getFile', $cmd);
} }
@@ -327,11 +299,7 @@ class IDF_Scm_Svn extends IDF_Scm
return $this->cache['branches']; return $this->cache['branches'];
} }
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' ls --no-auth-cache --username=%s --password=%s --revision=HEAD %s', $cmd = $this->svnCmd(array('ls'), $this->repo.'/branches', 'HEAD');
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($this->repo.'/branches'));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
self::exec('IDF_Scm_Svn::getBranches', $cmd, $out, $ret); self::exec('IDF_Scm_Svn::getBranches', $cmd, $out, $ret);
if ($ret == 0) { if ($ret == 0) {
foreach ($out as $entry) { foreach ($out as $entry) {
@@ -342,11 +310,8 @@ class IDF_Scm_Svn extends IDF_Scm
} }
} }
ksort($res); ksort($res);
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --username=%s --password=%s --revision=HEAD %s',
escapeshellarg($this->username), $cmd = $this->svnCmd(array('info'), $this->repo.'/trunk', 'HEAD');
escapeshellarg($this->password),
escapeshellarg($this->repo.'/trunk'));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
self::exec('IDF_Scm_Svn::getBranches', $cmd, $out, $ret); self::exec('IDF_Scm_Svn::getBranches', $cmd, $out, $ret);
if ($ret == 0) { if ($ret == 0) {
$res = array('trunk' => 'trunk') + $res; $res = array('trunk' => 'trunk') + $res;
@@ -366,11 +331,7 @@ class IDF_Scm_Svn extends IDF_Scm
return $this->cache['tags']; return $this->cache['tags'];
} }
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' ls --no-auth-cache --username=%s --password=%s --revision=HEAD %s', $cmd = $this->svnCmd(array('ls'), $this->repo.'/tags', 'HEAD');
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($this->repo.'/tags'));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
self::exec('IDF_Scm_Svn::getTags', $cmd, $out, $ret); self::exec('IDF_Scm_Svn::getTags', $cmd, $out, $ret);
if ($ret == 0) { if ($ret == 0) {
foreach ($out as $entry) { foreach ($out as $entry) {
@@ -423,12 +384,8 @@ class IDF_Scm_Svn extends IDF_Scm
return false; return false;
} }
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' log --no-auth-cache --xml --limit 1 -v --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('log', '--xml', '--limit', '1', '-v'),
escapeshellarg($this->username), $this->repo, $commit);
escapeshellarg($this->password),
escapeshellarg($commit),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlRes = self::shell_exec('IDF_Scm_Svn::getCommit', $cmd); $xmlRes = self::shell_exec('IDF_Scm_Svn::getCommit', $cmd);
$xml = simplexml_load_string($xmlRes); $xml = simplexml_load_string($xmlRes);
$res['author'] = (string) $xml->logentry->author; $res['author'] = (string) $xml->logentry->author;
@@ -470,12 +427,7 @@ class IDF_Scm_Svn extends IDF_Scm
private function getDiff($rev='HEAD') private function getDiff($rev='HEAD')
{ {
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' diff --no-auth-cache -c %s --username=%s --password=%s %s', $cmd = $this->svnCmd(array('diff', '-c', $rev), $this->repo);
escapeshellarg($rev),
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
return self::shell_exec('IDF_Scm_Svn::getDiff', $cmd); return self::shell_exec('IDF_Scm_Svn::getDiff', $cmd);
} }
@@ -487,13 +439,8 @@ class IDF_Scm_Svn extends IDF_Scm
if ($this->validateRevision($commit) != IDF_Scm::REVISION_VALID) { if ($this->validateRevision($commit) != IDF_Scm::REVISION_VALID) {
return null; return null;
} }
$cmd = sprintf(Pluf::f('svn_path', 'svn').' log --xml -v --no-auth-cache -r %s --username=%s --password=%s %s', $cmd = $this->svnCmd(array('log', '--xml', '-v'), $this->repo, $commit);
escapeshellarg($commit),
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($this->repo));
$out = array(); $out = array();
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$out = self::shell_exec('IDF_Scm_Svn::getChanges', $cmd); $out = self::shell_exec('IDF_Scm_Svn::getChanges', $cmd);
$xml = simplexml_load_string($out); $xml = simplexml_load_string($out);
if (count($xml) == 0) { if (count($xml) == 0) {
@@ -570,20 +517,15 @@ class IDF_Scm_Svn extends IDF_Scm
* *
* @return array Changes. * @return array Changes.
*/ */
public function getChangeLog($branch=null, $n=10) public function getChangeLog($rev=null, $n=10)
{ {
if ($branch != 'HEAD' and !preg_match('/^\d+$/', $branch)) { if ($rev != 'HEAD' and !preg_match('/^\d+$/', $rev)) {
// we accept only revisions or HEAD // we accept only revisions or HEAD
$branch = 'HEAD'; $rev = 'HEAD';
} }
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' log --no-auth-cache --xml -v --limit %s --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('log', '--xml', '-v', '--limit', $n),
escapeshellarg($n), $this->repo.'@'.$rev);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($branch),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlRes = self::shell_exec('IDF_Scm_Svn::getChangeLog', $cmd); $xmlRes = self::shell_exec('IDF_Scm_Svn::getChangeLog', $cmd);
$xml = simplexml_load_string($xmlRes); $xml = simplexml_load_string($xmlRes);
foreach ($xml->logentry as $entry) { foreach ($xml->logentry as $entry) {
@@ -609,12 +551,8 @@ class IDF_Scm_Svn extends IDF_Scm
public function getProperties($rev, $path='') public function getProperties($rev, $path='')
{ {
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' proplist --no-auth-cache --xml --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('proplist', '--xml'),
escapeshellarg($this->username), $this->repo.'/'.self::smartEncode($path), $rev);
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo.'/'.self::smartEncode($path)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlProps = self::shell_exec('IDF_Scm_Svn::getProperties', $cmd); $xmlProps = self::shell_exec('IDF_Scm_Svn::getProperties', $cmd);
$props = simplexml_load_string($xmlProps); $props = simplexml_load_string($xmlProps);
@@ -643,13 +581,8 @@ class IDF_Scm_Svn extends IDF_Scm
private function getProperty($property, $rev, $path='') private function getProperty($property, $rev, $path='')
{ {
$res = array(); $res = array();
$cmd = sprintf(Pluf::f('svn_path', 'svn').' propget --no-auth-cache --xml %s --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('propget', $property, '--xml'),
escapeshellarg($property), $this->repo.'/'.self::smartEncode($path), $rev);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo.'/'.self::smartEncode($path)));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlProp = self::shell_exec('IDF_Scm_Svn::getProperty', $cmd); $xmlProp = self::shell_exec('IDF_Scm_Svn::getProperty', $cmd);
$prop = simplexml_load_string($xmlProp); $prop = simplexml_load_string($xmlProp);
@@ -666,16 +599,38 @@ class IDF_Scm_Svn extends IDF_Scm
public function getLastCommit($rev='HEAD') public function getLastCommit($rev='HEAD')
{ {
$xmlInfo = ''; $xmlInfo = '';
$cmd = sprintf(Pluf::f('svn_path', 'svn').' info --no-auth-cache --xml --username=%s --password=%s --revision=%s %s', $cmd = $this->svnCmd(array('info', '--xml'), $this->repo, $rev);
escapeshellarg($this->username),
escapeshellarg($this->password),
escapeshellarg($rev),
escapeshellarg($this->repo));
$cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd;
$xmlInfo = self::shell_exec('IDF_Scm_Svn::getLastCommit', $cmd); $xmlInfo = self::shell_exec('IDF_Scm_Svn::getLastCommit', $cmd);
$xml = simplexml_load_string($xmlInfo); $xml = simplexml_load_string($xmlInfo);
return (string) $xml->entry->commit['revision']; return (string) $xml->entry->commit['revision'];
} }
private function svnCmd($args = array(), $repoarg = null, $revarg = null)
{
$cmdline = array();
$cmdline[] = Pluf::f('idf_exec_cmd_prefix', '');
$cmdline[] = Pluf::f('svn_path', 'svn');
$cmdline[] = '--no-auth-cache';
$cmdline[] = '--username='.escapeshellarg($this->username);
$cmdline[] = '--password='.escapeshellarg($this->password);
foreach ($args as $arg) {
$cmdline[] = escapeshellarg($arg);
}
if ($repoarg != null) {
if ($revarg != null) {
$repoarg .= '@'.$revarg;
}
$cmdline[] = escapeshellarg($repoarg);
}
if ($revarg != null) {
$cmdline[] = '--revision='.escapeshellarg($revarg);
}
return implode(' ', $cmdline);
}
} }

View File

@@ -150,7 +150,7 @@ class IDF_Upload extends Pluf_Model
if ($this->id == '') { if ($this->id == '') {
$this->creation_dtime = gmdate('Y-m-d H:i:s'); $this->creation_dtime = gmdate('Y-m-d H:i:s');
$this->modif_dtime = gmdate('Y-m-d H:i:s'); $this->modif_dtime = gmdate('Y-m-d H:i:s');
$this->md5 = md5_file (Pluf::f('upload_path') . '/' . $this->get_project()->shortname . '/files/' . $this->file); $this->md5 = md5_file ($this->getFullPath());
} }
} }
@@ -167,6 +167,11 @@ class IDF_Upload extends Pluf_Model
return Pluf::f('url_upload').'/'.$project->shortname.'/files/'.$this->file; return Pluf::f('url_upload').'/'.$project->shortname.'/files/'.$this->file;
} }
function getFullPath()
{
return(Pluf::f('upload_path').'/'.$this->get_project()->shortname.'/files/'.$this->file);
}
/** /**
* We drop the information from the timeline. * We drop the information from the timeline.
*/ */
@@ -256,4 +261,4 @@ class IDF_Upload extends Pluf_Model
} }
Pluf_Translation::loadSetLocale($current_locale); Pluf_Translation::loadSetLocale($current_locale);
} }
} }

View File

@@ -202,7 +202,11 @@ class IDF_Views_Download
$prj->inOr404($upload); $prj->inOr404($upload);
$upload->downloads += 1; $upload->downloads += 1;
$upload->update(); $upload->update();
return new Pluf_HTTP_Response_Redirect($upload->getAbsoluteUrl($prj)); $path = $upload->getFullPath();
$mime = IDF_FileUtil::getMimeType($path);
$render = new Pluf_HTTP_Response_File($path, $mime[0]);
$render->headers["Content-MD5"] = $upload->md5;
return $render;
} }
/** /**

View File

@@ -747,7 +747,13 @@ class IDF_Views_Issue
else { else {
// ID-based search // ID-based search
if (is_numeric($query)) { if (is_numeric($query)) {
$sql = new Pluf_SQL('project=%s AND id LIKE %s', array($prj->id, $query.'%')); $sql = 'project=%s AND CAST(id AS VARCHAR) LIKE %s';
// MySQL can't cast to VARCHAR and a CAST to CHAR converts
// the whole number, not just the first digit
if (strtolower(Pluf::f('db_engine')) == 'mysql') {
$sql = 'project=%s AND CAST(id AS CHAR) LIKE %s';
}
$sql = new Pluf_SQL($sql, array($prj->id, $query.'%'));
$tmp = Pluf::factory('IDF_Issue')->getList(array( $tmp = Pluf::factory('IDF_Issue')->getList(array(
'filter' => $sql->gen(), 'filter' => $sql->gen(),
'order' => 'id ASC' 'order' => 'id ASC'

View File

@@ -132,6 +132,12 @@ class IDF_Views_Source
$request); $request);
} }
public function repository($request, $match)
{
$scm = IDF_Scm::get($request->project);
return $scm->repository($request, $match);
}
public $treeBase_precond = array('IDF_Precondition::accessSource', public $treeBase_precond = array('IDF_Precondition::accessSource',
'IDF_Views_Source_Precondition::scmAvailable', 'IDF_Views_Source_Precondition::scmAvailable',
'IDF_Views_Source_Precondition::revisionValid'); 'IDF_Views_Source_Precondition::revisionValid');

View File

@@ -245,6 +245,11 @@ $ctl[] = array('regex' => '#^/p/([\-\w]+)/source/changesrev/$#',
'model' => 'IDF_Views_Source_Svn', 'model' => 'IDF_Views_Source_Svn',
'method' => 'changelogRev'); 'method' => 'changelogRev');
$ctl[] = array('regex' => '#^/r/([\-\w]+)/(.*)$#',
'base' => $base,
'model' => 'IDF_Views_Source',
'method' => 'repository');
// ---------- WIKI ----------------------------------------- // ---------- WIKI -----------------------------------------
$ctl[] = array('regex' => '#^/p/([\-\w]+)/doc/$#', $ctl[] = array('regex' => '#^/p/([\-\w]+)/doc/$#',

View File

@@ -10,12 +10,22 @@
{if $attachments.count() > 0} {if $attachments.count() > 0}
<hr align="left" class="attach" /> <hr align="left" class="attach" />
<ul> <ul>
{foreach $attachments as $a}<li><a href="{url 'IDF_Views_Issue::viewAttachment', array($project.shortname, $a.id, $a.filename)}">{$a.filename}</a> - {$a.filesize|ssize}</li>{/foreach} {foreach $attachments as $a}<li><a href="{url 'IDF_Views_Issue::viewAttachment', array($project.shortname, $a.id, $a.filename)}">{$a.filename}</a> - {$a.filesize|ssize}</li>{/foreach}
</ul>{/if} </ul>{/if}
{if $c.changes} {if $c.changes}
{foreach $c.changes as $w => $v} {foreach $c.changes as $w => $v}
<strong>{if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if}</strong> {if $w == 'lb'}{assign $l = implode(', ', $v)}{$l}{else}{$v}{/if}<br /> <strong>{if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if}{if $w == 'rel'}{trans 'Relations:'}{/if}</strong>
{if $w == 'lb' or $w == 'rel'}
{foreach $v as $t => $ls}
{foreach $ls as $l}
{if $t == 'rem'}<s>{/if}{$l}{if $t == 'rem'}</s>{/if}
{/foreach}
{/foreach}
{else}
{$v}
{/if}<br />
{/foreach} {/foreach}
{/if} {/if}
</div></content> </div></content>
</entry> </entry>

View File

@@ -16,7 +16,7 @@
{if strlen($c.content) > 0}{$c.content|safe}{/if}{if $c.changedIssue()} {if strlen($c.content) > 0}{$c.content|safe}{/if}{if $c.changedIssue()}
{foreach $c.changes as $w => $v} {foreach $c.changes as $w => $v}
{if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if} {if $w == 'lb'}{assign $l = implode(', ', $v)}{$l}{else}{$v}{/if}{/foreach}{/if}{assign $attachments = $c.get_attachment_list()}{if $attachments.count() > 0} {if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if}{if $w == 'rel'}{trans 'Relations:'}{/if} {if $w == 'lb' or $w == 'rel'}{foreach $v as $t => $ls}{foreach $ls as $l}{if $t == 'rem'}-{/if}{$l} {/foreach}{/foreach}{else}{$v}{/if}{/foreach}{/if}{assign $attachments = $c.get_attachment_list()}{if $attachments.count() > 0}
{trans 'Attachments:'}{foreach $attachments as $a} {trans 'Attachments:'}{foreach $attachments as $a}
- {$a.filename|safe} - {$a.filesize|ssize} - {$a.filename|safe} - {$a.filesize|ssize}

View File

@@ -625,6 +625,7 @@ END;
'additions' => array('new_dir', 'new_dir/new_file'), 'additions' => array('new_dir', 'new_dir/new_file'),
'deletions' => array('old_dir', 'old_dir/old_file'), 'deletions' => array('old_dir', 'old_dir/old_file'),
'renames' => array('dir_with_old_name' => 'new_dir/dir_with_new_name'), 'renames' => array('dir_with_old_name' => 'new_dir/dir_with_new_name'),
'copies' => array(), // this is always empty
'patches' => array('existing_file'), 'patches' => array('existing_file'),
'properties' => array( 'properties' => array(
'new_dir/dir_with_new_name' => array( 'new_dir/dir_with_new_name' => array(

55
test/IDF/Scm/SvnTest.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/*
# ***** BEGIN LICENSE BLOCK *****
# This file is part of InDefero, an open source project management application.
# Copyright (C) 2011 Céondo Ltd and contributors.
#
# InDefero is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# InDefero is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# ***** END LICENSE BLOCK ***** */
class IDF_Scm_SvnTest extends PHPUnit_Framework_TestCase
{
private $proj = null;
public function setUp()
{
$this->proj = new IDF_Project();
$this->proj->id = 1;
$this->proj->name = $this->proj->shortname = 'test';
$this->proj->create();
}
public function tearDown()
{
$this->proj->delete();
}
public function createMock($reponame)
{
$repourl = 'file://'.DATADIR.'/'.__CLASS__.'/'.$reponame;
$instance = new IDF_Scm_Svn($repourl, $this->proj);
return $instance;
}
public function testAccessHistoryOfRenamedAndDeletedFiles()
{
$instance = $this->createMock(__FUNCTION__);
$this->assertEquals('new-file', $instance->getPathInfo('new-file', 1)->fullpath);
$this->assertEquals('alternate-name', $instance->getPathInfo('alternate-name', 2)->fullpath);
}
}

View File

@@ -0,0 +1,2 @@
4
layout sharded 1000

View File

@@ -0,0 +1,5 @@
K 8
svn:date
V 27
2011-08-12T17:06:15.429110Z
END

View File

@@ -0,0 +1,13 @@
K 10
svn:author
V 7
patrick
K 8
svn:date
V 27
2011-08-12T17:07:33.111035Z
K 7
svn:log
V 12
added a file
END

View File

@@ -0,0 +1,13 @@
K 10
svn:author
V 7
patrick
K 8
svn:date
V 27
2011-08-12T17:07:55.894416Z
K 7
svn:log
V 14
renamed a file
END

View File

@@ -0,0 +1,13 @@
K 10
svn:author
V 7
patrick
K 8
svn:date
V 27
2011-08-12T17:08:12.646051Z
K 7
svn:log
V 14
deleted a file
END

View File

@@ -0,0 +1,11 @@
PLAIN
END
ENDREP
id: 0.0.r0/17
type: dir
count: 0
text: 0 0 4 4 2d2977d1c96f487abe4a1e202dd03b4e
cpath: /
17 107

View File

@@ -0,0 +1,28 @@
DELTA
SVNENDREP
id: 0-1.0.r1/17
type: file
count: 0
text: 1 0 4 0 d41d8cd98f00b204e9800998ecf8427e da39a3ee5e6b4b0d3255bfef95601890afd80709 0-0/_2
cpath: /new-file
copyroot: 0 /
PLAIN
K 8
new-file
V 16
file 0-1.0.r1/17
END
ENDREP
id: 0.0.r1/232
type: dir
pred: 0.0.r0/17
count: 1
text: 1 180 39 39 4a0c4e617d69f8a0bf11161d1e1b09a6
cpath: /
copyroot: 0 /
_0.0.t0-0 add-file true false /new-file
232 357

View File

@@ -0,0 +1,29 @@
id: 0-1.0-2.r2/0
type: file
pred: 0-1.0.r1/17
count: 1
text: 1 0 4 0 d41d8cd98f00b204e9800998ecf8427e da39a3ee5e6b4b0d3255bfef95601890afd80709 0-0/_2
cpath: /alternate-name
copyfrom: 1 /new-file
PLAIN
K 14
alternate-name
V 17
file 0-1.0-2.r2/0
END
ENDREP
id: 0.0.r2/256
type: dir
pred: 0.0.r1/232
count: 2
text: 2 196 47 47 518568bcb7e6fe3165235a9f9993f5e1
cpath: /
copyroot: 0 /
0-1.0.r1/17 delete-file false false /new-file
0-1._0.t1-1 add-file false false /alternate-name
1 /new-file
256 382

View File

@@ -0,0 +1,15 @@
PLAIN
END
ENDREP
id: 0.0.r3/17
type: dir
pred: 0.0.r2/256
count: 3
text: 3 0 4 4 2d2977d1c96f487abe4a1e202dd03b4e
cpath: /
copyroot: 0 /
0-1.0-2.r2/0 delete-file false false /alternate-name
17 138

View File

@@ -0,0 +1 @@
5d5eaf70-0fe3-4d07-ada0-45c336a5bfad