Manager.php 9.7 KB

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