1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* This program is free software: you can redistribute it and/or modify |
5
|
|
|
* it under the terms of the GNU Lesser General Public License as published by |
6
|
|
|
* the Free Software Foundation, either version 3 of the License, or |
7
|
|
|
* (at your option) any later version. |
8
|
|
|
* |
9
|
|
|
* This program is distributed in the hope that it will be useful, |
10
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
11
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12
|
|
|
* GNU Lesser General Public License for more details. |
13
|
|
|
* |
14
|
|
|
* You should have received a copy of the GNU Lesser General Public License |
15
|
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
16
|
|
|
*/ |
17
|
|
|
|
18
|
|
|
namespace fkooman\RemoteStorage; |
19
|
|
|
|
20
|
|
|
use fkooman\RemoteStorage\Exception\PathException; |
21
|
|
|
use fkooman\RemoteStorage\Http\Exception\HttpException; |
22
|
|
|
use fkooman\RemoteStorage\Http\Request; |
23
|
|
|
use fkooman\RemoteStorage\Http\Response; |
24
|
|
|
use fkooman\RemoteStorage\OAuth\TokenInfo; |
25
|
|
|
use InvalidArgumentException; |
26
|
|
|
|
27
|
|
|
class ApiModule |
28
|
|
|
{ |
29
|
|
|
/** @var RemoteStorage */ |
30
|
|
|
private $remoteStorage; |
31
|
|
|
|
32
|
|
|
/** @var string */ |
33
|
|
|
private $serverMode; |
34
|
|
|
|
35
|
|
|
public function __construct(RemoteStorage $remoteStorage, $serverMode) |
36
|
|
|
{ |
37
|
|
|
$this->remoteStorage = $remoteStorage; |
38
|
|
|
$this->serverMode = $serverMode; |
39
|
|
|
} |
40
|
|
|
|
41
|
|
|
/** |
42
|
|
|
* @param Request $request |
43
|
|
|
* @param string|false $tokenInfo |
44
|
|
|
*/ |
45
|
|
|
public function get(Request $request, $tokenInfo) |
46
|
|
|
{ |
47
|
|
|
$response = $this->getObject($request, $tokenInfo); |
|
|
|
|
48
|
|
|
$this->addNoCache($response); |
49
|
|
|
$this->addCors($response); |
50
|
|
|
|
51
|
|
|
return $response; |
52
|
|
|
} |
53
|
|
|
|
54
|
|
|
public function head(Request $request, $tokenInfo) |
55
|
|
|
{ |
56
|
|
|
// XXX return headers only? |
57
|
|
|
$response = $this->getObject($request, $tokenInfo); |
58
|
|
|
$this->addNoCache($response); |
59
|
|
|
$this->addCors($response); |
60
|
|
|
|
61
|
|
|
return $response; |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
public function put(Request $request, TokenInfo $tokenInfo) |
65
|
|
|
{ |
66
|
|
|
$response = $this->putDocument($request, $tokenInfo); |
67
|
|
|
$this->addCors($response); |
68
|
|
|
|
69
|
|
|
return $response; |
70
|
|
|
} |
71
|
|
|
|
72
|
|
|
public function delete(Request $request, TokenInfo $tokenInfo) |
73
|
|
|
{ |
74
|
|
|
$response = $this->deleteDocument($request, $tokenInfo); |
75
|
|
|
$this->addCors($response); |
76
|
|
|
|
77
|
|
|
return $response; |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
public function options(Request $request) |
|
|
|
|
81
|
|
|
{ |
82
|
|
|
$response = new Response(); |
83
|
|
|
$response->addHeader( |
84
|
|
|
'Access-Control-Allow-Methods', |
85
|
|
|
'GET, PUT, DELETE, HEAD, OPTIONS' |
86
|
|
|
); |
87
|
|
|
$response->addHeader( |
88
|
|
|
'Access-Control-Allow-Headers', |
89
|
|
|
'Authorization, Content-Length, Content-Type, Origin, X-Requested-With, If-Match, If-None-Match' |
90
|
|
|
); |
91
|
|
|
$this->addCors($response); |
92
|
|
|
|
93
|
|
|
return $response; |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
/** |
97
|
|
|
* @param Request $request |
98
|
|
|
* @param TokenInfo|false $tokenInfo |
99
|
|
|
*/ |
100
|
|
|
public function getObject(Request $request, $tokenInfo) |
101
|
|
|
{ |
102
|
|
|
$path = new Path($request->getPathInfo()); |
103
|
|
|
|
104
|
|
|
// allow requests to public files (GET|HEAD) without authentication |
105
|
|
|
if ($path->getIsPublic() && $path->getIsDocument()) { |
106
|
|
|
// XXX create a getPublicDocument call instead to make sure? |
107
|
|
|
return $this->getDocument($path, $request, $tokenInfo); |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
// past this point we MUST be authenticated |
111
|
|
|
if (!$tokenInfo) { |
112
|
|
|
throw new HttpException( |
113
|
|
|
'no_token', |
114
|
|
|
401, |
115
|
|
|
['WWW-Authenticate' => 'Bearer realm="remoteStorage API"'] |
116
|
|
|
); |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
if ($path->getIsFolder()) { |
120
|
|
|
return $this->getFolder($path, $request, $tokenInfo); |
121
|
|
|
} |
122
|
|
|
|
123
|
|
|
return $this->getDocument($path, $request, $tokenInfo); |
124
|
|
|
} |
125
|
|
|
|
126
|
|
|
public function getFolder(Path $path, Request $request, TokenInfo $tokenInfo) |
127
|
|
|
{ |
128
|
|
|
if ($path->getUserId() !== $tokenInfo->getUserId()) { |
129
|
|
|
throw new HttpException('path does not match authorized subject', 403); |
130
|
|
|
} |
131
|
|
|
if (!$this->hasReadScope($tokenInfo->getScope(), $path->getModuleName())) { |
132
|
|
|
throw new HttpException('path does not match authorized scope', 403); |
133
|
|
|
} |
134
|
|
|
|
135
|
|
|
$folderVersion = $this->remoteStorage->getVersion($path); |
136
|
|
|
if (null === $folderVersion) { |
137
|
|
|
// folder does not exist, so we just invent this |
138
|
|
|
// ETag that will be the same for all empty folders |
139
|
|
|
$folderVersion = 'e:404'; |
140
|
|
|
} |
141
|
|
|
|
142
|
|
|
$requestedVersion = $this->stripQuotes( |
143
|
|
|
$request->getHeader('If-None-Match', false, null) |
144
|
|
|
); |
145
|
|
|
|
146
|
|
View Code Duplication |
if (null !== $requestedVersion) { |
|
|
|
|
147
|
|
|
if (in_array($folderVersion, $requestedVersion)) { |
148
|
|
|
//return new RemoteStorageResponse($request, 304, $folderVersion); |
|
|
|
|
149
|
|
|
$response = new Response(304, 'application/ld+json'); |
150
|
|
|
$response->addHeader('ETag', '"'.$folderVersion.'"'); |
151
|
|
|
|
152
|
|
|
return $response; |
153
|
|
|
} |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
$rsr = new Response(200, 'application/ld+json'); |
157
|
|
|
$rsr->addHeader('ETag', '"'.$folderVersion.'"'); |
158
|
|
|
|
159
|
|
|
if ('GET' === $request->getRequestMethod()) { |
160
|
|
|
$rsr->setBody( |
161
|
|
|
$this->remoteStorage->getFolder( |
162
|
|
|
$path, |
163
|
|
|
$this->stripQuotes( |
164
|
|
|
$request->getHeader('If-None-Match', false, null) |
165
|
|
|
) |
166
|
|
|
) |
167
|
|
|
); |
168
|
|
|
} |
169
|
|
|
|
170
|
|
|
return $rsr; |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
public function getDocument(Path $path, Request $request, $tokenInfo) |
174
|
|
|
{ |
175
|
|
|
if ($tokenInfo) { |
176
|
|
|
if ($path->getUserId() !== $tokenInfo->getUserId()) { |
177
|
|
|
throw new HttpException('path does not match authorized subject', 403); |
178
|
|
|
} |
179
|
|
|
if (!$this->hasReadScope($tokenInfo->getScope(), $path->getModuleName())) { |
180
|
|
|
throw new HttpException('path does not match authorized scope', 403); |
181
|
|
|
} |
182
|
|
|
} |
183
|
|
|
$documentVersion = $this->remoteStorage->getVersion($path); |
184
|
|
|
if (null === $documentVersion) { |
185
|
|
|
throw new HttpException( |
186
|
|
|
sprintf('document "%s" not found', $path->getPath()), |
187
|
|
|
404 |
188
|
|
|
); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
$requestedVersion = $this->stripQuotes( |
192
|
|
|
$request->getHeader('If-None-Match', false, null) |
193
|
|
|
); |
194
|
|
|
$documentContentType = $this->remoteStorage->getContentType($path); |
195
|
|
|
|
196
|
|
View Code Duplication |
if (null !== $requestedVersion) { |
|
|
|
|
197
|
|
|
if (in_array($documentVersion, $requestedVersion)) { |
198
|
|
|
$response = new Response(304, $documentContentType); |
199
|
|
|
$response->addHeader('ETag', '"'.$documentVersion.'"'); |
200
|
|
|
|
201
|
|
|
return $response; |
202
|
|
|
} |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
$rsr = new Response(200, $documentContentType); |
206
|
|
|
$rsr->addHeader('ETag', '"'.$documentVersion.'"'); |
207
|
|
|
|
208
|
|
|
if ('development' !== $this->serverMode) { |
209
|
|
|
$rsr->addHeader('Accept-Ranges', 'bytes'); |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
if ('GET' === $request->getRequestMethod()) { |
213
|
|
|
if ('development' === $this->serverMode) { |
214
|
|
|
// use body |
215
|
|
|
$rsr->setBody( |
216
|
|
|
file_get_contents( |
217
|
|
|
$this->remoteStorage->getDocument( |
218
|
|
|
$path, |
219
|
|
|
$requestedVersion |
220
|
|
|
) |
221
|
|
|
) |
222
|
|
|
); |
223
|
|
|
} else { |
224
|
|
|
// use X-SendFile |
225
|
|
|
$rsr->setFile( |
226
|
|
|
$this->remoteStorage->getDocument( |
227
|
|
|
$path, |
228
|
|
|
$requestedVersion |
229
|
|
|
) |
230
|
|
|
); |
231
|
|
|
} |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
return $rsr; |
235
|
|
|
} |
236
|
|
|
|
237
|
|
|
public function putDocument(Request $request, TokenInfo $tokenInfo) |
238
|
|
|
{ |
239
|
|
|
$path = new Path($request->getPathInfo()); |
240
|
|
|
|
241
|
|
|
if ($path->getUserId() !== $tokenInfo->getUserId()) { |
242
|
|
|
throw new HttpException('path does not match authorized subject', 403); |
243
|
|
|
} |
244
|
|
|
if (!$this->hasWriteScope($tokenInfo->getScope(), $path->getModuleName())) { |
245
|
|
|
throw new HttpException('path does not match authorized scope', 403); |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
$ifMatch = $this->stripQuotes( |
249
|
|
|
$request->getHeader('If-Match', false, null) |
250
|
|
|
); |
251
|
|
|
$ifNoneMatch = $this->stripQuotes( |
252
|
|
|
$request->getHeader('If-None-Match', false, null) |
253
|
|
|
); |
254
|
|
|
|
255
|
|
|
$documentVersion = $this->remoteStorage->getVersion($path); |
256
|
|
View Code Duplication |
if (null !== $ifMatch && !in_array($documentVersion, $ifMatch)) { |
|
|
|
|
257
|
|
|
throw new HttpException('version mismatch', 412); |
258
|
|
|
} |
259
|
|
|
|
260
|
|
|
if (null !== $ifNoneMatch && in_array('*', $ifNoneMatch) && null !== $documentVersion) { |
261
|
|
|
throw new HttpException('document already exists', 412); |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
$x = $this->remoteStorage->putDocument( |
|
|
|
|
265
|
|
|
$path, |
266
|
|
|
$request->getHeader('HTTP_CONTENT_TYPE'), |
267
|
|
|
$request->getBody(), |
268
|
|
|
$ifMatch, |
269
|
|
|
$ifNoneMatch |
270
|
|
|
); |
271
|
|
|
// we have to get the version again after the PUT |
272
|
|
|
$documentVersion = $this->remoteStorage->getVersion($path); |
273
|
|
|
|
274
|
|
|
$rsr = new Response(); |
275
|
|
|
$rsr->addHeader('ETag', '"'.$documentVersion.'"'); |
276
|
|
|
$rsr->setBody($x); |
277
|
|
|
|
278
|
|
|
return $rsr; |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
public function deleteDocument(Request $request, TokenInfo $tokenInfo) |
282
|
|
|
{ |
283
|
|
|
$path = new Path($request->getPathInfo()); |
284
|
|
|
|
285
|
|
|
if ($path->getUserId() !== $tokenInfo->getUserId()) { |
286
|
|
|
throw new HttpException('path does not match authorized subject', 403); |
287
|
|
|
} |
288
|
|
|
if (!$this->hasWriteScope($tokenInfo->getScope(), $path->getModuleName())) { |
289
|
|
|
throw new HttpException('path does not match authorized scope', 403); |
290
|
|
|
} |
291
|
|
|
|
292
|
|
|
// need to get the version before the delete |
293
|
|
|
$documentVersion = $this->remoteStorage->getVersion($path); |
294
|
|
|
|
295
|
|
|
$ifMatch = $this->stripQuotes( |
296
|
|
|
$request->getHeader('If-Match', false, null) |
297
|
|
|
); |
298
|
|
|
|
299
|
|
|
// if document does not exist, and we have If-Match header set we should |
300
|
|
|
// return a 412 instead of a 404 |
301
|
|
View Code Duplication |
if (null !== $ifMatch && !in_array($documentVersion, $ifMatch)) { |
|
|
|
|
302
|
|
|
throw new HttpException('version mismatch', 412); |
303
|
|
|
} |
304
|
|
|
|
305
|
|
|
if (null === $documentVersion) { |
306
|
|
|
throw new HttpException( |
307
|
|
|
sprintf('document "%s" not found', $path->getPath()), |
308
|
|
|
404 |
309
|
|
|
); |
310
|
|
|
} |
311
|
|
|
|
312
|
|
|
$ifMatch = $this->stripQuotes( |
313
|
|
|
$request->getHeader('If-Match', false, null) |
314
|
|
|
); |
315
|
|
View Code Duplication |
if (null !== $ifMatch && !in_array($documentVersion, $ifMatch)) { |
|
|
|
|
316
|
|
|
throw new HttpException('version mismatch', 412); |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
$x = $this->remoteStorage->deleteDocument( |
|
|
|
|
320
|
|
|
$path, |
321
|
|
|
$ifMatch |
322
|
|
|
); |
323
|
|
|
$rsr = new Response(); |
324
|
|
|
$rsr->addHeader('ETag', '"'.$documentVersion.'"'); |
325
|
|
|
$rsr->setBody($x); |
326
|
|
|
|
327
|
|
|
return $rsr; |
328
|
|
|
} |
329
|
|
|
|
330
|
|
|
/** |
331
|
|
|
* ETag/If-Match/If-None-Match are always quoted, this method removes |
332
|
|
|
* the quotes. |
333
|
|
|
*/ |
334
|
|
|
public function stripQuotes($versionHeader) |
335
|
|
|
{ |
336
|
|
|
if (null === $versionHeader) { |
337
|
|
|
return; |
338
|
|
|
} |
339
|
|
|
|
340
|
|
|
$versions = []; |
341
|
|
|
|
342
|
|
|
if ('*' === $versionHeader) { |
343
|
|
|
return ['*']; |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
foreach (explode(',', $versionHeader) as $v) { |
347
|
|
|
$v = trim($v); |
348
|
|
|
$startQuote = strpos($v, '"'); |
349
|
|
|
$endQuote = strrpos($v, '"'); |
350
|
|
|
$length = strlen($v); |
351
|
|
|
|
352
|
|
|
if (0 !== $startQuote || $length - 1 !== $endQuote) { |
353
|
|
|
throw new HttpException('version header must start and end with a double quote', 400); |
354
|
|
|
} |
355
|
|
|
$versions[] = substr($v, 1, $length - 2); |
356
|
|
|
} |
357
|
|
|
|
358
|
|
|
return $versions; |
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
// public function run(Request $request = null) |
|
|
|
|
362
|
|
|
// { |
363
|
|
|
// if (null === $request) { |
364
|
|
|
// throw new InvalidArgumentException('must provide Request object'); |
365
|
|
|
// } |
366
|
|
|
|
367
|
|
|
// $response = null; |
|
|
|
|
368
|
|
|
// try { |
369
|
|
|
// $response = parent::run($request); |
370
|
|
|
// } catch (PathException $e) { |
371
|
|
|
// $e = new BadRequestException($e->getMessage()); |
372
|
|
|
// $response = $e->getJsonResponse(); |
373
|
|
|
// } |
374
|
|
|
|
375
|
|
|
// // if error, add CORS |
|
|
|
|
376
|
|
|
// $statusCode = $response->getStatusCode(); |
377
|
|
|
// if (400 <= $statusCode && 500 > $statusCode) { |
378
|
|
|
// $this->addCors($response); |
379
|
|
|
// $this->addNoCache($response); |
380
|
|
|
// } |
381
|
|
|
|
382
|
|
|
// return $response; |
|
|
|
|
383
|
|
|
// } |
384
|
|
|
|
385
|
|
|
private function hasReadScope($scope, $moduleName) |
386
|
|
|
{ |
387
|
|
|
$obtainedScopes = explode(' ', $scope); |
388
|
|
|
$requiredScopes = [ |
389
|
|
|
'*:r', |
390
|
|
|
'*:rw', |
391
|
|
|
sprintf('%s:%s', $moduleName, 'r'), |
392
|
|
|
sprintf('%s:%s', $moduleName, 'rw'), |
393
|
|
|
]; |
394
|
|
|
|
395
|
|
|
foreach ($requiredScopes as $requiredScope) { |
396
|
|
|
if (in_array($requiredScope, $obtainedScopes)) { |
397
|
|
|
return true; |
398
|
|
|
} |
399
|
|
|
} |
400
|
|
|
|
401
|
|
|
return false; |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
private function hasWriteScope($scope, $moduleName) |
405
|
|
|
{ |
406
|
|
|
$obtainedScopes = explode(' ', $scope); |
407
|
|
|
$requiredScopes = [ |
408
|
|
|
'*:rw', |
409
|
|
|
sprintf('%s:%s', $moduleName, 'rw'), |
410
|
|
|
]; |
411
|
|
|
|
412
|
|
|
foreach ($requiredScopes as $requiredScope) { |
413
|
|
|
if (in_array($requiredScope, $obtainedScopes)) { |
414
|
|
|
return true; |
415
|
|
|
} |
416
|
|
|
} |
417
|
|
|
|
418
|
|
|
return false; |
419
|
|
|
} |
420
|
|
|
|
421
|
|
|
private function addCors(Response &$response) |
422
|
|
|
{ |
423
|
|
|
$response->addHeader('Access-Control-Allow-Origin', '*'); |
424
|
|
|
$response->addHeader( |
425
|
|
|
'Access-Control-Expose-Headers', |
426
|
|
|
'ETag, Content-Length' |
427
|
|
|
); |
428
|
|
|
} |
429
|
|
|
|
430
|
|
|
private function addNoCache(Response &$response) |
431
|
|
|
{ |
432
|
|
|
$response->addHeader('Expires', '0'); |
433
|
|
|
$response->addHeader('Cache-Control', 'no-cache'); |
434
|
|
|
} |
435
|
|
|
} |
436
|
|
|
|
This check looks at variables that have been passed in as parameters and are passed out again to other methods.
If the outgoing method call has stricter type requirements than the method itself, an issue is raised.
An additional type check may prevent trouble.