diff --git a/local/o365/classes/rest/unified.php b/local/o365/classes/rest/unified.php index 7a42d20b2..86807a0e3 100644 --- a/local/o365/classes/rest/unified.php +++ b/local/o365/classes/rest/unified.php @@ -1830,6 +1830,163 @@ public function get_sharing_link(string $fileid, string $o365userid): string { return $response['link']['webUrl']; } + /** + * Upload a file to OneDrive using createUploadSession API. + * + * @param string $o365userid The user's O365 user ID. + * @param string $filepath The local path to the file to upload. + * @param string $filename The name of the new file. + * @param string $parentid The parent folder ID (optional, defaults to root). + * @return string The uploaded file's ID. + * @throws moodle_exception + */ + public function upload_file_with_session( + string $o365userid, + string $filepath, + string $filename, + string $parentid = '' + ): string { + // Create upload session. + $endpoint = "/users/$o365userid/drive/"; + if (!empty($parentid)) { + $endpoint .= "items/$parentid:/" . urlencode($filename) . ":/createUploadSession"; + } else { + $endpoint .= "root:/" . urlencode($filename) . ":/createUploadSession"; + } + + $behaviour = ['item' => ['@microsoft.graph.conflictBehavior' => 'rename']]; + $sessionresponse = $this->apicall('post', $endpoint, json_encode($behaviour)); + $session = $this->process_apicall_response($sessionresponse, ['uploadUrl' => null]); + + if (empty($session['uploadUrl'])) { + throw new moodle_exception('errorwhilesharing', 'repository_office365'); + } + + // Upload the file content. + $filesize = filesize($filepath); + if ($filesize === false) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + // Prepare curl clients - one without auth, one with auth. + $curl = new \curl(); + $authcurl = new \curl(); + $authcurl->setHeader(['Authorization: Bearer ' . $this->token->get_token()]); + + $options = ['file' => $filepath]; + + // Try each curl class in turn until we succeed. + // First attempt an upload with no auth headers (will work for personal onedrive accounts). + // If that fails, try an upload with the auth headers (will work for work onedrive accounts). + $curls = [$curl, $authcurl]; + $response = null; + foreach ($curls as $curlinstance) { + $curlinstance->setHeader('Content-Length: ' . $filesize); + $curlinstance->setHeader('Content-Range: bytes 0-' . ($filesize - 1) . '/' . $filesize); + $response = $curlinstance->put($session['uploadUrl'], $options); + if ($curlinstance->errno == 0) { + $response = json_decode($response, true); + } + if (is_array($response) && !empty($response['id'])) { + // We can stop now - there is a valid file returned. + return $response['id']; + } + } + + // If we get here, neither curl attempt succeeded. + throw new moodle_exception('errorwhilesharing', 'repository_office365'); + } + + /** + * Copy a OneDrive file by downloading and re-uploading it. + * + * @param string $fileid The source file id. + * @param string $o365userid The user's O365 user ID (for destination). + * @param string $newname The new file name (optional, defaults to original name with " - Shared" suffix). + * @param string $parentid The parent folder ID (optional, defaults to root). + * @return string The new file's ID. + * @throws moodle_exception + */ + public function copy_file(string $fileid, string $o365userid, string $newname = '', string $parentid = ''): string { + // Get file metadata including download URL. + $fileinfo = $this->get_file_metadata($fileid, $o365userid); + + if (empty($fileinfo['@microsoft.graph.downloadUrl'])) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + // Use original filename if no new name specified. + if (empty($newname)) { + $newname = $fileinfo['name']; + } + + // Download the file to a temporary location. + $tmpfilename = clean_param($fileid, PARAM_PATH); + $temppath = make_request_directory() . $tmpfilename; + + // Download without auth headers (as per Graph API requirements). + $curl = new \curl(); + $options = ['filepath' => $temppath, 'timeout' => 60, 'followlocation' => true, 'maxredirs' => 5]; + $result = $curl->download_one($fileinfo['@microsoft.graph.downloadUrl'], null, $options); + + if (!$result) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + // Upload to the destination. + $newfileid = $this->upload_file_with_session($o365userid, $temppath, $newname, $parentid); + + // Clean up temp file. + @unlink($temppath); + + return $newfileid; + } + + /** + * Copy a group OneDrive file to a user's OneDrive by downloading and re-uploading it. + * + * @param string $groupid The group's O365 group ID. + * @param string $fileid The source file id in the group. + * @param string $o365userid The user's O365 user ID (for destination). + * @param string $newname The new file name (optional, defaults to original name). + * @return string The new file's ID in the user's OneDrive. + * @throws moodle_exception + */ + public function copy_group_file_to_user(string $groupid, string $fileid, string $o365userid, string $newname = ''): string { + // Get file metadata including download URL. + $fileinfo = $this->get_group_file_metadata($groupid, $fileid); + + if (empty($fileinfo['@microsoft.graph.downloadUrl'])) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + // Use original filename if no new name specified. + if (empty($newname)) { + $newname = $fileinfo['name']; + } + + // Download the file to a temporary location. + $tmpfilename = clean_param($fileid, PARAM_PATH); + $temppath = make_request_directory() . $tmpfilename; + + // Download without auth headers (as per Graph API requirements). + $curl = new \curl(); + $options = ['filepath' => $temppath, 'timeout' => 60, 'followlocation' => true, 'maxredirs' => 5]; + $result = $curl->download_one($fileinfo['@microsoft.graph.downloadUrl'], null, $options); + + if (!$result) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + // Upload to the user's OneDrive root. + $newfileid = $this->upload_file_with_session($o365userid, $temppath, $newname, ''); + + // Clean up temp file. + @unlink($temppath); + + return $newfileid; + } + /** * Get a specific user's information. * diff --git a/local/o365/version.php b/local/o365/version.php index ce7422b62..5b1dd5fee 100644 --- a/local/o365/version.php +++ b/local/o365/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022112850; +$plugin->version = 2022112850.01; $plugin->requires = 2022112800; $plugin->release = '4.1.11'; $plugin->component = 'local_o365'; diff --git a/repository/office365/lang/en/repository_office365.php b/repository/office365/lang/en/repository_office365.php index a7efe3486..a82cc0f99 100644 --- a/repository/office365/lang/en/repository_office365.php +++ b/repository/office365/lang/en/repository_office365.php @@ -27,6 +27,53 @@ $string['configplugin'] = 'Configure Microsoft 365 Repository'; $string['coursegroup'] = 'Disable Groups (Courses) folder in file picker'; $string['defaultgroupsfolder'] = 'Course Files'; +$string['disableanonymousshare'] = 'Disable "{$a}" option'; +$string['disableanonymousshare_help'] = 'When unchecked (default), users can choose to create a copy of a file and share it with everyone in the organization. The copy is stored in the user\'s OneDrive and shared with all organization members. + +**How It Works:** +* Creates a copy of the selected file with a " - Shared" suffix. +* The copy is saved to the user\'s OneDrive (not the original location). +* An organization-scoped sharing link is created for the copy. +* The original file remains unchanged with its existing permissions. + +**Access Control:** +* Only members of your Microsoft 365 organization can access the fil.e +* Anyone in the organization with the link can VIEW the file. +* External users and anonymous users CANNOT access the file. +* The link does not expire automatically. +* The file owner can manage or revoke sharing from their OneDrive. + +**When to Use:** +* You want to share a file with all organization members. +* You want to protect the original file from accidental changes. +* Storage space in Moodle is a concern. +* The file content is appropriate for organization-wide access. + +**Important Notes:** +* The copy operation may take a few seconds for large files. +* Users should ensure the file content is appropriate for organization-wide sharing. +* The copied file will appear in the user\'s OneDrive with " - Shared" suffix. +* Changes made to the original file will NOT be reflected in the shared copy, and vice versa. + +Check this box to disable this option and prevent users from creating organization-shared copies.'; +$string['disableanonymoussharewarning'] = '
To use this plugin, you must first configure the Microsoft 365 plugins
'; $string['office365:view'] = 'View Microsoft 365 repository'; $string['onedrivegroup'] = 'Disable My OneDrive folder in file picker'; +$string['controlledsharelinkdesc'] = 'Shared copy (organization members only)'; +$string['copiedfile'] = 'Copy of file'; +$string['directlinkdesc'] = 'Direct link (existing permissions)'; $string['pluginname'] = 'Microsoft 365'; $string['pluginname_help'] = 'A Microsoft 365 Repository'; $string['privacy:metadata'] = 'This plugin communicates with the Microsoft 365 OneDrive API as the current user. Any files uploaded will be sent to the remote server'; diff --git a/repository/office365/lib.php b/repository/office365/lib.php index 3dcfdce21..2922fddc0 100644 --- a/repository/office365/lib.php +++ b/repository/office365/lib.php @@ -801,7 +801,22 @@ protected function contents_api_response_to_list($response, $path, $clienttype, * @return int */ public function supported_returntypes() { - return FILE_INTERNAL; + $returntypes = FILE_INTERNAL; + + // Check if direct link option is enabled. + $disabledirectlink = get_config('office365', 'disabledirectlink'); + if (empty($disabledirectlink)) { + $returntypes |= FILE_REFERENCE; + } + + // Check if anonymous share option is enabled. + // Use FILE_CONTROLLED_LINK which displays as "Create an access controlled link to the file". + $diableanonymousshare = get_config('office365', 'diableanonymousshare'); + if (empty($diableanonymousshare)) { + $returntypes |= FILE_CONTROLLED_LINK; + } + + return $returntypes; } /** @@ -819,6 +834,14 @@ public function get_file($reference, $filename = '') { $reference = $this->unpack_reference($reference); + // Check if this is a direct link (FILE_REFERENCE) - should not download. + if (isset($reference['linktype']) && $reference['linktype'] === 'directlink') { + // This shouldn't happen - FILE_REFERENCE should not call get_file(). + // But if it does, throw an exception to prevent unwanted downloads. + utils::debug('get_file() called for FILE_REFERENCE - this should not happen', __METHOD__, $reference); + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + if ($reference['source'] === 'onedrive') { if ($this->unifiedconfigured === true) { $sourceclient = $this->get_unified_apiclient(); @@ -886,6 +909,107 @@ protected function unpack_reference($reference) { return unserialize(base64_decode($reference)); } + /** + * Called after a file is selected with FILE_CONTROLLED_LINK. + * This creates a copy of the file and shares it with the organization. + * + * @param string $reference The reference from 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 Modified reference pointing to the copied and shared file + */ + public function reference_file_selected($reference, $context, $component, $filearea, $itemid) { + global $USER; + + $ref = $this->unpack_reference($reference); + + // Check if this is a direct link (FILE_REFERENCE) - no copy needed. + if (isset($ref['linktype']) && $ref['linktype'] === 'directlink') { + // For direct links, just return the reference unchanged. + // The file should be accessed with its existing permissions. + return $reference; + } + + // Check if already processed. + if (isset($ref['shared']) && $ref['shared'] === true) { + return $reference; + } + + $fileid = $ref['id']; + $filesource = $ref['source']; + + try { + if ($filesource === 'onedrive') { + if ($this->unifiedconfigured === true) { + $sourceclient = $this->get_unified_apiclient(); + $o365userid = utils::get_o365_userid($USER->id); + + // Get original file metadata. + $metadata = $sourceclient->get_file_metadata($fileid, $o365userid); + $filename = isset($metadata['name']) ? $metadata['name'] : 'file'; + + // Create a copy with " - Shared" suffix. + $newname = pathinfo($filename, PATHINFO_FILENAME) . ' - Shared'; + if (pathinfo($filename, PATHINFO_EXTENSION)) { + $newname .= '.' . pathinfo($filename, PATHINFO_EXTENSION); + } + + // Copy the file using download-and-upload approach. + // This returns the new file ID directly (synchronous operation). + $copiedfileid = $sourceclient->copy_file($fileid, $o365userid, $newname); + + // Create organization sharing link for the copy. + $shareurl = $sourceclient->get_sharing_link($copiedfileid, $o365userid); + + // Update reference to point to the copy. + $ref['id'] = $copiedfileid; + $ref['url'] = $shareurl; + $ref['shared'] = true; + $ref['linktype'] = 'controlledshare'; + } + } else if ($filesource === 'onedrivegroup') { + if ($this->unifiedconfigured !== true) { + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + + $sourceclient = $this->get_unified_apiclient(); + $o365userid = utils::get_o365_userid($USER->id); + + // Get original file metadata. + $metadata = $sourceclient->get_group_file_metadata($ref['groupid'], $fileid); + $filename = isset($metadata['name']) ? $metadata['name'] : 'file'; + + // Create a copy with " - Shared" suffix. + $newname = pathinfo($filename, PATHINFO_FILENAME) . ' - Shared'; + if (pathinfo($filename, PATHINFO_EXTENSION)) { + $newname .= '.' . pathinfo($filename, PATHINFO_EXTENSION); + } + + // Copy the group file to user's OneDrive using download-and-upload approach. + // This returns the new file ID directly (synchronous operation). + $copiedfileid = $sourceclient->copy_group_file_to_user($ref['groupid'], $fileid, $o365userid, $newname); + + // Create organization sharing link for the copy. + $shareurl = $sourceclient->get_sharing_link($copiedfileid, $o365userid); + + // Update reference to point to the copy. + $ref['id'] = $copiedfileid; + $ref['url'] = $shareurl; + $ref['source'] = 'onedrive'; // Copy is in user's OneDrive now. + $ref['shared'] = true; + $ref['linktype'] = 'controlledshare'; + unset($ref['groupid']); // No longer a group file. + } + } catch (moodle_exception $e) { + utils::debug('Error in reference_file_selected', __METHOD__, $e->getMessage()); + throw $e; + } + + return $this->pack_reference($ref); + } + /** * Prepare file reference information * @@ -900,10 +1024,22 @@ public function get_file_reference($source) { $fileid = $sourceunpacked['id']; $filesource = $sourceunpacked['source']; + // Detect link type based on optional parameter passed by file picker. + // When user selects FILE_REFERENCE, Moodle passes 'usefilereference' parameter. + $usefilereference = optional_param('usefilereference', false, PARAM_BOOL); + + // Determine link type based on user selection in file picker. + $linktype = 'default'; // FILE_INTERNAL - download and copy. + if ($usefilereference) { + $linktype = 'directlink'; // FILE_REFERENCE - direct link without changing permissions. + } + // Note: FILE_CONTROLLED_LINK is handled by reference_file_selected() callback, not here. + $reference = [ 'source' => $filesource, 'id' => $fileid, 'url' => '', + 'linktype' => $linktype, ]; if (isset($sourceunpacked['url'])) { @@ -912,31 +1048,66 @@ public function get_file_reference($source) { if (isset($sourceunpacked['downloadurl'])) { $reference['downloadurl'] = $sourceunpacked['downloadurl']; } + if (isset($sourceunpacked['groupid'])) { + $reference['groupid'] = $sourceunpacked['groupid']; + } try { - if ($filesource === 'onedrive') { - if ($this->unifiedconfigured === true) { + if ($linktype === 'directlink') { + // Handle direct link (FILE_REFERENCE) - get the file's webUrl WITHOUT changing permissions. + // This preserves the existing sharing settings in OneDrive. + if ($filesource === 'onedrive') { + if ($this->unifiedconfigured === true) { + $sourceclient = $this->get_unified_apiclient(); + $o365userid = utils::get_o365_userid($USER->id); + $metadata = $sourceclient->get_file_metadata($fileid, $o365userid); + if (isset($metadata['webUrl'])) { + $reference['url'] = $metadata['webUrl']; + } + } + } else if ($filesource === 'onedrivegroup') { + if ($this->unifiedconfigured !== true) { + utils::debug('Tried to access a onedrive group file while the graph api is disabled.', __METHOD__); + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } $sourceclient = $this->get_unified_apiclient(); - $o365userid = utils::get_o365_userid($USER->id); - $reference['url'] = $sourceclient->get_sharing_link($fileid, $o365userid); - } - } else if ($filesource === 'onedrivegroup') { - if ($this->unifiedconfigured !== true) { - utils::debug('Tried to access a onedrive group file while the graph api is disabled.', __METHOD__); - throw new moodle_exception('errorwhiledownload', 'repository_office365'); - } - $sourceclient = $this->get_unified_apiclient(); - $reference['groupid'] = $sourceunpacked['groupid']; - $reference['url'] = $sourceclient->get_group_file_sharing_link($sourceunpacked['groupid'], $fileid); - } else if ($filesource === 'trendingaround') { - if ($this->unifiedconfigured !== true) { - utils::debug('Tried to access a trending around me file while the graph api is disabled.', __METHOD__); - throw new moodle_exception('errorwhiledownload', 'repository_office365'); + $metadata = $sourceclient->get_group_file_metadata($sourceunpacked['groupid'], $fileid); + if (isset($metadata['webUrl'])) { + $reference['url'] = $metadata['webUrl']; + } } - $sourceclient = $this->get_unified_apiclient(); - $filedata = $sourceclient->get_file_data($fileid); - if (isset($filedata['@microsoft.graph.downloadUrl'])) { - $reference['url'] = $filedata['@microsoft.graph.downloadUrl']; + } else { + // Default behavior (FILE_INTERNAL) - download the file. + // Get the webUrl WITHOUT changing permissions. + if ($filesource === 'onedrive') { + if ($this->unifiedconfigured === true) { + $sourceclient = $this->get_unified_apiclient(); + $o365userid = utils::get_o365_userid($USER->id); + $metadata = $sourceclient->get_file_metadata($fileid, $o365userid); + if (isset($metadata['webUrl'])) { + $reference['url'] = $metadata['webUrl']; + } + } + } else if ($filesource === 'onedrivegroup') { + if ($this->unifiedconfigured !== true) { + utils::debug('Tried to access a onedrive group file while the graph api is disabled.', __METHOD__); + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + $sourceclient = $this->get_unified_apiclient(); + $metadata = $sourceclient->get_group_file_metadata($sourceunpacked['groupid'], $fileid); + if (isset($metadata['webUrl'])) { + $reference['url'] = $metadata['webUrl']; + } + } else if ($filesource === 'trendingaround') { + if ($this->unifiedconfigured !== true) { + utils::debug('Tried to access a trending around me file while the graph api is disabled.', __METHOD__); + throw new moodle_exception('errorwhiledownload', 'repository_office365'); + } + $sourceclient = $this->get_unified_apiclient(); + $filedata = $sourceclient->get_file_data($fileid); + if (isset($filedata['@microsoft.graph.downloadUrl'])) { + $reference['url'] = $filedata['@microsoft.graph.downloadUrl']; + } } } @@ -945,10 +1116,15 @@ public function get_file_reference($source) { $debugdata = [ 'source' => $filesource, 'id' => $fileid, + 'linktype' => $linktype, 'message' => $e->getMessage(), 'e' => $e, ]; utils::debug($errmsg, __METHOD__, $debugdata); + + // Re-throw the exception so the user is notified instead of silently failing. + // This prevents accidentally sharing the original file if copy/share operations fail. + throw $e; } return $this->pack_reference($reference); @@ -966,6 +1142,70 @@ public function get_file_reference($source) { return $source; } + /** + * Return reference file details as a human readable string for display. + * + * @param string $reference The file reference + * @param int $filestatus The file status (0 = OK, 666 = source missing) + * @return string A human readable reference string + */ + public function get_reference_details($reference, $filestatus = 0) { + global $USER; + + if ($filestatus == 666) { + // File source has been deleted or is no longer accessible. + return get_string('lostsource', 'repository', ''); + } + + $ref = $this->unpack_reference($reference); + + // Build a human-readable reference string. + $details = ''; + + try { + if (isset($ref['source']) && isset($ref['id'])) { + $linktype = isset($ref['linktype']) ? $ref['linktype'] : 'default'; + + // Add link type description. + if ($linktype === 'controlledshare') { + $details = get_string('controlledsharelinkdesc', 'repository_office365'); + } else if ($linktype === 'directlink') { + $details = get_string('directlinkdesc', 'repository_office365'); + } else { + $details = get_string('copiedfile', 'repository_office365'); + } + + // Try to get the file name and add it to the reference. + if ($ref['source'] === 'onedrive') { + if ($this->unifiedconfigured === true) { + $sourceclient = $this->get_unified_apiclient(); + $o365userid = utils::get_o365_userid($USER->id); + $metadata = $sourceclient->get_file_metadata($ref['id'], $o365userid); + + if (isset($metadata['name'])) { + $details .= ': ' . $metadata['name']; + } + } + } else if ($ref['source'] === 'onedrivegroup' && isset($ref['groupid'])) { + if ($this->unifiedconfigured === true) { + $sourceclient = $this->get_unified_apiclient(); + $metadata = $sourceclient->get_group_file_metadata($ref['groupid'], $ref['id']); + + if (isset($metadata['name'])) { + $details .= ': ' . $metadata['name']; + } + } + } + } + } catch (moodle_exception $e) { + // If we can't get details, return a generic message. + utils::debug('Could not get file reference details', __METHOD__, $e->getMessage()); + return get_string('unknownsource', 'repository'); + } + + return $details; + } + /** * Return file URL, for most plugins, the parameter is the original * url, but some plugins use a file id, so we need this function to @@ -1143,14 +1383,45 @@ public static function type_config_form($mform, $classname = 'repository') { $mform->setType('onedrivegroup', PARAM_INT); $mform->addElement('checkbox', 'trendinggroup', get_string('trendinggroup', 'repository_office365')); $mform->setType('trendinggroup', PARAM_INT); + + // File linking options. + $mform->addElement('header', 'filelinking', get_string('filelinkingheader', 'repository_office365')); + + $mform->addElement( + 'checkbox', + 'disabledirectlink', + get_string('disabledirectlink', 'repository_office365', get_string('makefilereference', 'repository')) + ); + $mform->setType('disabledirectlink', PARAM_INT); + $mform->addHelpButton('disabledirectlink', 'disabledirectlink', 'repository_office365'); + $mform->addElement( + 'static', + 'disabledirectlinkwarning', + '', + get_string('disabledirectlinkwarning', 'repository_office365', get_string('makefilereference', 'repository')) + ); + + $mform->addElement( + 'checkbox', + 'disableanonymousshare', + get_string('disableanonymousshare', 'repository_office365', get_string('makefilecontrolledlink', 'repository')) + ); + $mform->setType('disableanonymousshare', PARAM_INT); + $mform->addHelpButton('disableanonymousshare', 'disableanonymousshare', 'repository_office365'); + $mform->addElement( + 'static', + 'disableanonymoussharewarning', + '', + get_string('disableanonymoussharewarning', 'repository_office365', get_string('makefilecontrolledlink', 'repository')) + ); } /** - * Option names of dropbox office365 + * Option names of office365. * * @return array */ public static function get_type_option_names() { - return ['coursegroup', 'onedrivegroup', 'trendinggroup', 'pluginname']; + return ['coursegroup', 'onedrivegroup', 'trendinggroup', 'disabledirectlink', 'disableanonymousshare', 'pluginname']; } } diff --git a/repository/office365/version.php b/repository/office365/version.php index 6791e59c3..0c5d28b9b 100644 --- a/repository/office365/version.php +++ b/repository/office365/version.php @@ -24,11 +24,11 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022112845; +$plugin->version = 2022112845.01; $plugin->requires = 2022112800; $plugin->release = '4.1.10'; $plugin->component = 'repository_office365'; $plugin->maturity = MATURITY_STABLE; $plugin->dependencies = [ - 'local_o365' => 2022112845, + 'local_o365' => 2022112845.01, ];