|
1
|
|
|
<?php |
|
2
|
|
|
/** |
|
3
|
|
|
* BEdita, API-first content management framework |
|
4
|
|
|
* Copyright 2018 ChannelWeb Srl, Chialab Srl |
|
5
|
|
|
* |
|
6
|
|
|
* This file is part of BEdita: you can redistribute it and/or modify |
|
7
|
|
|
* it under the terms of the GNU Lesser General Public License as published |
|
8
|
|
|
* by the Free Software Foundation, either version 3 of the License, or |
|
9
|
|
|
* (at your option) any later version. |
|
10
|
|
|
* |
|
11
|
|
|
* See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details. |
|
12
|
|
|
*/ |
|
13
|
|
|
|
|
14
|
|
|
namespace App\Controller\Component; |
|
15
|
|
|
|
|
16
|
|
|
use App\Core\Exception\UploadException; |
|
17
|
|
|
use App\Utility\OEmbed; |
|
18
|
|
|
use BEdita\SDK\BEditaClient; |
|
19
|
|
|
use BEdita\SDK\BEditaClientException; |
|
20
|
|
|
use BEdita\WebTools\ApiClientProvider; |
|
21
|
|
|
use Cake\Cache\Cache; |
|
22
|
|
|
use Cake\Controller\Component; |
|
23
|
|
|
use Cake\Core\Configure; |
|
24
|
|
|
use Cake\Http\Exception\InternalErrorException; |
|
25
|
|
|
use Cake\Utility\Hash; |
|
26
|
|
|
use Psr\Log\LogLevel; |
|
27
|
|
|
|
|
28
|
|
|
/** |
|
29
|
|
|
* Component to load available modules. |
|
30
|
|
|
* |
|
31
|
|
|
* @property \Cake\Controller\Component\AuthComponent $Auth |
|
32
|
|
|
*/ |
|
33
|
|
|
class ModulesComponent extends Component |
|
34
|
|
|
{ |
|
35
|
|
|
|
|
36
|
|
|
/** |
|
37
|
|
|
* {@inheritDoc} |
|
38
|
|
|
*/ |
|
39
|
|
|
public $components = ['Auth']; |
|
40
|
|
|
|
|
41
|
|
|
/** |
|
42
|
|
|
* {@inheritDoc} |
|
43
|
|
|
*/ |
|
44
|
|
|
protected $_defaultConfig = [ |
|
45
|
|
|
'currentModuleName' => null, |
|
46
|
|
|
'clearHomeCache' => false, |
|
47
|
|
|
]; |
|
48
|
|
|
|
|
49
|
|
|
/** |
|
50
|
|
|
* Project modules for a user from `/home` endpoint |
|
51
|
|
|
* |
|
52
|
|
|
* @var array |
|
53
|
|
|
*/ |
|
54
|
|
|
protected $modules = []; |
|
55
|
|
|
|
|
56
|
|
|
/** |
|
57
|
|
|
* Read modules and project info from `/home' endpoint. |
|
58
|
|
|
* |
|
59
|
|
|
* @return void |
|
60
|
|
|
*/ |
|
61
|
|
|
public function startup(): void |
|
62
|
|
|
{ |
|
63
|
|
|
if (empty($this->Auth->user('id'))) { |
|
64
|
|
|
$this->getController()->set(['modules' => [], 'project' => []]); |
|
65
|
|
|
|
|
66
|
|
|
return; |
|
67
|
|
|
} |
|
68
|
|
|
|
|
69
|
|
|
if ($this->getConfig('clearHomeCache')) { |
|
70
|
|
|
Cache::delete(sprintf('home_%d', $this->Auth->user('id'))); |
|
71
|
|
|
} |
|
72
|
|
|
|
|
73
|
|
|
$modules = $this->getModules(); |
|
74
|
|
|
$project = $this->getProject(); |
|
75
|
|
|
$this->getController()->set(compact('modules', 'project')); |
|
76
|
|
|
|
|
77
|
|
|
$currentModuleName = $this->getConfig('currentModuleName'); |
|
78
|
|
|
if (!empty($currentModuleName)) { |
|
79
|
|
|
$currentModule = Hash::get($modules, $currentModuleName); |
|
80
|
|
|
} |
|
81
|
|
|
|
|
82
|
|
|
if (!empty($currentModule)) { |
|
83
|
|
|
$this->getController()->set(compact('currentModule')); |
|
84
|
|
|
} |
|
85
|
|
|
} |
|
86
|
|
|
|
|
87
|
|
|
/** |
|
88
|
|
|
* Getter for home endpoint metadata. |
|
89
|
|
|
* |
|
90
|
|
|
* @return array |
|
91
|
|
|
*/ |
|
92
|
|
|
protected function getMeta(): array |
|
93
|
|
|
{ |
|
94
|
|
|
try { |
|
95
|
|
|
$home = Cache::remember( |
|
96
|
|
|
sprintf('home_%d', $this->Auth->user('id')), |
|
97
|
|
|
function () { |
|
98
|
|
|
return ApiClientProvider::getApiClient()->get('/home'); |
|
99
|
|
|
} |
|
100
|
|
|
); |
|
101
|
|
|
} catch (BEditaClientException $e) { |
|
102
|
|
|
// Something bad happened. Returning an empty array instead. |
|
103
|
|
|
// The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback. |
|
104
|
|
|
$this->log($e, LogLevel::ERROR); |
|
105
|
|
|
|
|
106
|
|
|
return []; |
|
107
|
|
|
} |
|
108
|
|
|
|
|
109
|
|
|
return !empty($home['meta']) ? $home['meta'] : []; |
|
110
|
|
|
} |
|
111
|
|
|
|
|
112
|
|
|
/** |
|
113
|
|
|
* Create internal list of available modules in `$this->modules` as an array with `name` as key |
|
114
|
|
|
* and return it. |
|
115
|
|
|
* Modules are read from `/home` endpoint |
|
116
|
|
|
* |
|
117
|
|
|
* @return array |
|
118
|
|
|
*/ |
|
119
|
|
|
public function getModules(): array |
|
120
|
|
|
{ |
|
121
|
|
|
$modulesOrder = Configure::read('Modules.order'); |
|
122
|
|
|
|
|
123
|
|
|
$meta = $this->getMeta(); |
|
124
|
|
|
$modules = collection(Hash::get($meta, 'resources', [])) |
|
125
|
|
|
->map(function (array $data, $endpoint) { |
|
126
|
|
|
$name = substr($endpoint, 1); |
|
127
|
|
|
|
|
128
|
|
|
return $data + compact('name'); |
|
129
|
|
|
}) |
|
130
|
|
|
->reject(function (array $data) { |
|
131
|
|
|
return Hash::get($data, 'hints.object_type') !== true && Hash::get($data, 'name') !== 'trash'; |
|
132
|
|
|
}) |
|
133
|
|
|
->sortBy(function (array $data) use ($modulesOrder) { |
|
134
|
|
|
$name = Hash::get($data, 'name'); |
|
135
|
|
|
$idx = array_search($name, $modulesOrder); |
|
136
|
|
|
if ($idx === false) { |
|
137
|
|
|
// No configured order for this module. Use hash to preserve order, and ensure it is after other modules. |
|
138
|
|
|
$idx = count($modulesOrder) + hexdec(hash('crc32', $name)); |
|
139
|
|
|
|
|
140
|
|
|
if ($name === 'trash') { |
|
141
|
|
|
// Trash eventually. |
|
142
|
|
|
$idx = PHP_INT_MAX; |
|
143
|
|
|
} |
|
144
|
|
|
} |
|
145
|
|
|
|
|
146
|
|
|
return -$idx; |
|
147
|
|
|
}) |
|
148
|
|
|
->toList(); |
|
149
|
|
|
$plugins = Configure::read('Modules.plugins'); |
|
150
|
|
|
if ($plugins) { |
|
151
|
|
|
$modules = array_merge($modules, $plugins); |
|
152
|
|
|
} |
|
153
|
|
|
$this->modules = Hash::combine($modules, '{n}.name', '{n}'); |
|
154
|
|
|
|
|
155
|
|
|
return $this->modules; |
|
156
|
|
|
} |
|
157
|
|
|
|
|
158
|
|
|
/** |
|
159
|
|
|
* Get information about current project. |
|
160
|
|
|
* |
|
161
|
|
|
* @return array |
|
162
|
|
|
*/ |
|
163
|
|
|
public function getProject(): array |
|
164
|
|
|
{ |
|
165
|
|
|
$meta = $this->getMeta(); |
|
166
|
|
|
$project = [ |
|
167
|
|
|
'name' => Hash::get($meta, 'project.name', ''), |
|
168
|
|
|
'version' => Hash::get($meta, 'version', ''), |
|
169
|
|
|
'colophon' => '', // TODO: populate this value. |
|
170
|
|
|
]; |
|
171
|
|
|
|
|
172
|
|
|
return $project; |
|
173
|
|
|
} |
|
174
|
|
|
|
|
175
|
|
|
/** |
|
176
|
|
|
* Check if an object type is abstract or concrete. |
|
177
|
|
|
* This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized. |
|
178
|
|
|
* |
|
179
|
|
|
* @param string $name Name of object type. |
|
180
|
|
|
* @return bool True if abstract, false if concrete |
|
181
|
|
|
*/ |
|
182
|
|
|
public function isAbstract(string $name): bool |
|
183
|
|
|
{ |
|
184
|
|
|
return (bool)Hash::get($this->modules, sprintf('%s.hints.multiple_types', $name), false); |
|
185
|
|
|
} |
|
186
|
|
|
|
|
187
|
|
|
/** |
|
188
|
|
|
* Get list of object types |
|
189
|
|
|
* This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized. |
|
190
|
|
|
* |
|
191
|
|
|
* @param bool|null $abstract Only abstract or concrete types. |
|
192
|
|
|
* @return array Type names list |
|
193
|
|
|
*/ |
|
194
|
|
|
public function objectTypes(?bool $abstract = null): array |
|
195
|
|
|
{ |
|
196
|
|
|
$types = []; |
|
197
|
|
|
foreach ($this->modules as $name => $data) { |
|
198
|
|
|
if (!$data['hints']['object_type']) { |
|
199
|
|
|
continue; |
|
200
|
|
|
} |
|
201
|
|
|
if ($abstract === null || $data['hints']['multiple_types'] === $abstract) { |
|
202
|
|
|
$types[] = $name; |
|
203
|
|
|
} |
|
204
|
|
|
} |
|
205
|
|
|
|
|
206
|
|
|
return $types; |
|
207
|
|
|
} |
|
208
|
|
|
|
|
209
|
|
|
/** |
|
210
|
|
|
* Read oEmbed metadata |
|
211
|
|
|
* |
|
212
|
|
|
* @param string $url Remote URL |
|
213
|
|
|
* @return array|null |
|
214
|
|
|
* @codeCoverageIgnore |
|
215
|
|
|
*/ |
|
216
|
|
|
protected function oEmbedMeta(string $url): ?array |
|
217
|
|
|
{ |
|
218
|
|
|
return (new OEmbed())->readMetadata($url); |
|
219
|
|
|
} |
|
220
|
|
|
|
|
221
|
|
|
/** |
|
222
|
|
|
* Upload a file and store it in a media stream |
|
223
|
|
|
* Or create a remote media trying to get some metadata via oEmbed |
|
224
|
|
|
* |
|
225
|
|
|
* @param array $requestData The request data from form |
|
226
|
|
|
* @return void |
|
227
|
|
|
*/ |
|
228
|
|
|
public function upload(array &$requestData): void |
|
229
|
|
|
{ |
|
230
|
|
|
$uploadBehavior = Hash::get($requestData, 'upload_behavior', 'file'); |
|
231
|
|
|
|
|
232
|
|
|
if ($uploadBehavior === 'embed' && !empty($requestData['remote_url'])) { |
|
233
|
|
|
$data = $this->oEmbedMeta($requestData['remote_url']); |
|
234
|
|
|
$requestData = array_filter($requestData) + $data; |
|
235
|
|
|
|
|
236
|
|
|
return; |
|
237
|
|
|
} |
|
238
|
|
|
if (empty($requestData['file'])) { |
|
239
|
|
|
return; |
|
240
|
|
|
} |
|
241
|
|
|
|
|
242
|
|
|
// verify upload form data |
|
243
|
|
|
if ($this->checkRequestForUpload($requestData)) { |
|
244
|
|
|
// has another stream? drop it |
|
245
|
|
|
$this->removeStream($requestData); |
|
246
|
|
|
|
|
247
|
|
|
// upload file |
|
248
|
|
|
$filename = $requestData['file']['name']; |
|
249
|
|
|
$filepath = $requestData['file']['tmp_name']; |
|
250
|
|
|
$headers = ['Content-Type' => $requestData['file']['type']]; |
|
251
|
|
|
$apiClient = ApiClientProvider::getApiClient(); |
|
252
|
|
|
$response = $apiClient->upload($filename, $filepath, $headers); |
|
253
|
|
|
|
|
254
|
|
|
// assoc stream to media |
|
255
|
|
|
$streamId = $response['data']['id']; |
|
256
|
|
|
$requestData['id'] = $this->assocStreamToMedia($streamId, $requestData, $filename); |
|
257
|
|
|
} |
|
258
|
|
|
unset($requestData['file'], $requestData['remote_url']); |
|
259
|
|
|
} |
|
260
|
|
|
|
|
261
|
|
|
/** |
|
262
|
|
|
* Remove a stream from a media, if any |
|
263
|
|
|
* |
|
264
|
|
|
* @param array $requestData The request data from form |
|
265
|
|
|
* @return void |
|
266
|
|
|
*/ |
|
267
|
|
|
public function removeStream(array $requestData): void |
|
268
|
|
|
{ |
|
269
|
|
|
if (empty($requestData['id'])) { |
|
270
|
|
|
return; |
|
271
|
|
|
} |
|
272
|
|
|
|
|
273
|
|
|
$apiClient = ApiClientProvider::getApiClient(); |
|
274
|
|
|
$response = $apiClient->get(sprintf('/%s/%s/streams', $requestData['model-type'], $requestData['id'])); |
|
275
|
|
|
if (empty($response['data'])) { // no streams for media |
|
276
|
|
|
return; |
|
277
|
|
|
} |
|
278
|
|
|
$streamId = Hash::get($response, 'data.0.id'); |
|
279
|
|
|
$apiClient->deleteObject($streamId, 'streams'); |
|
280
|
|
|
} |
|
281
|
|
|
|
|
282
|
|
|
/** |
|
283
|
|
|
* Associate a stream to a media using API |
|
284
|
|
|
* If $requestData['id'] is null, create media from stream. |
|
285
|
|
|
* If $requestData['id'] is not null, replace properly related stream. |
|
286
|
|
|
* |
|
287
|
|
|
* @param string $streamId The stream ID |
|
288
|
|
|
* @param array $requestData The request data |
|
289
|
|
|
* @param string $defaultTitle The default title for media |
|
290
|
|
|
* @return string The media ID |
|
291
|
|
|
*/ |
|
292
|
|
|
public function assocStreamToMedia(string $streamId, array &$requestData, string $defaultTitle): string |
|
293
|
|
|
{ |
|
294
|
|
|
$apiClient = ApiClientProvider::getApiClient(); |
|
295
|
|
|
$type = $requestData['model-type']; |
|
296
|
|
|
if (empty($requestData['id'])) { |
|
297
|
|
|
// create media from stream |
|
298
|
|
|
// save only `title` (filename if not set) and `status` in new media object |
|
299
|
|
|
$attributes = array_filter([ |
|
300
|
|
|
'title' => !empty($requestData['title']) ? $requestData['title'] : $defaultTitle, |
|
301
|
|
|
'status' => Hash::get($requestData, 'status'), |
|
302
|
|
|
]); |
|
303
|
|
|
$data = compact('type', 'attributes'); |
|
304
|
|
|
$body = compact('data'); |
|
305
|
|
|
$response = $apiClient->createMediaFromStream($streamId, $type, $body); |
|
306
|
|
|
// `title` and `status` saved here, remove from next save |
|
307
|
|
|
unset($requestData['title'], $requestData['status']); |
|
308
|
|
|
|
|
309
|
|
|
return $response['data']['id']; |
|
310
|
|
|
} |
|
311
|
|
|
|
|
312
|
|
|
// assoc existing media to stream |
|
313
|
|
|
$id = $requestData['id']; |
|
314
|
|
|
$data = compact('id', 'type'); |
|
315
|
|
|
$apiClient->replaceRelated($streamId, 'streams', 'object', $data); |
|
316
|
|
|
|
|
317
|
|
|
return $id; |
|
318
|
|
|
} |
|
319
|
|
|
|
|
320
|
|
|
/** |
|
321
|
|
|
* Check request data for upload and return true if upload is boht possible and needed |
|
322
|
|
|
* |
|
323
|
|
|
* |
|
324
|
|
|
* @param array $requestData The request data |
|
325
|
|
|
* @return bool true if upload is possible and needed |
|
326
|
|
|
*/ |
|
327
|
|
|
public function checkRequestForUpload(array $requestData): bool |
|
328
|
|
|
{ |
|
329
|
|
|
// check if change file is empty |
|
330
|
|
|
if ($requestData['file']['error'] === UPLOAD_ERR_NO_FILE) { |
|
331
|
|
|
return false; |
|
332
|
|
|
} |
|
333
|
|
|
|
|
334
|
|
|
// if upload error, throw exception |
|
335
|
|
|
if ($requestData['file']['error'] !== UPLOAD_ERR_OK) { |
|
336
|
|
|
throw new UploadException(null, $requestData['file']['error']); |
|
337
|
|
|
} |
|
338
|
|
|
|
|
339
|
|
|
// verify presence and value of 'name', 'tmp_name', 'type' |
|
340
|
|
|
foreach (['name', 'tmp_name', 'type'] as $field) { |
|
341
|
|
|
if (empty($requestData['file'][$field]) || !is_string($requestData['file'][$field])) { |
|
342
|
|
|
throw new InternalErrorException(sprintf('Invalid form data: file.%s', $field)); |
|
343
|
|
|
} |
|
344
|
|
|
} |
|
345
|
|
|
// verify 'model-type' |
|
346
|
|
|
if (empty($requestData['model-type']) || !is_string($requestData['model-type'])) { |
|
347
|
|
|
throw new InternalErrorException('Invalid form data: model-type'); |
|
348
|
|
|
} |
|
349
|
|
|
|
|
350
|
|
|
return true; |
|
351
|
|
|
} |
|
352
|
|
|
|
|
353
|
|
|
/** |
|
354
|
|
|
* Update object, when failed save occurred. |
|
355
|
|
|
* Check session data by `failedSave.{type}.{id}` key and `failedSave.{type}.{id}__timestamp`. |
|
356
|
|
|
* If data is set and timestamp is not older than 5 minutes. |
|
357
|
|
|
* |
|
358
|
|
|
* @param array $object The object. |
|
359
|
|
|
* @return void |
|
360
|
|
|
*/ |
|
361
|
|
|
public function updateFromFailedSave(array &$object): void |
|
362
|
|
|
{ |
|
363
|
|
|
if (empty($object) || empty($object['id']) || empty($object['type'])) { |
|
364
|
|
|
return; |
|
365
|
|
|
} |
|
366
|
|
|
|
|
367
|
|
|
// check session data for object id => use `failedSave.{type}.{id}` as key |
|
368
|
|
|
$session = $this->request->getSession(); |
|
|
|
|
|
|
369
|
|
|
$key = sprintf('failedSave.%s.%s', $object['type'], $object['id']); |
|
370
|
|
|
$data = $session->read($key); |
|
371
|
|
|
if (empty($data)) { |
|
372
|
|
|
return; |
|
373
|
|
|
} |
|
374
|
|
|
|
|
375
|
|
|
// read timestamp session key |
|
376
|
|
|
$timestampKey = sprintf('%s__timestamp', $key); |
|
377
|
|
|
$timestamp = $session->read($timestampKey); |
|
378
|
|
|
|
|
379
|
|
|
// if data exist for {type} and {id} and `__timestamp` not too old (<= 5 minutes) |
|
380
|
|
|
if (strtotime($timestamp) < strtotime("-5 minutes")) { |
|
381
|
|
|
// => merge with $object['attributes'] |
|
382
|
|
|
$object['attributes'] = array_merge($object['attributes'], $data); |
|
|
|
|
|
|
383
|
|
|
} |
|
384
|
|
|
|
|
385
|
|
|
// remove session data |
|
386
|
|
|
$session->delete($key); |
|
387
|
|
|
$session->delete($timestampKey); |
|
388
|
|
|
} |
|
389
|
|
|
} |
|
390
|
|
|
|
This property has been deprecated. The supplier of the class has supplied an explanatory message.
The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.