php/nextcloud.php
<?php
require_once 'net.php';
if (PHP_VERSION < '8.4.0') {
function array_find($a, $c) {
foreach ($a as $b)
if ($c($b))
return $b;
}
}
NextCloud::ini();
class NextCloud {
/*
docs: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html
*/
public static $rowCfg = [
[ ['d:response', '', 'responseB', 'responseE', '']
, ['d:prop', '', 'propB', '', '']
, ['d:status', '', '', 'statusE', '']
, ['d:displayname', 'name', '', 'aText']
, ['d:getcontenttype', 'type', '', 'aText']
, ['d:collection', 'type', 'aDir', '', 'd:resourcetype']
, ['oc:fileid', 'id', '', 'aText']
, ['d:getlastmodified', 'modTst', '', 'aTst']
, ['oc:size', 'size', '', 'aText']
, ['oc:owner-id', 'owner', '', 'aText']
, ['oc:owner-display-name', 'ownerDisplay', '', 'aText']
, ['oc:favorite', 'favorite', '', 'aText']
, ['oc:favorixx', 'favorixx', '', 'aText']
, ['d:href', 'path', '', 'hrefE', '']
# , ['nc:group-folder-id', 'groupFolder', '', 'aText'] # always null, possibly acl related
# , ['d:creationdate', 'creDate ', '', ' aText '] # always 0. server translates it to UTc not local time
, ['nc:creation_time', 'creTst', '', 'aUTi'] # mostly 0.
, ['nc:version-label', 'versionLabel', '', 'aText']
, ['nc:version-author', 'versionAuthor', '', 'aText']
], ['parent' => 1, 'creTst' => 1]];
public const XMLV = '<?xml version="1.0" encoding="UTF-8"?>'
, XMLNS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'
;
public static function ini () {# class initialize
Pipeline::cfgBase(self::$rowCfg);
self::$rowCfg['%vrs'] = 'type modTst path versionLabel versionAuthor';
#out("nextcloud cfg 2", self::$rowCfg);
}
public static function rowCfg($flds) {
($flds) or $flds='%';
if (is_string($flds) and $flds[0] === '%')
return isset(($c = self::$rowCfg[$flds] ?? err("rowCfg $flds does not exist in", array_keys(self::$rowCfg)))['rwEm'])
? $c : self::$rowCfg[$flds] = self::rowCfg($c);
$rwc = Pipeline::rowCfg(self::$rowCfg, $flds);
$rwc['ncFlds'] = $rwc[4] ? "\n <" . implode("/>\n <", $rwc[4]) . "/>" : '<oc:fileid/>'; // search fails with an empty list!
return $rwc;
}
public $fun, $meta, $fPath, $pPath, $fUri, $sUri;
private $hAuth, $vPath, $vUri;
public function __construct(public $clId, $ty, public $host, public $user, private $pw, public $rootDir, public $flags = 0) {
$this->pPath = $this->fPath = "/remote.php/dav/files/$rootDir/"; # prefix in hrefs from propfind
$this->fUri = "https://$host$this->fPath"; # dav uri for rootdir
$this->sUri = "https://$host/remote.php/dav/";
$this->vPath = "/remote.php/dav/versions/$rootDir/";
$this->vUri = "https://$host{$this->vPath}versions/";
$this->hAuth = "WWW-Authenticate: Basic realm=\"$host\", charset=\"UTF-8\"\n"
. 'Authorization: Basic ' . base64_encode("$user:$pw");
}
public function pipeline($rwc=null, $trg = null, $doDef = Net::PHST, $out = null) {
is_string($rwc) and is_null($trg) and list($rwc, $trg, $doBi) = explode(',', trim($rwc) . ",,");
(is_array($rwc) and isset($rwc['rwEm'])) or $rwc = self::rowCfg($rwc);
$trg or $trg = 'metaRowOut';
$p = ($pipe = new Pipeline($this, ('' !== ($doBi ?? '') ? $doBi : $doDef), $rwc))->ppc;
$out and $p->out = $out;
$p->req = $trg === 'string' ? new Net2Str : new Net2XMLReader;
$p->httpChk = 'HTTP/1.1 207 Multi-Status';
if ($trg === 'string')
return $pipe;
$p->walk = new WalkXML();
return $pipe->makeWalk($trg);
}
public function meta($fun, $dir) {
$now = new DateTime();
return ['timestamp' => toLocalTst($now), 'cloudId' => $this->clId, 'fun' => $fun
, 'dir' => $dir, 'host' => $this->host, 'user' => $this->user
, 'rootDir' => $this->rootDir, 'rootDir Uri' => $this->fUri, 'timeZone' => $now->format('e P')];
}
public function req($ppl, $uriPa, $met='GET', $hC = []) {
is_object($ppl) or $ppl = $this->pipeline($ppl);
$ppl->ppc->httpChk = null;
is_string($hC) ? ($hC = ['header' => $this->hAuth . ($hC ? "\n$hC" : '')]) : ($hC['header'] = $this->hAuth . (($hC['header'] ?? false) ? "\n$hC[header]" : ''));
$hC['method'] = $met;
return $ppl->request("https://$this->host/$uriPa", stream_context_create(['http' => $hC]));
}
/*----- propfind: send a propFind request and process response by $ppl
tree depth is determined by $depth oder $doBits & Net::RECUR
-----*/
public function propfind($ppl, $path, $depth = null) {
is_object($ppl) or $ppl = $this->pipeline($ppl, doDef: Net::PHST | Net::RECUR);
$ctx = stream_context_create(['http' => [
'header' => "Content-Type: application/xml\n$this->hAuth\nDepth: " . ($depth ?? ($ppl->doBits & Net::RECUR ? 'infinity' : 1))
, 'method' => 'PROPFIND'
, 'content' => self::XMLV . "\n<d:propfind " . self::XMLNS
. ">\n <d:prop>{$ppl->ppc->rwcW['ncFlds']}\n </d:prop></d:propfind>"
]]);
return $ppl->request("$this->fUri$path", $ctx, pPath: $this->fPath);
}
/*----- proppatch: send a proppatch request to modify attributes -----*/
public function propPatch($path, ...$rwkVa) { # the only attribute to change seems favorite
$p = '';
foreach ($rwkVa as $k => $v) {
$ci = self::$rowCfg[1][$k][0] ?? err("rwk $k not found in", array_keys(self::$rowCfg[1]));
$w = match ($ci[3]) { 'aUTi' => strtotime($v), default => $v};
$p .= "\n <d:set><d:prop><$ci[0]>$w</$ci[0]></d:prop></d:set>";
}
$ctx = stream_context_create(['http' => $h = [
'header' => "Content-Type: application/xml\n$this->hAuth\nX-OC-CTime: " . strtotime('2024-01-29T06:17:59') # "\nDepth: $depth"
, 'method' => 'PROPPATCH'
, 'content' => self::XMLV . "\n<d:propertyupdate " . self::XMLNS . ">$p\n</d:propertyupdate>"]]);
/* . "\n <d:prop><d:getlastmodified>" . strtotime('2024-02-29T06:17:59') . "</d:getlastmodified></d:prop>" # ==> HTTP/1.1 403 Forbidden
. "\n <d:prop><nc:creation_time>" . strtotime('2023-12-29T06:17:59') . "</nc:creation_time></d:prop>"
. "\n <d:prop><oc:favorixx>xyz und noch mehr xyz</oc:favorixx></d:prop>" */
$r = ($n0 = new Net2Str)->request("$this->fUri$path", $ctx);
return ($n0->rspH[0] === 'HTTP/1.1 207 Multi-Status' and strpos($r, '</d:prop><d:status>HTTP/1.1 200 OK</'))
? 'updated' : throw new NetEx('propPatch failed', path: $path, rspE: $n0->rspH[0], res: $r, rspH: $n0->rspH);
}
/*----- versionPatch: send a proppatch request to modify attributes -----*/
public function versionPatch($id, $tst, $label) {
$n0 = new Net2Str;
$ctx = stream_context_create(['http' => $h = [
'header' => "Content-Type: application/xml\n$this->hAuth\n" # "X-OC-MTime: " . strtotime($modTst) # "\nDepth: $depth"
, 'method' => 'PROPPATCH'
, 'content' => self::XMLV . "\n<d:propertyupdate " . self::XMLNS . ">\n <d:set>"
. "\n <d:prop><nc:version-label>$label</nc:version-label></d:prop>"
. "\n </d:set>\n</d:propertyupdate>"]]);
$r = $n0->request("$this->vUri$id/" . strtotime($tst), $ctx);
return ($n0->rspH[0] === 'HTTP/1.1 207 Multi-Status' and strpos($r, '<d:prop><nc:version-label/></d:prop><d:status>HTTP/1.1 200 OK</'))
? 'updated' : throw new NetEx('propPatch failed', path: $path, rspE: $n0->rspH[0], res: $r, rspH: $n0->rspH);
}
public function search($ppl, $where = null, $scope = null, $id=null) {
is_object($ppl) or $ppl = $this->pipeline($ppl);
if ($id and ! $where)
$where = "<d:eq><d:prop><oc:fileid/></d:prop><d:literal>$id</d:literal></d:eq>";
$scp = "$this->rootDir/$scope";
$ctx = stream_context_create($c = ['http' =>
[ 'header' => "Content-Type: text/xml\n$this->hAuth"
, 'method' => 'SEARCH'
, 'content' => self::XMLV . "\n<d:searchrequest " . self::XMLNS . ">\n <d:basicsearch>"
. "\n <d:select>\n <d:prop>{$ppl->ppc->rwcW['ncFlds']}\n </d:prop>\n </d:select>"
. "\n <d:from>\n <d:scope>\n <d:href>/files/$scp</d:href>\n <d:depth>infinity</d:depth>\n </d:scope>\n </d:from>"
. "\n <d:where>\n $where\n </d:where>"
. "\n </d:basicsearch>\n</d:searchrequest> "
]]);
dbg1("search", $c);
return $ppl->request($this->sUri, $ctx);
}
public function copy($doBits, $fr, $to) {
$n0 = new Net2Str;
$c = ['header' => "$this->hAuth\nDestination: $this->fUri$to\nOverwrite: " . ($doBits & Net::OVERWR ? 'T' : 'F'), 'method' => 'COPY'];
try {
try {
$r = $n0->request("$this->fUri$fr", $ctx = stream_context_create(['http' => $c]));
dbg1("copy response:", $r, ",", $n0->rspH);
} catch(NetNotfound $d) {
dbg1("catch 1 $d");
$doBits & Net::CREPA or throw $d;
$this->create('id,Row1,PHST CREPA', dirname($to), Cloud::DIR);
$r = $n0->request("$this->fUri$fr", $ctx);
}
} catch (NetEx $e) {
dbg1("catch 2 $e");
$e->add("copy", from: $fr, to: $to, doBits: $doBits);
throw ($e->det['rspH'][0] === 'HTTP/1.1 412 Precondition failed') ? new NetDuplicate(...$e->det) : $e;
}
$l = ($l = array_find($h = $n0->rspH, fn ($x) => str_starts_with($x, 'OC-FileId:'))) ? trim(substr($l, 10)) : 'noid';
return $h[0] === 'HTTP/1.1 201 Created' ? "created $l" : ($h[0] === 'HTTP/1.1 204 No Content' ? "overwritten $l" : throw new NetEx("copy bad response $h[0]", rspH: $h));
}
/*----- get: get the row for the given id or path -----*/
public function get($ppl, $id=null, $path = null) {
if ($path)
return $this->propfind($ppl, $path, 0);
elseif ($id)
return $this->search($ppl, id: $id);
else
err("get: id and path null");
}
/*----- find files and directories using Propfind Request of WebDav from Nextcloud using http
and process them by ppl
the parent row is (currently) always written
-----*/
public function ff($ppl, $id=null, $path=null ) {
is_object($ppl) or $ppl = $this->pipeline($ppl, doDef: Net::PHST | Net::WRIPA | Net::RECUR);
$p = $ppl->ppc;
$p->cloud === $this or err("ppl->cloud not this");
if ($id and !$path)
$path = $this->search('path,row1', id: $id)['path'];
$p->meta = $this->meta('ff', $path);
return $this->propfind($ppl, $path);
}
public function create($ppl, $path, $ty, $cnt=null, $creTst=null, $modTst=null) {
(is_null($cnt) === ($ty === Cloud::DIR)) or err("type $ty but content", gettype($cnt));
is_object($ppl) or $ppl = $this->pipeline($ppl);
if ($ty === Cloud::DIR) { # native behaviour of nextcloud mkCol is create, or responsd method not allowed if already exists, creTst and modTst not supported
$n0 = new Net2Str;
$ctx = stream_context_create($c = ['http' => ['header' => $this->hAuth, 'method' => 'MKCOL']]);
try {
$rr = $n0->request("$this->fUri$path", $ctx);
$n0->rspH[0] === 'HTTP/1.1 201 Created' or throw new netEx("response {$n0->rspH[0]}, expected created", path: $path, rspH: $n0->rspH, res: "'$rr'");
} catch (NetNotfound $e) { # method not allowed means path points to an existing node, whether it is a folder or a normal file
if (~$ppl->doBits & Net::CREPA)
throw $e->add("parent missing without Net::CREPA", path: $path);
for ($pT=trim($path, '/ '), $px=-1; $px !== false and $p=substr($pT, 0, ($px = strpos($pT, '/', $px+1)) ? $px : null); ) { # create missing parents
try {
$rr = $n0->request("$this->fUri$p", $ctx);
$n0->rspH[0] === 'HTTP/1.1 201 Created' or throw new netEx("response {$n0->rspH[0]}, expected created", path: $p, rspH: $n0->rspH, res: "'$rr'");
} catch (NetDuplicate $e) { # HTTP/1.1 405 Method Not Allowed means path already exists collection or file, let's hope for collection
}
}
} catch (NetEx $e) {
throw $e->add(path: $path);
}
$this->fun = 'create';
return $ppl ? ($this->get($ppl, path: $path) ?? 'created') : 'created';
} else {
try { # check existence
$r = $this->get('type,row1', path: $path);
throw new NetDuplicate("create: already exists", path: $path, typeOld: $r['type']);
} catch (NetNotfound $e) {
$this->fun = 'create';
return $this->creUpd($ppl, $path, $ty, $cnt, $creTst, $modTst);
}
}
}
public function creUpd($ppl, $path, $ty, $cnt=null,$creTst=null, $modTst=null) {
(is_null($cnt) === ($ty === Cloud::DIR)) or err("type $ty but content", gettype($cnt));
is_object($ppl) or $ppl = $this->pipeline($ppl);
if ($ty === Cloud::DIR) {
try {
$this->fun = $rs = 'created';
return $this->create($ppl, $path, $ty, $cnt, $creTst, $modTst);
} catch (NetDuplicate $e) { # HTTP/1.1 405 Method Not Allowed means path already exists collection or file, let's hope for file
$this->fun = $rs = 'alreadyExists';
}
} else { # native behaviour of nextcloud put is create or update
$n0 = new Net2Str;
$ctx = stream_context_create(['http' => ['header' => "Content-Type: $ty\n$this->hAuth"
. ($creTst ? "\nX-OC-CTime: " . strtotime($creTst) : '')
. ($modTst ? "\nX-OC-MTime: " . strtotime($modTst) : '')
, 'method' => 'PUT', 'content' => $cnt]]);
try {
$rr = $n0->request("$this->fUri/$path", $ctx);
} catch (NetNotFound $e) {
isset($rr) and err("catch ex from try after request: $e");
if (~$ppl->doBits & Net::RECUR)
throw $e->add("parent missing without Net::RECUR", path: $path);
$this->create($ppl, substr($path, 0, ($x=strrpos($path, '/')) ? $x : null), Cloud::DIR, null);
$rr = $n0->request("$this->fUri/$path", $ctx);
}
$this->fun = $rs = ($h = $n0->rspH)[0] === 'HTTP/1.1 201 Created' ? 'create' : ($h[0] == "HTTP/1.1 204 No Content" ? 'update'
: netEx("response $h[0]", path: $path, hRsp: $h, res: "'$rr'"));
}
$r = $ppl->doBits & Net::WRIPA ? ($q = $this->get($ppl, path: $path)) ?? "{$rs}d" : "{$rs}d";
if ($ppl->doBits & Net::KRF) { # nextcloud does not have an attribute to keep a version forever. But, if we label the version, nextcloud will not delete it to reclaim space
if (! (isset($q['id']) and isset($q['modTst'])))
$q = $this->get('id modTst,Row1', path: $path);
if (! $this->flags)
$this->versionPatch($q['id'], $q['modTst'], "v$q[modTst]");
else
$this->copy(Net::CREPA, $path, ($pp = substr($path, 0, - strlen($pe = pathinfo($path, PATHINFO_EXTENSION ))-1)) . "/$pp-v$q[modTst].$pe");
$this->fun = $rs;
}
return $r;
}
public function update($ppl, $path, $ty, $cnt=null, $creTst=null, $modTst=null) {
(is_null($cnt) === ($ty === Cloud::DIR)) or err("type $ty but content", gettype($cnt));
is_object($ppl) or $ppl = $this->pipeline($ppl);
if ($ty === Cloud::DIR)
err('cannot update colletion');
$this->get('id,row1', path: $path); # ensure, it exists
return $this->creUpd($ppl, $path, $ty, $cnt, $creTst, $modTst);
}
/*----- versions: find the version of a file by id -----*/
public function versions($ppl, $id) {
is_object($ppl) or $ppl = $this->pipeline($ppl);
$ctx = stream_context_create(['http' => $h = [
'header' => "Content-Type: application/xml\n$this->hAuth"
, 'method' => 'PROPFIND'
, 'content' => self::XMLV . "\n<d:propfind " . self::XMLNS
. ">\n <d:prop>{$ppl->ppc->rwcW['ncFlds']}\n</d:prop></d:propfind>"]]);
return $ppl->request("{$this->vUri}$id", $ctx, pPath: $this->vPath);
}
public function ffCurl($dir='') {
/* find files and directories using Propfind Request of WebDav from Nextcloud using libcurl
on host $hst in directory $dir
return the generated xml
*/
#curl -u wa@wlkl.ch:RCisXp9HmX6Pk2flSqmWkz9w5LnOiB0xF8H0dVxIxXfefW3OFCmdCvx21H4AUNH7gQxf1188 -X GET 'https://tsueri.cloud/ocs/v1.php/cloud/users/wa@wlkl.ch' -H "OCS-
# $r = ffCurl("https://$host/remote.php/dav", $us, $pw, "$dir");
$ch = curl_init();
curl_setopt_array($ch,
[ CURLOPT_URL => "$this->fUri$dir" // => "https://cloud.hoststar.ch/remote.php/dav/files/wlkl.ch/bike"
, CURLOPT_USERNAME => $this->user
, CURLOPT_PASSWORD => $this->pw
, CURLOPT_RETURNTRANSFER => 1
, CURLOPT_VERBOSE => 1
, CURLOPT_CUSTOMREQUEST => 'PROPFIND'
, CURLOPT_HTTPHEADER => ['Depth: 99']
, CURLOPT_POSTFIELDS => <<<data
<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<oc:fileid />
<d:resourcetype/>
<d:getcontenttype/>
<d:getlastmodified/>
<oc:owner-id />
<!--oc:permissions -->
<oc:size />
</d:prop>
</d:propfind>
data
]);
$r = curl_exec($ch);
curl_close($ch);
return $r;
}
public function mv($fP, $tP) {
/* move file/directory $fr to new path/name $to
*/
$fr =implode('/', array_map('rawurlencode', is_array($fP) ? $fP : explode('/', $fP)));
$to =implode('/', array_map('rawurlencode', is_array($tP) ? $tP :explode('/', $tP)));
$ctx = stream_context_create(['http' => [
'header' => "$this->hAuth\nDestination: $this->fUri$to\nOverwrite: F"
, 'method' => 'MOVE'
]]);
$r = file_get_contents("$this->fUri$fr", 0, $ctx);
if ($http_response_header[0] !== 'HTTP/1.1 201 Created')
err("mv $fr to $to failed: http_response_header", $http_response_header);
}
public function rename($r, $f2v, $hi) {
/* check all resources in xmmResult $r, if they need to be renamed
$f2v: callback: rename last level of path
$hi($nx, $n, $fP, $fM, $tP): callback if last level should be rename:
$nx=count, $n array for resource, $fP from path old, $fM from path for mv, $tp to path for mv
*/
$nx = -1;
$fP = $tP = [];
$hR = function ($n) use (&$nx, &$fP, &$tP, $f2v, $hi) {
$nx++;
$aS = $n[0];
if (($fo = $n[2] === 'd:collection') !== str_ends_with($aS, '/'))
err("path $aS mismatches type $n[2]");
if ($aS === './')
return;
$aP = explode('/', $fo ? substr($aS, 0, -1) : $aS);
$aV = $f2v($aL = $aP[$aY = count($aP) - 1]);
if ($aL === $aV)
return;
$y = min($aY, count($fP));
for ($x=0; $x < $y and $aP[$x] === $fP[$x]; $x++) ; # index of first difference
out("a) $x $aY fP", $fP, ", tP", $tP);
for ( ; $x < $aY ; $x++) # copy new levels
$fP[$x] = $tP[$x] = $aP[$x];
out("b) $x $aY fP", $fP, ", tP", $tP);
while (array_key_last($fP) > $aY)
array_pop($fP);
while (array_key_last($tP) > $aY)
array_pop($tP);
$fM = $tP;
$fP[$aY] = $fM[$aY] = $aL;
$tP[$aY] = $aV;
$hi($nx, $n, $fP, $fM, $tP);
};
$this->ffRsrc($r, $hR);
echo "$nx nodes in $this->clId\n";
}
} # end class NextCloud