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