diff options
Diffstat (limited to 'application/api')
-rw-r--r-- | application/api/ApiMiddleware.php | 138 | ||||
-rw-r--r-- | application/api/ApiUtils.php | 137 | ||||
-rw-r--r-- | application/api/controllers/ApiController.php | 71 | ||||
-rw-r--r-- | application/api/controllers/History.php | 70 | ||||
-rw-r--r-- | application/api/controllers/Info.php | 42 | ||||
-rw-r--r-- | application/api/controllers/Links.php | 217 | ||||
-rw-r--r-- | application/api/exceptions/ApiAuthorizationException.php | 34 | ||||
-rw-r--r-- | application/api/exceptions/ApiBadParametersException.php | 19 | ||||
-rw-r--r-- | application/api/exceptions/ApiException.php | 77 | ||||
-rw-r--r-- | application/api/exceptions/ApiInternalException.php | 19 | ||||
-rw-r--r-- | application/api/exceptions/ApiLinkNotFoundException.php | 32 |
11 files changed, 856 insertions, 0 deletions
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php new file mode 100644 index 00000000..ff209393 --- /dev/null +++ b/application/api/ApiMiddleware.php | |||
@@ -0,0 +1,138 @@ | |||
1 | <?php | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Api\Exceptions\ApiException; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | use Slim\Container; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ApiMiddleware | ||
14 | * | ||
15 | * This will be called before accessing any API Controller. | ||
16 | * Its role is to make sure that the API is enabled, configured, and to validate the JWT token. | ||
17 | * | ||
18 | * If the request is validated, the controller is called, otherwise a JSON error response is returned. | ||
19 | * | ||
20 | * @package Api | ||
21 | */ | ||
22 | class ApiMiddleware | ||
23 | { | ||
24 | /** | ||
25 | * @var int JWT token validity in seconds (9 min). | ||
26 | */ | ||
27 | public static $TOKEN_DURATION = 540; | ||
28 | |||
29 | /** | ||
30 | * @var Container: contains conf, plugins, etc. | ||
31 | */ | ||
32 | protected $container; | ||
33 | |||
34 | /** | ||
35 | * @var ConfigManager instance. | ||
36 | */ | ||
37 | protected $conf; | ||
38 | |||
39 | /** | ||
40 | * ApiMiddleware constructor. | ||
41 | * | ||
42 | * @param Container $container instance. | ||
43 | */ | ||
44 | public function __construct($container) | ||
45 | { | ||
46 | $this->container = $container; | ||
47 | $this->conf = $this->container->get('conf'); | ||
48 | $this->setLinkDb($this->conf); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Middleware execution: | ||
53 | * - check the API request | ||
54 | * - execute the controller | ||
55 | * - return the response | ||
56 | * | ||
57 | * @param Request $request Slim request | ||
58 | * @param Response $response Slim response | ||
59 | * @param callable $next Next action | ||
60 | * | ||
61 | * @return Response response. | ||
62 | */ | ||
63 | public function __invoke($request, $response, $next) | ||
64 | { | ||
65 | try { | ||
66 | $this->checkRequest($request); | ||
67 | $response = $next($request, $response); | ||
68 | } catch(ApiException $e) { | ||
69 | $e->setResponse($response); | ||
70 | $e->setDebug($this->conf->get('dev.debug', false)); | ||
71 | $response = $e->getApiResponse(); | ||
72 | } | ||
73 | |||
74 | return $response; | ||
75 | } | ||
76 | |||
77 | /** | ||
78 | * Check the request validity (HTTP method, request value, etc.), | ||
79 | * that the API is enabled, and the JWT token validity. | ||
80 | * | ||
81 | * @param Request $request Slim request | ||
82 | * | ||
83 | * @throws ApiAuthorizationException The API is disabled or the token is invalid. | ||
84 | */ | ||
85 | protected function checkRequest($request) | ||
86 | { | ||
87 | if (! $this->conf->get('api.enabled', true)) { | ||
88 | throw new ApiAuthorizationException('API is disabled'); | ||
89 | } | ||
90 | $this->checkToken($request); | ||
91 | } | ||
92 | |||
93 | /** | ||
94 | * Check that the JWT token is set and valid. | ||
95 | * The API secret setting must be set. | ||
96 | * | ||
97 | * @param Request $request Slim request | ||
98 | * | ||
99 | * @throws ApiAuthorizationException The token couldn't be validated. | ||
100 | */ | ||
101 | protected function checkToken($request) { | ||
102 | if (! $request->hasHeader('Authorization')) { | ||
103 | throw new ApiAuthorizationException('JWT token not provided'); | ||
104 | } | ||
105 | |||
106 | if (empty($this->conf->get('api.secret'))) { | ||
107 | throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); | ||
108 | } | ||
109 | |||
110 | $authorization = $request->getHeaderLine('Authorization'); | ||
111 | |||
112 | if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { | ||
113 | throw new ApiAuthorizationException('Invalid JWT header'); | ||
114 | } | ||
115 | |||
116 | ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret')); | ||
117 | } | ||
118 | |||
119 | /** | ||
120 | * Instantiate a new LinkDB including private links, | ||
121 | * and load in the Slim container. | ||
122 | * | ||
123 | * FIXME! LinkDB could use a refactoring to avoid this trick. | ||
124 | * | ||
125 | * @param ConfigManager $conf instance. | ||
126 | */ | ||
127 | protected function setLinkDb($conf) | ||
128 | { | ||
129 | $linkDb = new \LinkDB( | ||
130 | $conf->get('resource.datastore'), | ||
131 | true, | ||
132 | $conf->get('privacy.hide_public_links'), | ||
133 | $conf->get('redirector.url'), | ||
134 | $conf->get('redirector.encode_url') | ||
135 | ); | ||
136 | $this->container['db'] = $linkDb; | ||
137 | } | ||
138 | } | ||
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php new file mode 100644 index 00000000..f154bb52 --- /dev/null +++ b/application/api/ApiUtils.php | |||
@@ -0,0 +1,137 @@ | |||
1 | <?php | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Base64Url; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | /** | ||
8 | * REST API utilities | ||
9 | */ | ||
10 | class ApiUtils | ||
11 | { | ||
12 | /** | ||
13 | * Validates a JWT token authenticity. | ||
14 | * | ||
15 | * @param string $token JWT token extracted from the headers. | ||
16 | * @param string $secret API secret set in the settings. | ||
17 | * | ||
18 | * @throws ApiAuthorizationException the token is not valid. | ||
19 | */ | ||
20 | public static function validateJwtToken($token, $secret) | ||
21 | { | ||
22 | $parts = explode('.', $token); | ||
23 | if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) { | ||
24 | throw new ApiAuthorizationException('Malformed JWT token'); | ||
25 | } | ||
26 | |||
27 | $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); | ||
28 | if ($parts[2] != $genSign) { | ||
29 | throw new ApiAuthorizationException('Invalid JWT signature'); | ||
30 | } | ||
31 | |||
32 | $header = json_decode(Base64Url::decode($parts[0])); | ||
33 | if ($header === null) { | ||
34 | throw new ApiAuthorizationException('Invalid JWT header'); | ||
35 | } | ||
36 | |||
37 | $payload = json_decode(Base64Url::decode($parts[1])); | ||
38 | if ($payload === null) { | ||
39 | throw new ApiAuthorizationException('Invalid JWT payload'); | ||
40 | } | ||
41 | |||
42 | if (empty($payload->iat) | ||
43 | || $payload->iat > time() | ||
44 | || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION | ||
45 | ) { | ||
46 | throw new ApiAuthorizationException('Invalid JWT issued time'); | ||
47 | } | ||
48 | } | ||
49 | |||
50 | /** | ||
51 | * Format a Link for the REST API. | ||
52 | * | ||
53 | * @param array $link Link data read from the datastore. | ||
54 | * @param string $indexUrl Shaarli's index URL (used for relative URL). | ||
55 | * | ||
56 | * @return array Link data formatted for the REST API. | ||
57 | */ | ||
58 | public static function formatLink($link, $indexUrl) | ||
59 | { | ||
60 | $out['id'] = $link['id']; | ||
61 | // Not an internal link | ||
62 | if ($link['url'][0] != '?') { | ||
63 | $out['url'] = $link['url']; | ||
64 | } else { | ||
65 | $out['url'] = $indexUrl . $link['url']; | ||
66 | } | ||
67 | $out['shorturl'] = $link['shorturl']; | ||
68 | $out['title'] = $link['title']; | ||
69 | $out['description'] = $link['description']; | ||
70 | $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); | ||
71 | $out['private'] = $link['private'] == true; | ||
72 | $out['created'] = $link['created']->format(\DateTime::ATOM); | ||
73 | if (! empty($link['updated'])) { | ||
74 | $out['updated'] = $link['updated']->format(\DateTime::ATOM); | ||
75 | } else { | ||
76 | $out['updated'] = ''; | ||
77 | } | ||
78 | return $out; | ||
79 | } | ||
80 | |||
81 | /** | ||
82 | * Convert a link given through a request, to a valid link for LinkDB. | ||
83 | * | ||
84 | * If no URL is provided, it will generate a local note URL. | ||
85 | * If no title is provided, it will use the URL as title. | ||
86 | * | ||
87 | * @param array $input Request Link. | ||
88 | * @param bool $defaultPrivate Request Link. | ||
89 | * | ||
90 | * @return array Formatted link. | ||
91 | */ | ||
92 | public static function buildLinkFromRequest($input, $defaultPrivate) | ||
93 | { | ||
94 | $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; | ||
95 | if (isset($input['private'])) { | ||
96 | $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); | ||
97 | } else { | ||
98 | $private = $defaultPrivate; | ||
99 | } | ||
100 | |||
101 | $link = [ | ||
102 | 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], | ||
103 | 'url' => $input['url'], | ||
104 | 'description' => ! empty($input['description']) ? $input['description'] : '', | ||
105 | 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', | ||
106 | 'private' => $private, | ||
107 | 'created' => new \DateTime(), | ||
108 | ]; | ||
109 | return $link; | ||
110 | } | ||
111 | |||
112 | /** | ||
113 | * Update link fields using an updated link object. | ||
114 | * | ||
115 | * @param array $oldLink data | ||
116 | * @param array $newLink data | ||
117 | * | ||
118 | * @return array $oldLink updated with $newLink values | ||
119 | */ | ||
120 | public static function updateLink($oldLink, $newLink) | ||
121 | { | ||
122 | foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { | ||
123 | $oldLink[$field] = $newLink[$field]; | ||
124 | } | ||
125 | $oldLink['updated'] = new \DateTime(); | ||
126 | |||
127 | if (empty($oldLink['url'])) { | ||
128 | $oldLink['url'] = '?' . $oldLink['shorturl']; | ||
129 | } | ||
130 | |||
131 | if (empty($oldLink['title'])) { | ||
132 | $oldLink['title'] = $oldLink['url']; | ||
133 | } | ||
134 | |||
135 | return $oldLink; | ||
136 | } | ||
137 | } | ||
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php new file mode 100644 index 00000000..3be85b98 --- /dev/null +++ b/application/api/controllers/ApiController.php | |||
@@ -0,0 +1,71 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | use \Slim\Container; | ||
7 | |||
8 | /** | ||
9 | * Abstract Class ApiController | ||
10 | * | ||
11 | * Defines REST API Controller dependencies injected from the container. | ||
12 | * | ||
13 | * @package Api\Controllers | ||
14 | */ | ||
15 | abstract class ApiController | ||
16 | { | ||
17 | /** | ||
18 | * @var Container | ||
19 | */ | ||
20 | protected $ci; | ||
21 | |||
22 | /** | ||
23 | * @var ConfigManager | ||
24 | */ | ||
25 | protected $conf; | ||
26 | |||
27 | /** | ||
28 | * @var \LinkDB | ||
29 | */ | ||
30 | protected $linkDb; | ||
31 | |||
32 | /** | ||
33 | * @var \History | ||
34 | */ | ||
35 | protected $history; | ||
36 | |||
37 | /** | ||
38 | * @var int|null JSON style option. | ||
39 | */ | ||
40 | protected $jsonStyle; | ||
41 | |||
42 | /** | ||
43 | * ApiController constructor. | ||
44 | * | ||
45 | * Note: enabling debug mode displays JSON with readable formatting. | ||
46 | * | ||
47 | * @param Container $ci Slim container. | ||
48 | */ | ||
49 | public function __construct(Container $ci) | ||
50 | { | ||
51 | $this->ci = $ci; | ||
52 | $this->conf = $ci->get('conf'); | ||
53 | $this->linkDb = $ci->get('db'); | ||
54 | $this->history = $ci->get('history'); | ||
55 | if ($this->conf->get('dev.debug', false)) { | ||
56 | $this->jsonStyle = JSON_PRETTY_PRINT; | ||
57 | } else { | ||
58 | $this->jsonStyle = null; | ||
59 | } | ||
60 | } | ||
61 | |||
62 | /** | ||
63 | * Get the container. | ||
64 | * | ||
65 | * @return Container | ||
66 | */ | ||
67 | public function getCi() | ||
68 | { | ||
69 | return $this->ci; | ||
70 | } | ||
71 | } | ||
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php new file mode 100644 index 00000000..2ff9deaf --- /dev/null +++ b/application/api/controllers/History.php | |||
@@ -0,0 +1,70 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Api\Controllers; | ||
5 | |||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class History | ||
12 | * | ||
13 | * REST API Controller: /history | ||
14 | * | ||
15 | * @package Shaarli\Api\Controllers | ||
16 | */ | ||
17 | class History extends ApiController | ||
18 | { | ||
19 | /** | ||
20 | * Service providing operation regarding Shaarli datastore and settings. | ||
21 | * | ||
22 | * @param Request $request Slim request. | ||
23 | * @param Response $response Slim response. | ||
24 | * | ||
25 | * @return Response response. | ||
26 | * | ||
27 | * @throws ApiBadParametersException Invalid parameters. | ||
28 | */ | ||
29 | public function getHistory($request, $response) | ||
30 | { | ||
31 | $history = $this->history->getHistory(); | ||
32 | |||
33 | // Return history operations from the {offset}th, starting from {since}. | ||
34 | $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since')); | ||
35 | $offset = $request->getParam('offset'); | ||
36 | if (empty($offset)) { | ||
37 | $offset = 0; | ||
38 | } | ||
39 | else if (ctype_digit($offset)) { | ||
40 | $offset = (int) $offset; | ||
41 | } else { | ||
42 | throw new ApiBadParametersException('Invalid offset'); | ||
43 | } | ||
44 | |||
45 | // limit parameter is either a number of links or 'all' for everything. | ||
46 | $limit = $request->getParam('limit'); | ||
47 | if (empty($limit)) { | ||
48 | $limit = count($history); | ||
49 | } else if (ctype_digit($limit)) { | ||
50 | $limit = (int) $limit; | ||
51 | } else { | ||
52 | throw new ApiBadParametersException('Invalid limit'); | ||
53 | } | ||
54 | |||
55 | $out = []; | ||
56 | $i = 0; | ||
57 | foreach ($history as $entry) { | ||
58 | if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) { | ||
59 | break; | ||
60 | } | ||
61 | if (++$i > $offset) { | ||
62 | $out[$i] = $entry; | ||
63 | $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM); | ||
64 | } | ||
65 | } | ||
66 | $out = array_values($out); | ||
67 | |||
68 | return $response->withJson($out, 200, $this->jsonStyle); | ||
69 | } | ||
70 | } | ||
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php new file mode 100644 index 00000000..25433f72 --- /dev/null +++ b/application/api/controllers/Info.php | |||
@@ -0,0 +1,42 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Slim\Http\Request; | ||
6 | use Slim\Http\Response; | ||
7 | |||
8 | /** | ||
9 | * Class Info | ||
10 | * | ||
11 | * REST API Controller: /info | ||
12 | * | ||
13 | * @package Api\Controllers | ||
14 | * @see http://shaarli.github.io/api-documentation/#links-instance-information-get | ||
15 | */ | ||
16 | class Info extends ApiController | ||
17 | { | ||
18 | /** | ||
19 | * Service providing various information about Shaarli instance. | ||
20 | * | ||
21 | * @param Request $request Slim request. | ||
22 | * @param Response $response Slim response. | ||
23 | * | ||
24 | * @return Response response. | ||
25 | */ | ||
26 | public function getInfo($request, $response) | ||
27 | { | ||
28 | $info = [ | ||
29 | 'global_counter' => count($this->linkDb), | ||
30 | 'private_counter' => count_private($this->linkDb), | ||
31 | 'settings' => array( | ||
32 | 'title' => $this->conf->get('general.title', 'Shaarli'), | ||
33 | 'header_link' => $this->conf->get('general.header_link', '?'), | ||
34 | 'timezone' => $this->conf->get('general.timezone', 'UTC'), | ||
35 | 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), | ||
36 | 'default_private_links' => $this->conf->get('privacy.default_private_links', false), | ||
37 | ), | ||
38 | ]; | ||
39 | |||
40 | return $response->withJson($info, 200, $this->jsonStyle); | ||
41 | } | ||
42 | } | ||
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php new file mode 100644 index 00000000..eb78dd26 --- /dev/null +++ b/application/api/controllers/Links.php | |||
@@ -0,0 +1,217 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Api\ApiUtils; | ||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Shaarli\Api\Exceptions\ApiLinkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class Links | ||
13 | * | ||
14 | * REST API Controller: all services related to links collection. | ||
15 | * | ||
16 | * @package Api\Controllers | ||
17 | * @see http://shaarli.github.io/api-documentation/#links-links-collection | ||
18 | */ | ||
19 | class Links extends ApiController | ||
20 | { | ||
21 | /** | ||
22 | * @var int Number of links returned if no limit is provided. | ||
23 | */ | ||
24 | public static $DEFAULT_LIMIT = 20; | ||
25 | |||
26 | /** | ||
27 | * Retrieve a list of links, allowing different filters. | ||
28 | * | ||
29 | * @param Request $request Slim request. | ||
30 | * @param Response $response Slim response. | ||
31 | * | ||
32 | * @return Response response. | ||
33 | * | ||
34 | * @throws ApiBadParametersException Invalid parameters. | ||
35 | */ | ||
36 | public function getLinks($request, $response) | ||
37 | { | ||
38 | $private = $request->getParam('visibility'); | ||
39 | $links = $this->linkDb->filterSearch( | ||
40 | [ | ||
41 | 'searchtags' => $request->getParam('searchtags', ''), | ||
42 | 'searchterm' => $request->getParam('searchterm', ''), | ||
43 | ], | ||
44 | false, | ||
45 | $private | ||
46 | ); | ||
47 | |||
48 | // Return links from the {offset}th link, starting from 0. | ||
49 | $offset = $request->getParam('offset'); | ||
50 | if (! empty($offset) && ! ctype_digit($offset)) { | ||
51 | throw new ApiBadParametersException('Invalid offset'); | ||
52 | } | ||
53 | $offset = ! empty($offset) ? intval($offset) : 0; | ||
54 | if ($offset > count($links)) { | ||
55 | return $response->withJson([], 200, $this->jsonStyle); | ||
56 | } | ||
57 | |||
58 | // limit parameter is either a number of links or 'all' for everything. | ||
59 | $limit = $request->getParam('limit'); | ||
60 | if (empty($limit)) { | ||
61 | $limit = self::$DEFAULT_LIMIT; | ||
62 | } else if (ctype_digit($limit)) { | ||
63 | $limit = intval($limit); | ||
64 | } else if ($limit === 'all') { | ||
65 | $limit = count($links); | ||
66 | } else { | ||
67 | throw new ApiBadParametersException('Invalid limit'); | ||
68 | } | ||
69 | |||
70 | // 'environment' is set by Slim and encapsulate $_SERVER. | ||
71 | $index = index_url($this->ci['environment']); | ||
72 | |||
73 | $out = []; | ||
74 | $cpt = 0; | ||
75 | foreach ($links as $link) { | ||
76 | if (count($out) >= $limit) { | ||
77 | break; | ||
78 | } | ||
79 | if ($cpt++ >= $offset) { | ||
80 | $out[] = ApiUtils::formatLink($link, $index); | ||
81 | } | ||
82 | } | ||
83 | |||
84 | return $response->withJson($out, 200, $this->jsonStyle); | ||
85 | } | ||
86 | |||
87 | /** | ||
88 | * Return a single formatted link by its ID. | ||
89 | * | ||
90 | * @param Request $request Slim request. | ||
91 | * @param Response $response Slim response. | ||
92 | * @param array $args Path parameters. including the ID. | ||
93 | * | ||
94 | * @return Response containing the link array. | ||
95 | * | ||
96 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
97 | */ | ||
98 | public function getLink($request, $response, $args) | ||
99 | { | ||
100 | if (!isset($this->linkDb[$args['id']])) { | ||
101 | throw new ApiLinkNotFoundException(); | ||
102 | } | ||
103 | $index = index_url($this->ci['environment']); | ||
104 | $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); | ||
105 | |||
106 | return $response->withJson($out, 200, $this->jsonStyle); | ||
107 | } | ||
108 | |||
109 | /** | ||
110 | * Creates a new link from posted request body. | ||
111 | * | ||
112 | * @param Request $request Slim request. | ||
113 | * @param Response $response Slim response. | ||
114 | * | ||
115 | * @return Response response. | ||
116 | */ | ||
117 | public function postLink($request, $response) | ||
118 | { | ||
119 | $data = $request->getParsedBody(); | ||
120 | $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | ||
121 | // duplicate by URL, return 409 Conflict | ||
122 | if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { | ||
123 | return $response->withJson( | ||
124 | ApiUtils::formatLink($dup, index_url($this->ci['environment'])), | ||
125 | 409, | ||
126 | $this->jsonStyle | ||
127 | ); | ||
128 | } | ||
129 | |||
130 | $link['id'] = $this->linkDb->getNextId(); | ||
131 | $link['shorturl'] = link_small_hash($link['created'], $link['id']); | ||
132 | |||
133 | // note: general relative URL | ||
134 | if (empty($link['url'])) { | ||
135 | $link['url'] = '?' . $link['shorturl']; | ||
136 | } | ||
137 | |||
138 | if (empty($link['title'])) { | ||
139 | $link['title'] = $link['url']; | ||
140 | } | ||
141 | |||
142 | $this->linkDb[$link['id']] = $link; | ||
143 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
144 | $this->history->addLink($link); | ||
145 | $out = ApiUtils::formatLink($link, index_url($this->ci['environment'])); | ||
146 | $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]); | ||
147 | return $response->withAddedHeader('Location', $redirect) | ||
148 | ->withJson($out, 201, $this->jsonStyle); | ||
149 | } | ||
150 | |||
151 | /** | ||
152 | * Updates an existing link from posted request body. | ||
153 | * | ||
154 | * @param Request $request Slim request. | ||
155 | * @param Response $response Slim response. | ||
156 | * @param array $args Path parameters. including the ID. | ||
157 | * | ||
158 | * @return Response response. | ||
159 | * | ||
160 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
161 | */ | ||
162 | public function putLink($request, $response, $args) | ||
163 | { | ||
164 | if (! isset($this->linkDb[$args['id']])) { | ||
165 | throw new ApiLinkNotFoundException(); | ||
166 | } | ||
167 | |||
168 | $index = index_url($this->ci['environment']); | ||
169 | $data = $request->getParsedBody(); | ||
170 | |||
171 | $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | ||
172 | // duplicate URL on a different link, return 409 Conflict | ||
173 | if (! empty($requestLink['url']) | ||
174 | && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) | ||
175 | && $dup['id'] != $args['id'] | ||
176 | ) { | ||
177 | return $response->withJson( | ||
178 | ApiUtils::formatLink($dup, $index), | ||
179 | 409, | ||
180 | $this->jsonStyle | ||
181 | ); | ||
182 | } | ||
183 | |||
184 | $responseLink = $this->linkDb[$args['id']]; | ||
185 | $responseLink = ApiUtils::updateLink($responseLink, $requestLink); | ||
186 | $this->linkDb[$responseLink['id']] = $responseLink; | ||
187 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
188 | $this->history->updateLink($responseLink); | ||
189 | |||
190 | $out = ApiUtils::formatLink($responseLink, $index); | ||
191 | return $response->withJson($out, 200, $this->jsonStyle); | ||
192 | } | ||
193 | |||
194 | /** | ||
195 | * Delete an existing link by its ID. | ||
196 | * | ||
197 | * @param Request $request Slim request. | ||
198 | * @param Response $response Slim response. | ||
199 | * @param array $args Path parameters. including the ID. | ||
200 | * | ||
201 | * @return Response response. | ||
202 | * | ||
203 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
204 | */ | ||
205 | public function deleteLink($request, $response, $args) | ||
206 | { | ||
207 | if (! isset($this->linkDb[$args['id']])) { | ||
208 | throw new ApiLinkNotFoundException(); | ||
209 | } | ||
210 | $link = $this->linkDb[$args['id']]; | ||
211 | unset($this->linkDb[(int) $args['id']]); | ||
212 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
213 | $this->history->deleteLink($link); | ||
214 | |||
215 | return $response->withStatus(204); | ||
216 | } | ||
217 | } | ||
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php new file mode 100644 index 00000000..0e3f4776 --- /dev/null +++ b/application/api/exceptions/ApiAuthorizationException.php | |||
@@ -0,0 +1,34 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiAuthorizationException | ||
7 | * | ||
8 | * Request not authorized, return a 401 HTTP code. | ||
9 | */ | ||
10 | class ApiAuthorizationException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * {@inheritdoc} | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | $this->setMessage('Not authorized'); | ||
18 | return $this->buildApiResponse(401); | ||
19 | } | ||
20 | |||
21 | /** | ||
22 | * Set the exception message. | ||
23 | * | ||
24 | * We only return a generic error message in production mode to avoid giving | ||
25 | * to much security information. | ||
26 | * | ||
27 | * @param $message string the exception message. | ||
28 | */ | ||
29 | public function setMessage($message) | ||
30 | { | ||
31 | $original = $this->debug === true ? ': '. $this->getMessage() : ''; | ||
32 | $this->message = $message . $original; | ||
33 | } | ||
34 | } | ||
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php new file mode 100644 index 00000000..e5cc19ea --- /dev/null +++ b/application/api/exceptions/ApiBadParametersException.php | |||
@@ -0,0 +1,19 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiBadParametersException | ||
7 | * | ||
8 | * Invalid request exception, return a 400 HTTP code. | ||
9 | */ | ||
10 | class ApiBadParametersException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * {@inheritdoc} | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | return $this->buildApiResponse(400); | ||
18 | } | ||
19 | } | ||
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php new file mode 100644 index 00000000..c8490e0c --- /dev/null +++ b/application/api/exceptions/ApiException.php | |||
@@ -0,0 +1,77 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | use Slim\Http\Response; | ||
6 | |||
7 | /** | ||
8 | * Abstract class ApiException | ||
9 | * | ||
10 | * Parent Exception related to the API, able to generate a valid Response (ResponseInterface). | ||
11 | * Also can include various information in debug mode. | ||
12 | */ | ||
13 | abstract class ApiException extends \Exception { | ||
14 | |||
15 | /** | ||
16 | * @var Response instance from Slim. | ||
17 | */ | ||
18 | protected $response; | ||
19 | |||
20 | /** | ||
21 | * @var bool Debug mode enabled/disabled. | ||
22 | */ | ||
23 | protected $debug; | ||
24 | |||
25 | /** | ||
26 | * Build the final response. | ||
27 | * | ||
28 | * @return Response Final response to give. | ||
29 | */ | ||
30 | public abstract function getApiResponse(); | ||
31 | |||
32 | /** | ||
33 | * Creates ApiResponse body. | ||
34 | * In production mode, it will only return the exception message, | ||
35 | * but in dev mode, it includes additional information in an array. | ||
36 | * | ||
37 | * @return array|string response body | ||
38 | */ | ||
39 | protected function getApiResponseBody() { | ||
40 | if ($this->debug !== true) { | ||
41 | return $this->getMessage(); | ||
42 | } | ||
43 | return [ | ||
44 | 'message' => $this->getMessage(), | ||
45 | 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() | ||
46 | ]; | ||
47 | } | ||
48 | |||
49 | /** | ||
50 | * Build the Response object to return. | ||
51 | * | ||
52 | * @param int $code HTTP status. | ||
53 | * | ||
54 | * @return Response with status + body. | ||
55 | */ | ||
56 | protected function buildApiResponse($code) | ||
57 | { | ||
58 | $style = $this->debug ? JSON_PRETTY_PRINT : null; | ||
59 | return $this->response->withJson($this->getApiResponseBody(), $code, $style); | ||
60 | } | ||
61 | |||
62 | /** | ||
63 | * @param Response $response | ||
64 | */ | ||
65 | public function setResponse($response) | ||
66 | { | ||
67 | $this->response = $response; | ||
68 | } | ||
69 | |||
70 | /** | ||
71 | * @param bool $debug | ||
72 | */ | ||
73 | public function setDebug($debug) | ||
74 | { | ||
75 | $this->debug = $debug; | ||
76 | } | ||
77 | } | ||
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php new file mode 100644 index 00000000..1cb05532 --- /dev/null +++ b/application/api/exceptions/ApiInternalException.php | |||
@@ -0,0 +1,19 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiInternalException | ||
7 | * | ||
8 | * Generic exception, return a 500 HTTP code. | ||
9 | */ | ||
10 | class ApiInternalException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * @inheritdoc | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | return $this->buildApiResponse(500); | ||
18 | } | ||
19 | } | ||
diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php new file mode 100644 index 00000000..de7e14f5 --- /dev/null +++ b/application/api/exceptions/ApiLinkNotFoundException.php | |||
@@ -0,0 +1,32 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | |||
6 | use Slim\Http\Response; | ||
7 | |||
8 | /** | ||
9 | * Class ApiLinkNotFoundException | ||
10 | * | ||
11 | * Link selected by ID couldn't be found, results in a 404 error. | ||
12 | * | ||
13 | * @package Shaarli\Api\Exceptions | ||
14 | */ | ||
15 | class ApiLinkNotFoundException extends ApiException | ||
16 | { | ||
17 | /** | ||
18 | * ApiLinkNotFoundException constructor. | ||
19 | */ | ||
20 | public function __construct() | ||
21 | { | ||
22 | $this->message = 'Link not found'; | ||
23 | } | ||
24 | |||
25 | /** | ||
26 | * {@inheritdoc} | ||
27 | */ | ||
28 | public function getApiResponse() | ||
29 | { | ||
30 | return $this->buildApiResponse(404); | ||
31 | } | ||
32 | } | ||