initial webfinger support

develop
Sascha Nitsch 2024-07-20 23:21:27 +02:00
parent eed9678dbf
commit 47efd74b6c
12 changed files with 364 additions and 59 deletions

View File

@ -39,6 +39,7 @@ To configure an apache server, add the following rewrite rules:
RewriteEngine on RewriteEngine on
RewriteBase / RewriteBase /
RewriteRule ^api/(.+)$ api.php?_call=$1 [L] RewriteRule ^api/(.+)$ api.php?_call=$1 [L]
RewriteRule ^(\.well-known/.*)$ /api.php?_call=$1 [L,END]
</Directory> </Directory>
With the dummy plugin and everything installed correctly a With the dummy plugin and everything installed correctly a

View File

@ -1,3 +1,6 @@
[generic]
externaldomain = 'your.fqdn'
[database] [database]
host = '127.0.0.1' host = '127.0.0.1'
username = 'federator' username = 'federator'
@ -5,7 +8,7 @@ password = '*change*me*'
database = 'federator' database = 'federator'
[templates] [templates]
path = '../templates/' path = '../templates/federator/'
compiledir = '../cache' compiledir = '../cache'
[plugins] [plugins]

View File

@ -46,7 +46,6 @@ class Api extends Main
*/ */
public function __construct() public function __construct()
{ {
$this->smarty = null;
$this->contentType = "application/json"; $this->contentType = "application/json";
Main::__construct(); Main::__construct();
} }
@ -77,14 +76,11 @@ class Api extends Main
$this->loadPlugins(); $this->loadPlugins();
$retval = ""; $retval = "";
$handler = null; $handler = null;
if (!array_key_exists('HTTP_X_SESSION', $_SERVER) || !array_key_exists('HTTP_X_PROFILE', $_SERVER)) {
http_response_code(403);
return;
}
if ($this->connector === null) { if ($this->connector === null) {
http_response_code(500); http_response_code(500);
return; return;
} }
if (array_key_exists('HTTP_X_SESSION', $_SERVER) && array_key_exists('HTTP_X_PROFILE', $_SERVER)) {
$this->user = DIO\User::getUserBySession( $this->user = DIO\User::getUserBySession(
$this->dbh, $this->dbh,
$_SERVER['HTTP_X_SESSION'], $_SERVER['HTTP_X_SESSION'],
@ -96,21 +92,23 @@ class Api extends Main
http_response_code(403); http_response_code(403);
return; return;
} }
switch ($this->path[0]) { }
case 'v': switch ($this->paths[0]) {
if ($this->paths[0] === "v1") { case 'v1':
switch ($this->paths[1]) { switch ($this->paths[1]) {
case 'dummy': case 'dummy':
$handler = new Api\V1\Dummy($this); $handler = new Api\V1\Dummy($this);
break; break;
} }
} break;
case '.well-known':
$handler = new Api\WellKnown($this);
break; break;
} }
$printresponse = true; $printresponse = true;
if ($handler !== null) { if ($handler !== null) {
try { try {
$printresponse = $handler->exec($this->paths); $printresponse = $handler->exec($this->paths, $this->user);
if ($printresponse) { if ($printresponse) {
$retval = $handler->toJson(); $retval = $handler->toJson();
} }

View File

@ -11,15 +11,17 @@ namespace Federator\Api;
/** /**
* API interface * API interface
*/ */
interface V1 interface APIInterface
{ {
/** /**
* run given url path * run given url path
* *
* @param array<string> $paths path array split by / * @param array<string> $paths path array split by /
*
* @param \Federator\Data\User|false $user user who is calling us
* @return bool true on success * @return bool true on success
*/ */
public function exec($paths); public function exec($paths, $user);
/** /**
* get internal represenation as json string * get internal represenation as json string

View File

@ -11,7 +11,7 @@ namespace Federator\Api\V1;
/** /**
* dummy api class for functional poc * dummy api class for functional poc
*/ */
class Dummy implements \Federator\Api\V1 class Dummy implements \Federator\Api\APIInterface
{ {
/** /**
* \Federator\Main instance * \Federator\Main instance
@ -41,10 +41,15 @@ class Dummy implements \Federator\Api\V1
* run given url path * run given url path
* *
* @param array<string> $paths path array split by / * @param array<string> $paths path array split by /
* @param \Federator\Data\User|false $user user who is calling us
* @return bool true on success * @return bool true on success
*/ */
public function exec($paths) : bool public function exec($paths, $user) : bool
{ {
// only for user with the 'publish' permission
if ($user === false || $user->hasPermission('publish') === false) {
throw new \Federator\Exceptions\PermissionDenied();
}
$method = $_SERVER["REQUEST_METHOD"]; $method = $_SERVER["REQUEST_METHOD"];
switch ($method) { switch ($method) {
case 'GET': case 'GET':

View File

@ -0,0 +1,84 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator\Api;
/**
* .well-known handlers
*/
class WellKnown implements APIInterface
{
/**
* main instance
*
* @var \Federator\Main $main
*/
private $main;
/**
* response from sub-calls
*
* @var string $response
*/
private $response;
/**
* constructor
*
* @param \Federator\Main $main main instance
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* run given url path
*
* @param array<string> $paths path array split by /
* @param \Federator\Data\User|false $user user who is calling us @unused-param
* @return bool true on success
*/
public function exec($paths, $user)
{
$method = $_SERVER["REQUEST_METHOD"];
switch ($method) {
case 'GET':
switch (sizeof($paths)) {
case 2:
if ($paths[1] === 'webfinger') {
$wf = new WellKnown\WebFinger($this, $this->main);
return $wf->exec();
}
}
break;
}
$this->main->setResponseCode(404);
return false;
}
/**
* set response
*
* @param string $response response to set
* @return void
*/
public function setResponse($response)
{
$this->response = $response;
}
/**
* get internal represenation as json string
* @return string json string or html
*/
public function toJson()
{
return $this->response;
}
}

View File

@ -0,0 +1,66 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator\Api\WellKnown;
class WebFinger
{
/**
* parent instance
*
* @var \Federator\Api\WellKnown $wellKnown
*/
private $wellKnown;
/**
* main instance
*
* @var \Federator\Main $main
*/
private $main;
/**
* constructor
*
* @param \Federator\Api\WellKnown $wellKnown parent instance
* @param \Federator\Main $main main instance
* @return void
*/
public function __construct($wellKnown, $main)
{
$this->wellKnown = $wellKnown;
$this->main = $main;
}
/**
* handle webfinger request
*
* @return bool true on success
*/
public function exec()
{
$_resource = $this->main->extractFromURI('resource');
$matches = [];
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1 || $matches[2] !== $domain) {
throw new \Federator\Exceptions\InvalidArgument();
}
$user = \Federator\DIO\User::getUserByName($this->main->getDatabase(), $matches[1]);
if ($user->id == 0) {
throw new \Federator\Exceptions\FileNotFound();
}
$data = [
'username' => $user->id,
'domain' => $domain,
];
$response = $this->main->renderTemplate('webfinger_acct.json', $data);
$this->wellKnown->setResponse($response);
return true;
}
}

View File

@ -70,6 +70,33 @@ class User
// no further processing for now // no further processing for now
} }
/**
* get user by name
*
* @param \mysqli $dbh
* database handle
* @param string $_name
* user name
* @return \Federator\Data\User
*/
public static function getUserByName($dbh, $_name)
{
$sql = 'select id from users where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_name);
$user = new \Federator\Data\User();
$ret = $stmt->bind_result($user->id);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
return $user;
}
/** /**
* get User by session id * get User by session id
* *

View File

@ -7,7 +7,9 @@
* @author Author: Sascha Nitsch (grumpydeveloper) * @author Author: Sascha Nitsch (grumpydeveloper)
**/ **/
namespace Federator; namespace Federator;
require_once($_SERVER['DOCUMENT_ROOT'] . '../vendor/autoload.php');
/** /**
* Base class for Api and related classes * Base class for Api and related classes
@ -51,30 +53,20 @@ class Main
* @var array<string,string> $headers * @var array<string,string> $headers
*/ */
protected $headers = []; protected $headers = [];
/**
* languange instance
*
* @var Language $lang
*/
protected $lang = null;
/** /**
* redirect URL * redirect URL
* *
* @var ?string $redirect * @var ?string $redirect
*/ */
protected $redirect = null; protected $redirect = null;
/** /**
* response code * response code
* *
* @var int $responseCode * @var int $responseCode
*/ */
protected $responseCode = 200; protected $responseCode = 200;
/**
* smarty instance
*
* @var \Smarty\Smarty|null $smarty
*/
protected $smarty;
/** /**
* constructor * constructor
@ -90,6 +82,29 @@ class Main
} }
} }
/**
* extract parameter from URI
*
* @param string $param
* parameter to extract
* @param string $fallback
* optional fallback
* @return string
*/
public static function extractFromURI($param, $fallback = '')
{
$uri = $_SERVER['REQUEST_URI'];
$params = substr($uri, (int)(strpos($uri, '?') + 1));
$params = explode('&', $params);
foreach ($params as $p) {
$tokens = explode('=', $p);
if ($tokens[0] === $param) {
return urldecode($tokens[1]);
}
}
return $fallback;
}
/** /**
* do a remote call and return results * do a remote call and return results
* @param string $remoteURL remote URL * @param string $remoteURL remote URL
@ -128,7 +143,7 @@ class Main
/** /**
* get database handle * get database handle
* *
* @return \mysqli|false database handle * @return \mysqli database handle
*/ */
public function getDatabase() public function getDatabase()
{ {
@ -175,6 +190,27 @@ class Main
} }
} }
/**
* render template
*
* @param string $template template file to render
* @param array<string, mixed> $data template variables
* @return string
*/
public function renderTemplate($template, $data)
{
$smarty = new \Smarty\Smarty();
$root = $_SERVER['DOCUMENT_ROOT'];
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
$smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path']));
$smarty->assign('database', $this->dbh);
$smarty->assign('maininstance', $this);
foreach ($data as $key => $value) {
$smarty->assign($key, $value);
}
return $smarty->fetch($template);
}
/** /**
* set cache * set cache
*/ */
@ -215,32 +251,21 @@ class Main
* optional parameters * optional parameters
* @return string translation * @return string translation
*/ */
public function translate(?string $lang, string $group, string $key, array $parameters = array()) : string public static function translate(?string $lang, string $group, string $key, array $parameters = array()) : string
{ {
if ($this->lang === null) {
$this->validLanguage($lang);
}
if ($this->lang !== null) {
if ($this->lang->getLang() !== $lang) {
$l = new Language($lang); $l = new Language($lang);
return $l->printlang($group, $key, $parameters); return $l->printlang($group, $key, $parameters);
} }
return $this->lang->printlang($group, $key, $parameters);
} else {
return $key;
}
}
/** /**
* check if language is valid by loading it * check if language is valid by loading it
* *
* @param ?string $lang * @param ?string $lang
*/ */
public function validLanguage(?string $lang) : bool public static function validLanguage(?string $lang) : bool
{ {
$language = new Language($lang); $language = new Language($lang);
if ($language->getLang() === $lang) { if ($language->getLang() === $lang) {
$this->lang = $language;
return true; return true;
} }
return false; return false;

View File

@ -0,0 +1,83 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator\Connector;
/**
* Connector to ContentNation.net
*/
class ContentNation implements Connector
{
/**
* service-URL
*
* @var string $service
*/
private $service;
/**
* constructor
*
* @param array<string, mixed> $config
*/
public function __construct($config)
{
$this->service = $config['service-uri'];
}
/**
* get remote user by given session
*
* @param string $_session session id
* @param string $_user user or profile name
* @return \Federator\Data\User | false
*/
public function getRemoteUserBySession(string $_session, string $_user)
{
// validate $_session and $user
if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
return false;
}
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) {
return false;
}
$remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
$headers = ['Cookie: session=' . $_session, 'Accept: application/json'];
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
if ($info['http_code'] != 200) {
return false;
}
$r = json_decode($response, true);
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
return false;
}
$user = new \Federator\Data\User();
$user->externalid = $_user;
$user->permissions = [];
$user->session = $_session;
foreach ($r[$_user] as $p) {
$user->permissions[] = $p;
}
return $user;
}
}
namespace Federator;
/**
* Function to initialize plugin
*
* @param \Federator\Main $main main instance
* @return void
*/
function contentnation_load($main)
{
$cn = new Connector\ContentNation($main->getConfig()['contentnation']);
$main->setConnector($cn);
}

View File

@ -17,7 +17,7 @@ primary goal is to connect ContentNation via ActivityPub again.
- [X] cache layer for users minmal version - [X] cache layer for users minmal version
- [X] overlay to extend with needed info like private keys, urls, ... - [X] overlay to extend with needed info like private keys, urls, ...
- [X] full cache for users - [X] full cache for users
- [ ] webfinger - [X] webfinger
- [ ] discovery endpoints - [ ] discovery endpoints
- [ ] ap outbox - [ ] ap outbox
- [ ] ap inbox - [ ] ap inbox

View File

@ -0,0 +1,11 @@
{ldelim}
"subject": "acct:{$username}@{$domain}",
"aliases": ["https://{$domain}/@{$username}"],
"links": [
{ldelim}"rel": "self", "type": "application/activity+json", "href": "https://{$domain}/{$username}"{rdelim},
{if $type=='Group'}
{ldelim}"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://{$domain}/@{$username}/"{rdelim},
{/if}
{ldelim}"rel": "http://ostatus.org/schema/1.0/subscribe", "template": "https://{$domain}/authorize_interaction?uri={ldelim}uri{rdelim}"{rdelim}
]
{rdelim}