From b4e24a82db46d0dce1a7c18d2f6bd0a26ac00c96 Mon Sep 17 00:00:00 2001 From: Nina Herrmann Date: Wed, 15 Aug 2018 17:16:08 +0200 Subject: [PATCH] MDL-58943 repository: Add repository_nextcloud Based on repository_owncloud from https://github.com/learnweb/moodle-repository_owncloud at 5b5fdbb. Differences to the original: * Renamed to repository_nextcloud * Updated version.php for core * Removed compatibility polyfills (webdav, privacy) * Removed boilerplate files (e.g. README, CI config) * Implement null_provider for privacy API as personal data is neither stored nor transmitted to the external system --- .../access_controlled_link_manager.php | 456 +++++++++ .../classes/configuration_exception.php | 47 + .../nextcloud/classes/issuer_management.php | 87 ++ repository/nextcloud/classes/ocs_client.php | 179 ++++ .../nextcloud/classes/privacy/provider.php | 46 + .../nextcloud/classes/request_exception.php | 47 + repository/nextcloud/db/access.php | 35 + .../lang/en/repository_nextcloud.php | 61 ++ repository/nextcloud/lib.php | 882 ++++++++++++++++ repository/nextcloud/pix/icon.svg | 1 + .../access_controlled_link_manager_test.php | 633 ++++++++++++ ...estable_access_controlled_link_manager.php | 57 ++ repository/nextcloud/tests/generator/lib.php | 84 ++ repository/nextcloud/tests/lib_test.php | 948 ++++++++++++++++++ repository/nextcloud/tests/ocs_test.php | 67 ++ repository/nextcloud/version.php | 29 + 16 files changed, 3659 insertions(+) create mode 100644 repository/nextcloud/classes/access_controlled_link_manager.php create mode 100755 repository/nextcloud/classes/configuration_exception.php create mode 100644 repository/nextcloud/classes/issuer_management.php create mode 100644 repository/nextcloud/classes/ocs_client.php create mode 100644 repository/nextcloud/classes/privacy/provider.php create mode 100755 repository/nextcloud/classes/request_exception.php create mode 100755 repository/nextcloud/db/access.php create mode 100755 repository/nextcloud/lang/en/repository_nextcloud.php create mode 100755 repository/nextcloud/lib.php create mode 100644 repository/nextcloud/pix/icon.svg create mode 100644 repository/nextcloud/tests/access_controlled_link_manager_test.php create mode 100644 repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php create mode 100755 repository/nextcloud/tests/generator/lib.php create mode 100644 repository/nextcloud/tests/lib_test.php create mode 100644 repository/nextcloud/tests/ocs_test.php create mode 100755 repository/nextcloud/version.php diff --git a/repository/nextcloud/classes/access_controlled_link_manager.php b/repository/nextcloud/classes/access_controlled_link_manager.php new file mode 100644 index 00000000000..0cd7d13a3d9 --- /dev/null +++ b/repository/nextcloud/classes/access_controlled_link_manager.php @@ -0,0 +1,456 @@ +. + +/** + * Manages the creation and usage of access controlled links. + * + * @package repository_nextcloud + * @copyright 2017 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace repository_nextcloud; + +use context; +use \core\oauth2\api; +use \core\notification; + +defined('MOODLE_INTERNAL') || die(); +require_once($CFG->libdir . '/webdavlib.php'); + +/** + * Manages the creation and usage of access controlled links. + * + * @package repository_nextcloud + * @copyright 2017 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class access_controlled_link_manager{ + /** + * OCS client that uses the Open Collaboration Services REST API. + * @var ocs_client + */ + protected $ocsclient; + /** + * ocsclient of the systemaccount. + * @var ocs_client + */ + protected $systemocsclient; + /** + * Client to manage oauth2 features from the systemaccount. + * @var \core\oauth2\client + */ + protected $systemoauthclient; + /** + * Client to manage webdav request from the systemaccount.. + * @var \webdav_client + */ + protected $systemwebdavclient; + /** + * Issuer from the oauthclient. + * @var \core\oauth2\issuer + */ + protected $issuer; + /** + * Name of the related repository. + * @var string + */ + protected $repositoryname; + + /** + * Access_controlled_link_manager constructor. + * @param ocs_client $ocsclient + * @param \core\oauth2\client $systemoauthclient + * @param ocs_client $systemocsclient + * @param \core\oauth2\issuer $issuer + * @param string $repositoryname + * @throws configuration_exception + */ + public function __construct($ocsclient, $systemoauthclient, $systemocsclient, $issuer, $repositoryname) { + $this->ocsclient = $ocsclient; + $this->systemoauthclient = $systemoauthclient; + $this->systemocsclient = $systemocsclient; + + $this->repositoryname = $repositoryname; + $this->issuer = $issuer; + $this->systemwebdavclient = $this->create_system_dav(); + } + + /** + * Deletes the share of the systemaccount and a user. In case the share could not be deleted a notification is + * displayed. + * @param int $shareid Remote ID of the share to be deleted. + */ + public function delete_share_dataowner_sysaccount($shareid) { + $shareid = (int) $shareid; + $deleteshareparams = [ + 'share_id' => $shareid + ]; + $deleteshareresponse = $this->ocsclient->call('delete_share', $deleteshareparams); + $xml = simplexml_load_string($deleteshareresponse); + + if (empty($xml->meta->statuscode) || $xml->meta->statuscode != 100 ) { + notification::warning('You just shared a file with a access controlled link. + However, the share between you and the systemaccount could not be deleted and is still present in your instance.'); + } + } + + /** + * Creates a share between a user and the system account. If $username is set the sharing direction is system account -> user, + * otherwise user -> system account. + * @param string $path Remote path of the file that will be shared + * @param string $username optional when set the file is shared with the corresponding user otherwise with + * the systemaccount. + * @param bool $maywrite if false, only(!) read access is granted. + * @return array statuscode, shareid, and filetarget + * @throws request_exception + */ + public function create_share_user_sysaccount($path, $username = null, $maywrite = false) { + $result = array(); + + if ($username != null) { + $shareusername = $username; + } else { + $systemaccount = \core\oauth2\api::get_system_account($this->issuer); + $shareusername = $systemaccount->get('username'); + } + $permissions = ocs_client::SHARE_PERMISSION_READ; + if ($maywrite) { + // Add more privileges (write, reshare) if allowed for the given user. + $permissions |= ocs_client::SHARE_PERMISSION_ALL; + } + $createshareparams = [ + 'path' => $path, + 'shareType' => ocs_client::SHARE_TYPE_USER, + 'publicUpload' => false, + 'shareWith' => $shareusername, + 'permissions' => $permissions, + ]; + + // File is now shared with the system account. + if ($username === null) { + $createshareresponse = $this->ocsclient->call('create_share', $createshareparams); + } else { + $createshareresponse = $this->systemocsclient->call('create_share', $createshareparams); + } + $xml = simplexml_load_string($createshareresponse); + + $statuscode = (int)$xml->meta->statuscode; + if ($statuscode != 100 && $statuscode != 403) { + $details = get_string('filenotaccessed', 'repository_nextcloud'); + throw new request_exception(get_string('request_exception', + 'repository_nextcloud', array('instance' => $this->repositoryname, 'errormessage' => $details))); + } + $result['shareid'] = (int)$xml->data->id; + $result['statuscode'] = $statuscode; + $result['filetarget'] = (string)$xml->data[0]->file_target; + + return $result; + } + + /** Copy or moves a file to a new path. + * @param string $srcpath source path + * @param string $dstpath + * @param string $operation move or copy + * @param \webdav_client $webdavclient needed when moving files. + * @return String Http-status of the request + * @throws configuration_exception + * @throws \coding_exception + * @throws \moodle_exception + * @throws \repository_nextcloud\request_exception + */ + public function transfer_file_to_path($srcpath, $dstpath, $operation, $webdavclient = null) { + $this->systemwebdavclient->open(); + $webdavendpoint = issuer_management::parse_endpoint_url('webdav', $this->issuer); + + $srcpath = ltrim($srcpath, '/'); + $sourcepath = $webdavendpoint['path'] . $srcpath; + $dstpath = ltrim($dstpath, '/'); + $destinationpath = $webdavendpoint['path'] . $dstpath . '/' . $srcpath; + + if ($operation === 'copy') { + $result = $this->systemwebdavclient->copy_file($sourcepath, $destinationpath, true); + } else if ($operation === 'move') { + $result = $webdavclient->move($sourcepath, $destinationpath, false); + if ($result == 412) { + // A file with that name already exists at that target. Find a unique location! + $increment = 0; // Will be appended to/inserted into the filename. + // Define the pattern that is used to insert the increment to the filename. + if (substr_count($srcpath, '.') === 0) { + // No file extension; append increment to the (sprintf-escaped) name. + $namepattern = str_replace('%', '%%', $destinationpath) . ' (%s)'; + } else { + // Append the increment to the second-to-last component, which is presumably the one before the extension. + // Again, the original path is sprintf-escaped. + $components = explode('.', str_replace('%', '%%', $destinationpath)); + $components[count($components) - 2] .= ' (%s)'; + $namepattern = implode('.', $components); + } + } + while ($result == 412) { + $increment++; + $destinationpath = sprintf($namepattern, $increment); + $result = $webdavclient->move($sourcepath, $destinationpath, false); + } + } + $this->systemwebdavclient->close(); + if (!($result == 201 || $result == 412)) { + $details = get_string('contactadminwith', 'repository_nextcloud', + 'A webdav request to ' . $operation . ' a file failed.'); + throw new request_exception(array('instance' => $this->repositoryname, 'errormessage' => $details)); + } + return $result; + } + + /** + * Creates a unique folder path for the access controlled link. + * @param context $context + * @param string $component + * @param string $filearea + * @param string $itemid + * @return string $result full generated path. + * @throws request_exception If the folder path cannot be created. + */ + public function create_folder_path_access_controlled_links($context, $component, $filearea, $itemid) { + global $CFG, $SITE; + // The fullpath to store the file is generated from the context. + $contextlist = array_reverse($context->get_parent_contexts(true)); + $fullpath = ''; + $allfolders = []; + foreach ($contextlist as $ctx) { + // Prepare human readable context folders names, making sure they are still unique within the site. + $prevlang = force_current_language($CFG->lang); + $foldername = $ctx->get_context_name(); + force_current_language($prevlang); + + if ($ctx->contextlevel === CONTEXT_SYSTEM) { + // Append the site short name to the root folder. + $foldername .= ' ('.$SITE->shortname.')'; + // Append the relevant object id. + } else if ($ctx->instanceid) { + $foldername .= ' (id '.$ctx->instanceid.')'; + } else { + // This does not really happen but just in case. + $foldername .= ' (ctx '.$ctx->id.')'; + } + + $foldername = clean_param($foldername, PARAM_FILE); + $allfolders[] = $foldername; + } + + $allfolders[] = clean_param($component, PARAM_FILE); + $allfolders[] = clean_param($filearea, PARAM_FILE); + $allfolders[] = clean_param($itemid, PARAM_FILE); + + // Extracts the end of the webdavendpoint. + $parsedwebdavurl = issuer_management::parse_endpoint_url('webdav', $this->issuer); + $webdavprefix = $parsedwebdavurl['path']; + $this->systemwebdavclient->open(); + // Checks whether folder exist and creates non-existent folders. + foreach ($allfolders as $foldername) { + $fullpath .= '/' . $foldername; + $isdir = $this->systemwebdavclient->is_dir($webdavprefix . $fullpath); + // Folder already exist, continue. + if ($isdir === true) { + continue; + } + $response = $this->systemwebdavclient->mkcol($webdavprefix . $fullpath); + + if ($response != 201) { + $this->systemwebdavclient->close(); + $details = get_string('contactadminwith', 'repository_nextcloud', + "Folder path $fullpath could not be created in the system account."); + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => $details)); + } + } + $this->systemwebdavclient->close(); + return $fullpath; + } + + /** Creates a new webdav_client for the system account. + * @return \webdav_client + * @throws configuration_exception + */ + public function create_system_dav() { + $webdavendpoint = issuer_management::parse_endpoint_url('webdav', $this->issuer); + + // Selects the necessary information (port, type, server) from the path to build the webdavclient. + $server = $webdavendpoint['host']; + if ($webdavendpoint['scheme'] === 'https') { + $webdavtype = 'ssl://'; + $webdavport = 443; + } else if ($webdavendpoint['scheme'] === 'http') { + $webdavtype = ''; + $webdavport = 80; + } + + // Override default port, if a specific one is set. + if (isset($webdavendpoint['port'])) { + $webdavport = $webdavendpoint['port']; + } + + // Authentication method is `bearer` for OAuth 2. Pass oauth client from which WebDAV obtains the token when needed. + $dav = new \webdav_client($server, '', '', 'bearer', $webdavtype, + $this->systemoauthclient->get_accesstoken()->token, $webdavendpoint['path']); + + $dav->port = $webdavport; + $dav->debug = false; + return $dav; + } + + /** Creates a folder to store access controlled links. + * @param string $controlledlinkfoldername + * @param \webdav_client $webdavclient + * @throws \coding_exception + * @throws configuration_exception + * @throws request_exception + */ + public function create_storage_folder($controlledlinkfoldername, $webdavclient) { + $parsedwebdavurl = issuer_management::parse_endpoint_url('webdav', $this->issuer); + $webdavprefix = $parsedwebdavurl['path']; + // Checks whether folder exist and creates non-existent folders. + $webdavclient->open(); + $isdir = $webdavclient->is_dir($webdavprefix . $controlledlinkfoldername); + // Folder already exist, continue. + if (!$isdir) { + $responsecreateshare = $webdavclient->mkcol($webdavprefix . $controlledlinkfoldername); + + if ($responsecreateshare != 201) { + $webdavclient->close(); + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('contactadminwith', 'repository_nextcloud', + 'The folder to store files in the user account could not be created.'))); + } + } + $webdavclient->close(); + } + + /** Gets all shares from a path (the path is file specific) and extracts the share of a specific user. In case + * multiple shares exist the first one is taken. Multiple shares can only appear when shares are created outside + * of this plugin, therefore this case is not handled. + * @param string $path + * @param string $username + * @return \SimpleXMLElement + * @throws \moodle_exception + */ + public function get_shares_from_path($path, $username) { + $ocsparams = [ + 'path' => $path, + 'reshares' => true + ]; + + $getsharesresponse = $this->systemocsclient->call('get_shares', $ocsparams); + $xml = simplexml_load_string($getsharesresponse); + $validelement = array(); + foreach ($fileid = $xml->data->element as $element) { + if ($element->share_with == $username) { + $validelement = $element; + break; + } + } + if (empty($validelement)) { + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('filenotaccessed', 'repository_nextcloud'))); + + } + return $validelement->id; + } + + /** This method can only be used if the response is from a newly created share. In this case there is more information + * in the response. For a reference refer to + * https://docs.nextcloud.com/server/13/developer_manual/core/ocs-share-api.html#get-information-about-a-known-share. + * @param int $shareid + * @param string $username + * @return mixed the id of the share + * @throws \coding_exception + * @throws \repository_nextcloud\request_exception + */ + public function get_share_information_from_shareid($shareid, $username) { + $ocsparams = [ + 'share_id' => (int) $shareid + ]; + + $shareinformation = $this->ocsclient->call('get_information_of_share', $ocsparams); + $xml = simplexml_load_string($shareinformation); + foreach ($fileid = $xml->data->element as $element) { + if ($element->share_with == $username) { + $validelement = $element; + break; + } + } + if (empty($validelement)) { + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('filenotaccessed', 'repository_nextcloud'))); + + } + return (string) $validelement->file_target; + } + + /** + * Find a file that has previously been shared with the system account. + * @param string $path Path to file in user context. + * @return array shareid: ID of share, filetarget: path to file in sys account. + * @throws request_exception If the share cannot be resolved. + */ + public function find_share_in_sysaccount($path) { + $systemaccount = \core\oauth2\api::get_system_account($this->issuer); + $systemaccountuser = $systemaccount->get('username'); + + // Find out share ID from user files. + $ocsparams = [ + 'path' => $path, + 'reshares' => true + ]; + + $getsharesresponse = $this->ocsclient->call('get_shares', $ocsparams); + $xml = simplexml_load_string($getsharesresponse); + $validelement = array(); + foreach ($fileid = $xml->data->element as $element) { + if ($element->share_with == $systemaccountuser) { + $validelement = $element; + break; + } + } + if (empty($validelement)) { + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('filenotaccessed', 'repository_nextcloud'))); + } + $shareid = (int) $validelement->id; + + // Use share id to find file name in system account's context. + $ocsparams = [ + 'share_id' => $shareid + ]; + + $shareinformation = $this->systemocsclient->call('get_information_of_share', $ocsparams); + $xml = simplexml_load_string($shareinformation); + foreach ($fileid = $xml->data->element as $element) { + if ($element->share_with == $systemaccountuser) { + $validfile = $element; + break; + } + } + if (empty($validfile)) { + throw new request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('filenotaccessed', 'repository_nextcloud'))); + + } + return [ + 'shareid' => $shareid, + 'filetarget' => (string) $validfile->file_target + ]; + } +} diff --git a/repository/nextcloud/classes/configuration_exception.php b/repository/nextcloud/classes/configuration_exception.php new file mode 100755 index 00000000000..d8cf769dd8e --- /dev/null +++ b/repository/nextcloud/classes/configuration_exception.php @@ -0,0 +1,47 @@ +. + +/** + * Exception for when client configuration data is missing. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace repository_nextcloud; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Exception for when client configuration data is missing. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class configuration_exception extends \moodle_exception { + + /** + * This exception is used when the configuration of the plugin can not be processed or database entries are + * missing. + * @param string $hint optional param for additional information of the problem + * @param string $debuginfo detailed information how to fix problem + */ + public function __construct($hint = '', $debuginfo = null) { + parent::__construct('configuration_exception', 'repository_nextcloud', '', $hint, $debuginfo); + } +} \ No newline at end of file diff --git a/repository/nextcloud/classes/issuer_management.php b/repository/nextcloud/classes/issuer_management.php new file mode 100644 index 00000000000..d697d2a8c7b --- /dev/null +++ b/repository/nextcloud/classes/issuer_management.php @@ -0,0 +1,87 @@ +. +// + +/** + * Provide static functions for creating and validating issuers. + * + * @package repository_nextcloud + * @copyright 2018 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace repository_nextcloud; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Provide static functions for creating and validating issuers. + * + * @package repository_nextcloud + * @copyright 2018 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class issuer_management { + + /** + * Check if an issuer provides all endpoints that are required by repository_nextcloud. + * @param \core\oauth2\issuer $issuer An issuer. + * @return bool True, if all endpoints exist; false otherwise. + */ + public static function is_valid_issuer(\core\oauth2\issuer $issuer) { + $endpointwebdav = false; + $endpointocs = false; + $endpointtoken = false; + $endpointauth = false; + $endpointuserinfo = false; + $endpoints = \core\oauth2\api::get_endpoints($issuer); + foreach ($endpoints as $endpoint) { + $name = $endpoint->get('name'); + switch ($name) { + case 'webdav_endpoint': + $endpointwebdav = true; + break; + case 'ocs_endpoint': + $endpointocs = true; + break; + case 'token_endpoint': + $endpointtoken = true; + break; + case 'authorization_endpoint': + $endpointauth = true; + break; + case 'userinfo_endpoint': + $endpointuserinfo = true; + break; + } + } + return $endpointwebdav && $endpointocs && $endpointtoken && $endpointauth && $endpointuserinfo; + } + + /** + * Returns the parsed url parts of an endpoint of an issuer. + * @param string $endpointname + * @param \core\oauth2\issuer $issuer + * @return array parseurl [scheme => https/http, host=>'hostname', port=>443, path=>'path'] + * @throws configuration_exception if an endpoint is undefined + */ + public static function parse_endpoint_url(string $endpointname, \core\oauth2\issuer $issuer): array { + $url = $issuer->get_endpoint_url($endpointname); + if (empty($url)) { + throw new configuration_exception(sprintf('Endpoint %s not defined.', $endpointname)); + } + return parse_url($url); + } +} diff --git a/repository/nextcloud/classes/ocs_client.php b/repository/nextcloud/classes/ocs_client.php new file mode 100644 index 00000000000..9048fbf39fa --- /dev/null +++ b/repository/nextcloud/classes/ocs_client.php @@ -0,0 +1,179 @@ +. + +/** + * REST interface to Nextcloud's implementation of Open Collaboration Services. + * + * @package repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace repository_nextcloud; + +use core\oauth2\client; +use core\oauth2\rest; + +defined('MOODLE_INTERNAL') || die(); + +/** + * REST interface to Nextcloud's implementation of Open Collaboration Services. + * + * @package repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class ocs_client extends rest { + + /** + * shareType=0 creates a private user share. + */ + const SHARE_TYPE_USER = 0; + + /** + * shareType=3 creates a public share. + */ + const SHARE_TYPE_PUBLIC = 3; + + /** + * permissions=1 gives read permission for a share. + */ + const SHARE_PERMISSION_READ = 1; + + /** + * permissions=1 gives read permission for a share. + */ + const SHARE_PERMISSION_ALL = 31; + + /** + * OCS endpoint as configured for the used issuer. + * @var \moodle_url + */ + private $ocsendpoint; + + + /** + * Get endpoint URLs from the used issuer to use them in get_api_functions(). + * @param client $oauthclient OAuth-authenticated Nextcloud client + * @throws configuration_exception Exception if critical endpoints are missing. + * @throws \moodle_exception when trying to construct a moodleurl + */ + public function __construct(client $oauthclient) { + parent::__construct($oauthclient); + + $issuer = $oauthclient->get_issuer(); + $ocsendpoint = $issuer->get_endpoint_url('ocs'); + if ($ocsendpoint === false) { + throw new configuration_exception('Endpoint ocs_endpoint not defined.'); + } + $this->ocsendpoint = new \moodle_url($ocsendpoint); + if (empty($this->ocsendpoint->get_param('format'))) { + $this->ocsendpoint->params(array('format' => 'xml')); + } + } + + /** + * Define relevant functions of the OCS API. + * + * Previously, the instruction to create a oauthclient recommended the user to enter the return format (format=xml). + * However, in this case the shareid is appended at the wrong place. Therefore, a new url is build which inserts the + * shareid at the suitable place for delete_share and get_information_of_share. + * create_share docs: https://docs.nextcloud.com/server/13/developer_manual/core/ocs-share-api.html#create-a-new-share + * + */ + public function get_api_functions() { + return [ + 'create_share' => [ + 'endpoint' => $this->ocsendpoint->out(false), + 'method' => 'post', + 'args' => [ + 'path' => PARAM_TEXT, // Could be PARAM_PATH, we really don't want to enforce a Moodle understanding of paths. + 'shareType' => PARAM_INT, + 'shareWith' => PARAM_TEXT, // Name of receiving user/group. Required if SHARE_TYPE_USER. + 'publicUpload' => PARAM_RAW, // Actually Boolean, but neither String-Boolean ('false') nor PARAM_BOOL (0/1). + 'permissions' => PARAM_INT, + 'shareWith' => PARAM_TEXT, + 'expireDate' => PARAM_TEXT + ], + 'response' => 'text/xml' + ], + 'delete_share' => [ + 'endpoint' => $this->build_share_url(), + 'method' => 'delete', + 'args' => [ + 'share_id' => PARAM_INT + ], + 'response' => 'text/xml' + ], + 'get_shares' => [ + 'endpoint' => $this->ocsendpoint->out(false), + 'method' => 'get', + 'args' => [ + 'path' => PARAM_TEXT, + 'reshares' => PARAM_RAW, // Returns not only the shares from the current user but all of the given file. + 'subfiles' => PARAM_RAW, // Returns all shares within a folder, given that path defines a folder. + ], + 'response' => 'text/xml' + ], + 'get_information_of_share' => [ + 'endpoint' => $this->build_share_url(), + 'method' => 'get', + 'args' => [ + 'share_id' => PARAM_INT + ], + 'response' => 'text/xml' + ], + ]; + } + + /** + * Private Function to return a url with the shareid in the path. + * @return string + */ + private function build_share_url() { + // Out_omit_querystring() in combination with ocsendpoint->get_path() is not used since both function include + // /ocs/v1.php. + $shareurl = $this->ocsendpoint->get_scheme() . '://' . $this->ocsendpoint->get_host() . ':' . + $this->ocsendpoint->get_port() . $this->ocsendpoint->get_path() . '/{share_id}?' . + $this->ocsendpoint->get_query_string(false); + return $shareurl; + } + + /** + * In POST requests, Moodle's REST API assumes that params are + * - transmitted as part of the URL or + * - expressed in JSON. + * Neither is true; we are passing an array to $functionargs which is then put into CURLOPT_POSTFIELDS. + * Curl assumes the content type to be `multipart/form-data` then, but the Moodle REST API tries to put + * a JSON content type. As a result, clients would fail. + * To make this less tedious to use, we assume that the params-as-array-in-$functionargs is the default for us. + * + * @param string $functionname Name of a function from get_api_functions() + * @param array $functionargs Request parameters + * @param bool|string $rawpost Optional param to include in the body of a post + * @param bool|string $contenttype Content type of the request body. Default: multipart/form-data if !$rawpost, JSON otherwise + * @return object|string + * @throws \coding_exception + * @throws \core\oauth2\rest_exception + */ + public function call($functionname, $functionargs, $rawpost = false, $contenttype = false) { + if ($rawpost === false && $contenttype === false) { + return parent::call($functionname, $functionargs, false, 'multipart/form-data'); + } else { + return parent::call($functionname, $functionargs, $rawpost, $contenttype); + } + } + +} \ No newline at end of file diff --git a/repository/nextcloud/classes/privacy/provider.php b/repository/nextcloud/classes/privacy/provider.php new file mode 100644 index 00000000000..afbcad15dab --- /dev/null +++ b/repository/nextcloud/classes/privacy/provider.php @@ -0,0 +1,46 @@ +. + +/** + * Privacy provider. + * + * @package repository_nextcloud + * @copyright 2018 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace repository_nextcloud\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy provider implementing null_provider. + * + * @package repository_nextcloud + * @copyright 2018 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } +} diff --git a/repository/nextcloud/classes/request_exception.php b/repository/nextcloud/classes/request_exception.php new file mode 100755 index 00000000000..6f626a76359 --- /dev/null +++ b/repository/nextcloud/classes/request_exception.php @@ -0,0 +1,47 @@ +. + +/** + * Exception for when an OCS request fails + * + * @package repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace repository_nextcloud; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Exception for when an OCS request fails + * + * @package repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class request_exception extends \moodle_exception { + + /** + * An OCS request has failed. + * + * @param string $hint optional param for additional information of the problem + * @param string $debuginfo detailed information how to fix problem + */ + public function __construct($hint = '', $debuginfo = null) { + parent::__construct('request_exception', 'repository_nextcloud', '', $hint, $debuginfo); + } +} \ No newline at end of file diff --git a/repository/nextcloud/db/access.php b/repository/nextcloud/db/access.php new file mode 100755 index 00000000000..15ea0599904 --- /dev/null +++ b/repository/nextcloud/db/access.php @@ -0,0 +1,35 @@ +. + +/** + * Capability definitions for Nextcloud repository. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$capabilities = array( + 'repository/nextcloud:view' => array( + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => array( + 'user' => CAP_ALLOW + ) + ) +); diff --git a/repository/nextcloud/lang/en/repository_nextcloud.php b/repository/nextcloud/lang/en/repository_nextcloud.php new file mode 100755 index 00000000000..c56b5e870f1 --- /dev/null +++ b/repository/nextcloud/lang/en/repository_nextcloud.php @@ -0,0 +1,61 @@ +. + +/** + * Language strings' definition for Nextcloud repository. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +// General. +$string['pluginname'] = 'Nextcloud'; +$string['configplugin'] = 'Nextcloud repository configuration'; +$string['nextcloud'] = 'Nextcloud'; +$string['nextcloud:view'] = 'View Nextcloud'; +$string['configplugin'] = 'Nextcloud configuration'; +$string['pluginname_help'] = 'Nextcloud repository'; + +// Settings. +$string['issuervalidation_without'] = 'You have not selected an Nextcloud server as the OAuth 2 issuer yet.'; +$string['issuervalidation_valid'] = 'Currently the {$a} issuer is active.'; +$string['issuervalidation_invalid'] = 'Currently the {$a} issuer is active, however it does not implement all necessary endpoints. The repository will not work.'; +$string['right_issuers'] = 'The following issuers implement the required endpoints:
{$a}'; +$string['no_right_issuers'] = 'None of the existing issuers implement all required endpoints. Please register an appropriate issuer.'; +$string['chooseissuer'] = 'Issuer'; +$string['chooseissuer_help'] = 'To add a new issuer visit the admin OAuth 2 services page.
+For additional help with the OAuth 2 API please refer to the Moodle documentation.'; +$string['foldername'] = 'Name of folder created in Nextcloud users\' private space that holds all access controlled links'; +$string['foldername_help'] = 'To assure users find files shared with them, shares are saved into a specific folder.
+This setting determines the name of the folder. It is recommended to chose a name associated with your Moodle instance.'; +$string['oauth2serviceslink'] = 'OAuth 2 services configuration'; +$string['privacy:metadata'] = 'The Nextcloud repository plugin neither stores any personal data nor transmits user data to the remote system.'; +$string['internal'] = 'Internal (files stored in Moodle)'; +$string['external'] = 'External (only links stored in Moodle)'; +$string['both'] = 'Internal and external'; +$string['supportedreturntypes'] = 'Supported files'; +$string['defaultreturntype'] = 'Default return type'; +$string['fileoptions'] = 'The types and defaults for returned files is configurable here. Note that all files linked externally will be updated so that the owner is the Moodle system account.'; + +// Exceptions. +$string['configuration_exception'] = 'An error in the configuration of the OAuth 2 client occurred: {$a}'; +$string['request_exception'] = 'A request to {$a->instance} has failed. {$a->errormessage}'; +$string['requestnotexecuted'] = 'The request could not be executed. If this happens frequently please contact the course or site administrator.'; +$string['notauthorized'] = 'You are not authorized to execute the demanded request. Please ensure you are authenticated with the right account.'; +$string['contactadminwith'] = 'The requested action could not be executed. In case this happens frequently please contact the side administrator with the following additional information:
"{$a}"'; +$string['cannotconnect'] = 'The user could not be authenticated, please log in and then upload the file.'; +$string['filenotaccessed'] = 'The requested file could not be accessed. Please check whether you have chosen a valid file and you are authenticated with the right account.'; +$string['couldnotmove'] = 'The requested file could not be moved in the {$a} folder.'; diff --git a/repository/nextcloud/lib.php b/repository/nextcloud/lib.php new file mode 100755 index 00000000000..f8d66245dd5 --- /dev/null +++ b/repository/nextcloud/lib.php @@ -0,0 +1,882 @@ +. + +/** + * Nextcloud repository plugin library. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or + */ + +use repository_nextcloud\issuer_management; +use repository_nextcloud\ocs_client; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/repository/lib.php'); +require_once($CFG->libdir . '/webdavlib.php'); + +/** + * Nextcloud repository class. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class repository_nextcloud extends repository { + /** + * OAuth 2 client + * @var \core\oauth2\client + */ + private $client = null; + + /** + * OAuth 2 Issuer + * @var \core\oauth2\issuer + */ + private $issuer = null; + + /** + * Additional scopes needed for the repository. Currently, nextcloud does not actually support/use scopes, so + * this is intended as a hint at required functionality and will help declare future scopes. + */ + const SCOPES = 'files ocs'; + + /** + * Webdav client which is used for webdav operations. + * + * @var \webdav_client + */ + private $dav = null; + + /** + * Basepath for WebDAV operations + * @var string + */ + private $davbasepath; + + /** + * OCS client that uses the Open Collaboration Services REST API. + * @var ocs_client + */ + private $ocsclient; + + /** + * @var oauth2_client System account client. + */ + private $systemoauthclient = false; + + /** + * OCS systemocsclient that uses the Open Collaboration Services REST API. + * @var ocs_client + */ + private $systemocsclient = null; + /** + * Name of the folder for controlled links. + * @var string + */ + private $controlledlinkfoldername; + + /** + * repository_nextcloud constructor. + * @param int $repositoryid + * @param bool|int|stdClass $context + * @param array $options + */ + public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array()) { + parent::__construct($repositoryid, $context, $options); + try { + // Issuer from repository instance config. + $issuerid = $this->get_option('issuerid'); + $this->issuer = \core\oauth2\api::get_issuer($issuerid); + } catch (dml_missing_record_exception $e) { + // A repository is marked as disabled when no issuer is present. + $this->disabled = true; + return; + } + + try { + // Load the webdav endpoint and parse the basepath. + $webdavendpoint = issuer_management::parse_endpoint_url('webdav', $this->issuer); + // Get basepath without trailing slash, because future uses will come with a leading slash. + $basepath = $webdavendpoint['path']; + if (strlen($basepath) > 0 && substr($basepath, -1) === '/') { + $basepath = substr($basepath, 0, -1); + } + $this->davbasepath = $basepath; + } catch (\repository_nextcloud\configuration_exception $e) { + // A repository is marked as disabled when no webdav_endpoint is present + // or it fails to parse, because all operations concerning files + // rely on the webdav endpoint. + $this->disabled = true; + return; + } + $this->controlledlinkfoldername = $this->get_option('controlledlinkfoldername'); + + if (!$this->issuer) { + $this->disabled = true; + return; + } else if (!$this->issuer->get('enabled')) { + // In case the Issuer is not enabled, the repository is disabled. + $this->disabled = true; + return; + } else if (!issuer_management::is_valid_issuer($this->issuer)) { + // Check if necessary endpoints are present. + $this->disabled = true; + return; + } + + $this->ocsclient = new ocs_client($this->get_user_oauth_client()); + } + + /** + * Get or initialise an oauth client for the system account. + * @return false|oauth2_client False if initialisation was unsuccessful, otherwise an initialised client. + */ + private function get_system_oauth_client() { + if ($this->systemoauthclient === false) { + try { + $this->systemoauthclient = \core\oauth2\api::get_system_oauth_client($this->issuer); + } catch (\moodle_exception $e) { + $this->systemoauthclient = false; + } + } + return $this->systemoauthclient; + } + + /** + * Get or initialise an ocs client for the system account. + * @return null|ocs_client Null if initialisation was unsuccessful, otherwise an initialised client. + */ + private function get_system_ocs_client() { + if ($this->systemocsclient === null) { + try { + $systemoauth = $this->get_system_oauth_client(); + if (!$systemoauth) { + return null; + } + $this->systemocsclient = new ocs_client($systemoauth); + } catch (\moodle_exception $e) { + $this->systemocsclient = null; + } + } + return $this->systemocsclient; + } + + /** + * Initiates the webdav client. + * @throws \repository_nextcloud\configuration_exception If configuration is missing (endpoints). + */ + private function initiate_webdavclient() { + if ($this->dav !== null) { + return $this->dav; + } + + $webdavendpoint = issuer_management::parse_endpoint_url('webdav', $this->issuer); + + // Selects the necessary information (port, type, server) from the path to build the webdavclient. + $server = $webdavendpoint['host']; + if ($webdavendpoint['scheme'] === 'https') { + $webdavtype = 'ssl://'; + $webdavport = 443; + } else if ($webdavendpoint['scheme'] === 'http') { + $webdavtype = ''; + $webdavport = 80; + } + + // Override default port, if a specific one is set. + if (isset($webdavendpoint['port'])) { + $webdavport = $webdavendpoint['port']; + } + + // Authentication method is `bearer` for OAuth 2. Pass token of authenticated client, too. + $this->dav = new \webdav_client($server, '', '', 'bearer', $webdavtype, + $this->get_user_oauth_client()->get_accesstoken()->token); + + $this->dav->port = $webdavport; + $this->dav->debug = false; + return $this->dav; + } + + /** + * This function does exactly the same as in the WebDAV repository. The only difference is, that + * the nextcloud OAuth2 client uses OAuth2 instead of Basic Authentication. + * + * @param string $reference relative path to the file. + * @param string $title title of the file. + * @return array|bool returns either the moodle path to the file or false. + */ + public function get_file($reference, $title = '') { + // Normal file. + $reference = urldecode($reference); + + // Prepare a file with an arbitrary name - cannot be $title because of special chars (cf. MDL-57002). + $path = $this->prepare_file(uniqid()); + $this->initiate_webdavclient(); + if (!$this->dav->open()) { + return false; + } + $this->dav->get_file($this->davbasepath . $reference, $path); + $this->dav->close(); + + return array('path' => $path); + } + + /** + * This function does exactly the same as in the WebDAV repository. The only difference is, that + * the nextcloud OAuth2 client uses OAuth2 instead of Basic Authentication. + * + * @param string $path relative path to the directory or file. + * @param string $page page number (given multiple pages of elements). + * @return array directory properties. + */ + public function get_listing($path='', $page = '') { + if (empty($path)) { + $path = '/'; + } + + $ret = $this->get_listing_prepare_response($path); + + // Before any WebDAV method can be executed, a WebDAV client socket needs to be opened + // which connects to the server. + $this->initiate_webdavclient(); + if (!$this->dav->open()) { + return $ret; + } + + // Since the paths which are received from the PROPFIND WebDAV method are url encoded + // (because they depict actual web-paths), the received paths need to be decoded back + // for the plugin to be able to work with them. + $ls = $this->dav->ls($this->davbasepath . urldecode($path)); + $this->dav->close(); + + // The method get_listing return all information about all child files/folders of the + // current directory. If no information was received, the directory must be empty. + if (!is_array($ls)) { + return $ret; + } + + // Process WebDAV output and convert it into Moodle format. + $ret['list'] = $this->get_listing_convert_response($path, $ls); + return $ret; + + } + + /** + * Use OCS to generate a public share to the requested file. + * This method derives a download link from the public share URL. + * + * @param string $url relative path to the chosen file + * @return string the generated download link. + * @throws \repository_nextcloud\request_exception If nextcloud responded badly + * + */ + public function get_link($url) { + $ocsparams = [ + 'path' => $url, + 'shareType' => ocs_client::SHARE_TYPE_PUBLIC, + 'publicUpload' => false, + 'permissions' => ocs_client::SHARE_PERMISSION_READ + ]; + + $response = $this->ocsclient->call('create_share', $ocsparams); + $xml = simplexml_load_string($response); + $repositoryname = get_string('pluginname', 'repository_nextcloud'); + + if ($xml === false ) { + throw new \repository_nextcloud\request_exception(array('instance' => $repositoryname, + 'errormessage' => 'Invalid response')); + } + + if ((string)$xml->meta->status !== 'ok') { + throw new \repository_nextcloud\request_exception(array('instance' => $repositoryname, 'errormessage' => sprintf( + '(%s) %s', $xml->meta->statuscode, $xml->meta->message))); + } + + // Take the share link and convert it into a download link. + return ((string)$xml->data[0]->url) . '/download'; + } + + /** + * This method converts the source from the file picker (chosen by the user) into + * information, which will be received by methods that fetch files/references from + * the Nextcloud server. + * + * @param string $source source of the file, returned by repository as 'source' and received back from user (not cleaned) + * @return string file reference, ready to be stored + */ + public function get_file_reference($source) { + $usefilereference = optional_param('usefilereference', false, PARAM_BOOL); + + // A filereference is requested if an alias/shortcut shall be created, i.e. a FILE_REFERENCE option is selected. + // Therefore, generate and return a public link to the file. + if ($usefilereference) { + $reference = $this->get_link($source); + $filereturn = new stdClass(); + $filereturn->type = 'FILE_REFERENCE'; + $filereturn->link = $reference; + return json_encode($filereturn); + } + + // Otherwise, the simple relative path to the file is enough. + return $source; + } + + /** Called when a file is selected as a "access control link". + * Invoked at MOODLE/repository/repository_ajax.php + * + * This is called at the point the reference files are being copied from the draft area to the real area. + * What is done here is transfer ownership to the system user (by copying) then delete the intermediate share + * used for that. Finally update the reference to point to new file name. + * + * @param string $reference this reference is generated by repository::get_file_reference() + * @param context $context the target context for this new file. + * @param string $component the target component for this new file. + * @param string $filearea the target filearea for this new file. + * @param string $itemid the target itemid for this new file. + * @return string updated reference (final one before it's saved to db). + * @throws \repository_nextcloud\configuration_exception + * @throws \repository_nextcloud\request_exception + * @throws coding_exception + * @throws moodle_exception + * @throws repository_exception + */ + public function reference_file_selected($reference, $context, $component, $filearea, $itemid) { + $source = json_decode($reference); + + if (is_object($source)) { + if ($source->type != 'FILE_CONTROLLED_LINK') { + // Only access controlled links need special handling; we are done. + return $reference; + } + if (!empty($source->usesystem)) { + // If we already copied this file to the system account - we are done. + return $reference; + } + } + + // Check this issuer is enabled. + if ($this->disabled || $this->get_system_oauth_client() === false || $this->get_system_ocs_client() === null) { + throw new repository_exception('cannotdownload', 'repository'); + } + + $linkmanager = new \repository_nextcloud\access_controlled_link_manager($this->ocsclient, $this->get_system_oauth_client(), + $this->get_system_ocs_client(), $this->issuer, $this->get_name()); + + // Get the current user. + $userauth = $this->get_user_oauth_client(); + if ($userauth === false) { + $details = get_string('cannotconnect', 'repository_nextcloud'); + throw new \repository_nextcloud\request_exception(array('instance' => $this->get_name(), 'errormessage' => $details)); + } + // 1. Share the File with the system account. + $responsecreateshare = $linkmanager->create_share_user_sysaccount($reference); + if ($responsecreateshare['statuscode'] == 403) { + // File has already been shared previously => find file in system account and use that. + $responsecreateshare = $linkmanager->find_share_in_sysaccount($reference); + } + + // 2. Create a unique path in the system account. + $createdfolder = $linkmanager->create_folder_path_access_controlled_links($context, $component, $filearea, + $itemid); + + // 3. Copy File to the new folder path. + $linkmanager->transfer_file_to_path($responsecreateshare['filetarget'], $createdfolder, 'copy'); + + // 4. Delete the share. + $linkmanager->delete_share_dataowner_sysaccount($responsecreateshare['shareid']); + + // Update the returned reference so that the stored_file in moodle points to the newly copied file. + $filereturn = new stdClass(); + $filereturn->type = 'FILE_CONTROLLED_LINK'; + $filereturn->link = $createdfolder . $responsecreateshare['filetarget']; + $filereturn->name = $reference; + $filereturn->usesystem = true; + $filereturn = json_encode($filereturn); + + return $filereturn; + } + + /** + * Repository method that serves the referenced file (created e.g. via get_link). + * All parameters are there for compatibility with superclass, but they are ignored. + * + * @param stored_file $storedfile + * @param int $lifetime (ignored) + * @param int $filter (ignored) + * @param bool $forcedownload (ignored) + * @param array $options (ignored) + * @throws \repository_nextcloud\configuration_exception + * @throws \repository_nextcloud\request_exception + * @throws coding_exception + * @throws moodle_exception + */ + public function send_file($storedfile, $lifetime=null , $filter=0, $forcedownload=false, array $options = null) { + $repositoryname = $this->get_name(); + $reference = json_decode($storedfile->get_reference()); + + if ($reference->type == 'FILE_REFERENCE') { + redirect($reference->link); + } + + // 1. assure the client and user is logged in. + if (empty($this->client) || $this->get_system_oauth_client() === false || $this->get_system_ocs_client() === null) { + $details = get_string('contactadminwith', 'repository_nextcloud', + 'The OAuth client could not be connected.'); + throw new \repository_nextcloud\request_exception(array('instance' => $repositoryname, 'errormessage' => $details)); + } + + if (!$this->client->is_logged_in()) { + $this->print_login_popup(['style' => 'margin-top: 250px']); + return; + } + + // Determining writeability of file from the using context. + // Variable $info is null|\file_info. file_info::is_writable is only true if user may write for any reason. + $fb = get_file_browser(); + $context = context::instance_by_id($storedfile->get_contextid(), MUST_EXIST); + $info = $fb->get_file_info($context, + $storedfile->get_component(), + $storedfile->get_filearea(), + $storedfile->get_itemid(), + $storedfile->get_filepath(), + $storedfile->get_filename()); + $maywrite = !empty($info) && $info->is_writable(); + + $this->initiate_webdavclient(); + + // Create the a manager to handle steps. + $linkmanager = new \repository_nextcloud\access_controlled_link_manager($this->ocsclient, $this->get_system_oauth_client(), + $this->get_system_ocs_client(), $this->issuer, $repositoryname); + + // 2. Check whether user has folder for files otherwise create it. + $linkmanager->create_storage_folder($this->controlledlinkfoldername, $this->dav); + + $userinfo = $this->client->get_userinfo(); + $username = $userinfo['username']; + + // Creates a share between the systemaccount and the user. + $responsecreateshare = $linkmanager->create_share_user_sysaccount($reference->link, $username, $maywrite); + + $statuscode = $responsecreateshare['statuscode']; + + if ($statuscode == 403) { + $shareid = $linkmanager->get_shares_from_path($reference->link, $username); + } else if ($statuscode == 100) { + $filetarget = $linkmanager->get_share_information_from_shareid($responsecreateshare['shareid'], $username); + $copyresult = $linkmanager->transfer_file_to_path($filetarget, $this->controlledlinkfoldername, + 'move', $this->dav); + if (!($copyresult == 201 || $copyresult == 412)) { + throw new \repository_nextcloud\request_exception(array('instance' => $this->repositoryname, + 'errormessage' => get_string('couldnotmove', 'repository_nextcloud', $this->controlledlinkfoldername))); + } + $shareid = $responsecreateshare['shareid']; + } else if ($statuscode == 997) { + throw new \repository_nextcloud\request_exception(array('instance' => $repositoryname, + 'errormessage' => get_string('notauthorized', 'repository_nextcloud'))); + } else { + $details = get_string('filenotaccessed', 'repository_nextcloud'); + throw new \repository_nextcloud\request_exception(array('instance' => $repositoryname, 'errormessage' => $details)); + } + $filetarget = $linkmanager->get_share_information_from_shareid((int)$shareid, $username); + + // Obtain the file from Nextcloud using a Bearer token authenticated connection because we cannot perform a redirect here. + // The reason is that Nextcloud uses samesite cookie validation, i.e. a redirected request would not be authenticated. + // (Also the browser might use the session of a Nextcloud user that is different from the one that is known to Moodle.) + $filename = basename($filetarget); + $tmppath = make_request_directory() . '/' . $filename; + $this->dav->open(); + + // Concat webdav path with file path. + $webdavendpoint = issuer_management::parse_endpoint_url('webdav', $this->issuer); + $filetarget = ltrim($filetarget, '/'); + $filetarget = $webdavendpoint['path'] . $filetarget; + + // Write file into temp location. + if (!$this->dav->get_file($filetarget, $tmppath)) { + $this->dav->close(); + throw new repository_exception('cannotdownload', 'repository'); + } + $this->dav->close(); + + // Output the obtained file to the user and remove it from disk. + send_temp_file($tmppath, $filename); + } + + /** + * Which return type should be selected by default. + * + * @return int + */ + public function default_returntype() { + $setting = $this->get_option('defaultreturntype'); + $supported = $this->get_option('supportedreturntypes'); + if (($setting == FILE_INTERNAL && $supported !== 'external') || $supported === 'internal') { + return FILE_INTERNAL; + } + return FILE_CONTROLLED_LINK; + } + + /** + * Return names of the general options. + * By default: no general option name. + * + * @return array + */ + public static function get_type_option_names() { + return array(); + } + + /** + * Function which checks whether the user is logged in on the Nextcloud instance. + * + * @return bool false, if no Access Token is set or can be requested. + */ + public function check_login() { + $client = $this->get_user_oauth_client(); + return $client->is_logged_in(); + } + + /** + * Get a cached user authenticated oauth client. + * @param bool|moodle_url $overrideurl Use this url instead of the repo callback. + * @return \core\oauth2\client + */ + protected function get_user_oauth_client($overrideurl = false) { + if ($this->client) { + return $this->client; + } + if ($overrideurl) { + $returnurl = $overrideurl; + } else { + $returnurl = new moodle_url('/repository/repository_callback.php'); + $returnurl->param('callback', 'yes'); + $returnurl->param('repo_id', $this->id); + $returnurl->param('sesskey', sesskey()); + } + $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES); + return $this->client; + } + + + /** + * Prints a simple Login Button which redirects to an authorization window from Nextcloud. + * + * @return mixed login window properties. + * @throws coding_exception + */ + public function print_login() { + $client = $this->get_user_oauth_client(); + $loginurl = $client->get_login_url(); + if ($this->options['ajax']) { + $ret = array(); + $btn = new \stdClass(); + $btn->type = 'popup'; + $btn->url = $loginurl->out(false); + $ret['login'] = array($btn); + return $ret; + } else { + echo html_writer::link($loginurl, get_string('login', 'repository'), + array('target' => '_blank', 'rel' => 'noopener noreferrer')); + } + } + + /** + * Deletes the held Access Token and prints the Login window. + * + * @return array login window properties. + */ + public function logout() { + $client = $this->get_user_oauth_client(); + $client->log_out(); + return parent::logout(); + } + + /** + * Sets up access token after the redirection from Nextcloud. + */ + public function callback() { + $client = $this->get_user_oauth_client(); + // If an Access Token is stored within the client, it has to be deleted to prevent the addition + // of an Bearer authorization header in the request method. + $client->log_out(); + + // This will upgrade to an access token if we have an authorization code and save the access token in the session. + $client->is_logged_in(); + } + + + /** + * Create an instance for this plug-in + * + * @param string $type the type of the repository + * @param int $userid the user id + * @param stdClass $context the context + * @param array $params the options for this instance + * @param int $readonly whether to create it readonly or not (defaults to not) + * @return mixed + * @throws dml_exception + * @throws required_capability_exception + */ + public static function create($type, $userid, $context, $params, $readonly=0) { + require_capability('moodle/site:config', context_system::instance()); + return parent::create($type, $userid, $context, $params, $readonly); + } + + + /** + * This method adds a select form and additional information to the settings form.. + * + * @param \moodleform $mform Moodle form (passed by reference) + * @return bool|void + * @throws coding_exception + * @throws dml_exception + */ + public static function instance_config_form($mform) { + if (!has_capability('moodle/site:config', context_system::instance())) { + $mform->addElement('static', null, '', get_string('nopermissions', 'error', get_string('configplugin', + 'repository_nextcloud'))); + return false; + } + + // Load configured issuers. + $issuers = core\oauth2\api::get_all_issuers(); + $types = array(); + + // Validates which issuers implement the right endpoints. WebDav is necessary for Nextcloud. + $validissuers = []; + foreach ($issuers as $issuer) { + $types[$issuer->get('id')] = $issuer->get('name'); + if (\repository_nextcloud\issuer_management::is_valid_issuer($issuer)) { + $validissuers[] = $issuer->get('name'); + } + } + + // Render the form. + $url = new \moodle_url('/admin/tool/oauth2/issuers.php'); + $mform->addElement('static', null, '', get_string('oauth2serviceslink', 'repository_nextcloud', $url->out())); + + $mform->addElement('select', 'issuerid', get_string('chooseissuer', 'repository_nextcloud'), $types); + $mform->addRule('issuerid', get_string('required'), 'required', null, 'issuer'); + $mform->addHelpButton('issuerid', 'chooseissuer', 'repository_nextcloud'); + $mform->setType('issuerid', PARAM_INT); + + // All issuers that are valid are displayed seperately (if any). + if (count($validissuers) === 0) { + $mform->addElement('static', null, '', get_string('no_right_issuers', 'repository_nextcloud')); + } else { + $mform->addElement('static', null, '', get_string('right_issuers', 'repository_nextcloud', + implode(', ', $validissuers))); + } + + $mform->addElement('text', 'controlledlinkfoldername', get_string('foldername', 'repository_nextcloud')); + $mform->addHelpButton('controlledlinkfoldername', 'foldername', 'repository_nextcloud'); + $mform->setType('controlledlinkfoldername', PARAM_TEXT); + $mform->setDefault('controlledlinkfoldername', 'Moodlefiles'); + + $mform->addElement('static', null, '', get_string('fileoptions', 'repository_nextcloud')); + $choices = [ + 'both' => get_string('both', 'repository_nextcloud'), + 'internal' => get_string('internal', 'repository_nextcloud'), + 'external' => get_string('external', 'repository_nextcloud'), + ]; + $mform->addElement('select', 'supportedreturntypes', get_string('supportedreturntypes', 'repository_nextcloud'), $choices); + + $choices = [ + FILE_INTERNAL => get_string('internal', 'repository_nextcloud'), + FILE_CONTROLLED_LINK => get_string('external', 'repository_nextcloud'), + ]; + $mform->addElement('select', 'defaultreturntype', get_string('defaultreturntype', 'repository_nextcloud'), $choices); + } + + /** + * Save settings for repository instance + * + * @param array $options settings + * @return bool + */ + public function set_option($options = array()) { + $options['issuerid'] = clean_param($options['issuerid'], PARAM_INT); + $options['controlledlinkfoldername'] = clean_param($options['controlledlinkfoldername'], PARAM_TEXT); + + $ret = parent::set_option($options); + return $ret; + } + + /** + * Names of the plugin settings + * + * @return array + */ + public static function get_instance_option_names() { + return ['issuerid', 'controlledlinkfoldername', + 'defaultreturntype', 'supportedreturntypes']; + } + + /** + * Method to define which file-types are supported (hardcoded can not be changed in Admin Menu) + * By default FILE_INTERNAL is supported. In case a system account is connected and an issuer exist, + * FILE_CONTROLLED_LINK is supported. + * FILE_INTERNAL - the file is uploaded/downloaded and stored directly within the Moodle file system. + * FILE_CONTROLLED_LINK - creates a copy of the file in Nextcloud from which private shares to permitted users will be + * created. The file itself can not be changed any longer by the owner. + * @return int return type bitmask supported + */ + public function supported_returntypes() { + // We can only support access controlled links if the system account is connected. + $setting = $this->get_option('supportedreturntypes'); + $sysisconnected = !empty($this->issuer) && $this->issuer->is_system_account_connected(); + if ($setting === 'internal') { + return FILE_INTERNAL; + } + if ($setting === 'external') { + if ($sysisconnected) { + return FILE_CONTROLLED_LINK | FILE_REFERENCE | FILE_EXTERNAL; + } + return FILE_REFERENCE | FILE_EXTERNAL; + } + // Otherwise all of them are supported (controlled link only with system account). + if ($sysisconnected) { + return FILE_CONTROLLED_LINK | FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL; + } + return FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL; + + } + + /** + * Take the WebDAV `ls()' output and convert it into a format that Moodle's filepicker understands. + * + * @param string $dirpath Relative (urlencoded) path of the folder of interest. + * @param array $ls Output by WebDAV + * @return array Moodle-formatted list of directory contents; ready for use as $ret['list'] in get_listings + */ + private function get_listing_convert_response($dirpath, $ls) { + global $OUTPUT; + $folders = array(); + $files = array(); + $parsedurl = issuer_management::parse_endpoint_url('webdav', $this->issuer); + $basepath = rtrim('/' . ltrim($parsedurl['path'], '/ '), '/ '); + + foreach ($ls as $item) { + if (!empty($item['lastmodified'])) { + $item['lastmodified'] = strtotime($item['lastmodified']); + } else { + $item['lastmodified'] = null; + } + + // Extracting object title from absolute path: First remove Nextcloud basepath. + $item['href'] = substr(urldecode($item['href']), strlen($basepath)); + // Then remove relative path to current folder. + $title = substr($item['href'], strlen($dirpath)); + + if (!empty($item['resourcetype']) && $item['resourcetype'] == 'collection') { + // A folder. + if ($dirpath == $item['href']) { + // Skip "." listing. + continue; + } + + $folders[strtoupper($title)] = array( + 'title' => rtrim($title, '/'), + 'thumbnail' => $OUTPUT->image_url(file_folder_icon(90))->out(false), + 'children' => array(), + 'datemodified' => $item['lastmodified'], + 'path' => $item['href'] + ); + } else { + // A file. + $size = !empty($item['getcontentlength']) ? $item['getcontentlength'] : ''; + $files[strtoupper($title)] = array( + 'title' => $title, + 'thumbnail' => $OUTPUT->image_url(file_extension_icon($title, 90))->out(false), + 'size' => $size, + 'datemodified' => $item['lastmodified'], + 'source' => $item['href'] + ); + } + } + ksort($files); + ksort($folders); + return array_merge($folders, $files); + } + /** + * Print the login in a popup. + * + * @param array|null $attr Custom attributes to be applied to popup div. + */ + private function print_login_popup($attr = null) { + global $OUTPUT; + + $this->client = $this->get_user_oauth_client(); + $url = new moodle_url($this->client->get_login_url()); + $state = $url->get_param('state') . '&reloadparent=true'; + $url->param('state', $state); + + echo $OUTPUT->header(); + + $repositoryname = get_string('pluginname', 'repository_nextcloud'); + + $button = new single_button($url, get_string('logintoaccount', 'repository', $repositoryname), + 'post', true); + $button->add_action(new popup_action('click', $url, 'Login')); + $button->class = 'mdl-align'; + $button = $OUTPUT->render($button); + echo html_writer::div($button, '', $attr); + + echo $OUTPUT->footer(); + } + /** + * Prepare response of get_listing; namely + * - defining setting elements, + * - filling in the parent path of the currently-viewed directory. + * @param string $path Relative path + * @return array ret array for use as get_listing's $ret + */ + private function get_listing_prepare_response($path) { + $ret = [ + // Fetch the list dynamically. An AJAX request is sent to the server as soon as the user opens a folder. + 'dynload' => true, + 'nosearch' => true, // Disable search. + 'nologin' => false, // Provide a login link because a user logs into his/her private Nextcloud storage. + 'path' => array([ // Contains all parent paths to the current path. + 'name' => $this->get_meta()->name, + 'path' => '', + ]), + 'defaultreturntype' => $this->default_returntype(), + 'manage' => $this->issuer->get('baseurl'), // Provide button to go into file management interface quickly. + 'list' => array(), // Contains all file/folder information and is required to build the file/folder tree. + ]; + + // If relative path is a non-top-level path, calculate all its parents' paths. + // This is used for navigation in the file picker. + if ($path != '/') { + $chunks = explode('/', trim($path, '/')); + $parent = '/'; + // Every sub-path to the last part of the current path is a parent path. + foreach ($chunks as $chunk) { + $subpath = $parent . $chunk . '/'; + $ret['path'][] = [ + 'name' => urldecode($chunk), + 'path' => $subpath + ]; + // Prepare next iteration. + $parent = $subpath; + } + } + return $ret; + } +} \ No newline at end of file diff --git a/repository/nextcloud/pix/icon.svg b/repository/nextcloud/pix/icon.svg new file mode 100644 index 00000000000..a7e98470c37 --- /dev/null +++ b/repository/nextcloud/pix/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/repository/nextcloud/tests/access_controlled_link_manager_test.php b/repository/nextcloud/tests/access_controlled_link_manager_test.php new file mode 100644 index 00000000000..178adefd762 --- /dev/null +++ b/repository/nextcloud/tests/access_controlled_link_manager_test.php @@ -0,0 +1,633 @@ +. + +/** + * This file contains tests for the repository_nextcloud class. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core\oauth2\system_account; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/webdavlib.php'); +require_once($CFG->dirroot . '/repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php'); + +/** + * Class repository_nextcloud_testcase + * @group repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class repository_nextcloud_access_controlled_link_manager_testcase extends advanced_testcase { + + /** @var null|testable_access_controlled_link_manager a malleable variant of the access_controlled_link_manager. */ + public $linkmanager = null; + + /** @var null|\repository_nextcloud\ocs_client The ocs_client used to send requests. */ + public $ocsmockclient = null; + + /** @var null|\core\oauth2\client Mock oauth client for the system account. */ + private $oauthsystemmock = null; + + /** @var null|\core\oauth2\issuer which belongs to the repository_nextcloud object. */ + public $issuer = null; + + /** + * SetUp to create an repository instance. + */ + protected function setUp() { + $this->resetAfterTest(true); + + // Admin is necessary to create issuer object. + $this->setAdminUser(); + + $generator = $this->getDataGenerator()->get_plugin_generator('repository_nextcloud'); + $this->issuer = $generator->test_create_issuer(); + $generator->test_create_endpoints($this->issuer->get('id')); + + // Mock clients. + $this->ocsmockclient = $this->getMockBuilder(repository_nextcloud\ocs_client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $this->oauthsystemmock = $this->getMockBuilder(\core\oauth2\client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $systemwebdavclient = $this->getMockBuilder(\webdav_client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $systemocsclient = $systemocsclient = $this->getMockBuilder(repository_nextcloud\ocs_client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + + // Pseudo system account user. + $this->systemaccountusername = 'pseudouser'; + $record = new stdClass(); + $record->issuerid = $this->issuer->get('id'); + $record->refreshtoken = 'pseudotoken'; + $record->grantedscopes = 'scopes'; + $record->email = ''; + $record->username = $this->systemaccountusername; + $systemaccount = new system_account(0, $record); + $systemaccount->create(); + + $this->linkmanager = new testable_access_controlled_link_manager($this->ocsmockclient, + $this->oauthsystemmock, $systemocsclient, + $this->issuer, 'Nextcloud', $systemwebdavclient); + + } + + /** + * Function to test the private function create_share_user_sysaccount. + */ + public function test_create_share_user_sysaccount_user_shares() { + $params = [ + 'path' => "/ambient.txt", + 'shareType' => \repository_nextcloud\ocs_client::SHARE_TYPE_USER, + 'publicUpload' => false, + 'shareWith' => $this->systemaccountusername, + 'permissions' => \repository_nextcloud\ocs_client::SHARE_PERMISSION_READ, + ]; + $expectedresponse = << + + + ok + 100 + + + + 207 + 0 + user1 + user1 + 19 + 1511532198 + + + + user1 + user1 + /ambient.txt + file + text/plain + home::user1 + 3 + 545 + 545 + 20 + /ambient.txt + tech + tech + 0 + + +XML; + $this->ocsmockclient->expects($this->once())->method('call')->with('create_share', $params)->will( + $this->returnValue($expectedresponse)); + + $result = $this->linkmanager->create_share_user_sysaccount("/ambient.txt"); + $xml = simplexml_load_string($expectedresponse); + $expected = array(); + $expected['statuscode'] = (int)$xml->meta->statuscode; + $expected['shareid'] = (int)$xml->data->id; + $expected['filetarget'] = (string)$xml->data[0]->file_target; + $this->assertEquals($expected, $result); + } + /** + * Test the delete_share_function. In case the request fails, the function throws an exception, however this + * can not be tested in phpUnit since it is javascript. + */ + public function test_delete_share_dataowner_sysaccount() { + $shareid = 5; + $deleteshareparams = [ + 'share_id' => $shareid + ]; + $returnxml = << + + + ok + 100 + + + + +XML; + $this->ocsmockclient->expects($this->once())->method('call')->with('delete_share', $deleteshareparams)->will( + $this->returnValue($returnxml)); + $this->linkmanager->delete_share_dataowner_sysaccount($shareid, 'repository_nextcloud'); + + } + + /** + * Function which test that create folder path does return the adequate results (path and success). + * Additionally mock checks whether the right params are passed to the corresponding functions. + */ + public function test_create_folder_path_folders_are_not_created() { + + $mocks = $this->set_up_mocks_for_create_folder_path(true, 'somename'); + $this->set_private_property($mocks['mockclient'], 'systemwebdavclient', $this->linkmanager); + $result = $this->linkmanager->create_folder_path_access_controlled_links($mocks['mockcontext'], "mod_resource", + 'content', 0); + $this->assertEquals('/somename (ctx )/mod_resource/content/0', $result); + } + /** + * Function which test that create folder path does return the adequate results (path and success). + * Additionally mock checks whether the right params are passed to the corresponding functions. + */ + public function test_create_folder_path_folders_are_created() { + + // In Context is okay, number of context counts for number of iterations. + $mocks = $this->set_up_mocks_for_create_folder_path(false, 'somename/withslash', true, 201); + $this->set_private_property($mocks['mockclient'], 'systemwebdavclient', $this->linkmanager); + $result = $this->linkmanager->create_folder_path_access_controlled_links($mocks['mockcontext'], "mod_resource", + 'content', 0); + $this->assertEquals('/somenamewithslash (ctx )/mod_resource/content/0', $result); + } + /** + * Test whether the create_folder_path methode throws exception. + */ + public function test_create_folder_path_folder_creation_fails() { + + $mocks = $this->set_up_mocks_for_create_folder_path(false, 'somename', true, 400); + $this->set_private_property($mocks['mockclient'], 'systemwebdavclient', $this->linkmanager); + $this->expectException(\repository_nextcloud\request_exception::class); + $this->linkmanager->create_folder_path_access_controlled_links($mocks['mockcontext'], "mod_resource", + 'content', 0); + } + + /** + * Helper function to generate mocks for testing create folder path. + * @param bool $returnisdir Return value mocking the result of invoking is_dir + * @param bool $returnestedcontext Name of the folder that is simulated to be checked/created + * @param bool $callmkcol Also mock creation of the folder + * @param int $returnmkcol Return value mocking the result of invoking mkcol + * @return array ['mockcontext' context_module mock, 'mockclient' => webdav client mock] + */ + protected function set_up_mocks_for_create_folder_path($returnisdir, $returnestedcontext, $callmkcol = false, + $returnmkcol = 201) { + $mockcontext = $this->createMock(context_module::class); + $mockclient = $this->getMockBuilder(\webdav_client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $parsedwebdavurl = parse_url($this->issuer->get_endpoint_url('webdav')); + $webdavprefix = $parsedwebdavurl['path']; + // Empty ctx 'id' expected because using code will not be able to access $ctx->id. + $cleanedcontextname = clean_param($returnestedcontext, PARAM_FILE); + $dirstring = $webdavprefix . '/' . $cleanedcontextname . ' (ctx )'; + $mockclient->expects($this->atMost(4))->method('is_dir')->with($this->logicalOr( + $dirstring, $dirstring . '/mod_resource', $dirstring . '/mod_resource/content', + $dirstring . '/mod_resource/content/0'))->willReturn($returnisdir); + if ($callmkcol == true) { + $mockclient->expects($this->atMost(4))->method('mkcol')->willReturn($returnmkcol); + } + $mockcontext->method('get_parent_contexts')->willReturn(array('1' => $mockcontext)); + $mockcontext->method('get_context_name')->willReturn($returnestedcontext); + + return array('mockcontext' => $mockcontext, 'mockclient' => $mockclient); + } + + /** + * Test whether the right methods from the webdavclient are called when the storage_folder is created. + * 1. Directory already exist -> no further action needed. + */ + public function test_create_storage_folder_success() { + $mockwebdavclient = $this->createMock(\webdav_client::class); + $url = $this->issuer->get_endpoint_url('webdav'); + $parsedwebdavurl = parse_url($url); + $webdavprefix = $parsedwebdavurl['path']; + $mockwebdavclient->expects($this->once())->method('open')->willReturn(true); + $mockwebdavclient->expects($this->once())->method('is_dir')->with($webdavprefix . 'myname')->willReturn(true); + $mockwebdavclient->expects($this->once())->method('close'); + $this->linkmanager->create_storage_folder('myname', $mockwebdavclient); + + } + /** + * Test whether the right methods from the webdavclient are called when the storage_folder is created. + * 2. Directory does not exist. It is created with mkcol and returns a success. + * + */ + public function test_create_storage_folder_success_mkcol() { + $mockwebdavclient = $this->createMock(\webdav_client::class); + $url = $this->issuer->get_endpoint_url('webdav'); + $parsedwebdavurl = parse_url($url); + $webdavprefix = $parsedwebdavurl['path']; + $mockwebdavclient->expects($this->once())->method('open')->willReturn(true); + $mockwebdavclient->expects($this->once())->method('is_dir')->with($webdavprefix . 'myname')->willReturn(false); + $mockwebdavclient->expects($this->once())->method('mkcol')->with($webdavprefix . 'myname')->willReturn(201); + $mockwebdavclient->expects($this->once())->method('close'); + + $this->linkmanager->create_storage_folder('myname', $mockwebdavclient); + } + /** + * Test whether the right methods from the webdavclient are called when the storage_folder is created. + * 3. Request to create Folder fails. + */ + public function test_create_storage_folder_failure() { + $mockwebdavclient = $this->createMock(\webdav_client::class); + $url = $this->issuer->get_endpoint_url('webdav'); + $parsedwebdavurl = parse_url($url); + $webdavprefix = $parsedwebdavurl['path']; + $mockwebdavclient->expects($this->once())->method('open')->willReturn(true); + $mockwebdavclient->expects($this->once())->method('is_dir')->with($webdavprefix . 'myname')->willReturn(false); + $mockwebdavclient->expects($this->once())->method('mkcol')->with($webdavprefix . 'myname')->willReturn(400); + + $this->expectException(\repository_nextcloud\request_exception::class); + $this->linkmanager->create_storage_folder('myname', $mockwebdavclient); + } + /** + * Test whether the webdav client gets the right params and whether function differentiates between move and copy. + */ + public function test_transfer_file_to_path_copyfile() { + // Initialize params. + $parsedwebdavurl = parse_url($this->issuer->get_endpoint_url('webdav')); + $webdavprefix = $parsedwebdavurl['path']; + $srcpath = 'sourcepath'; + $dstpath = "destinationpath/another/path"; + + // Mock the Webdavclient and set expected methods. + $systemwebdavclientmock = $this->createMock(\webdav_client::class); + $systemwebdavclientmock->expects($this->once())->method('open')->willReturn(true); + $systemwebdavclientmock->expects($this->once())->method('copy_file')->with($webdavprefix . $srcpath, + $webdavprefix . $dstpath . '/' . $srcpath, true)->willReturn(201); + $this->set_private_property($systemwebdavclientmock, 'systemwebdavclient', $this->linkmanager); + + // Call of function. + $result = $this->linkmanager->transfer_file_to_path($srcpath, $dstpath, 'copy'); + + $this->assertEquals(201, $result); + } + /** + * This function tests whether the function transfer_file_to_path() moves or copies a given file to a given path + * It tests whether the webdav_client gets the right parameter and whether function distinguishes between move and copy. + * + */ + public function test_transfer_file_to_path_copyfile_movefile() { + // Initialize params. + $parsedwebdavurl = parse_url($this->issuer->get_endpoint_url('webdav')); + $webdavprefix = $parsedwebdavurl['path']; + $srcpath = 'sourcepath'; + $dstpath = "destinationpath/another/path"; + + $systemwebdavclientmock = $this->createMock(\webdav_client::class); + + $systemwebdavclientmock->expects($this->once())->method('open')->willReturn(true); + $this->set_private_property($systemwebdavclientmock, 'systemwebdavclient', $this->linkmanager); + $webdavclientmock = $this->createMock(\webdav_client::class); + + $webdavclientmock->expects($this->once())->method('move')->with($webdavprefix . $srcpath, + $webdavprefix . $dstpath . '/' . $srcpath, false)->willReturn(201); + $result = $this->linkmanager->transfer_file_to_path($srcpath, $dstpath, 'move', $webdavclientmock); + $this->assertEquals(201, $result); + } + + /** + * Test the get_shares_from path() function. This function extracts from an list of shares the share of a given user + * (the username is a parameter in the function call) and returns the id. The test firstly test whether the right fileid + * for user1 is extracted then for user2 and last but least whether an error is thrown if the user does not have a share. + * @throws moodle_exception + */ + public function test_get_shares_from_path() { + $params = [ + 'path' => '/Kernsystem/Kursbereich Miscellaneous/Kurs Example Course/Datei zet/mod_resource/content/0/picture.png', + 'reshares' => true + ]; + $reference = new stdClass(); + $reference->link = "/Kernsystem/Kursbereich Miscellaneous/Kurs Example Course/Datei zet/mod_resource/content/0/picture.png"; + $reference->name = "f\u00fcrdennis.png"; + $reference->usesystem = true; + $expectedresponse = << + + + ok + 100 + + + + + 292 + 0 + tech + tech + 19 + 1515752494 + + + + tech + tech + some/path/of/some/file.pdf + file + image/png + home::tech + 4 + 1085 + 1085 + 1084 + /fehler (3).png + user1 + user1 + 0 + + + 293 + 0 + tech + tech + 19 + 1515752494 + + + + tech + tech + some/path/of/some/file.pdf + file + image/png + home::tech + 4 + 1085 + 1085 + 1084 + /fehler (3).png + user2 + user2 + 0 + + + +XML; + $this->set_private_property($this->ocsmockclient, 'systemocsclient', $this->linkmanager); + + $this->ocsmockclient->expects($this->exactly(3))->method('call')->with('get_shares', $params)->will( + $this->returnValue($expectedresponse)); + $xmlobjuser1 = (int) $this->linkmanager->get_shares_from_path($reference->link, 'user2'); + $xmlobjuser2 = (int) $this->linkmanager->get_shares_from_path($reference->link, 'user1'); + + $this->assertEquals(293, $xmlobjuser1); + $this->assertEquals(292, $xmlobjuser2); + + $this->expectException(\repository_nextcloud\request_exception::class); + + $this->expectExceptionMessage('A request to Nextcloud has failed. The requested file could not be accessed. Please ' . + 'check whether you have chosen a valid file and you are authenticated with the right account.'); + $this->linkmanager->get_shares_from_path($reference->link, 'user3'); + + } + /** Test whether the systemwebdav client is constructed correctly. Port is set to 443 in case of https, to 80 in + * case of http and exception is thrown when endpoint does not exist. + * @throws \repository_nextcloud\configuration_exception + * @throws coding_exception + */ + public function test_create_system_dav() { + // Initialize mock and params. + $fakeaccesstoken = new stdClass(); + $fakeaccesstoken->token = "fake access token"; + // Use `atLeastOnce` instead of `exactly(2)` because it is only called a second time on dev systems that allow http://. + $this->oauthsystemmock->expects($this->atLeastOnce())->method('get_accesstoken')->willReturn($fakeaccesstoken); + $parsedwebdavurl = parse_url($this->issuer->get_endpoint_url('webdav')); + + // Call function and create own client. + $dav = $this->linkmanager->create_system_dav(); + $mydav = new \webdav_client($parsedwebdavurl['host'], '', '', 'bearer', 'ssl://', + "fake access token", $parsedwebdavurl['path']); + $mydav->port = 443; + $mydav->debug = false; + $this->assertEquals($mydav, $dav); + + // Deletes the old webdav endpoint and ... + $this->delete_endpoints('webdav_endpoint'); + // Creates a new one which requires different ports. + try { + $endpoint = new stdClass(); + $endpoint->name = "webdav_endpoint"; + $endpoint->url = 'http://www.default.test/webdav/index.php'; + $endpoint->issuerid = $this->issuer->get('id'); + \core\oauth2\api::create_endpoint($endpoint); + + // Call function and create own client. + $dav = $this->linkmanager->create_system_dav(); + $mydav = new \webdav_client($parsedwebdavurl['host'], '', '', 'bearer', '', + "fake access token"); + $mydav->port = 80; + $mydav->debug = false; + $this->assertEquals($mydav, $dav); + } catch (core\invalid_persistent_exception $e) { + // In some cases Moodle does not allow to create http connections. In those cases the exception + // is catched here and the test are executed. + $this->expectException(\core\invalid_persistent_exception::class); + $this->linkmanager->create_system_dav(); + } finally { + + // Delte endpoints and ... + $this->delete_endpoints('webdav_endpoint'); + + // Do not insert new ones, therefore exception is thrown. + $this->expectException(\repository_nextcloud\configuration_exception::class); + $this->linkmanager->create_system_dav(); + } + } + + /** + * Tests the function get_share_information_from_shareid(). From a response with two element it is tested + * whether the right file_target is extracted and lastly it is checked whether an error is thrown in case no suitable + * element exists. + * @throws \repository_nextcloud\request_exception + * @throws coding_exception + */ + public function test_get_share_information_from_shareid() { + $params303 = [ + 'share_id' => 303, + ]; + $params302 = [ + 'share_id' => 302, + ]; + $expectedresponse = << + + + ok + 100 + + + + + 302 + 0 + tech + tech + 19 + 1516096325 + + + + tech + tech + /some/target (2).png + file + image/png + shared::/some/target.png + 4 + 1125 + 1125 + 20 + /some/target.png + user1 + user1 + 0 + + + 303 + 0 + tech + tech + 19 + 1516096325 + + + + tech + tech + /some/target (2).pdf + file + image/png + shared::/some/target.pdf + 4 + 1125 + 1125 + 20 + /some/target.pdf + user2 + user1 + 0 + + + +XML; + $this->set_private_property($this->ocsmockclient, 'systemocsclient', $this->linkmanager); + + $this->ocsmockclient->expects($this->exactly(3))->method('call')->with('get_information_of_share', + $this->logicalOr($params303, $params302))->will($this->returnValue($expectedresponse)); + + // Test function for two different users. Setting the id is just a dummy value since always $expectedresponse ... + // ... is returned. + $filetarget = $this->linkmanager->get_share_information_from_shareid(303, 'user2'); + $this->assertEquals('/some/target.pdf', $filetarget); + + $filetarget = $this->linkmanager->get_share_information_from_shareid(302, 'user1'); + $this->assertEquals('/some/target.png', $filetarget); + + // Expect exception in case no suitable elemtn exist in the response. + $this->expectException(\repository_nextcloud\request_exception::class); + $this->expectExceptionMessage('A request to Nextcloud has failed. The requested file could not be accessed. Please ' . + 'check whether you have chosen a valid file and you are authenticated with the right account.'); + $this->linkmanager->get_share_information_from_shareid(302, 'user3'); + } + + /** + * Helper method which inserts a value into a non-public field of an object. + * + * @param mixed $value mock value that will be inserted. + * @param string $propertyname name of the private property. + * @param object $class Instance that is being modified. + * @return ReflectionProperty the resulting reflection property. + */ + protected function set_private_property($value, $propertyname, $class) { + $refclient = new ReflectionClass($class); + $private = $refclient->getProperty($propertyname); + $private->setAccessible(true); + $private->setValue($class, $value); + return $private; + } + /** + * Helper method which gets a value from a non-public field of an object. + * + * @param string $propertyname name of the private property. + * @param object $class Instance that is being modified. + * @return mixed the resulting value. + */ + protected function get_private_property($propertyname, $class) { + $refclient = new ReflectionClass($class); + $private = $refclient->getProperty($propertyname); + $private->setAccessible(true); + $property = $private->getValue($private); + return $property; + } + /** + * Deletes all endpoint with the given name. + * @param string $endpointname + * @return array|null + * @throws moodle_exception + */ + protected function delete_endpoints($endpointname) { + $endpoints = \core\oauth2\api::get_endpoints($this->issuer); + $arrayofids = array(); + foreach ($endpoints as $endpoint) { + $name = $endpoint->get('name'); + if ($name === $endpointname) { + $arrayofids[$endpoint->get('id')] = $endpoint->get('id'); + } + } + if (empty($arrayofids)) { + return; + } + foreach ($arrayofids as $id) { + core\oauth2\api::delete_endpoint($id); + } + } + +} \ No newline at end of file diff --git a/repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php b/repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php new file mode 100644 index 00000000000..561bfbbe2e9 --- /dev/null +++ b/repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php @@ -0,0 +1,57 @@ +. + +/** + * Test support class for testing access_controlled_link_manager. + * + * @package repository_nextcloud + * @copyright 2018 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +use core\oauth2\client; +use repository_nextcloud\access_controlled_link_manager; +use repository_nextcloud\ocs_client; + +/** + * Test support class for testing access_controlled_link_manager. + * + * @package repository_nextcloud + * @copyright 2018 Nina Herrmann (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_access_controlled_link_manager extends access_controlled_link_manager { + + /** + * Access_controlled_link_manager constructor. + * @param ocs_client $ocsclient + * @param client $systemoauthclient + * @param ocs_client $systemocsclient + * @param \core\oauth2\issuer $issuer + * @param string $repositoryname + * @param \webdav_client $systemdav + */ + public function __construct($ocsclient, $systemoauthclient, $systemocsclient, \core\oauth2\issuer $issuer, $repositoryname, + $systemdav) { + $this->ocsclient = $ocsclient; + $this->systemoauthclient = $systemoauthclient; + $this->systemocsclient = $systemocsclient; + $this->repositoryname = $repositoryname; + $this->issuer = $issuer; + $this->systemwebdavclient = $systemdav; + } +} diff --git a/repository/nextcloud/tests/generator/lib.php b/repository/nextcloud/tests/generator/lib.php new file mode 100755 index 00000000000..d905ce85812 --- /dev/null +++ b/repository/nextcloud/tests/generator/lib.php @@ -0,0 +1,84 @@ +. + +/** + * Data generator for repository plugin. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Data generator for repository plugin. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class repository_nextcloud_generator extends testing_repository_generator { + + /** + * Creates an issuer and a user. + * @return \core\oauth2\issuer + */ + public function test_create_issuer () { + $issuerdata = new stdClass(); + $issuerdata->name = "Service"; + $issuerdata->clientid = "Clientid"; + $issuerdata->clientsecret = "Secret"; + $issuerdata->loginscopes = "openid profile email"; + $issuerdata->loginscopesoffline = "openid profile email"; + $issuerdata->baseurl = ""; + $issuerdata->image = "aswdf"; + + // Create the issuer. + $issuer = \core\oauth2\api::create_issuer($issuerdata); + return $issuer; + } + + /** + * Creates the required endpoints. + * @param int $issuerid + * @return \core\oauth2\issuer + */ + public function test_create_endpoints ($issuerid) { + $this->test_create_single_endpoint($issuerid, "ocs_endpoint"); + $this->test_create_single_endpoint($issuerid, "authorization_endpoint"); + $this->test_create_single_endpoint($issuerid, "webdav_endpoint", "https://www.default.test/webdav/index.php"); + $this->test_create_single_endpoint($issuerid, "token_endpoint"); + $this->test_create_single_endpoint($issuerid, "userinfo_endpoint"); + } + + /** + * Create a single endpoint. + * + * @param int $issuerid + * @param string $endpointtype + * @param string $url + * @return \core\oauth2\endpoint An instantiated endpoint + */ + public function test_create_single_endpoint($issuerid, $endpointtype, $url="https://www.default.test") { + $endpoint = new stdClass(); + $endpoint->name = $endpointtype; + $endpoint->url = $url; + $endpoint->issuerid = $issuerid; + $return = \core\oauth2\api::create_endpoint($endpoint); + return $return; + } +} diff --git a/repository/nextcloud/tests/lib_test.php b/repository/nextcloud/tests/lib_test.php new file mode 100644 index 00000000000..48212e6fe97 --- /dev/null +++ b/repository/nextcloud/tests/lib_test.php @@ -0,0 +1,948 @@ +. + +/** + * This file contains tests for the repository_nextcloud class. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/repository/lib.php'); +require_once($CFG->libdir . '/webdavlib.php'); + +/** + * Class repository_nextcloud_lib_testcase + * @group repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class repository_nextcloud_lib_testcase extends advanced_testcase { + + /** @var null|\repository_nextcloud the repository_nextcloud object, which the tests are run on. */ + private $repo = null; + + /** @var null|\core\oauth2\issuer which belongs to the repository_nextcloud object.*/ + private $issuer = null; + + /** + * SetUp to create an repository instance. + */ + protected function setUp() { + $this->resetAfterTest(true); + + // Admin is neccessary to create api and issuer objects. + $this->setAdminUser(); + + /** @var repository_nextcloud_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('repository_nextcloud'); + $this->issuer = $generator->test_create_issuer(); + + // Create Endpoints for issuer. + $generator->test_create_endpoints($this->issuer->get('id')); + + // Params for the config form. + $reptype = $generator->create_type([ + 'visible' => 1, + 'enableuserinstances' => 0, + 'enablecourseinstances' => 0, + ]); + + $instance = $generator->create_instance([ + 'issuerid' => $this->issuer->get('id'), + 'pluginname' => 'Nextcloud', + 'controlledlinkfoldername' => 'Moodlefiles', + 'supportedreturntypes' => 'both', + 'defaultreturntype' => FILE_INTERNAL, + ]); + + // At last, create a repository_nextcloud object from the instance id. + $this->repo = new repository_nextcloud($instance->id); + $this->repo->options['typeid'] = $reptype->id; + $this->repo->options['sortorder'] = 1; + $this->resetAfterTest(true); + } + + /** + * Checks the is_visible method in case the repository is set to hidden in the database. + */ + public function test_is_visible_parent_false() { + global $DB; + $id = $this->repo->options['typeid']; + + // Check, if the method returns false, when the repository is set to visible in the database + // and the client configuration data is complete. + $DB->update_record('repository', (object) array('id' => $id, 'visible' => 0)); + + $this->assertFalse($this->repo->is_visible()); + } + + /** + * Test whether the repo is disabled. + */ + public function test_repo_creation() { + $issuerid = $this->repo->get_option('issuerid'); + + // Config saves the right id. + $this->assertEquals($this->issuer->get('id'), $issuerid); + + // Function that is used in construct method returns the right id. + $constructissuer = \core\oauth2\api::get_issuer($issuerid); + $this->assertEquals($this->issuer->get('id'), $constructissuer->get('id')); + + $this->assertEquals(true, $constructissuer->get('enabled')); + $this->assertFalse($this->repo->disabled); + } + + /** + * Returns an array of endpoints or null. + * @param string $endpointname + * @return array|null + */ + private function get_endpoint_id($endpointname) { + $endpoints = \core\oauth2\api::get_endpoints($this->issuer); + $id = array(); + foreach ($endpoints as $endpoint) { + $name = $endpoint->get('name'); + if ($name === $endpointname) { + $id[$endpoint->get('id')] = $endpoint->get('id'); + } + } + if (empty($id)) { + return null; + } + return $id; + } + /** + * Test if repository is disabled when webdav_endpoint is deleted. + */ + public function test_issuer_webdav() { + $idwebdav = $this->get_endpoint_id('webdav_endpoint'); + if (!empty($idwebdav)) { + foreach ($idwebdav as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + $this->assertFalse(\repository_nextcloud\issuer_management::is_valid_issuer($this->issuer)); + } + /** + * Test if repository is disabled when ocs_endpoint is deleted. + */ + public function test_issuer_ocs() { + $idocs = $this->get_endpoint_id('ocs_endpoint'); + if (!empty($idocs)) { + foreach ($idocs as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + $this->assertFalse(\repository_nextcloud\issuer_management::is_valid_issuer($this->issuer)); + } + + /** + * Test if repository is disabled when userinfo_endpoint is deleted. + */ + public function test_issuer_userinfo() { + $idtoken = $this->get_endpoint_id('userinfo_endpoint'); + if (!empty($idtoken)) { + foreach ($idtoken as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + $this->assertFalse(\repository_nextcloud\issuer_management::is_valid_issuer($this->issuer)); + } + + /** + * Test if repository is disabled when token_endpoint is deleted. + */ + public function test_issuer_token() { + $idtoken = $this->get_endpoint_id('token_endpoint'); + if (!empty($idtoken)) { + foreach ($idtoken as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + $this->assertFalse(\repository_nextcloud\issuer_management::is_valid_issuer($this->issuer)); + } + + /** + * Test if repository is disabled when auth_endpoint is deleted. + */ + public function test_issuer_authorization() { + $idauth = $this->get_endpoint_id('authorization_endpoint'); + if (!empty($idauth)) { + foreach ($idauth as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + $this->assertFalse(\repository_nextcloud\issuer_management::is_valid_issuer($this->issuer)); + } + /** + * Test if repository throws an error when endpoint does not exist. + */ + public function test_parse_endpoint_url_error() { + $this->expectException(\repository_nextcloud\configuration_exception::class); + \repository_nextcloud\issuer_management::parse_endpoint_url('notexisting', $this->issuer); + } + /** + * Test get_listing method with an example directory. Tests error cases. + */ + public function test_get_listing_error() { + $ret = $this->get_initialised_return_array(); + $this->setUser(); + // WebDAV socket is not opened. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(false)); + $private = $this->set_private_property($mock, 'dav'); + + $this->assertEquals($ret, $this->repo->get_listing('/')); + + // Response is not an array. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(true)); + $mock->expects($this->once())->method('ls')->will($this->returnValue('notanarray')); + $private->setValue($this->repo, $mock); + + $this->assertEquals($ret, $this->repo->get_listing('/')); + } + /** + * Test get_listing method with an example directory. Tests the root directory. + */ + public function test_get_listing_root() { + $this->setUser(); + $ret = $this->get_initialised_return_array(); + + // This is the expected response from the ls method. + $response = array( + array( + 'href' => 'remote.php/webdav/', + 'lastmodified' => 'Thu, 08 Dec 2016 16:06:26 GMT', + 'resourcetype' => 'collection', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '' + ), + array( + 'href' => 'remote.php/webdav/Documents/', + 'lastmodified' => 'Thu, 08 Dec 2016 16:06:26 GMT', + 'resourcetype' => 'collection', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '' + ), + array( + 'href' => 'remote.php/webdav/welcome.txt', + 'lastmodified' => 'Thu, 08 Dec 2016 16:06:26 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '163' + ) + ); + + // The expected result from the get_listing method in the repository_nextcloud class. + $ret['list'] = array( + 'DOCUMENTS/' => array( + 'title' => 'Documents', + 'thumbnail' => null, + 'children' => array(), + 'datemodified' => 1481213186, + 'path' => '/Documents/' + ), + 'WELCOME.TXT' => array( + 'title' => 'welcome.txt', + 'thumbnail' => null, + 'size' => '163', + 'datemodified' => 1481213186, + 'source' => '/welcome.txt' + ) + ); + + // Valid response from the client. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(true)); + $mock->expects($this->once())->method('ls')->will($this->returnValue($response)); + $this->set_private_property($mock, 'dav'); + + $ls = $this->repo->get_listing('/'); + + // Those attributes can not be tested properly. + $ls['list']['DOCUMENTS/']['thumbnail'] = null; + $ls['list']['WELCOME.TXT']['thumbnail'] = null; + + $this->assertEquals($ret, $ls); + } + /** + * Test get_listing method with an example directory. Tests a different directory than the root + * directory. + */ + public function test_get_listing_directory() { + $ret = $this->get_initialised_return_array(); + $this->setUser(); + + // An additional directory path has to be added to the 'path' field within the returned array. + $ret['path'][1] = array( + 'name' => 'dir', + 'path' => '/dir/' + ); + + // This is the expected response from the get_listing method in the Nextcloud client. + $response = array( + array( + 'href' => 'remote.php/webdav/dir/', + 'lastmodified' => 'Thu, 08 Dec 2016 16:06:26 GMT', + 'resourcetype' => 'collection', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '' + ), + array( + 'href' => 'remote.php/webdav/dir/Documents/', + 'lastmodified' => null, + 'resourcetype' => 'collection', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '' + ), + array( + 'href' => 'remote.php/webdav/dir/welcome.txt', + 'lastmodified' => 'Thu, 08 Dec 2016 16:06:26 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '163' + ) + ); + + // The expected result from the get_listing method in the repository_nextcloud class. + $ret['list'] = array( + 'DOCUMENTS/' => array( + 'title' => 'Documents', + 'thumbnail' => null, + 'children' => array(), + 'datemodified' => null, + 'path' => '/dir/Documents/' + ), + 'WELCOME.TXT' => array( + 'title' => 'welcome.txt', + 'thumbnail' => null, + 'size' => '163', + 'datemodified' => 1481213186, + 'source' => '/dir/welcome.txt' + ) + ); + + // Valid response from the client. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(true)); + $mock->expects($this->once())->method('ls')->will($this->returnValue($response)); + $this->set_private_property($mock, 'dav'); + + $ls = $this->repo->get_listing('/dir/'); + + // Can not be tested properly. + $ls['list']['DOCUMENTS/']['thumbnail'] = null; + $ls['list']['WELCOME.TXT']['thumbnail'] = null; + + $this->assertEquals($ret, $ls); + } + /** + * Test the get_link method. + */ + public function test_get_link_success() { + $mock = $this->getMockBuilder(\repository_nextcloud\ocs_client::class)->disableOriginalConstructor()->disableOriginalClone( + )->getMock(); + $file = '/datei'; + $expectedresponse = << + + + ok + 100 + + + + 2 + 3 + admin + admin + 1 + 1502883721 + + + QXbqrJj8DcMaXen + admin + admin + /somefile + file + application/pdf + home::admin + 1 + 6 + 6 + 4 + /somefile + + + + https://www.default.test/somefile + 0 + + +XML; + // Expected Parameters. + $ocsquery = [ + 'path' => $file, + 'shareType' => \repository_nextcloud\ocs_client::SHARE_TYPE_PUBLIC, + 'publicUpload' => false, + 'permissions' => \repository_nextcloud\ocs_client::SHARE_PERMISSION_READ + ]; + + // With test whether mock is called with right parameters. + $mock->expects($this->once())->method('call')->with('create_share', $ocsquery)->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + + // Method does extract the link from the xml format. + $this->assertEquals('https://www.default.test/somefile/download', $this->repo->get_link($file)); + } + + /** + * get_link can get OCS failure responses. Test that this is handled appropriately. + */ + public function test_get_link_failure() { + $mock = $this->getMockBuilder(\repository_nextcloud\ocs_client::class)->disableOriginalConstructor()->disableOriginalClone( + )->getMock(); + $file = '/datei'; + $expectedresponse = << + + + failure + 404 + Msg + + + +XML; + // Expected Parameters. + $ocsquery = [ + 'path' => $file, + 'shareType' => \repository_nextcloud\ocs_client::SHARE_TYPE_PUBLIC, + 'publicUpload' => false, + 'permissions' => \repository_nextcloud\ocs_client::SHARE_PERMISSION_READ + ]; + + // With test whether mock is called with right parameters. + $mock->expects($this->once())->method('call')->with('create_share', $ocsquery)->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + + // Suppress (expected) XML parse error... Nextcloud sometimes returns JSON on extremely bad errors. + libxml_use_internal_errors(true); + + // Method get_link correctly raises an exception that contains error code and message. + $this->expectException(\repository_nextcloud\request_exception::class); + $this->expectExceptionMessage(get_string('request_exception', 'repository_nextcloud', array('instance' => 'Nextcloud', + 'errormessage' => sprintf('(%s) %s', '404', 'Msg')))); + $this->repo->get_link($file); + } + + /** + * get_link can get OCS responses that are not actually XML. Test that this is handled appropriately. + */ + public function test_get_link_problem() { + $mock = $this->getMockBuilder(\repository_nextcloud\ocs_client::class)->disableOriginalConstructor()->disableOriginalClone( + )->getMock(); + $file = '/datei'; + $expectedresponse = << $file, + 'shareType' => \repository_nextcloud\ocs_client::SHARE_TYPE_PUBLIC, + 'publicUpload' => false, + 'permissions' => \repository_nextcloud\ocs_client::SHARE_PERMISSION_READ + ]; + + // With test whether mock is called with right parameters. + $mock->expects($this->once())->method('call')->with('create_share', $ocsquery)->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + + // Suppress (expected) XML parse error... Nextcloud sometimes returns JSON on extremely bad errors. + libxml_use_internal_errors(true); + + // Method get_link correctly raises an exception. + $this->expectException(\repository_nextcloud\request_exception::class); + $this->repo->get_link($file); + } + + /** + * Test get_file reference, merely returns the input if no optional_param is set. + */ + public function test_get_file_reference_withoutoptionalparam() { + $this->assertEquals('/somefile', $this->repo->get_file_reference('/somefile')); + } + + /** + * Test get_file reference in case the optional param is set. Therefore has to simulate the get_link method. + */ + public function test_get_file_reference_withoptionalparam() { + $_GET['usefilereference'] = true; + $filename = '/somefile'; + // Calls for get link(). Therefore, mocks for get_link are build. + $mock = $this->getMockBuilder(\repository_nextcloud\ocs_client::class)->disableOriginalConstructor()->disableOriginalClone( + )->getMock(); + $expectedresponse = << + + + ok + 100 + + + + 2 + 3 + admin + admin + 1 + 1502883721 + + + QXbqrJj8DcMaXen + admin + admin + /somefile + file + application/pdf + home::admin + 1 + 6 + 6 + 4 + /somefile + + + + https://www.default.test/somefile + 0 + + +XML; + // Expected Parameters. + $ocsquery = ['path' => $filename, + 'shareType' => \repository_nextcloud\ocs_client::SHARE_TYPE_PUBLIC, + 'publicUpload' => false, + 'permissions' => \repository_nextcloud\ocs_client::SHARE_PERMISSION_READ, + ]; + + // With test whether mock is called with right parameters. + $mock->expects($this->once())->method('call')->with('create_share', $ocsquery)->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + + $expected = new \stdClass(); + $expected->type = 'FILE_REFERENCE'; + $expected->link = 'https://www.default.test' . $filename . '/download'; + // Method redirects to get_link() and return the suitable value. + $this->assertEquals(json_encode($expected), $this->repo->get_file_reference($filename)); + } + + /** + * Test logout. + */ + public function test_logout() { + $mock = $this->createMock(\core\oauth2\client::class); + + $mock->expects($this->exactly(2))->method('log_out'); + $this->set_private_property($mock, 'client'); + $this->repo->options['ajax'] = false; + $this->expectOutputString('Log in to your account' . + 'Log in to your account'); + + $this->assertEquals($this->repo->print_login(), $this->repo->logout()); + + $mock->expects($this->exactly(2))->method('get_login_url')->will($this->returnValue(new moodle_url('url'))); + + $this->repo->options['ajax'] = true; + $this->assertEquals($this->repo->print_login(), $this->repo->logout()); + + } + /** + * Test for the get_file method from the repository_nextcloud class. + */ + public function test_get_file() { + // WebDAV socket is not open. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(false)); + $private = $this->set_private_property($mock, 'dav'); + + $this->assertFalse($this->repo->get_file('path')); + + // WebDAV socket is open and the request successful. + $mock = $this->createMock(\webdav_client::class); + $mock->expects($this->once())->method('open')->will($this->returnValue(true)); + $mock->expects($this->once())->method('get_file')->will($this->returnValue(true)); + $private->setValue($this->repo, $mock); + + $result = $this->repo->get_file('path', 'file'); + + $this->assertNotNull($result['path']); + } + + /** + * Test callback. + */ + public function test_callback() { + $mock = $this->createMock(\core\oauth2\client::class); + // Should call check_login exactly once. + $mock->expects($this->once())->method('log_out'); + $mock->expects($this->once())->method('is_logged_in'); + + $this->set_private_property($mock, 'client'); + + $this->repo->callback(); + } + /** + * Test check_login. + */ + public function test_check_login() { + $mock = $this->createMock(\core\oauth2\client::class); + $mock->expects($this->once())->method('is_logged_in')->will($this->returnValue(true)); + $this->set_private_property($mock, 'client'); + + $this->assertTrue($this->repo->check_login()); + } + /** + * Test print_login. + */ + public function test_print_login() { + $mock = $this->createMock(\core\oauth2\client::class); + $mock->expects($this->exactly(2))->method('get_login_url')->will($this->returnValue(new moodle_url('url'))); + $this->set_private_property($mock, 'client'); + + // Test with ajax activated. + $this->repo->options['ajax'] = true; + + $url = new moodle_url('url'); + $ret = array(); + $btn = new \stdClass(); + $btn->type = 'popup'; + $btn->url = $url->out(false); + $ret['login'] = array($btn); + + $this->assertEquals($ret, $this->repo->print_login()); + + // Test without ajax. + $this->repo->options['ajax'] = false; + + $output = html_writer::link($url, get_string('login', 'repository'), + array('target' => '_blank', 'rel' => 'noopener noreferrer')); + $this->expectOutputString($output); + $this->repo->print_login(); + } + + /** + * Test the initiate_webdavclient function. + */ + public function test_initiate_webdavclient() { + global $CFG; + + $idwebdav = $this->get_endpoint_id('webdav_endpoint'); + if (!empty($idwebdav)) { + foreach ($idwebdav as $id) { + \core\oauth2\api::delete_endpoint($id); + } + } + + $generator = $this->getDataGenerator()->get_plugin_generator('repository_nextcloud'); + $generator->test_create_single_endpoint($this->issuer->get('id'), "webdav_endpoint", + "https://www.default.test:8080/webdav/index.php"); + + $fakeaccesstoken = new stdClass(); + $fakeaccesstoken->token = "fake access token"; + $oauthmock = $this->createMock(\core\oauth2\client::class); + $oauthmock->expects($this->once())->method('get_accesstoken')->will($this->returnValue($fakeaccesstoken)); + $this->set_private_property($oauthmock, 'client'); + + $dav = phpunit_util::call_internal_method($this->repo, "initiate_webdavclient", [], 'repository_nextcloud'); + + // Verify that port is set correctly (private property). + $refclient = new ReflectionClass($dav); + + $property = $refclient->getProperty('_port'); + $property->setAccessible(true); + + $port = $property->getValue($dav); + + $this->assertEquals('8080', $port); + } + + /** + * Test supported_returntypes. + * FILE_INTERNAL when no system account is connected. + * FILE_INTERNAL | FILE_CONTROLLED_LINK | FILE_EXTERNAL | FILE_REFERENCE when a system account is connected. + */ + public function test_supported_returntypes() { + global $DB; + $this->assertEquals(FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE, $this->repo->supported_returntypes()); + $dataobject = new stdClass(); + $dataobject->timecreated = time(); + $dataobject->timemodified = time(); + $dataobject->usermodified = 2; + $dataobject->issuerid = $this->issuer->get('id'); + $dataobject->refreshtoken = 'sometokenthatwillnotbeused'; + $dataobject->grantedscopes = 'openid profile email'; + $dataobject->email = 'some.email@some.de'; + $dataobject->username = 'someusername'; + + $DB->insert_record('oauth2_system_account', $dataobject); + // When a system account is registered the file_type FILE_CONTROLLED_LINK is supported. + $this->assertEquals(FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK | FILE_REFERENCE, + $this->repo->supported_returntypes()); + } + + /** + * The reference_file_selected() methode is called every time a FILE_CONTROLLED_LINK is chosen for upload. + * Since the function is very long the private function are tested separately, and merely the abortion of the + * function are tested. + * + */ + public function test_reference_file_selected_error() { + $this->repo->disabled = true; + $this->expectException(\repository_exception::class); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->disabled = false; + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('Cannot connect as system user'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $mock = $this->createMock(\core\oauth2\client::class); + $mock->expects($this->once())->method('get_system_oauth_client')->with($this->issuer)->willReturn(true); + + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('Cannot connect as current user'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->expects($this->once())->method('get_user_oauth_client')->willReturn(true); + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('cannotdownload'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->expects($this->once())->method('get_user_oauth_client')->willReturn(true); + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('cannotdownload'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->expects($this->once())->method('get_user_oauth_client')->willReturn(true); + $this->repo->expects($this->once())->method('copy_file_to_path')->willReturn(array('statuscode' => + array('success' => 400))); + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('Could not copy file'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->expects($this->once())->method('get_user_oauth_client')->willReturn(true); + $this->repo->expects($this->once())->method('copy_file_to_path')->willReturn(array('statuscode' => + array('success' => 201))); + $this->repo->expects($this->once())->method('delete_share_dataowner_sysaccount')->willReturn( + array('statuscode' => array('success' => 400))); + $this->expectException(\repository_exception::class); + $this->expectExceptionMessage('Share is still present'); + $this->repo->reference_file_selected('', context_system::instance(), '', '', ''); + + $this->repo->expects($this->once())->method('get_user_oauth_client')->willReturn(true); + $this->repo->expects($this->once())->method('copy_file_to_path')->willReturn(array('statuscode' => + array('success' => 201))); + $this->repo->expects($this->once())->method('delete_share_dataowner_sysaccount')->willReturn( + array('statuscode' => array('success' => 100))); + $filereturn = array(); + $filereturn->link = 'some/fullpath' . 'some/target/path'; + $filereturn->name = 'mysource'; + $filereturn->usesystem = true; + $filereturn = json_encode($filereturn); + $return = $this->repo->reference_file_selected('mysource', context_system::instance(), '', '', ''); + $this->assertEquals($filereturn, $return); + } + + /** + * Test the send_file function for access controlled links. + */ + public function test_send_file_errors() { + $fs = get_file_storage(); + $storedfile = $fs->create_file_from_reference([ + 'contextid' => context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'unittest', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'testfile.txt', + ], $this->repo->id, json_encode([ + 'type' => 'FILE_CONTROLLED_LINK', + 'link' => 'https://test.local/fakelink/', + 'usesystem' => true, + ])); + $this->set_private_property('', 'client'); + $this->expectException(repository_nextcloud\request_exception::class); + $this->expectExceptionMessage(get_string('contactadminwith', 'repository_nextcloud', + 'The OAuth client could not be connected.')); + + $this->repo->send_file($storedfile, '', '', ''); + + // Testing whether the mock up appears is topic to behat. + $mock = $this->createMock(\core\oauth2\client::class); + $mock->expects($this->once())->method('is_logged_in')->willReturn(true); + $this->repo->send_file($storedfile, '', '', ''); + + // Checks that setting for foldername are used. + $mock->expects($this->once())->method('is_dir')->with('Moodlefiles')->willReturn(false); + // In case of false as return value mkcol is called to create the folder. + $parsedwebdavurl = parse_url($this->issuer->get_endpoint_url('webdav')); + $webdavprefix = $parsedwebdavurl['path']; + $mock->expects($this->once())->method('mkcol')->with( + $webdavprefix . 'Moodlefiles')->willReturn(400); + $this->expectException(\repository_nextcloud\request_exception::class); + $this->expectExceptionMessage(get_string('requestnotexecuted', 'repository_nextcloud')); + $this->repo->send_file($storedfile, '', '', ''); + + $expectedresponse = << + + + ok + 100 + + + + + 6 + 0 + tech + tech + 19 + 1511877999 + + + + tech + tech + /System/Category Miscellaneous/Course Example Course/File morefiles/mod_resource/content/0/merge.txt + file + text/plain + home::tech + 4 + 824 + 824 + 823 + /merge (3).txt + user2 + user1 + 0 + + + 5 + 0 + tech + tech + 19 + 1511877999 + + + + tech + tech + /System/Category Miscellaneous/Course Example Course/File morefiles/mod_resource/content/0/merge.txt + file + text/plain + home::tech + 4 + 824 + 824 + 823 + /merged (3).txt + user1 + user1 + 0 + + + +XML; + + // Checks that setting for foldername are used. + $mock->expects($this->once())->method('is_dir')->with('Moodlefiles')->willReturn(true); + // In case of true as return value mkcol is not called to create the folder. + $shareid = 5; + + $mockocsclient = $this->getMockBuilder( + \repository_nextcloud\ocs_client::class)->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $mockocsclient->expects($this->exactly(2))->method('call')->with('get_information_of_share', + array('share_id' => $shareid))->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + $this->repo->expects($this->once())->method('move_file_to_folder')->with('/merged (3).txt', 'Moodlefiles', + $mock)->willReturn(array('success' => 201)); + + $this->repo->send_file('', '', '', ''); + + // Create test for statuscode 403. + + // Checks that setting for foldername are used. + $mock->expects($this->once())->method('is_dir')->with('Moodlefiles')->willReturn(true); + // In case of true as return value mkcol is not called to create the folder. + $shareid = 5; + $mockocsclient = $this->getMockBuilder(\repository_nextcloud\ocs_client::class + )->disableOriginalConstructor()->disableOriginalClone()->getMock(); + $mockocsclient->expects($this->exactly(1))->method('call')->with('get_shares', + array('path' => '/merged (3).txt', 'reshares' => true))->will($this->returnValue($expectedresponse)); + $mockocsclient->expects($this->exactly(1))->method('call')->with('get_information_of_share', + array('share_id' => $shareid))->will($this->returnValue($expectedresponse)); + $this->set_private_property($mock, 'ocsclient'); + $this->repo->expects($this->once())->method('move_file_to_folder')->with('/merged (3).txt', 'Moodlefiles', + $mock)->willReturn(array('success' => 201)); + $this->repo->send_file('', '', '', ''); + } + + /** + * Helper method, which inserts a given mock value into the repository_nextcloud object. + * + * @param mixed $value mock value that will be inserted. + * @param string $propertyname name of the private property. + * @return ReflectionProperty the resulting reflection property. + */ + protected function set_private_property($value, $propertyname) { + $refclient = new ReflectionClass($this->repo); + $private = $refclient->getProperty($propertyname); + $private->setAccessible(true); + $private->setValue($this->repo, $value); + + return $private; + } + /** + * Helper method to set required return parameters for get_listing. + * + * @return array array, which contains the parameters. + */ + protected function get_initialised_return_array() { + $ret = array(); + $ret['dynload'] = true; + $ret['nosearch'] = true; + $ret['nologin'] = false; + $ret['path'] = [ + [ + 'name' => $this->repo->get_meta()->name, + 'path' => '', + ] + ]; + $ret['manage'] = ''; + $ret['defaultreturntype'] = FILE_INTERNAL; + $ret['list'] = array(); + + return $ret; + } +} diff --git a/repository/nextcloud/tests/ocs_test.php b/repository/nextcloud/tests/ocs_test.php new file mode 100644 index 00000000000..a96691b8953 --- /dev/null +++ b/repository/nextcloud/tests/ocs_test.php @@ -0,0 +1,67 @@ +. + +/** + * This file contains tests for the repository_nextcloud class. + * + * @package repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class repository_nextcloud_ocs_testcase + * @group repository_nextcloud + * @copyright 2017 Jan Dageförde (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class repository_nextcloud_ocs_testcase extends advanced_testcase { + + /** + * @var \core\oauth2\issuer + */ + private $issuer; + + /** + * SetUp to create issuer and endpoints for OCS testing. + */ + protected function setUp() { + $this->resetAfterTest(true); + + // Admin is neccessary to create issuer object. + $this->setAdminUser(); + + $generator = $this->getDataGenerator()->get_plugin_generator('repository_nextcloud'); + $this->issuer = $generator->test_create_issuer(); + $generator->test_create_endpoints($this->issuer->get('id')); + } + + /** + * Test whether required REST API functions are declared. + */ + public function test_api_functions() { + $mock = $this->createMock(\core\oauth2\client::class); + $mock->expects($this->once())->method('get_issuer')->willReturn($this->issuer); + + $client = new \repository_nextcloud\ocs_client($mock); + $functions = $client->get_api_functions(); + + // Assert that relevant (and used) functions are actually present. + $this->assertArrayHasKey('create_share', $functions); + } +} diff --git a/repository/nextcloud/version.php b/repository/nextcloud/version.php new file mode 100755 index 00000000000..24a2bf1d997 --- /dev/null +++ b/repository/nextcloud/version.php @@ -0,0 +1,29 @@ +. + +/** + * Nextcloud repository version details. + * + * @package repository_nextcloud + * @copyright 2017 Project seminar (Learnweb, University of Münster) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2018091300; // The current plugin version (Date: YYYYMMDDXX). +$plugin->requires = 2018090700; // Requires this Moodle version. +$plugin->component = 'repository_nextcloud'; // Full name of the plugin (used for diagnostics).