Manager.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OC\DirectEditing;
  7. use Doctrine\DBAL\FetchMode;
  8. use OCP\AppFramework\Http\NotFoundResponse;
  9. use OCP\AppFramework\Http\Response;
  10. use OCP\AppFramework\Http\TemplateResponse;
  11. use OCP\Constants;
  12. use OCP\DB\QueryBuilder\IQueryBuilder;
  13. use OCP\DirectEditing\ACreateFromTemplate;
  14. use OCP\DirectEditing\IEditor;
  15. use OCP\DirectEditing\IManager;
  16. use OCP\DirectEditing\IToken;
  17. use OCP\Encryption\IManager as EncryptionManager;
  18. use OCP\Files\File;
  19. use OCP\Files\Folder;
  20. use OCP\Files\IRootFolder;
  21. use OCP\Files\Node;
  22. use OCP\Files\NotFoundException;
  23. use OCP\IDBConnection;
  24. use OCP\IL10N;
  25. use OCP\IUserSession;
  26. use OCP\L10N\IFactory;
  27. use OCP\Security\ISecureRandom;
  28. use OCP\Share\IShare;
  29. use Throwable;
  30. use function array_key_exists;
  31. use function in_array;
  32. class Manager implements IManager {
  33. private const TOKEN_CLEANUP_TIME = 12 * 60 * 60 ;
  34. public const TABLE_TOKENS = 'direct_edit';
  35. /** @var IEditor[] */
  36. private $editors = [];
  37. /** @var string|null */
  38. private $userId;
  39. /** @var IL10N */
  40. private $l10n;
  41. public function __construct(
  42. private ISecureRandom $random,
  43. private IDBConnection $connection,
  44. private IUserSession $userSession,
  45. private IRootFolder $rootFolder,
  46. private IFactory $l10nFactory,
  47. private EncryptionManager $encryptionManager,
  48. ) {
  49. $this->userId = $userSession->getUser() ? $userSession->getUser()->getUID() : null;
  50. $this->l10n = $l10nFactory->get('lib');
  51. }
  52. public function registerDirectEditor(IEditor $directEditor): void {
  53. $this->editors[$directEditor->getId()] = $directEditor;
  54. }
  55. public function getEditors(): array {
  56. return $this->editors;
  57. }
  58. public function getTemplates(string $editor, string $type): array {
  59. if (!array_key_exists($editor, $this->editors)) {
  60. throw new \RuntimeException('No matching editor found');
  61. }
  62. $templates = [];
  63. foreach ($this->editors[$editor]->getCreators() as $creator) {
  64. if ($creator->getId() === $type) {
  65. $templates = [
  66. 'empty' => [
  67. 'id' => 'empty',
  68. 'title' => $this->l10n->t('Empty file'),
  69. 'preview' => null
  70. ]
  71. ];
  72. if ($creator instanceof ACreateFromTemplate) {
  73. $templates = $creator->getTemplates();
  74. }
  75. $templates = array_map(function ($template) use ($creator) {
  76. $template['extension'] = $creator->getExtension();
  77. $template['mimetype'] = $creator->getMimetype();
  78. return $template;
  79. }, $templates);
  80. }
  81. }
  82. $return = [];
  83. $return['templates'] = $templates;
  84. return $return;
  85. }
  86. public function create(string $path, string $editorId, string $creatorId, $templateId = null): string {
  87. $userFolder = $this->rootFolder->getUserFolder($this->userId);
  88. if ($userFolder->nodeExists($path)) {
  89. throw new \RuntimeException('File already exists');
  90. } else {
  91. if (!$userFolder->nodeExists(dirname($path))) {
  92. throw new \RuntimeException('Invalid path');
  93. }
  94. /** @var Folder $folder */
  95. $folder = $userFolder->get(dirname($path));
  96. $file = $folder->newFile(basename($path));
  97. $editor = $this->getEditor($editorId);
  98. $creators = $editor->getCreators();
  99. foreach ($creators as $creator) {
  100. if ($creator->getId() === $creatorId) {
  101. $creator->create($file, $creatorId, $templateId);
  102. return $this->createToken($editorId, $file, $path);
  103. }
  104. }
  105. }
  106. throw new \RuntimeException('No creator found');
  107. }
  108. public function open(string $filePath, ?string $editorId = null, ?int $fileId = null): string {
  109. $userFolder = $this->rootFolder->getUserFolder($this->userId);
  110. $file = $userFolder->get($filePath);
  111. if ($fileId !== null && $file instanceof Folder) {
  112. $files = $file->getById($fileId);
  113. // Workaround to always open files with edit permissions if multiple occurences of
  114. // the same file id are in the user home, ideally we should also track the path of the file when opening
  115. usort($files, function (Node $a, Node $b) {
  116. return ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE);
  117. });
  118. $file = array_shift($files);
  119. }
  120. if (!$file instanceof File) {
  121. throw new NotFoundException();
  122. }
  123. $filePath = $userFolder->getRelativePath($file->getPath());
  124. if ($editorId === null) {
  125. $editorId = $this->findEditorForFile($file);
  126. }
  127. if (!array_key_exists($editorId, $this->editors)) {
  128. throw new \RuntimeException("Editor $editorId is unknown");
  129. }
  130. return $this->createToken($editorId, $file, $filePath);
  131. }
  132. private function findEditorForFile(File $file) {
  133. foreach ($this->editors as $editor) {
  134. if (in_array($file->getMimeType(), $editor->getMimetypes())) {
  135. return $editor->getId();
  136. }
  137. }
  138. throw new \RuntimeException('No default editor found for files mimetype');
  139. }
  140. public function edit(string $token): Response {
  141. try {
  142. /** @var IEditor $editor */
  143. $tokenObject = $this->getToken($token);
  144. if ($tokenObject->hasBeenAccessed()) {
  145. throw new \RuntimeException('Token has already been used and can only be used for followup requests');
  146. }
  147. $editor = $this->getEditor($tokenObject->getEditor());
  148. $this->accessToken($token);
  149. } catch (Throwable $throwable) {
  150. $this->invalidateToken($token);
  151. return new NotFoundResponse();
  152. }
  153. try {
  154. $this->invokeTokenScope($tokenObject->getUser());
  155. return $editor->open($tokenObject);
  156. } finally {
  157. $this->revertTokenScope();
  158. }
  159. }
  160. public function editSecure(File $file, string $editorId): TemplateResponse {
  161. // TODO: Implementation in follow up
  162. }
  163. private function getEditor($editorId): IEditor {
  164. if (!array_key_exists($editorId, $this->editors)) {
  165. throw new \RuntimeException('No editor found');
  166. }
  167. return $this->editors[$editorId];
  168. }
  169. public function getToken(string $token): IToken {
  170. $query = $this->connection->getQueryBuilder();
  171. $query->select('*')->from(self::TABLE_TOKENS)
  172. ->where($query->expr()->eq('token', $query->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
  173. $result = $query->executeQuery();
  174. if ($tokenRow = $result->fetch(FetchMode::ASSOCIATIVE)) {
  175. return new Token($this, $tokenRow);
  176. }
  177. throw new \RuntimeException('Failed to validate the token');
  178. }
  179. public function cleanup(): int {
  180. $query = $this->connection->getQueryBuilder();
  181. $query->delete(self::TABLE_TOKENS)
  182. ->where($query->expr()->lt('timestamp', $query->createNamedParameter(time() - self::TOKEN_CLEANUP_TIME)));
  183. return $query->executeStatement();
  184. }
  185. public function refreshToken(string $token): bool {
  186. $query = $this->connection->getQueryBuilder();
  187. $query->update(self::TABLE_TOKENS)
  188. ->set('timestamp', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
  189. ->where($query->expr()->eq('token', $query->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
  190. $result = $query->executeStatement();
  191. return $result !== 0;
  192. }
  193. public function invalidateToken(string $token): bool {
  194. $query = $this->connection->getQueryBuilder();
  195. $query->delete(self::TABLE_TOKENS)
  196. ->where($query->expr()->eq('token', $query->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
  197. $result = $query->executeStatement();
  198. return $result !== 0;
  199. }
  200. public function accessToken(string $token): bool {
  201. $query = $this->connection->getQueryBuilder();
  202. $query->update(self::TABLE_TOKENS)
  203. ->set('accessed', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
  204. ->set('timestamp', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
  205. ->where($query->expr()->eq('token', $query->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
  206. $result = $query->executeStatement();
  207. return $result !== 0;
  208. }
  209. public function invokeTokenScope($userId): void {
  210. \OC_User::setUserId($userId);
  211. }
  212. public function revertTokenScope(): void {
  213. $this->userSession->setUser(null);
  214. }
  215. public function createToken($editorId, File $file, string $filePath, ?IShare $share = null): string {
  216. $token = $this->random->generate(64, ISecureRandom::CHAR_HUMAN_READABLE);
  217. $query = $this->connection->getQueryBuilder();
  218. $query->insert(self::TABLE_TOKENS)
  219. ->values([
  220. 'token' => $query->createNamedParameter($token),
  221. 'editor_id' => $query->createNamedParameter($editorId),
  222. 'file_id' => $query->createNamedParameter($file->getId()),
  223. 'file_path' => $query->createNamedParameter($filePath),
  224. 'user_id' => $query->createNamedParameter($this->userId),
  225. 'share_id' => $query->createNamedParameter($share !== null ? $share->getId(): null),
  226. 'timestamp' => $query->createNamedParameter(time())
  227. ]);
  228. $query->executeStatement();
  229. return $token;
  230. }
  231. /**
  232. * @param string $userId
  233. * @param int $fileId
  234. * @param ?string $filePath
  235. * @throws NotFoundException
  236. */
  237. public function getFileForToken($userId, $fileId, $filePath = null): Node {
  238. $userFolder = $this->rootFolder->getUserFolder($userId);
  239. if ($filePath !== null) {
  240. return $userFolder->get($filePath);
  241. }
  242. $file = $userFolder->getFirstNodeById($fileId);
  243. if (!$file) {
  244. throw new NotFoundException('File nound found by id ' . $fileId);
  245. }
  246. return $file;
  247. }
  248. public function isEnabled(): bool {
  249. if (!$this->encryptionManager->isEnabled()) {
  250. return true;
  251. }
  252. try {
  253. $moduleId = $this->encryptionManager->getDefaultEncryptionModuleId();
  254. $module = $this->encryptionManager->getEncryptionModule($moduleId);
  255. /** @var \OCA\Encryption\Util $util */
  256. $util = \OCP\Server::get(\OCA\Encryption\Util::class);
  257. if ($module->isReadyForUser($this->userId) && $util->isMasterKeyEnabled()) {
  258. return true;
  259. }
  260. } catch (Throwable $e) {
  261. }
  262. return false;
  263. }
  264. }