richdocuments/lib/Controller/WopiController.php
Mike Kaganski 68ebfaa526 Implement support for insertion of multimedia from Nextcloud assets
This change adds ability to insert multimedia files from Nextcloud
to Impress documents. It advertises EnableInsertRemoteFile capability,
so that Office activates the feature (depends on Office including
commit 60d9a9c20bf69b3f1d8591b2d8400c4a453970ab - older builds won't
show the respective button).

Signed-off-by: Mike Kaganski <mike.kaganski@collabora.com>
2024-11-19 18:05:48 +05:00

850 lines
32 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\Controller;
use OCA\Files_Versions\Versions\IVersionManager;
use OCA\Richdocuments\AppConfig;
use OCA\Richdocuments\AppInfo\Application;
use OCA\Richdocuments\Controller\Attribute\RestrictToWopiServer;
use OCA\Richdocuments\Db\Wopi;
use OCA\Richdocuments\Db\WopiMapper;
use OCA\Richdocuments\Events\DocumentOpenedEvent;
use OCA\Richdocuments\Exceptions\ExpiredTokenException;
use OCA\Richdocuments\Exceptions\UnknownTokenException;
use OCA\Richdocuments\Helper;
use OCA\Richdocuments\PermissionManager;
use OCA\Richdocuments\Service\FederationService;
use OCA\Richdocuments\Service\UserScopeService;
use OCA\Richdocuments\TemplateManager;
use OCA\Richdocuments\TokenManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\StreamResponse;
use OCP\AppFramework\QueryException;
use OCP\Constants;
use OCP\Encryption\IManager as IEncryptionManager;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
use OCP\Files\IRootFolder;
use OCP\Files\Lock\ILock;
use OCP\Files\Lock\ILockManager;
use OCP\Files\Lock\LockContext;
use OCP\Files\Lock\NoLockProviderException;
use OCP\Files\Lock\OwnerLockedException;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as IShareManager;
use OCP\Share\IShare;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
#[RestrictToWopiServer]
class WopiController extends Controller {
// Signifies LOOL that document has been changed externally in this storage
public const LOOL_STATUS_DOC_CHANGED = 1010;
public const WOPI_AVATAR_SIZE = 64;
public function __construct(
$appName,
IRequest $request,
private IRootFolder $rootFolder,
private IURLGenerator $urlGenerator,
private IConfig $config,
private AppConfig $appConfig,
private TokenManager $tokenManager,
private PermissionManager $permissionManager,
private IUserManager $userManager,
private WopiMapper $wopiMapper,
private LoggerInterface $logger,
private TemplateManager $templateManager,
private IShareManager $shareManager,
private UserScopeService $userScopeService,
private FederationService $federationService,
private IEncryptionManager $encryptionManager,
private IGroupManager $groupManager,
private ILockManager $lockManager,
private IEventDispatcher $eventDispatcher,
) {
parent::__construct($appName, $request);
}
/**
* Returns general info about a file.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'GET', url: 'wopi/files/{fileId}')]
public function checkFileInfo(string $fileId, string $access_token): JSONResponse {
try {
[$fileId, , $version] = Helper::parseFileId($fileId);
$wopi = $this->wopiMapper->getWopiForToken($access_token);
$file = $this->getFileForWopiToken($wopi);
if (!($file instanceof File)) {
throw new NotFoundException('No valid file found for ' . $fileId);
}
} catch (NotFoundException|UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$isPublic = empty($wopi->getEditorUid());
$guestUserId = 'Guest-' . \OC::$server->getSecureRandom()->generate(8);
$user = $this->userManager->get($wopi->getEditorUid());
$userDisplayName = $user !== null && !$isPublic ? $user->getDisplayName() : $wopi->getGuestDisplayname();
$isVersion = $version !== '0';
// If the file is locked manually by a user we want to open it read only for all others
$canWriteThroughLock = true;
try {
$locks = $this->lockManager->getLocks($wopi->getFileid());
$canWriteThroughLock = count($locks) > 0 && $locks[0]->getType() === ILock::TYPE_USER && $locks[0]->getOwner() !== $wopi->getEditorUid() ? false : true;
} catch (NoLockProviderException|PreConditionNotMetException) {
}
$response = [
'BaseFileName' => $file->getName(),
'Size' => $file->getSize(),
'Version' => $version,
'UserId' => !$isPublic ? $wopi->getEditorUid() : $guestUserId,
'OwnerId' => $wopi->getOwnerUid(),
'UserFriendlyName' => $userDisplayName,
'UserExtraInfo' => [],
'UserPrivateInfo' => [],
'UserCanWrite' => $canWriteThroughLock && (bool)$wopi->getCanwrite(),
'UserCanNotWriteRelative' => $isPublic || $this->encryptionManager->isEnabled() || $wopi->getHideDownload() || $wopi->isRemoteToken(),
'PostMessageOrigin' => $wopi->getServerHost(),
'LastModifiedTime' => Helper::toISO8601($file->getMTime()),
'SupportsRename' => !$isVersion && !$wopi->isRemoteToken(),
'UserCanRename' => !$isPublic && !$isVersion && !$wopi->isRemoteToken(),
'EnableInsertRemoteImage' => !$isPublic,
'EnableInsertRemoteFile' => !$isPublic,
'EnableShare' => $file->isShareable() && !$isVersion && !$isPublic,
'HideUserList' => '',
'EnableOwnerTermination' => $wopi->getCanwrite() && !$isPublic,
'DisablePrint' => $wopi->getHideDownload(),
'DisableExport' => $wopi->getHideDownload(),
'DisableCopy' => $wopi->getHideDownload(),
'HideExportOption' => $wopi->getHideDownload(),
'HidePrintOption' => $wopi->getHideDownload(),
'DownloadAsPostMessage' => $wopi->getDirect(),
'SupportsLocks' => $this->lockManager->isLockProviderAvailable(),
'IsUserLocked' => $this->permissionManager->userIsFeatureLocked($wopi->getEditorUid()),
'EnableRemoteLinkPicker' => (bool)$wopi->getCanwrite() && !$isPublic && !$wopi->getDirect(),
'HasContentRange' => true,
];
$enableZotero = $this->config->getAppValue(Application::APPNAME, 'zoteroEnabled', 'yes') === 'yes';
if (!$isPublic && $enableZotero) {
$zoteroAPIKey = $this->config->getUserValue($wopi->getEditorUid(), 'richdocuments', 'zoteroAPIKey', '');
$response['UserPrivateInfo']['ZoteroAPIKey'] = $zoteroAPIKey;
}
$enableDocumentSigning = $this->config->getAppValue(Application::APPNAME, 'documentSigningEnabled', 'yes') === 'yes';
if (!$isPublic && $enableDocumentSigning) {
$documentSigningCert = $this->config->getUserValue($wopi->getEditorUid(), 'richdocuments', 'documentSigningCert', '');
$response['UserPrivateInfo']['SignatureCert'] = $documentSigningCert;
$documentSigningKey = $this->config->getUserValue($wopi->getEditorUid(), 'richdocuments', 'documentSigningKey', '');
$response['UserPrivateInfo']['SignatureKey'] = $documentSigningKey;
$documentSigningCa = $this->config->getUserValue($wopi->getEditorUid(), 'richdocuments', 'documentSigningCa', '');
$response['UserPrivateInfo']['SignatureCa'] = $documentSigningCa;
}
if ($wopi->hasTemplateId()) {
$response['TemplateSource'] = $this->getWopiUrlForTemplate($wopi);
}
$share = $this->getShareForWopiToken($wopi);
if ($this->permissionManager->shouldWatermark($file, $wopi->getEditorUid(), $share)) {
$email = $user !== null && !$isPublic ? $user->getEMailAddress() : '';
$replacements = [
'userId' => $wopi->getEditorUid(),
'date' => (new \DateTime())->format('Y-m-d H:i:s'),
'themingName' => \OC::$server->getThemingDefaults()->getName(),
'userDisplayName' => $userDisplayName,
'email' => $email,
];
$watermarkTemplate = $this->appConfig->getAppValue('watermark_text');
$response['WatermarkText'] = preg_replace_callback('/{(.+?)}/', fn ($matches) => $replacements[$matches[1]], $watermarkTemplate);
}
$user = $this->userManager->get($wopi->getEditorUid());
if ($user !== null) {
$response['UserExtraInfo']['avatar'] = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $wopi->getEditorUid(), 'size' => self::WOPI_AVATAR_SIZE]);
if ($this->groupManager->isAdmin($wopi->getEditorUid())) {
$response['UserExtraInfo']['is_admin'] = true; // DEPRECATED
$response['IsAdminUser'] = true;
} else {
$response['UserExtraInfo']['is_admin'] = false; // DEPRECATED
$response['IsAdminUser'] = false;
}
} else {
$response['UserExtraInfo']['avatar'] = $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => urlencode($wopi->getGuestDisplayname()), 'size' => self::WOPI_AVATAR_SIZE]);
$response['UserExtraInfo']['is_admin'] = false; // DEPRECATED
$response['IsAdminUser'] = false;
}
if ($isPublic) {
$response['UserExtraInfo']['is_guest'] = true; // DEPRECATED
$response['IsAnonymousUser'] = true;
} else {
$response['IsAnonymousUser'] = false;
}
if ($wopi->isRemoteToken()) {
$response = $this->setFederationFileInfo($wopi, $response);
}
$response = array_merge($response, $this->appConfig->getWopiOverride());
$this->eventDispatcher->dispatchTyped(new DocumentOpenedEvent(
$user?->getUID(),
$file
));
return new JSONResponse($response);
}
private function setFederationFileInfo(Wopi $wopi, $response) {
$response['UserId'] = 'Guest-' . \OC::$server->getSecureRandom()->generate(8);
if ($wopi->getTokenType() === Wopi::TOKEN_TYPE_REMOTE_USER) {
$remoteUserId = $wopi->getGuestDisplayname();
$cloudID = \OC::$server->getCloudIdManager()->resolveCloudId($remoteUserId);
$response['UserId'] = $cloudID->getDisplayId();
$response['UserFriendlyName'] = $cloudID->getDisplayId();
$response['UserExtraInfo']['avatar'] = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => explode('@', $remoteUserId)[0], 'size' => self::WOPI_AVATAR_SIZE]);
$cleanCloudId = str_replace(['http://', 'https://'], '', $cloudID->getId());
$addressBookEntries = \OC::$server->getContactsManager()->search($cleanCloudId, ['CLOUD']);
foreach ($addressBookEntries as $entry) {
if (isset($entry['CLOUD'])) {
foreach ($entry['CLOUD'] as $cloudID) {
if ($cloudID === $cleanCloudId) {
$response['UserFriendlyName'] = $entry['FN'];
break;
}
}
}
}
}
$initiator = $this->federationService->getRemoteFileDetails($wopi->getRemoteServer(), $wopi->getRemoteServerToken());
if ($initiator === null) {
return $response;
}
$response['UserFriendlyName'] = $this->tokenManager->prepareGuestName($initiator->getGuestDisplayname());
if ($initiator->hasTemplateId()) {
$templateUrl = $wopi->getRemoteServer() . '/index.php/apps/richdocuments/wopi/template/' . $initiator->getTemplateId() . '?access_token=' . $initiator->getToken();
$response['TemplateSource'] = $templateUrl;
}
if ($wopi->getTokenType() === Wopi::TOKEN_TYPE_REMOTE_USER || ($wopi->getTokenType() === Wopi::TOKEN_TYPE_REMOTE_GUEST && $initiator->getEditorUid())) {
$response['UserExtraInfo']['avatar'] = $wopi->getRemoteServer() . '/index.php/avatar/' . $initiator->getEditorUid() . '/' . self::WOPI_AVATAR_SIZE;
}
return $response;
}
/**
* Given an access token and a fileId, returns the contents of the file.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'GET', url: 'wopi/files/{fileId}/contents')]
public function getFile(string $fileId, string $access_token): JSONResponse|StreamResponse {
[$fileId, , $version] = Helper::parseFileId($fileId);
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);
} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if ((int)$fileId !== $wopi->getFileid()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
try {
/** @var File $file */
$file = $this->getFileForWopiToken($wopi);
\OC_User::setIncognitoMode(true);
if ($version !== '0') {
$versionManager = \OC::$server->get(IVersionManager::class);
$info = $versionManager->getVersionFile($this->userManager->get($wopi->getUserForFileAccess()), $file, $version);
if ($info->getSize() === 0) {
$response = new Http\Response();
} else {
$response = new StreamResponse($info->fopen('rb'));
}
} else {
if ($file->getSize() === 0) {
$response = new Http\Response();
} else {
$filesize = $file->getSize();
if ($this->request->getHeader('Range')) {
$partialContent = true;
preg_match('/bytes=(\d+)-(\d+)?/', $this->request->getHeader('Range'), $matches);
$offset = intval($matches[1] ?? 0);
$length = intval($matches[2] ?? 0) - $offset + 1;
if ($length <= 0) {
$length = $filesize - $offset;
}
$fp = $file->fopen('rb');
$rangeStream = fopen('php://temp', 'w+b');
stream_copy_to_stream($fp, $rangeStream, $length, $offset);
fclose($fp);
fseek($rangeStream, 0);
$response = new StreamResponse($rangeStream);
$response->addHeader('Accept-Ranges', 'bytes');
$response->addHeader('Content-Length', $filesize);
$response->setStatus(Http::STATUS_PARTIAL_CONTENT);
$response->addHeader('Content-Range', 'bytes ' . $offset . '-' . ($offset + $length) . '/' . $filesize);
} else {
$response = new StreamResponse($file->fopen('rb'));
}
}
}
$response->addHeader('Content-Disposition', 'attachment');
$response->addHeader('Content-Type', 'application/octet-stream');
return $response;
} catch (\Exception $e) {
$this->logger->error('getFile failed: ' . $e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
$this->logger->error('Version manager could not be found when trying to restore file. Versioning app disabled?: ' . $e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
}
/**
* Given an access token and a fileId, replaces the files with the request body.
* Expects a valid token in access_token parameter.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}/contents')]
public function putFile(string $fileId, string $access_token): JSONResponse {
[$fileId, , ] = Helper::parseFileId($fileId);
$isPutRelative = ($this->request->getHeader('X-WOPI-Override') === 'PUT_RELATIVE');
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);
} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if (!$wopi->getCanwrite()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if (!$this->encryptionManager->isEnabled() || $this->isMasterKeyEnabled()) {
// Set the user to register the change under his name
$this->userScopeService->setUserScope($wopi->getEditorUid());
$this->userScopeService->setFilesystemScope($isPutRelative ? $wopi->getEditorUid() : $wopi->getUserForFileAccess());
} else {
// Per-user encryption is enabled so that collabora isn't able to store the file by using the
// user's private key. Because of that we have to use the incognito mode for writing the file.
\OC_User::setIncognitoMode(true);
}
try {
if ($isPutRelative) {
// the new file needs to be installed in the current user dir
$userFolder = $this->rootFolder->getUserFolder($wopi->getEditorUid());
$file = $userFolder->getFirstNodeById($fileId);
if ($file === null) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$suggested = $this->request->getHeader('X-WOPI-SuggestedTarget');
$suggested = mb_convert_encoding($suggested, 'utf-8', 'utf-7');
if ($suggested[0] === '.') {
$path = dirname($file->getPath()) . '/New File' . $suggested;
} elseif ($suggested[0] !== '/') {
$path = dirname($file->getPath()) . '/' . $suggested;
} else {
$path = $userFolder->getPath() . $suggested;
}
if ($path === '') {
return new JSONResponse([
'status' => 'error',
'message' => 'Cannot create the file'
]);
}
// create the folder first
if (!$this->rootFolder->nodeExists(dirname($path))) {
$this->rootFolder->newFolder(dirname($path));
}
// create a unique new file
$path = $this->rootFolder->getNonExistingName($path);
$this->rootFolder->newFile($path);
$file = $this->rootFolder->get($path);
} else {
$file = $this->getFileForWopiToken($wopi);
$wopiHeaderTime = $this->request->getHeader('X-LOOL-WOPI-Timestamp');
if (!empty($wopiHeaderTime) && $wopiHeaderTime !== Helper::toISO8601($file->getMTime() ?? 0)) {
$this->logger->debug('Document timestamp mismatch ! WOPI client says mtime {headerTime} but storage says {storageTime}', [
'headerTime' => $wopiHeaderTime,
'storageTime' => Helper::toISO8601($file->getMTime() ?? 0)
]);
// Tell WOPI client about this conflict.
return new JSONResponse(['LOOLStatusCode' => self::LOOL_STATUS_DOC_CHANGED], Http::STATUS_CONFLICT);
}
}
$content = fopen('php://input', 'rb');
$freespace = $file->getParent()->getFreeSpace();
$contentLength = (int)$this->request->getHeader('Content-Length');
try {
if ($freespace >= 0 && $contentLength > $freespace) {
throw new \Exception('Not enough storage');
}
$this->wrappedFilesystemOperation($wopi, fn () => $file->putContent($content));
} catch (LockedException $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['message' => 'File locked'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
if ($isPutRelative) {
// generate a token for the new file (the user still has to be logged in)
$wopi = $this->tokenManager->generateWopiToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
return new JSONResponse(['Name' => $file->getName(), 'Url' => $this->getWopiUrlForFile($wopi, $file)], Http::STATUS_OK);
}
if ($wopi->hasTemplateId()) {
$wopi->setTemplateId(null);
$this->wopiMapper->update($wopi);
}
return new JSONResponse(['LastModifiedTime' => Helper::toISO8601($file->getMTime())]);
} catch (NotFoundException $e) {
$this->logger->warning($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Given an access token and a fileId, replaces the files with the request body.
* Expects a valid token in access_token parameter.
* Just actually routes to the PutFile, the implementation of PutFile
* handles both saving and saving as.* Given an access token and a fileId, replaces the files with the request body.
*
* FIXME Cleanup this code as is a lot of shared logic between putFile and putRelativeFile
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}')]
public function postFile(string $fileId, string $access_token): JSONResponse {
try {
$wopiOverride = $this->request->getHeader('X-WOPI-Override');
$wopiLock = $this->request->getHeader('X-WOPI-Lock');
[$fileId, , ] = Helper::parseFileId($fileId);
$wopi = $this->wopiMapper->getWopiForToken($access_token);
if ((int)$fileId !== $wopi->getFileid()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
switch ($wopiOverride) {
case 'LOCK':
return $this->lock($wopi, $wopiLock);
case 'UNLOCK':
return $this->unlock($wopi, $wopiLock);
case 'REFRESH_LOCK':
return $this->refreshLock($wopi, $wopiLock);
case 'GET_LOCK':
return $this->getLock($wopi, $wopiLock);
case 'RENAME_FILE':
break; //FIXME: Move to function
default:
break; //FIXME: Move to function and add error for unsupported method
}
$isRenameFile = ($this->request->getHeader('X-WOPI-Override') === 'RENAME_FILE');
if (!$wopi->getCanwrite()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Unless the editor is empty (public link) we modify the files as the current editor
$editor = $wopi->getEditorUid();
if ($editor === null && !$wopi->isRemoteToken()) {
$editor = $wopi->getOwnerUid();
}
try {
// the new file needs to be installed in the current user dir
$userFolder = $this->rootFolder->getUserFolder($editor);
if ($isRenameFile) {
// the new file needs to be installed in the current user dir
$file = $this->getFileForWopiToken($wopi);
$suggested = $this->request->getHeader('X-WOPI-RequestedName');
$suggested = mb_convert_encoding($suggested, 'utf-8', 'utf-7') . '.' . $file->getExtension();
if (str_starts_with($suggested, '.')) {
$path = dirname($file->getPath()) . '/New File' . $suggested;
} elseif (!str_starts_with($suggested, '/')) {
$path = dirname($file->getPath()) . '/' . $suggested;
} else {
$path = $userFolder->getPath() . $suggested;
}
if ($path === '') {
return new JSONResponse([
'status' => 'error',
'message' => 'Cannot rename the file'
]);
}
// create the folder first
if (!$this->rootFolder->nodeExists(dirname($path))) {
$this->rootFolder->newFolder(dirname($path));
}
// create a unique new file
$path = $this->rootFolder->getNonExistingName($path);
$this->lockManager->runInScope(new LockContext(
$this->getFileForWopiToken($wopi),
ILock::TYPE_APP,
Application::APPNAME
), function () use (&$file, $path): void {
$file = $file->move($path);
});
} else {
$file = $this->getFileForWopiToken($wopi);
$suggested = $this->request->getHeader('X-WOPI-SuggestedTarget');
$suggested = mb_convert_encoding($suggested, 'utf-8', 'utf-7');
if ($suggested[0] === '.') {
$path = dirname($file->getPath()) . '/New File' . $suggested;
} elseif ($suggested[0] !== '/') {
$path = dirname($file->getPath()) . '/' . $suggested;
} else {
$path = $userFolder->getPath() . $suggested;
}
if ($path === '') {
return new JSONResponse([
'status' => 'error',
'message' => 'Cannot create the file'
]);
}
// create the folder first
if (!$this->rootFolder->nodeExists(dirname($path))) {
$this->rootFolder->newFolder(dirname($path));
}
// create a unique new file
$path = $this->rootFolder->getNonExistingName($path);
$file = $this->rootFolder->newFile($path);
}
$content = fopen('php://input', 'rb');
// Set the user to register the change under his name
$this->userScopeService->setUserScope($wopi->getEditorUid());
$this->userScopeService->setFilesystemScope($wopi->getEditorUid());
try {
$this->wrappedFilesystemOperation($wopi, fn () => $file->putContent($content));
} catch (LockedException) {
return new JSONResponse(['message' => 'File locked'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// epub is exception (can be uploaded but not opened so don't try to get access token)
if ($file->getMimeType() == 'application/epub+zip') {
return new JSONResponse(['Name' => $file->getName()], Http::STATUS_OK);
}
// generate a token for the new file (the user still has to be
// logged in)
$wopi = $this->tokenManager->generateWopiToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
return new JSONResponse(['Name' => $file->getName(), 'Url' => $this->getWopiUrlForFile($wopi, $file)], Http::STATUS_OK);
} catch (NotFoundException $e) {
$this->logger->warning($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function lock(Wopi $wopi, string $lock): JSONResponse {
try {
$lock = $this->lockManager->lock(new LockContext(
$this->getFileForWopiToken($wopi),
ILock::TYPE_APP,
Application::APPNAME
));
return new JSONResponse();
} catch (NoLockProviderException|PreConditionNotMetException) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
} catch (OwnerLockedException $e) {
// If a file is manually locked by a user we want to all this user to still perform a WOPI lock and write
if ($e->getLock()->getType() === ILock::TYPE_USER && $e->getLock()->getOwner() === $wopi->getEditorUid()) {
return new JSONResponse();
}
return new JSONResponse([], Http::STATUS_LOCKED);
} catch (\Exception) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function unlock(Wopi $wopi, string $lock): JSONResponse {
try {
$this->lockManager->unlock(new LockContext(
$this->getFileForWopiToken($wopi),
ILock::TYPE_APP,
Application::APPNAME
));
return new JSONResponse();
} catch (NoLockProviderException|PreConditionNotMetException) {
$locks = $this->lockManager->getLocks($wopi->getFileid());
$canWriteThroughLock = count($locks) > 0 && $locks[0]->getType() === ILock::TYPE_USER && $locks[0]->getOwner() !== $wopi->getEditorUid() ? false : true;
if ($canWriteThroughLock) {
return new JSONResponse();
}
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
} catch (\Exception) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function refreshLock(Wopi $wopi, string $lock): JSONResponse {
try {
$lock = $this->lockManager->lock(new LockContext(
$this->getFileForWopiToken($wopi),
ILock::TYPE_APP,
Application::APPNAME
));
return new JSONResponse();
} catch (NoLockProviderException|PreConditionNotMetException) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
} catch (OwnerLockedException) {
return new JSONResponse([], Http::STATUS_LOCKED);
} catch (\Exception) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function getLock(Wopi $wopi, string $lock): JSONResponse {
$locks = $this->lockManager->getLocks($wopi->getFileid());
return new JSONResponse();
}
/**
* @throws NotFoundException
* @throws GenericFileException
* @throws LockedException
* @throws ShareNotFound
*/
protected function wrappedFilesystemOperation(Wopi $wopi, callable $filesystemOperation): void {
$retryOperation = function () use ($filesystemOperation): void {
$this->retryOperation($filesystemOperation);
};
try {
$this->lockManager->runInScope(new LockContext(
$this->getFileForWopiToken($wopi),
ILock::TYPE_APP,
Application::APPNAME
), $retryOperation);
} catch (NoLockProviderException) {
$retryOperation();
}
}
/**
* Retry operation if a LockedException occurred
* Other exceptions will still be thrown
* @param callable $operation
* @throws LockedException
* @throws GenericFileException
*/
private function retryOperation(callable $operation) {
for ($i = 0; $i < 5; $i++) {
try {
if ($operation() !== false) {
return;
}
} catch (LockedException $e) {
if ($i === 4) {
throw $e;
}
usleep(500000);
}
}
throw new GenericFileException('Operation failed after multiple retries');
}
/**
* @param Wopi $wopi
* @return File|Folder|Node|null
* @throws NotFoundException
* @throws ShareNotFound
*/
private function getFileForWopiToken(Wopi $wopi) {
if (!empty($wopi->getShare())) {
$share = $this->shareManager->getShareByToken($wopi->getShare());
$node = $share->getNode();
if ($node instanceof File) {
return $node;
}
return $node->getFirstNodeById($wopi->getFileid());
}
// Group folders requires an active user to be set in order to apply the proper acl permissions as for anonymous requests it requires share permissions for read access
// https://github.com/nextcloud/groupfolders/blob/e281b1e4514cf7ef4fb2513fb8d8e433b1727eb6/lib/Mount/MountProvider.php#L169
$this->userScopeService->setUserScope($wopi->getEditorUid());
// Unless the editor is empty (public link) we modify the files as the current editor
// TODO: add related share token to the wopi table so we can obtain the
$userFolder = $this->rootFolder->getUserFolder($wopi->getUserForFileAccess());
$files = $userFolder->getById($wopi->getFileid());
if (count($files) === 0) {
throw new NotFoundException('No valid file found for wopi token');
}
// Workaround to always open files with edit permissions if multiple occurrences of
// the same file id are in the user home, ideally we should also track the path of the file when opening
usort($files, fn (Node $a, Node $b) => ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE));
return array_shift($files);
}
private function getShareForWopiToken(Wopi $wopi): ?IShare {
try {
return $wopi->getShare() ? $this->shareManager->getShareByToken($wopi->getShare()) : null;
} catch (ShareNotFound) {
}
return null;
}
/**
* Endpoint to return the template file that is requested by collabora to create a new document
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'GET', url: 'wopi/template/{fileId}')]
public function getTemplate(string $fileId, string $access_token): JSONResponse|StreamResponse {
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);
} catch (UnknownTokenException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
}
if ((int)$fileId !== $wopi->getTemplateId()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
try {
$this->templateManager->setUserId($wopi->getOwnerUid());
$file = $this->templateManager->get($wopi->getTemplateId());
$response = new StreamResponse($file->fopen('rb'));
$response->addHeader('Content-Disposition', 'attachment');
$response->addHeader('Content-Type', 'application/octet-stream');
return $response;
} catch (\Exception $e) {
$this->logger->error('getTemplate failed: ' . $e->getMessage(), ['exception' => $e]);
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if the encryption module uses a master key.
*/
private function isMasterKeyEnabled(): bool {
try {
$util = \OC::$server->query(\OCA\Encryption\Util::class);
return $util->isMasterKeyEnabled();
} catch (QueryException) {
// No encryption module enabled
return false;
}
}
private function getWopiUrlForFile(Wopi $wopi, File $file): string {
$nextcloudUrl = $this->appConfig->getNextcloudUrl() ?: trim($this->urlGenerator->getAbsoluteURL(''), '/');
return $nextcloudUrl . '/index.php/apps/richdocuments/wopi/files/' . $file->getId() . '_' . $this->config->getSystemValue('instanceid') . '?access_token=' . $wopi->getToken();
}
private function getWopiUrlForTemplate(Wopi $wopi): string {
$nextcloudUrl = $this->appConfig->getNextcloudUrl() ?: trim($this->urlGenerator->getAbsoluteURL(''), '/');
return $nextcloudUrl . '/index.php/apps/richdocuments/wopi/template/' . $wopi->getTemplateId() . '?access_token=' . $wopi->getToken();
}
}