Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shaarli API first step #616

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions application/LinkDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -474,4 +474,22 @@ public function days()

return $linkDays;
}

/**
* Reload all links from the datastore.
*/
public function refresh()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could replace _readDB(), along with a scope change

I'm planning to tidy LinkDB anyway, as it doesn't comply with our coding conventions :p

{
$this->_readDB();
}

/**
* Force the user login state.
*
* @param boolean $loggedIn true logged in user (can load private links).
*/
public function setLoggedIn($loggedIn)
{
$this->_loggedIn = $loggedIn;
}
}
2 changes: 1 addition & 1 deletion application/LinkUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function html_extract_charset($html)
/**
* Count private links in given linklist.
*
* @param array $links Linklist.
* @param array|Countable $links Linklist.
*
* @return int Number of private links.
*/
Expand Down
23 changes: 23 additions & 0 deletions application/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,29 @@ public function updateMethodEscapeUnescapedConfig()
}
return true;
}

/**
* Initialize API settings:
* - api.enabled: true
* - api.secret: generated secret
*/
public function updateMethodApiSettings()
{
if ($this->conf->exists('api.secret')) {
return true;
}

$this->conf->set('api.enabled', true);
$this->conf->set(
'api.secret',
generate_api_secret(
$this->conf->get('credentials.login'),
$this->conf->get('credentials.salt')
)
);
$this->conf->write($this->isLoggedIn);
return true;
}
}

/**
Expand Down
26 changes: 26 additions & 0 deletions application/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,29 @@ function autoLocale($headerLocale)
}
setlocale(LC_ALL, $attempts);
}

/**
* Generates a default API secret.
*
* Note that the random-ish methods used in this function are predictable,
* which makes them NOT suitable for crypto.
* BUT the random string is salted with the salt and hashed with the username.
* It makes the generated API secret secured enough for Shaarli.
*
* PHP 7 provides random_int(), designed for cryptography.
* More info: http://stackoverflow.com/questions/4356289/php-random-string-generator

* @param string $username Shaarli login username
* @param string $salt Shaarli password hash salt
*
* @return string|bool Generated API secret, 12 char length.
* Or false if invalid parameters are provided (which will make the API unusable).
*/
function generate_api_secret($username, $salt)
{
if (empty($username) || empty($salt)) {
return false;
}

return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
}
180 changes: 180 additions & 0 deletions application/api/Api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

require_once 'ApiResponse.php';
require_once 'ApiException.php';

/**
* Class Api
*
* Shaarli's API V1. This class validates and processes API request, and returns an ApiResponse.
*/
class Api
{
/**
* @var int Number of item returned by default.
*/
public static $DEFAULT_LIMIT = 20;

/**
* @var int JWT token validity in seconds.
*/
public static $TOKEN_DURATION = 540;

/**
* @var ConfigManager instance.
*/
protected $conf;

/**
* @var LinkDB instance.
*/
protected $linkDB;

/**
* @var PluginManager instance.
*/
protected $pluginManager;

/**
* @var array List of allowed service methods.
*/
protected static $allowedMethod = array(
'getInfo',
);

/**
* Api constructor.
*
* @param $conf ConfigManager instance.
* @param $linkDB LinkDB instance.
* @param $pluginManager PluginManager instnace.
*/
public function __construct(&$conf, $linkDB, $pluginManager)
{
$this->conf = $conf;
$this->linkDB = $linkDB;
/*
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not instantiating a new LinkDB to override the user's login state?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To preserve the dependency injection system, and make it unit testable. However, LinkDB can be instanciated with $conf values, so I can change this.

FIXME!
This is a workaround to load private links even though the user is not logged in Shaarli.
We need to refactor how links are loaded and rendered (also needed for other features).
*/
$this->linkDB->setLoggedIn(true);
$this->linkDB->refresh();
$this->pluginManager = $pluginManager;
}

/**
* Service request processor:
* - Validates the request (token, generic parameters, etc.)
* - Calls the appropriate service with formatted parameters.
* - Returns the ApiResponse processed by the service, created due to an error.
*
* @param $server array $_SERVER.
* @param $headers array List of all request headers (must include `jwt`).
* @param $get array $_GET.
* @param $body string Request body content as a string.
*
* @return ApiResponse
*/
public function call($server, $headers, $get, $body)
{
try {
$this->checkRequest($server, $headers, $get);
$pathParams = ApiUtils::getPathParameters($get['q']);
$method = ApiUtils::getMethod($server['REQUEST_METHOD'], $get['q']);
if (! in_array($method, static::$allowedMethod)) {
throw new ApiAuthorizationException('Method "'. $method .'"" is not allowed');
}

$body = ApiUtils::parseRequestBody($body);
$response = $this->$method($get, $pathParams, $body);
if (! $response instanceof ApiResponse) {
throw new ApiInternalException('Couldn\'t build the response');
}
return $response;
} catch (Exception $e) {
if (! $e instanceof ApiException) {
$e = new ApiInternalException($e->getMessage(), 500, $e);
}

$e->setDebug($this->conf->get('dev.debug'));
$e->setServer($server);
$e->setHeaders($headers);
$e->setGet($get);
$e->setBody($body);
return $e->getApiResponse();
}
}

/**
* Produces link counters and a few useful settings.
*
* @return array Information about Shaarli's instance.
*/
public function getInfo()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: define endpoints in dedicated classes inheriting from ApiResponse

{
$info = array(
'global_counter' => count($this->linkDB),
'private_counter' => count_private($this->linkDB),
'settings' => array(
'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'),
'timezone' => $this->conf->get('general.timezone', 'UTC'),
'enabled_plugins' => $this->conf->get('general.enabled_plugins', array()),
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
),
);
return new ApiResponse(200, array(), $info);
}

/**
* Check the request validity (HTTP method, request value, etc.),
* that the API is enabled, and the JWT token validity.
*
* @param array $server $_SERVER array.
* @param array $headers All request headers.
* @param array $get Request parameters.
*
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
* @throws ApiBadParametersException Invalid request.
*/
protected function checkRequest($server, $headers, $get)
{
if (! $this->conf->get('api.enabled', true)) {
throw new ApiAuthorizationException('API is disabled');
}
if (empty($get['q']) || empty($server['REQUEST_METHOD'])) {
throw new ApiBadParametersException('Invalid API call');
}
$this->checkToken($headers);
}

/**
* Check that the JWT token is set and valid.
* The API secret setting must be set.
*
* @param array $headers HTTP headers.
*
* @throws ApiAuthorizationException The token couldn't be validated.
*/
protected function checkToken($headers) {
if (empty($headers['jwt'])) {
throw new ApiAuthorizationException('JWT token not provided');
}

$secret = $this->conf->get('api.secret');
if (empty($secret)) {
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
}

ApiUtils::validateJwtToken($headers['jwt'], $this->conf->get('api.secret'));
}

/**
* @param ConfigManager $conf
*/
public function setConf($conf)
{
$this->conf = $conf;
}
}
Loading