Commit 82cefec2 authored by Guillaume Heurtier's avatar Guillaume Heurtier
Browse files

FEAT #10001 TIME 17:00 editing document with office 365 via Sharepoint Online

parent 046566cc
......@@ -703,4 +703,8 @@ $app->put('/plugins/outlook/configuration', \Outlook\controllers\OutlookControll
$app->put('/plugins/outlook/password', \Outlook\controllers\OutlookController::class . ':saveOutlookPassword');
$app->put('/plugins/outlook/attachments', \Outlook\controllers\OutlookController::class . ':saveEmailAttachments');
$app->post('/office365', \ContentManagement\controllers\Office365SharepointController::class . ':sendDocument');
$app->get('/office365/{id}', \ContentManagement\controllers\Office365SharepointController::class . ':getFileContent');
$app->delete('/office365/{id}', \ContentManagement\controllers\Office365SharepointController::class . ':deleteFile');
$app->run();
......@@ -17,6 +17,7 @@ namespace Configuration\controllers;
use Attachment\models\AttachmentTypeModel;
use Basket\models\BasketModel;
use Configuration\models\ConfigurationModel;
use ContentManagement\controllers\Office365SharepointController;
use Doctype\models\DoctypeModel;
use Group\controllers\PrivilegeController;
use History\controllers\HistoryController;
......@@ -159,6 +160,22 @@ class ConfigurationController
} elseif (!Validator::boolType()->validate($editor['ssl'] ?? null)) {
return $response->withStatus(400)->withJson(['errors' => "Body collaboraonline['ssl'] is not set or not a boolean"]);
}
} elseif ($key == 'office365sharepoint') {
if (!Validator::notEmpty()->stringType()->validate($editor['tenantId'] ?? null)) {
return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['tenantId'] is empty or not a string"]);
} elseif (!Validator::notEmpty()->stringType()->validate($editor['clientId'] ?? null)) {
return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['clientId'] is empty or not a string"]);
} elseif (!Validator::notEmpty()->stringType()->validate($editor['clientSecret'] ?? null)) {
return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['clientSecret'] is empty or not a string"]);
} elseif (!Validator::notEmpty()->stringType()->validate($editor['siteUrl'] ?? null)) {
return $response->withStatus(400)->withJson(['errors' => "Body office365sharepoint['siteUrl'] is empty or not a string"]);
}
$data[$key]['siteId'] = Office365SharepointController::getSiteId([
'tenantId' => $editor['tenantId'],
'clientId' => $editor['clientId'],
'clientSecret' => $editor['clientSecret'],
'siteUrl' => $editor['siteUrl']
]);
}
}
} elseif ($args['privilege'] == 'admin_shippings') {
......
......@@ -495,7 +495,7 @@ class CollaboraOnlineController
];
}
private static function getDocument(array $args)
public static function getDocument(array $args)
{
ValidatorModel::notEmpty($args, ['id', 'type']);
ValidatorModel::stringType($args, ['type', 'format', 'path']);
......
......@@ -21,7 +21,7 @@ use SrcCore\models\ValidatorModel;
class DocumentEditorController
{
const DOCUMENT_EDITION_METHODS = ['java', 'onlyoffice', 'collaboraonline'];
const DOCUMENT_EDITION_METHODS = ['java', 'onlyoffice', 'collaboraonline', 'office365sharepoint'];
public static function get(Request $request, Response $response)
{
......
<?php
/**
* Copyright Maarch since 2008 under licence GPLv3.
* See LICENCE.txt file at the root folder for more details.
* This file is part of Maarch software.
*/
/**
* @brief Office 365 Sharepoint Controller
*
* @author dev@maarch.org
*/
namespace ContentManagement\controllers;
use Attachment\models\AttachmentModel;
use Configuration\models\ConfigurationModel;
use Docserver\models\DocserverModel;
use Docserver\models\DocserverTypeModel;
use Resource\controllers\StoreController;
use Resource\models\ResModel;
use Respect\Validation\Validator;
use Slim\Http\Request;
use Slim\Http\Response;
use SrcCore\models\CurlModel;
use SrcCore\models\ValidatorModel;
use User\models\UserModel;
class Office365SharepointController
{
public function sendDocument(Request $request, Response $response, array $args)
{
$configuration = ConfigurationModel::getByPrivilege(['privilege' => 'admin_document_editors', 'select' => ['value']]);
$configuration = !empty($configuration['value']) ? json_decode($configuration['value'], true) : [];
if (empty($configuration) || empty($configuration['office365sharepoint'])) {
return $response->withStatus(400)->withJson(['errors' => 'Office 365 Sharepoint Online is not enabled', 'lang' => 'office365SharepointNotEnabled']);
}
$configuration = $configuration['office365sharepoint'];
$body = $request->getParsedBody();
if (!Validator::intVal()->notEmpty()->validate($body['resId'])) {
return $response->withStatus(400)->withJson(['errors' => 'Body resId is empty or not an integer']);
}
if (!Validator::stringType()->notEmpty()->validate($body['type'])) {
return $response->withStatus(400)->withJson(['errors' => 'Body type is empty or not a string']);
}
if (!empty($body['format']) && !Validator::stringType()->validate($body['format'])) {
return $response->withStatus(400)->withJson(['errors' => 'Body format is not a string']);
}
if (!empty($body['path']) && !Validator::stringType()->validate($body['path'])) {
return $response->withStatus(400)->withJson(['errors' => 'Body path is not a string']);
}
if (!empty($body['data']) && !Validator::arrayType()->validate($body['data'])) {
return $response->withStatus(400)->withJson(['errors' => 'Body data is not a string']);
}
$document = CollaboraOnlineController::getDocument([
'id' => $body['resId'],
'type' => $body['type'],
'format' => $body['format'],
'path' => $body['path']
]);
if (!empty($document['errors'])) {
return $response->withStatus($document['code'])->withJson(['errors' => $document['errors']]);
}
if (!empty($document['docserver_id'])) {
$docserver = DocserverModel::getByDocserverId(['docserverId' => $document['docserver_id'], 'select' => ['path_template', 'docserver_type_id']]);
if (empty($docserver['path_template']) || !file_exists($docserver['path_template'])) {
return $response->withStatus(400)->withJson(['errors' => 'Docserver does not exist']);
}
} else {
$docserver['path_template'] = '';
}
$pathToDocument = $docserver['path_template'] . str_replace('#', DIRECTORY_SEPARATOR, $document['path']) . $document['filename'];
if (!file_exists($pathToDocument)) {
return $response->withStatus(404)->withJson(['errors' => 'Document not found on docserver']);
}
if ($body['type'] == 'resourceModification' || $body['type'] == 'attachmentModification') {
$docserverType = DocserverTypeModel::getById(['id' => $docserver['docserver_type_id'], 'select' => ['fingerprint_mode']]);
$fingerprint = StoreController::getFingerPrint(['filePath' => $pathToDocument, 'mode' => $docserverType['fingerprint_mode']]);
if (empty($document['fingerprint']) && $body['type'] == 'resourceModification') {
ResModel::update(['set' => ['fingerprint' => $fingerprint], 'where' => ['res_id = ?'], 'data' => [$args['id']]]);
$document['fingerprint'] = $fingerprint;
} elseif (empty($document['fingerprint']) && $body['type'] == 'attachmentModification') {
AttachmentModel::update(['set' => ['fingerprint' => $fingerprint], 'where' => ['res_id = ?'], 'data' => [$args['id']]]);
$document['fingerprint'] = $fingerprint;
}
if ($document['fingerprint'] != $fingerprint) {
return $response->withStatus(400)->withJson(['errors' => 'Fingerprints do not match']);
}
}
if ($body['type'] == 'resourceCreation' || $body['type'] == 'attachmentCreation') {
$dataToMerge = ['userId' => $GLOBALS['id']];
if (!empty($tokenCheckResult['data']) && is_array($tokenCheckResult['data'])) {
$dataToMerge = array_merge($dataToMerge, $tokenCheckResult['data']);
}
$mergedDocument = MergeController::mergeDocument([
'path' => $pathToDocument,
'data' => $dataToMerge
]);
$content = $mergedDocument['encodedDocument'];
} else {
$fileContent = file_get_contents($pathToDocument);
if ($fileContent === false) {
return $response->withStatus(404)->withJson(['errors' => 'Document not found']);
}
$content = base64_encode($fileContent);
}
$fileContent = base64_decode($content);
$fileSize = strlen($fileContent);
$pathInfo = pathinfo($pathToDocument);
$filename = "maarch_{$GLOBALS['login']}_" . rand() . ".{$pathInfo['extension']}";
$accessToken = Office365SharepointController::getAuthenticationToken(['configuration' => $configuration]);
if (!empty($accessToken['errors'])) {
return $response->withStatus(400)->withJson(['errors' => $accessToken['errors']]);
}
$sendResult = CurlModel::exec([
'url' => 'https://graph.microsoft.com/v1.0/sites/' . $configuration['siteId'] . '/drive/root:/' . $filename . ':/content',
'bearerAuth' => ['token' => $accessToken],
'headers' => ['Content-Type: text/plain'],
'method' => 'PUT',
'body' => 'maarch'
]);
if ($sendResult['code'] != 201) {
return $response->withStatus(400)->withJson(['errors' => 'Could not create the document in sharepoint', 'sharepointError' => $sendResult['response']['error']]);
}
$id = $sendResult['response']['id'];
$sendResult = CurlModel::exec([
'url' => 'https://graph.microsoft.com/v1.0/sites/' . $configuration['siteId'] . '/drive/items/' . $id . '/createUploadSession',
'bearerAuth' => ['token' => $accessToken],
'headers' => ['Content-Type: application/json'],
'method' => 'POST',
'body' => ['item' => ['@microsoft.graph.conflictBehavior' => 'replace']]
]);
if ($sendResult['code'] != 200) {
return $response->withStatus(400)->withJson(['errors' => 'Could not create upload session to send the document to sharepoint', 'sharepointError' => $sendResult['response']['error']]);
}
$sendResult = CurlModel::exec([
'url' => $sendResult['response']['uploadUrl'],
'bearerAuth' => ['token' => $accessToken],
'headers' => ['Content-Type: text/plain', 'Content-Range: bytes 0-' . ($fileSize - 1) . '/' . $fileSize],
'method' => 'PUT',
'body' => $fileContent
]);
if ($sendResult['code'] != 200) {
return $response->withStatus(400)->withJson(['errors' => 'Could not send the document to sharepoint', 'sharepointError' => $sendResult['response']['error']]);
}
$webUrl = $sendResult['response']['webUrl'];
$id = $sendResult['response']['id'];
$currentUser = UserModel::getById(['id' => $GLOBALS['id'], 'select' => ['mail']]);
$body = [
'requireSignIn' => true,
'sendInvitation' => false,
'roles' => ['read', 'write'],
'recipients' => [
['email' => $currentUser['mail']]
]
];
// Add access permission to the document to the current user
$result = CurlModel::exec([
'url' => 'https://graph.microsoft.com/v1.0/sites/' . $configuration['siteId'] . '/drive/items/' . $id . '/invite',
'bearerAuth' => ['token' => $accessToken],
'headers' => ['Content-Type: application/json'],
'method' => 'POST',
'body' => json_encode($body)
]);
if ($result['code'] != 200) {
return $response->withStatus(400)->withJson(['errors' => 'Could not share the document with user', 'sharepointError' => $result['response']['error']]);
}
return $response->withJson(['webUrl' => $webUrl, 'documentId' => $id]);
}
public function getFileContent(Request $request, Response $response, array $args)
{
$configuration = ConfigurationModel::getByPrivilege(['privilege' => 'admin_document_editors', 'select' => ['value']]);
$configuration = !empty($configuration['value']) ? json_decode($configuration['value'], true) : [];
if (empty($configuration) || empty($configuration['office365sharepoint'])) {
return $response->withStatus(400)->withJson(['errors' => 'Office 365 Sharepoint Online is not enabled', 'lang' => 'office365SharepointNotEnabled']);
}
$configuration = $configuration['office365sharepoint'];
if (!Validator::stringType()->notEmpty()->validate($args['id'])) {
return $response->withStatus(400)->withJson(['errors' => 'Argument id is empty or not a string']);
}
$accessToken = Office365SharepointController::getAuthenticationToken(['configuration' => $configuration]);
if (!empty($accessToken['errors'])) {
return $response->withStatus(400)->withJson(['errors' => $accessToken['errors']]);
}
$result = CurlModel::exec([
'url' => 'https://graph.microsoft.com/v1.0/sites/' . $configuration['siteId'] . '/drive/items/' . $args['id'] . '/content',
'bearerAuth' => ['token' => $accessToken],
'method' => 'GET',
'followRedirect' => true,
'fileResponse' => true
]);
if ($result['code'] != 200) {
return $response->withStatus(400)->withJson(['errors' => 'Could not get the document from sharepoint', 'sharepointError' => $result['response']['error']]);
}
if (empty($result['response'])) {
return $response->withStatus(400)->withJson(['errors' => 'Could not get the document content from sharepoint']);
}
$content = $result['response'];
return $response->withJson(['content' => base64_encode($content)]);
}
public function deleteFile(Request $request, Response $response, array $args)
{
$configuration = ConfigurationModel::getByPrivilege(['privilege' => 'admin_document_editors', 'select' => ['value']]);
$configuration = !empty($configuration['value']) ? json_decode($configuration['value'], true) : [];
if (empty($configuration) || empty($configuration['office365sharepoint'])) {
return $response->withStatus(400)->withJson(['errors' => 'Office 365 Sharepoint Online is not enabled', 'lang' => 'office365SharepointNotEnabled']);
}
$configuration = $configuration['office365sharepoint'];
if (!Validator::stringType()->notEmpty()->validate($args['id'])) {
return $response->withStatus(400)->withJson(['errors' => 'Argument id is empty or not a string']);
}
$accessToken = Office365SharepointController::getAuthenticationToken(['configuration' => $configuration]);
if (!empty($accessToken['errors'])) {
return $response->withStatus(400)->withJson(['errors' => $accessToken['errors']]);
}
$result = CurlModel::exec([
'url' => 'https://graph.microsoft.com/v1.0/sites/' . $configuration['siteId'] . '/drive/items/' . $args['id'],
'bearerAuth' => ['token' => $accessToken],
'method' => 'DELETE'
]);
if ($result['code'] != 204) {
return $response->withStatus(400)->withJson(['errors' => 'Could not delete document in Sharepoint', 'sharepointError' => $result['response']['error']]);
}
return $response->withStatus(204);
}
public static function getSiteId(array $args) {
ValidatorModel::notEmpty($args, ['clientId', 'clientSecret', 'tenantId', 'siteUrl']);
ValidatorModel::stringType($args, ['clientId', 'clientSecret', 'tenantId', 'siteUrl']);
$accessToken = Office365SharepointController::getAuthenticationToken([
'configuration' => [
'clientId' => $args['clientId'],
'clientSecret' => $args['clientSecret'],
'tenantId' => $args['tenantId']
]
]);
$args['siteUrl'] = str_replace('https://', '', $args['siteUrl']);
$args['siteUrl'] = str_replace('http://', '', $args['siteUrl']);
$explodedSite = explode('/', $args['siteUrl']);
$tenantDomain = $explodedSite[0];
unset($explodedSite[0]);
$sitePath = implode('/', $explodedSite);
$url = 'https://graph.microsoft.com/v1.0/sites/' . $tenantDomain . ':/' . $sitePath;
$result = CurlModel::exec([
'url' => $url,
'bearerAuth' => ['token' => $accessToken],
'method' => 'GET'
]);
if ($result['code'] != 200) {
return ['errors' => 'Could not get the site id'];
}
if (empty($result['response']['id'])) {
return ['errors' => 'Could not get the site id'];
}
return $result['response']['id'];
}
private static function getAuthenticationToken(array $args) {
ValidatorModel::notEmpty($args, ['configuration']);
ValidatorModel::arrayType($args, ['configuration']);
$configuration = $args['configuration'];
$body = [
'grant_type=client_credentials',
'scope=https://graph.microsoft.com/.default',
'client_id=' . $configuration['clientId'],
'client_secret=' . $configuration['clientSecret']
];
$curlResponse = CurlModel::exec([
'url' => 'https://login.microsoftonline.com/' . $configuration['tenantId'] . '/oauth2/v2.0/token',
'headers' => ['Content-Type: application/x-www-form-urlencoded'],
'method' => 'POST',
'body' => implode('&', $body)
]);
if ($curlResponse['code'] != 200) {
if (!empty($curlResponse['errors'])) {
return ['errors' => $curlResponse['errors']];
}
return ['errors' => 'Error while getting token for Microsoft Graph'];
}
if (empty($curlResponse['response']['access_token'])) {
return ['errors' => 'Microsoft Graph access token is empty'];
}
return $curlResponse['response']['access_token'];
}
}
......@@ -113,12 +113,16 @@ class CurlModel
ValidatorModel::notEmpty($args, ['url', 'method']);
ValidatorModel::stringType($args, ['url', 'method', 'cookie']);
ValidatorModel::arrayType($args, ['headers', 'queryParams', 'basicAuth', 'bearerAuth']);
ValidatorModel::boolType($args, ['isXml']);
ValidatorModel::boolType($args, ['isXml', 'followRedirect']);
$args['isXml'] = $args['isXml'] ?? false;
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_CONNECTTIMEOUT => 10];
if (!empty($args['followRedirect'])) {
$opts[CURLOPT_FOLLOWLOCATION] = true;
}
//Headers
if (!empty($args['headers'])) {
$opts[CURLOPT_HTTPHEADER] = $args['headers'];
......@@ -188,6 +192,8 @@ class CurlModel
if (empty($args['noLogs'])) {
if (in_array('Accept: application/zip', $args['headers'])) {
$logResponse = 'Zip file content';
} elseif (!empty($args['fileResponse'])) {
$logResponse = 'File content';
} else {
$logResponse = $response;
}
......@@ -206,7 +212,7 @@ class CurlModel
$response = simplexml_load_string($response);
} elseif (in_array('Accept: application/zip', $args['headers'])) {
$response = trim($response);
} else {
} elseif(empty($args['fileResponse'])) {
$response = json_decode($response, true);
}
......
......@@ -51,6 +51,12 @@ export class OtherParametersComponent implements OnInit {
ssl: new FormControl(false),
uri: new FormControl('192.168.0.11', [Validators.required]),
port: new FormControl(9980, [Validators.required]),
},
office365sharepoint: {
tenantId: new FormControl('abc-123456789-efd', [Validators.required]),
clientId: new FormControl('abc-123456789-efd', [Validators.required]),
clientSecret: new FormControl('abc-123456789-efd'),
siteUrl: new FormControl('https://exemple.sharepoint.com/sites/example', [Validators.required]),
}
};
......@@ -335,7 +341,9 @@ export class OtherParametersComponent implements OnInit {
Object.keys(data[confId]).forEach(itemId => {
console.log(confId, itemId);
this.editorsConf[confId][itemId].setValue(data[confId][itemId]);
if (!this.functions.empty(this.editorsConf[confId][itemId])) {
this.editorsConf[confId][itemId].setValue(data[confId][itemId]);
}
});
});
resolve(true);
......
......@@ -9,6 +9,12 @@
<app-collabora-online-viewer *ngIf="editorType === 'collaboraonline'" #collaboraOnlineViewer style="height:100%;width:100%;" [params]="editorOptions" [file]="file"
[editMode]="true" (triggerAfterUpdatedDoc)="close()"
(triggerCloseEditor)="dialogRef.close('');" (triggerModifiedDocument)="documentIsModified = true"></app-collabora-online-viewer>
<div *ngIf="editorType === 'office365sharepoint'" class="office-sharepoint-container">
<app-office-sharepoint-viewer #officeSharepointViewer style="height:100%;width:100%;" [params]="editorOptions" [file]="file"
[editMode]="true" (triggerAfterUpdatedDoc)="close()"
(triggerCloseEditor)="dialogRef.close('')" (triggerModifiedDocument)="documentIsModified = true"
(triggerDocumentDownload)="close()"></app-office-sharepoint-viewer>
</div>
</mat-dialog-content>
<span class="divider-modal"></span>
<div mat-dialog-actions class="actions">
......
.office-sharepoint-container {
width: 100%;
height: 100%;
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
background-color: var(--maarch-color-primary);
text-align: center;
color: white;
}
import { Component, OnInit, Inject, ViewChild } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { EcplOnlyofficeViewerComponent } from '../../../../plugins/onlyoffice-api-js/onlyoffice-viewer.component';
import { CollaboraOnlineViewerComponent } from '../../../../plugins/collabora-online/collabora-online-viewer.component';
import { EcplOnlyofficeViewerComponent } from '@plugins/onlyoffice-api-js/onlyoffice-viewer.component';
import { CollaboraOnlineViewerComponent } from '@plugins/collabora-online/collabora-online-viewer.component';
import { take, tap } from 'rxjs/operators';
import { Office365SharepointViewerComponent } from '@plugins/office365-sharepoint/office365-sharepoint-viewer.component';
@Component({
templateUrl: 'template-file-editor-modal.component.html',
......@@ -13,6 +14,7 @@ export class TemplateFileEditorModalComponent implements OnInit {
@ViewChild('onlyofficeViewer', { static: false }) onlyofficeViewer: EcplOnlyofficeViewerComponent;
@ViewChild('collaboraOnlineViewer', { static: false }) collaboraOnlineViewer: CollaboraOnlineViewerComponent;
@ViewChild('officeSharepointViewer', { static: false }) officeSharepointViewer: Office365SharepointViewerComponent;
loading: boolean = false;
editorOptions: any = null;
......@@ -46,6 +48,14 @@ export class TemplateFileEditorModalComponent implements OnInit {
this.dialogRef.close(data);
})
).subscribe();
} else if (this.editorType === 'office365sharepoint') {
this.officeSharepointViewer.getFile().pipe(
take(1),
tap((data: any) => {
this.loading = false;
this.dialogRef.close(data);
})
).subscribe();
} else {
this.loading = false;
this.dialogRef.close();
......
<ng-container *ngIf="editInProgress && editor.mode !== 'onlyoffice' && editor.mode !== 'collaboraOnline'">
<ng-container *ngIf="editInProgress && editor.mode !== 'onlyoffice' && editor.mode !== 'collaboraOnline' && editor.mode !== 'office365sharepoint'">
<div class="editInProgress">
<i class="fas fa-file-word bounce"></i>
<div>
......@@ -125,12 +125,21 @@
<mat-spinner style="margin:auto;"></mat-spinner>
</div>
</ng-container>
<button mat-fab *ngIf="isDocModified && mode === 'mainDocument' && resId !== null" color="accent"
<ng-container *ngIf="editInProgress && editor.mode === 'office365sharepoint'">
<app-office-sharepoint-viewer #officeSharepointViewer style="height:100%;width:100%;" [params]="editor.options" [file]="file"
[editMode]="true" (triggerAfterUpdatedDoc)="triggerEvent.emit()"
(triggerCloseEditor)="closeEditor()" (triggerModifiedDocument)="isDocModified = true"
(triggerDocumentDownload)="mode === 'mainDocument' && resId !== null ? saveMainDocument() : saveTmpDocument()"></app-office-sharepoint-viewer>
<div *ngIf="officeSharepointViewer.isSaving" class="example-loading-shade">
<mat-spinner style="margin:auto;"></mat-spinner>
</div>
</ng-container>
<button mat-fab *ngIf="isDocModified && mode === 'mainDocument' && resId !== null && editor.mode !== 'office365sharepoint'" color="accent"
[title]="'lang.saveModifications' | translate" style="position: absolute;z-index: 3;bottom: 40px;right: 60px;"
(click)="saveMainDocument()">
<mat-icon style="height:auto;font-size:20px;" class="fas fa-check"></mat-icon>
</button>
<button mat-fab *ngIf="isDocModified && mode === 'attachment'" color="accent"
<button mat-fab *ngIf="isDocModified && mode === 'attachment' && editor.mode !== 'office365sharepoint'" color="accent"
[title]="'lang.saveModifications' | translate" style="position: absolute;z-index: 3;bottom: 40px;right: 60px;"
(click)="saveTmpDocument()">