The legacy OAuth server is removed
This commit is contained in:
parent
110f8018eb
commit
6668066099
16 changed files with 347 additions and 1314 deletions
46
database.sql
46
database.sql
|
@ -231,21 +231,6 @@ CREATE TABLE IF NOT EXISTS `tag` (
|
|||
INDEX `url` (`url`)
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='tags and mentions';
|
||||
|
||||
--
|
||||
-- TABLE clients
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS `clients` (
|
||||
`client_id` varchar(20) NOT NULL COMMENT '',
|
||||
`pw` varchar(20) NOT NULL DEFAULT '' COMMENT '',
|
||||
`redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '',
|
||||
`name` text COMMENT '',
|
||||
`icon` text COMMENT '',
|
||||
`uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id',
|
||||
PRIMARY KEY(`client_id`),
|
||||
INDEX `uid` (`uid`),
|
||||
FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage';
|
||||
|
||||
--
|
||||
-- TABLE permissionset
|
||||
--
|
||||
|
@ -434,20 +419,6 @@ CREATE TABLE IF NOT EXISTS `attach` (
|
|||
FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='file attachments';
|
||||
|
||||
--
|
||||
-- TABLE auth_codes
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS `auth_codes` (
|
||||
`id` varchar(40) NOT NULL COMMENT '',
|
||||
`client_id` varchar(20) NOT NULL DEFAULT '' COMMENT '',
|
||||
`redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '',
|
||||
`expires` int NOT NULL DEFAULT 0 COMMENT '',
|
||||
`scope` varchar(250) NOT NULL DEFAULT '' COMMENT '',
|
||||
PRIMARY KEY(`id`),
|
||||
INDEX `client_id` (`client_id`),
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON UPDATE RESTRICT ON DELETE CASCADE
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage';
|
||||
|
||||
--
|
||||
-- TABLE cache
|
||||
--
|
||||
|
@ -1486,23 +1457,6 @@ CREATE TABLE IF NOT EXISTS `storage` (
|
|||
PRIMARY KEY(`id`)
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Data stored by Database storage backend';
|
||||
|
||||
--
|
||||
-- TABLE tokens
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS `tokens` (
|
||||
`id` varchar(40) NOT NULL COMMENT '',
|
||||
`secret` text COMMENT '',
|
||||
`client_id` varchar(20) NOT NULL DEFAULT '',
|
||||
`expires` int NOT NULL DEFAULT 0 COMMENT '',
|
||||
`scope` varchar(200) NOT NULL DEFAULT '' COMMENT '',
|
||||
`uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id',
|
||||
PRIMARY KEY(`id`),
|
||||
INDEX `client_id` (`client_id`),
|
||||
INDEX `uid` (`uid`),
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE
|
||||
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage';
|
||||
|
||||
--
|
||||
-- TABLE userd
|
||||
--
|
||||
|
|
|
@ -13,9 +13,7 @@ Database Tables
|
|||
| [application](help/database/db_application) | OAuth application |
|
||||
| [application-token](help/database/db_application-token) | OAuth user token |
|
||||
| [attach](help/database/db_attach) | file attachments |
|
||||
| [auth_codes](help/database/db_auth_codes) | OAuth usage |
|
||||
| [cache](help/database/db_cache) | Stores temporary data |
|
||||
| [clients](help/database/db_clients) | OAuth usage |
|
||||
| [config](help/database/db_config) | main configuration storage |
|
||||
| [contact](help/database/db_contact) | contact table |
|
||||
| [contact-relation](help/database/db_contact-relation) | Contact relations |
|
||||
|
@ -69,7 +67,6 @@ Database Tables
|
|||
| [session](help/database/db_session) | web session storage |
|
||||
| [storage](help/database/db_storage) | Data stored by Database storage backend |
|
||||
| [tag](help/database/db_tag) | tags and mentions |
|
||||
| [tokens](help/database/db_tokens) | OAuth usage |
|
||||
| [user](help/database/db_user) | The local users |
|
||||
| [user-contact](help/database/db_user-contact) | User specific public contact data |
|
||||
| [userd](help/database/db_userd) | Deleted usernames |
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
Table auth_codes
|
||||
===========
|
||||
|
||||
OAuth usage
|
||||
|
||||
Fields
|
||||
------
|
||||
|
||||
| Field | Description | Type | Null | Key | Default | Extra |
|
||||
| ------------ | ----------- | ------------ | ---- | --- | ------- | ----- |
|
||||
| id | | varchar(40) | NO | PRI | NULL | |
|
||||
| client_id | | varchar(20) | NO | | | |
|
||||
| redirect_uri | | varchar(200) | NO | | | |
|
||||
| expires | | int | NO | | 0 | |
|
||||
| scope | | varchar(250) | NO | | | |
|
||||
|
||||
Indexes
|
||||
------------
|
||||
|
||||
| Name | Fields |
|
||||
| --------- | --------- |
|
||||
| PRIMARY | id |
|
||||
| client_id | client_id |
|
||||
|
||||
Foreign Keys
|
||||
------------
|
||||
|
||||
| Field | Target Table | Target Field |
|
||||
|-------|--------------|--------------|
|
||||
| client_id | [clients](help/database/db_clients) | client_id |
|
||||
|
||||
Return to [database documentation](help/database)
|
|
@ -1,33 +0,0 @@
|
|||
Table clients
|
||||
===========
|
||||
|
||||
OAuth usage
|
||||
|
||||
Fields
|
||||
------
|
||||
|
||||
| Field | Description | Type | Null | Key | Default | Extra |
|
||||
| ------------ | ----------- | ------------------ | ---- | --- | ------- | ----- |
|
||||
| client_id | | varchar(20) | NO | PRI | NULL | |
|
||||
| pw | | varchar(20) | NO | | | |
|
||||
| redirect_uri | | varchar(200) | NO | | | |
|
||||
| name | | text | YES | | NULL | |
|
||||
| icon | | text | YES | | NULL | |
|
||||
| uid | User id | mediumint unsigned | NO | | 0 | |
|
||||
|
||||
Indexes
|
||||
------------
|
||||
|
||||
| Name | Fields |
|
||||
| ------- | --------- |
|
||||
| PRIMARY | client_id |
|
||||
| uid | uid |
|
||||
|
||||
Foreign Keys
|
||||
------------
|
||||
|
||||
| Field | Target Table | Target Field |
|
||||
|-------|--------------|--------------|
|
||||
| uid | [user](help/database/db_user) | uid |
|
||||
|
||||
Return to [database documentation](help/database)
|
|
@ -1,35 +0,0 @@
|
|||
Table tokens
|
||||
===========
|
||||
|
||||
OAuth usage
|
||||
|
||||
Fields
|
||||
------
|
||||
|
||||
| Field | Description | Type | Null | Key | Default | Extra |
|
||||
| --------- | ----------- | ------------------ | ---- | --- | ------- | ----- |
|
||||
| id | | varchar(40) | NO | PRI | NULL | |
|
||||
| secret | | text | YES | | NULL | |
|
||||
| client_id | | varchar(20) | NO | | | |
|
||||
| expires | | int | NO | | 0 | |
|
||||
| scope | | varchar(200) | NO | | | |
|
||||
| uid | User id | mediumint unsigned | NO | | 0 | |
|
||||
|
||||
Indexes
|
||||
------------
|
||||
|
||||
| Name | Fields |
|
||||
| --------- | --------- |
|
||||
| PRIMARY | id |
|
||||
| client_id | client_id |
|
||||
| uid | uid |
|
||||
|
||||
Foreign Keys
|
||||
------------
|
||||
|
||||
| Field | Target Table | Target Field |
|
||||
|-------|--------------|--------------|
|
||||
| client_id | [clients](help/database/db_clients) | client_id |
|
||||
| uid | [user](help/database/db_user) | uid |
|
||||
|
||||
Return to [database documentation](help/database)
|
|
@ -57,10 +57,7 @@ use Friendica\Network\HTTPException\UnauthorizedException;
|
|||
use Friendica\Object\Image;
|
||||
use Friendica\Protocol\Activity;
|
||||
use Friendica\Protocol\Diaspora;
|
||||
use Friendica\Security\FKOAuth1;
|
||||
use Friendica\Security\OAuth;
|
||||
use Friendica\Security\OAuth1\OAuthRequest;
|
||||
use Friendica\Security\OAuth1\OAuthUtil;
|
||||
use Friendica\Util\DateTimeFormat;
|
||||
use Friendica\Util\Images;
|
||||
use Friendica\Util\Network;
|
||||
|
@ -206,24 +203,6 @@ function api_login(App $a)
|
|||
}
|
||||
|
||||
if (empty($_SERVER['PHP_AUTH_USER'])) {
|
||||
// Try OAuth when no user is provided
|
||||
$oauth1 = new FKOAuth1();
|
||||
// login with oauth
|
||||
try {
|
||||
$request = OAuthRequest::from_request();
|
||||
list($consumer, $token) = $oauth1->verify_request($request);
|
||||
if (!is_null($token)) {
|
||||
$oauth1->loginUser($token->uid);
|
||||
Session::set('allow_api', true);
|
||||
return;
|
||||
}
|
||||
echo __FILE__.__LINE__.__FUNCTION__ . "<pre>";
|
||||
var_dump($consumer, $token);
|
||||
die();
|
||||
} catch (Exception $e) {
|
||||
Logger::warning(API_LOG_PREFIX . 'OAuth error', ['module' => 'api', 'action' => 'login', 'exception' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
Logger::debug(API_LOG_PREFIX . 'failed', ['module' => 'api', 'action' => 'login', 'parameters' => $_SERVER]);
|
||||
header('WWW-Authenticate: Basic realm="Friendica"');
|
||||
throw new UnauthorizedException("This API requires login");
|
||||
|
@ -4057,48 +4036,6 @@ api_register_func('api/direct_messages/all', 'api_direct_messages_all', true);
|
|||
api_register_func('api/direct_messages/sent', 'api_direct_messages_sentbox', true);
|
||||
api_register_func('api/direct_messages', 'api_direct_messages_inbox', true);
|
||||
|
||||
/**
|
||||
* Returns an OAuth Request Token.
|
||||
*
|
||||
* @see https://oauth.net/core/1.0/#auth_step1
|
||||
*/
|
||||
function api_oauth_request_token()
|
||||
{
|
||||
$oauth1 = new FKOAuth1();
|
||||
try {
|
||||
$r = $oauth1->fetch_request_token(OAuthRequest::from_request());
|
||||
} catch (Exception $e) {
|
||||
echo "error=" . OAuthUtil::urlencode_rfc3986($e->getMessage());
|
||||
exit();
|
||||
}
|
||||
echo $r;
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an OAuth Access Token.
|
||||
*
|
||||
* @return array|string
|
||||
* @see https://oauth.net/core/1.0/#auth_step3
|
||||
*/
|
||||
function api_oauth_access_token()
|
||||
{
|
||||
$oauth1 = new FKOAuth1();
|
||||
try {
|
||||
$r = $oauth1->fetch_access_token(OAuthRequest::from_request());
|
||||
} catch (Exception $e) {
|
||||
echo "error=". OAuthUtil::urlencode_rfc3986($e->getMessage());
|
||||
exit();
|
||||
}
|
||||
echo $r;
|
||||
exit();
|
||||
}
|
||||
|
||||
/// @TODO move to top of file or somewhere better
|
||||
api_register_func('api/oauth/request_token', 'api_oauth_request_token', false);
|
||||
api_register_func('api/oauth/access_token', 'api_oauth_access_token', false);
|
||||
|
||||
|
||||
/**
|
||||
* delete a complete photoalbum with all containing photos from database through api
|
||||
*
|
||||
|
|
92
mod/api.php
92
mod/api.php
|
@ -20,32 +20,10 @@
|
|||
*/
|
||||
|
||||
use Friendica\App;
|
||||
use Friendica\Core\Renderer;
|
||||
use Friendica\Database\DBA;
|
||||
use Friendica\DI;
|
||||
use Friendica\Module\Security\Login;
|
||||
use Friendica\Security\OAuth1\OAuthRequest;
|
||||
use Friendica\Security\OAuth1\OAuthUtil;
|
||||
|
||||
require_once __DIR__ . '/../include/api.php';
|
||||
|
||||
function oauth_get_client(OAuthRequest $request)
|
||||
{
|
||||
$params = $request->get_parameters();
|
||||
$token = $params['oauth_token'];
|
||||
|
||||
$r = q("SELECT `clients`.*
|
||||
FROM `clients`, `tokens`
|
||||
WHERE `clients`.`client_id`=`tokens`.`client_id`
|
||||
AND `tokens`.`id`='%s' AND `tokens`.`scope`='request'", DBA::escape($token));
|
||||
|
||||
if (!DBA::isResult($r)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $r[0];
|
||||
}
|
||||
|
||||
function api_post(App $a)
|
||||
{
|
||||
if (!local_user()) {
|
||||
|
@ -61,76 +39,6 @@ function api_post(App $a)
|
|||
|
||||
function api_content(App $a)
|
||||
{
|
||||
if (DI::args()->getCommand() == 'api/oauth/authorize') {
|
||||
/*
|
||||
* api/oauth/authorize interact with the user. return a standard page
|
||||
*/
|
||||
|
||||
DI::page()['template'] = "minimal";
|
||||
|
||||
// get consumer/client from request token
|
||||
try {
|
||||
$request = OAuthRequest::from_request();
|
||||
} catch (Exception $e) {
|
||||
echo "<pre>";
|
||||
var_dump($e);
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!empty($_POST['oauth_yes'])) {
|
||||
$app = oauth_get_client($request);
|
||||
if (is_null($app)) {
|
||||
return "Invalid request. Unknown token.";
|
||||
}
|
||||
$consumer = new OAuthConsumer($app['client_id'], $app['pw'], $app['redirect_uri']);
|
||||
|
||||
$verifier = md5($app['secret'] . local_user());
|
||||
DI::config()->set("oauth", $verifier, local_user());
|
||||
|
||||
if ($consumer->callback_url != null) {
|
||||
$params = $request->get_parameters();
|
||||
$glue = "?";
|
||||
if (strstr($consumer->callback_url, $glue)) {
|
||||
$glue = "?";
|
||||
}
|
||||
DI::baseUrl()->redirect($consumer->callback_url . $glue . 'oauth_token=' . OAuthUtil::urlencode_rfc3986($params['oauth_token']) . '&oauth_verifier=' . OAuthUtil::urlencode_rfc3986($verifier));
|
||||
exit();
|
||||
}
|
||||
|
||||
$tpl = Renderer::getMarkupTemplate("oauth_authorize_done.tpl");
|
||||
$o = Renderer::replaceMacros($tpl, [
|
||||
'$title' => DI::l10n()->t('Authorize application connection'),
|
||||
'$info' => DI::l10n()->t('Return to your app and insert this Securty Code:'),
|
||||
'$code' => $verifier,
|
||||
]);
|
||||
|
||||
return $o;
|
||||
}
|
||||
|
||||
if (!local_user()) {
|
||||
/// @TODO We need login form to redirect to this page
|
||||
notice(DI::l10n()->t('Please login to continue.'));
|
||||
return Login::form(DI::args()->getQueryString(), false, $request->get_parameters());
|
||||
}
|
||||
//FKOAuth1::loginUser(4);
|
||||
|
||||
$app = oauth_get_client($request);
|
||||
if (is_null($app)) {
|
||||
return "Invalid request. Unknown token.";
|
||||
}
|
||||
|
||||
$tpl = Renderer::getMarkupTemplate('oauth_authorize.tpl');
|
||||
$o = Renderer::replaceMacros($tpl, [
|
||||
'$title' => DI::l10n()->t('Authorize application connection'),
|
||||
'$app' => $app,
|
||||
'$authorize' => DI::l10n()->t('Do you want to authorize this application to access your posts and contacts, and/or create new posts for you?'),
|
||||
'$yes' => DI::l10n()->t('Yes'),
|
||||
'$no' => DI::l10n()->t('No'),
|
||||
]);
|
||||
|
||||
return $o;
|
||||
}
|
||||
|
||||
echo api_call($a);
|
||||
exit();
|
||||
}
|
||||
|
|
|
@ -66,63 +66,6 @@ function settings_post(App $a)
|
|||
return;
|
||||
}
|
||||
|
||||
$old_page_flags = $a->user['page-flags'];
|
||||
|
||||
if (($a->argc > 1) && ($a->argv[1] === 'oauth') && !empty($_POST['remove'])) {
|
||||
BaseModule::checkFormSecurityTokenRedirectOnError('/settings/oauth', 'settings_oauth');
|
||||
|
||||
$key = $_POST['remove'];
|
||||
DBA::delete('tokens', ['id' => $key, 'uid' => local_user()]);
|
||||
DI::baseUrl()->redirect('settings/oauth/', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (($a->argc > 2) && ($a->argv[1] === 'oauth') && ($a->argv[2] === 'edit'||($a->argv[2] === 'add')) && !empty($_POST['submit'])) {
|
||||
BaseModule::checkFormSecurityTokenRedirectOnError('/settings/oauth', 'settings_oauth');
|
||||
|
||||
$name = $_POST['name'] ?? '';
|
||||
$key = $_POST['key'] ?? '';
|
||||
$secret = $_POST['secret'] ?? '';
|
||||
$redirect = $_POST['redirect'] ?? '';
|
||||
$icon = $_POST['icon'] ?? '';
|
||||
|
||||
if ($name == "" || $key == "" || $secret == "") {
|
||||
notice(DI::l10n()->t("Missing some important data!"));
|
||||
} else {
|
||||
if ($_POST['submit'] == DI::l10n()->t("Update")) {
|
||||
q("UPDATE clients SET
|
||||
client_id='%s',
|
||||
pw='%s',
|
||||
name='%s',
|
||||
redirect_uri='%s',
|
||||
icon='%s',
|
||||
uid=%d
|
||||
WHERE client_id='%s'",
|
||||
DBA::escape($key),
|
||||
DBA::escape($secret),
|
||||
DBA::escape($name),
|
||||
DBA::escape($redirect),
|
||||
DBA::escape($icon),
|
||||
local_user(),
|
||||
DBA::escape($key)
|
||||
);
|
||||
} else {
|
||||
q("INSERT INTO clients
|
||||
(client_id, pw, name, redirect_uri, icon, uid)
|
||||
VALUES ('%s', '%s', '%s', '%s', '%s',%d)",
|
||||
DBA::escape($key),
|
||||
DBA::escape($secret),
|
||||
DBA::escape($name),
|
||||
DBA::escape($redirect),
|
||||
DBA::escape($icon),
|
||||
local_user()
|
||||
);
|
||||
}
|
||||
}
|
||||
DI::baseUrl()->redirect('settings/oauth/', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (($a->argc > 1) && ($a->argv[1] == 'addon')) {
|
||||
BaseModule::checkFormSecurityTokenRedirectOnError('/settings/addon', 'settings_addon');
|
||||
|
||||
|
|
|
@ -81,7 +81,8 @@ class DBStructure
|
|||
|
||||
$old_tables = ['fserver', 'gcign', 'gcontact', 'gcontact-relation', 'gfollower' ,'glink', 'item-delivery-data',
|
||||
'item-activity', 'item-content', 'item_id', 'participation', 'poll', 'poll_result', 'queue', 'retriever_rule',
|
||||
'deliverq', 'dsprphotoq', 'ffinder', 'sign', 'spam', 'term', 'user-item', 'thread', 'item', 'challenge'];
|
||||
'deliverq', 'dsprphotoq', 'ffinder', 'sign', 'spam', 'term', 'user-item', 'thread', 'item', 'challenge',
|
||||
'auth_codes', 'clients', 'tokens'];
|
||||
|
||||
$tables = DBA::selectToArray(['INFORMATION_SCHEMA' => 'TABLES'], ['TABLE_NAME'],
|
||||
['TABLE_SCHEMA' => DBA::databaseName(), 'TABLE_TYPE' => 'BASE TABLE']);
|
||||
|
@ -1369,7 +1370,7 @@ class DBStructure
|
|||
echo "permissionset: Table not found\n";
|
||||
}
|
||||
|
||||
if (!self::existsForeignKeyForField('tokens', 'client_id')) {
|
||||
if (self::existsTable('tokens') && self::existsTable('clients') && !self::existsForeignKeyForField('tokens', 'client_id')) {
|
||||
$tokens = DBA::p("SELECT `tokens`.`id` FROM `tokens`
|
||||
LEFT JOIN `clients` ON `clients`.`client_id` = `tokens`.`client_id`
|
||||
WHERE `clients`.`client_id` IS NULL");
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @copyright Copyright (C) 2010-2021, the Friendica project
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace Friendica\Security;
|
||||
|
||||
use Friendica\Core\Logger;
|
||||
use Friendica\Database\DBA;
|
||||
use Friendica\DI;
|
||||
use Friendica\Security\OAuth1\OAuthServer;
|
||||
use Friendica\Security\OAuth1\Signature\OAuthSignatureMethod_HMAC_SHA1;
|
||||
use Friendica\Security\OAuth1\Signature\OAuthSignatureMethod_PLAINTEXT;
|
||||
|
||||
/**
|
||||
* OAuth protocol
|
||||
*/
|
||||
class FKOAuth1 extends OAuthServer
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new FKOAuthDataStore());
|
||||
$this->add_signature_method(new OAuthSignatureMethod_PLAINTEXT());
|
||||
$this->add_signature_method(new OAuthSignatureMethod_HMAC_SHA1());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $uid user id
|
||||
* @return void
|
||||
* @throws HTTPException\ForbiddenException
|
||||
* @throws HTTPException\InternalServerErrorException
|
||||
*/
|
||||
public function loginUser($uid)
|
||||
{
|
||||
Logger::notice("FKOAuth1::loginUser $uid");
|
||||
$a = DI::app();
|
||||
$record = DBA::selectFirst('user', [], ['uid' => $uid, 'blocked' => 0, 'account_expired' => 0, 'account_removed' => 0, 'verified' => 1]);
|
||||
|
||||
if (!DBA::isResult($record) || empty($uid)) {
|
||||
Logger::info('FKOAuth1::loginUser failure', ['server' => $_SERVER]);
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
die('This api requires login');
|
||||
}
|
||||
|
||||
DI::auth()->setForUser($a, $record, true);
|
||||
}
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @copyright Copyright (C) 2010-2021, the Friendica project
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace Friendica\Security;
|
||||
|
||||
use Friendica\Core\Logger;
|
||||
use Friendica\Database\DBA;
|
||||
use Friendica\DI;
|
||||
use Friendica\Util\Strings;
|
||||
use Friendica\Security\OAuth1\OAuthConsumer;
|
||||
use Friendica\Security\OAuth1\OAuthDataStore;
|
||||
use Friendica\Security\OAuth1\OAuthToken;
|
||||
|
||||
define('REQUEST_TOKEN_DURATION', 300);
|
||||
define('ACCESS_TOKEN_DURATION', 31536000);
|
||||
|
||||
/**
|
||||
* Friendica\Security\OAuth1\OAuthDataStore class
|
||||
*/
|
||||
class FKOAuthDataStore extends OAuthDataStore
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
private static function genToken()
|
||||
{
|
||||
return Strings::getRandomHex(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $consumer_key key
|
||||
* @return OAuthConsumer|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function lookup_consumer($consumer_key)
|
||||
{
|
||||
Logger::log(__function__ . ":" . $consumer_key);
|
||||
|
||||
$s = DBA::select('clients', ['client_id', 'pw', 'redirect_uri'], ['client_id' => $consumer_key]);
|
||||
$r = DBA::toArray($s);
|
||||
|
||||
if (DBA::isResult($r)) {
|
||||
return new OAuthConsumer($r[0]['client_id'], $r[0]['pw'], $r[0]['redirect_uri']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OAuthConsumer $consumer
|
||||
* @param string $token_type
|
||||
* @param string $token_id
|
||||
* @return OAuthToken|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function lookup_token(OAuthConsumer $consumer, $token_type, $token_id)
|
||||
{
|
||||
Logger::log(__function__ . ":" . $consumer . ", " . $token_type . ", " . $token_id);
|
||||
|
||||
$s = DBA::select('tokens', ['id', 'secret', 'scope', 'expires', 'uid'], ['client_id' => $consumer->key, 'scope' => $token_type, 'id' => $token_id]);
|
||||
$r = DBA::toArray($s);
|
||||
|
||||
if (DBA::isResult($r)) {
|
||||
$ot = new OAuthToken($r[0]['id'], $r[0]['secret']);
|
||||
$ot->scope = $r[0]['scope'];
|
||||
$ot->expires = $r[0]['expires'];
|
||||
$ot->uid = $r[0]['uid'];
|
||||
return $ot;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OAuthConsumer $consumer
|
||||
* @param OAuthToken $token
|
||||
* @param string $nonce
|
||||
* @param int $timestamp
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function lookup_nonce(OAuthConsumer $consumer, OAuthToken $token, $nonce, int $timestamp)
|
||||
{
|
||||
$token = DBA::selectFirst('tokens', ['id', 'secret'], ['client_id' => $consumer->key, 'id' => $nonce, 'expires' => $timestamp]);
|
||||
if (DBA::isResult($token)) {
|
||||
return new OAuthToken($token['id'], $token['secret']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OAuthConsumer $consumer
|
||||
* @param string $callback
|
||||
* @return OAuthToken|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function new_request_token(OAuthConsumer $consumer, $callback = null)
|
||||
{
|
||||
Logger::log(__function__ . ":" . $consumer . ", " . $callback);
|
||||
$key = self::genToken();
|
||||
$sec = self::genToken();
|
||||
|
||||
if ($consumer->key) {
|
||||
$k = $consumer->key;
|
||||
} else {
|
||||
$k = $consumer;
|
||||
}
|
||||
|
||||
$r = DBA::insert(
|
||||
'tokens',
|
||||
[
|
||||
'id' => $key,
|
||||
'secret' => $sec,
|
||||
'client_id' => $k,
|
||||
'scope' => 'request',
|
||||
'expires' => time() + REQUEST_TOKEN_DURATION
|
||||
]
|
||||
);
|
||||
|
||||
if (!$r) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OAuthToken($key, $sec);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OAuthToken $token token
|
||||
* @param OAuthConsumer $consumer consumer
|
||||
* @param string $verifier optional, defult null
|
||||
* @return OAuthToken
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function new_access_token(OAuthToken $token, OAuthConsumer $consumer, $verifier = null)
|
||||
{
|
||||
Logger::log(__function__ . ":" . $token . ", " . $consumer . ", " . $verifier);
|
||||
|
||||
// return a new access token attached to this consumer
|
||||
// for the user associated with this token if the request token
|
||||
// is authorized
|
||||
// should also invalidate the request token
|
||||
|
||||
$ret = null;
|
||||
|
||||
// get user for this verifier
|
||||
$uverifier = DI::config()->get("oauth", $verifier);
|
||||
Logger::log(__function__ . ":" . $verifier . "," . $uverifier);
|
||||
|
||||
if (is_null($verifier) || ($uverifier !== false)) {
|
||||
$key = self::genToken();
|
||||
$sec = self::genToken();
|
||||
$r = DBA::insert(
|
||||
'tokens',
|
||||
[
|
||||
'id' => $key,
|
||||
'secret' => $sec,
|
||||
'client_id' => $consumer->key,
|
||||
'scope' => 'access',
|
||||
'expires' => time() + ACCESS_TOKEN_DURATION,
|
||||
'uid' => $uverifier
|
||||
]
|
||||
);
|
||||
|
||||
if ($r) {
|
||||
$ret = new OAuthToken($key, $sec);
|
||||
}
|
||||
}
|
||||
|
||||
DBA::delete('tokens', ['id' => $token->key]);
|
||||
|
||||
if (!is_null($ret) && !is_null($uverifier)) {
|
||||
DI::config()->delete("oauth", $verifier);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
|
@ -1,290 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Friendica\Security\OAuth1;
|
||||
|
||||
use Friendica\Security\FKOAuthDataStore;
|
||||
use Friendica\Security\OAuth1\Signature;
|
||||
|
||||
class OAuthServer
|
||||
{
|
||||
protected $timestamp_threshold = 300; // in seconds, five minutes
|
||||
protected $version = '1.0'; // hi blaine
|
||||
/** @var Signature\OAuthSignatureMethod[] */
|
||||
protected $signature_methods = [];
|
||||
|
||||
/** @var FKOAuthDataStore */
|
||||
protected $data_store;
|
||||
|
||||
function __construct(FKOAuthDataStore $data_store)
|
||||
{
|
||||
$this->data_store = $data_store;
|
||||
}
|
||||
|
||||
public function add_signature_method(Signature\OAuthSignatureMethod $signature_method)
|
||||
{
|
||||
$this->signature_methods[$signature_method->get_name()] =
|
||||
$signature_method;
|
||||
}
|
||||
|
||||
// high level functions
|
||||
|
||||
/**
|
||||
* process a request_token request
|
||||
* returns the request token on success
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return OAuthToken|null
|
||||
* @throws OAuthException
|
||||
*/
|
||||
public function fetch_request_token(OAuthRequest $request)
|
||||
{
|
||||
$this->get_version($request);
|
||||
|
||||
$consumer = $this->get_consumer($request);
|
||||
|
||||
// no token required for the initial token request
|
||||
$token = null;
|
||||
|
||||
$this->check_signature($request, $consumer, $token);
|
||||
|
||||
// Rev A change
|
||||
$callback = $request->get_parameter('oauth_callback');
|
||||
$new_token = $this->data_store->new_request_token($consumer, $callback);
|
||||
|
||||
return $new_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* process an access_token request
|
||||
* returns the access token on success
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return object
|
||||
* @throws OAuthException
|
||||
*/
|
||||
public function fetch_access_token(OAuthRequest $request)
|
||||
{
|
||||
$this->get_version($request);
|
||||
|
||||
$consumer = $this->get_consumer($request);
|
||||
|
||||
// requires authorized request token
|
||||
$token = $this->get_token($request, $consumer, "request");
|
||||
|
||||
$this->check_signature($request, $consumer, $token);
|
||||
|
||||
// Rev A change
|
||||
$verifier = $request->get_parameter('oauth_verifier');
|
||||
$new_token = $this->data_store->new_access_token($token, $consumer, $verifier);
|
||||
|
||||
return $new_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* verify an api call, checks all the parameters
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return array
|
||||
* @throws OAuthException
|
||||
*/
|
||||
public function verify_request(OAuthRequest $request)
|
||||
{
|
||||
$this->get_version($request);
|
||||
$consumer = $this->get_consumer($request);
|
||||
$token = $this->get_token($request, $consumer, "access");
|
||||
$this->check_signature($request, $consumer, $token);
|
||||
return [$consumer, $token];
|
||||
}
|
||||
|
||||
// Internals from here
|
||||
|
||||
/**
|
||||
* version 1
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return string
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function get_version(OAuthRequest $request)
|
||||
{
|
||||
$version = $request->get_parameter("oauth_version");
|
||||
if (!$version) {
|
||||
// Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present.
|
||||
// Chapter 7.0 ("Accessing Protected Ressources")
|
||||
$version = '1.0';
|
||||
}
|
||||
if ($version !== $this->version) {
|
||||
throw new OAuthException("OAuth version '$version' not supported");
|
||||
}
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* figure out the signature with some defaults
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return Signature\OAuthSignatureMethod
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function get_signature_method(OAuthRequest $request)
|
||||
{
|
||||
$signature_method =
|
||||
@$request->get_parameter("oauth_signature_method");
|
||||
|
||||
if (!$signature_method) {
|
||||
// According to chapter 7 ("Accessing Protected Ressources") the signature-method
|
||||
// parameter is required, and we can't just fallback to PLAINTEXT
|
||||
throw new OAuthException('No signature method parameter. This parameter is required');
|
||||
}
|
||||
|
||||
if (!in_array(
|
||||
$signature_method,
|
||||
array_keys($this->signature_methods)
|
||||
)) {
|
||||
throw new OAuthException(
|
||||
"Signature method '$signature_method' not supported " .
|
||||
"try one of the following: " .
|
||||
implode(", ", array_keys($this->signature_methods))
|
||||
);
|
||||
}
|
||||
return $this->signature_methods[$signature_method];
|
||||
}
|
||||
|
||||
/**
|
||||
* try to find the consumer for the provided request's consumer key
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
*
|
||||
* @return OAuthConsumer
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function get_consumer(OAuthRequest $request)
|
||||
{
|
||||
$consumer_key = @$request->get_parameter("oauth_consumer_key");
|
||||
if (!$consumer_key) {
|
||||
throw new OAuthException("Invalid consumer key");
|
||||
}
|
||||
|
||||
$consumer = $this->data_store->lookup_consumer($consumer_key);
|
||||
if (!$consumer) {
|
||||
throw new OAuthException("Invalid consumer");
|
||||
}
|
||||
|
||||
return $consumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* try to find the token for the provided request's token key
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
* @param $consumer
|
||||
* @param string $token_type
|
||||
*
|
||||
* @return OAuthToken|null
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function get_token(OAuthRequest &$request, $consumer, $token_type = "access")
|
||||
{
|
||||
$token_field = @$request->get_parameter('oauth_token');
|
||||
$token = $this->data_store->lookup_token(
|
||||
$consumer,
|
||||
$token_type,
|
||||
$token_field
|
||||
);
|
||||
if (!$token) {
|
||||
throw new OAuthException("Invalid $token_type token: $token_field");
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* all-in-one function to check the signature on a request
|
||||
* should guess the signature method appropriately
|
||||
*
|
||||
* @param OAuthRequest $request
|
||||
* @param OAuthConsumer $consumer
|
||||
* @param OAuthToken|null $token
|
||||
*
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function check_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null)
|
||||
{
|
||||
// this should probably be in a different method
|
||||
$timestamp = @$request->get_parameter('oauth_timestamp');
|
||||
$nonce = @$request->get_parameter('oauth_nonce');
|
||||
|
||||
$this->check_timestamp($timestamp);
|
||||
$this->check_nonce($consumer, $token, $nonce, $timestamp);
|
||||
|
||||
$signature_method = $this->get_signature_method($request);
|
||||
|
||||
$signature = $request->get_parameter('oauth_signature');
|
||||
$valid_sig = $signature_method->check_signature(
|
||||
$request,
|
||||
$consumer,
|
||||
$signature,
|
||||
$token
|
||||
);
|
||||
|
||||
if (!$valid_sig) {
|
||||
throw new OAuthException("Invalid signature");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check that the timestamp is new enough
|
||||
*
|
||||
* @param int $timestamp
|
||||
*
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function check_timestamp($timestamp)
|
||||
{
|
||||
if (!$timestamp)
|
||||
throw new OAuthException(
|
||||
'Missing timestamp parameter. The parameter is required'
|
||||
);
|
||||
|
||||
// verify that timestamp is recentish
|
||||
$now = time();
|
||||
if (abs($now - $timestamp) > $this->timestamp_threshold) {
|
||||
throw new OAuthException(
|
||||
"Expired timestamp, yours $timestamp, ours $now"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check that the nonce is not repeated
|
||||
*
|
||||
* @param OAuthConsumer $consumer
|
||||
* @param OAuthToken $token
|
||||
* @param string $nonce
|
||||
* @param int $timestamp
|
||||
*
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function check_nonce(OAuthConsumer $consumer, OAuthToken $token, $nonce, int $timestamp)
|
||||
{
|
||||
if (!$nonce)
|
||||
throw new OAuthException(
|
||||
'Missing nonce parameter. The parameter is required'
|
||||
);
|
||||
|
||||
// verify that the nonce is uniqueish
|
||||
$found = $this->data_store->lookup_nonce(
|
||||
$consumer,
|
||||
$token,
|
||||
$nonce,
|
||||
$timestamp
|
||||
);
|
||||
if ($found) {
|
||||
throw new OAuthException("Nonce already used: $nonce");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,14 +40,12 @@ class OptimizeTables
|
|||
|
||||
Logger::info('Optimize start');
|
||||
|
||||
DBA::e("OPTIMIZE TABLE `auth_codes`");
|
||||
DBA::e("OPTIMIZE TABLE `cache`");
|
||||
DBA::e("OPTIMIZE TABLE `locks`");
|
||||
DBA::e("OPTIMIZE TABLE `oembed`");
|
||||
DBA::e("OPTIMIZE TABLE `parsed_url`");
|
||||
DBA::e("OPTIMIZE TABLE `profile_check`");
|
||||
DBA::e("OPTIMIZE TABLE `session`");
|
||||
DBA::e("OPTIMIZE TABLE `tokens`");
|
||||
|
||||
Logger::info('Optimize end');
|
||||
|
||||
|
|
|
@ -289,21 +289,6 @@ return [
|
|||
"url" => ["url"]
|
||||
]
|
||||
],
|
||||
"clients" => [
|
||||
"comment" => "OAuth usage",
|
||||
"fields" => [
|
||||
"client_id" => ["type" => "varchar(20)", "not null" => "1", "primary" => "1", "comment" => ""],
|
||||
"pw" => ["type" => "varchar(20)", "not null" => "1", "default" => "", "comment" => ""],
|
||||
"redirect_uri" => ["type" => "varchar(200)", "not null" => "1", "default" => "", "comment" => ""],
|
||||
"name" => ["type" => "text", "comment" => ""],
|
||||
"icon" => ["type" => "text", "comment" => ""],
|
||||
"uid" => ["type" => "mediumint unsigned", "not null" => "1", "default" => "0", "foreign" => ["user" => "uid"], "comment" => "User id"],
|
||||
],
|
||||
"indexes" => [
|
||||
"PRIMARY" => ["client_id"],
|
||||
"uid" => ["uid"],
|
||||
]
|
||||
],
|
||||
"permissionset" => [
|
||||
"comment" => "",
|
||||
"fields" => [
|
||||
|
@ -494,21 +479,6 @@ return [
|
|||
"uid" => ["uid"],
|
||||
]
|
||||
],
|
||||
"auth_codes" => [
|
||||
"comment" => "OAuth usage",
|
||||
"fields" => [
|
||||
"id" => ["type" => "varchar(40)", "not null" => "1", "primary" => "1", "comment" => ""],
|
||||
"client_id" => ["type" => "varchar(20)", "not null" => "1", "default" => "", "foreign" => ["clients" => "client_id"],
|
||||
"comment" => ""],
|
||||
"redirect_uri" => ["type" => "varchar(200)", "not null" => "1", "default" => "", "comment" => ""],
|
||||
"expires" => ["type" => "int", "not null" => "1", "default" => "0", "comment" => ""],
|
||||
"scope" => ["type" => "varchar(250)", "not null" => "1", "default" => "", "comment" => ""],
|
||||
],
|
||||
"indexes" => [
|
||||
"PRIMARY" => ["id"],
|
||||
"client_id" => ["client_id"]
|
||||
]
|
||||
],
|
||||
"cache" => [
|
||||
"comment" => "Stores temporary data",
|
||||
"fields" => [
|
||||
|
@ -1506,22 +1476,6 @@ return [
|
|||
"PRIMARY" => ["id"]
|
||||
]
|
||||
],
|
||||
"tokens" => [
|
||||
"comment" => "OAuth usage",
|
||||
"fields" => [
|
||||
"id" => ["type" => "varchar(40)", "not null" => "1", "primary" => "1", "comment" => ""],
|
||||
"secret" => ["type" => "text", "comment" => ""],
|
||||
"client_id" => ["type" => "varchar(20)", "not null" => "1", "default" => "", "foreign" => ["clients" => "client_id"]],
|
||||
"expires" => ["type" => "int", "not null" => "1", "default" => "0", "comment" => ""],
|
||||
"scope" => ["type" => "varchar(200)", "not null" => "1", "default" => "", "comment" => ""],
|
||||
"uid" => ["type" => "mediumint unsigned", "not null" => "1", "default" => "0", "foreign" => ["user" => "uid"], "comment" => "User id"],
|
||||
],
|
||||
"indexes" => [
|
||||
"PRIMARY" => ["id"],
|
||||
"client_id" => ["client_id"],
|
||||
"uid" => ["uid"]
|
||||
]
|
||||
],
|
||||
"userd" => [
|
||||
"comment" => "Deleted usernames",
|
||||
"fields" => [
|
||||
|
|
13
update.php
13
update.php
|
@ -240,9 +240,12 @@ function pre_update_1348()
|
|||
|
||||
update_1348();
|
||||
|
||||
DBA::e("DELETE FROM `auth_codes` WHERE NOT `client_id` IN (SELECT `client_id` FROM `clients`)");
|
||||
DBA::e("DELETE FROM `tokens` WHERE NOT `client_id` IN (SELECT `client_id` FROM `clients`)");
|
||||
|
||||
if (DBStructure::existsTable('auth_codes') && DBStructure::existsTable('clients')) {
|
||||
DBA::e("DELETE FROM `auth_codes` WHERE NOT `client_id` IN (SELECT `client_id` FROM `clients`)");
|
||||
}
|
||||
if (DBStructure::existsTable('tokens') && DBStructure::existsTable('clients')) {
|
||||
DBA::e("DELETE FROM `tokens` WHERE NOT `client_id` IN (SELECT `client_id` FROM `clients`)");
|
||||
}
|
||||
return Update::SUCCESS;
|
||||
}
|
||||
|
||||
|
@ -391,7 +394,7 @@ function pre_update_1364()
|
|||
return Update::FAILED;
|
||||
}
|
||||
|
||||
if (!DBA::e("DELETE FROM `clients` WHERE NOT `uid` IN (SELECT `uid` FROM `user`)")) {
|
||||
if (DBStructure::existsTable('clients') && !DBA::e("DELETE FROM `clients` WHERE NOT `uid` IN (SELECT `uid` FROM `user`)")) {
|
||||
return Update::FAILED;
|
||||
}
|
||||
|
||||
|
@ -463,7 +466,7 @@ function pre_update_1364()
|
|||
return Update::FAILED;
|
||||
}
|
||||
|
||||
if (!DBA::e("DELETE FROM `tokens` WHERE NOT `uid` IN (SELECT `uid` FROM `user`)")) {
|
||||
if (DBStructure::existsTable('tokens') && !DBA::e("DELETE FROM `tokens` WHERE NOT `uid` IN (SELECT `uid` FROM `user`)")) {
|
||||
return Update::FAILED;
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue