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
RewriteBase /
RewriteRule ^api/(.+)$ api.php?_call=$1 [L]
RewriteRule ^(\.well-known/.*)$ /api.php?_call=$1 [L,END]
</Directory>
With the dummy plugin and everything installed correctly a

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ namespace Federator\Api\V1;
/**
* dummy api class for functional poc
*/
class Dummy implements \Federator\Api\V1
class Dummy implements \Federator\Api\APIInterface
{
/**
* \Federator\Main instance
@ -41,10 +41,15 @@ class Dummy implements \Federator\Api\V1
* run given url path
*
* @param array<string> $paths path array split by /
* @param \Federator\Data\User|false $user user who is calling us
* @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"];
switch ($method) {
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
}
/**
* 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
*

View File

@ -9,6 +9,8 @@
namespace Federator;
require_once($_SERVER['DOCUMENT_ROOT'] . '../vendor/autoload.php');
/**
* Base class for Api and related classes
* @author Sascha Nitsch
@ -51,30 +53,20 @@ class Main
* @var array<string,string> $headers
*/
protected $headers = [];
/**
* languange instance
*
* @var Language $lang
*/
protected $lang = null;
/**
* redirect URL
*
* @var ?string $redirect
*/
protected $redirect = null;
/**
* response code
*
* @var int $responseCode
*/
protected $responseCode = 200;
/**
* smarty instance
*
* @var \Smarty\Smarty|null $smarty
*/
protected $smarty;
/**
* 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
* @param string $remoteURL remote URL
@ -128,7 +143,7 @@ class Main
/**
* get database handle
*
* @return \mysqli|false database handle
* @return \mysqli database handle
*/
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
*/
@ -215,32 +251,21 @@ class Main
* optional parameters
* @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);
return $l->printlang($group, $key, $parameters);
}
return $this->lang->printlang($group, $key, $parameters);
} else {
return $key;
}
}
/**
* check if language is valid by loading it
*
* @param ?string $lang
*/
public function validLanguage(?string $lang) : bool
public static function validLanguage(?string $lang) : bool
{
$language = new Language($lang);
if ($language->getLang() === $lang) {
$this->lang = $language;
return true;
}
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] overlay to extend with needed info like private keys, urls, ...
- [X] full cache for users
- [ ] webfinger
- [X] webfinger
- [ ] discovery endpoints
- [ ] ap outbox
- [ ] 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}