support for user discovery endpoint

Sascha Nitsch 2024-07-23 22:37:02 +02:00
parent 4c8e765a9e
commit 6b3d2c133b
10 changed files with 420 additions and 31 deletions

View File

@ -25,7 +25,10 @@ Needed SQL commands:
create user if not exists 'federatoradmin'@'localhost' identified by '*change*me*as*well';
grant all privileges on federator.* to 'federatoradmin'@'localhost';
This will be changed, but works for the current develop verison.
After this, change the settings in the ini file to match above changed passwords.
change to php/federator and run
php maintenance dbupgrade
to install all the needed table. Also run this after an update.
If the include redis cache is enabled, create a users.acl for redis with the content:
@ -38,8 +41,15 @@ To configure an apache server, add the following rewrite rules:
<Directory /where/ever/you/put/it>
RewriteEngine on
RewriteBase /
RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L]
RewriteCond expr "%{HTTP:accept} -strcmatch '*application/ld+json*'" [OR]
RewriteCond expr "%{HTTP:accept} -strcmatch '*application/jrd+json*'" [OR]
RewriteCond expr "%{HTTP:accept} -strcmatch '*application/activity+json*'" [OR]
RewriteCond expr "%{HTTP:content-type} -strcmatch '*application/activity+json*'"
RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END]
RewriteRule ^users/(.*)$ /federator.php?_call=fedusers/$1 [L,END]
RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L,END]
RewriteRule ^(\.well-known/.*)$ /federator.php?_call=$1 [L,END]
RewriteRule ^(nodeinfo/2\.[01])$ /federator.php?_call=$1 [L,END]
</Directory>
With the dummy plugin and everything installed correctly a
@ -47,4 +57,3 @@ With the dummy plugin and everything installed correctly a
> curl -v http://localhost/api/federator/v1/dummy/moo -H "X-Session: somethingvalid" -H "X-Profile: ihaveone"
should return a piece of ascii art.

View File

@ -98,6 +98,9 @@ class Api extends Main
case 'nodeinfo':
$handler = new Api\WellKnown($this);
break;
case 'fedusers':
$handler = new Api\FedUsers($this);
break;
case 'v1':
switch ($this->paths[1]) {
case 'dummy':

View File

@ -0,0 +1,116 @@
<?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;
/**
* /@username or /users/ handlers
*/
class FedUsers 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:
// /users/username or /@username
return $this->returnUserProfile($paths[1]);
}
break;
}
$this->main->setResponseCode(404);
return false;
}
/**
* return user profile
*
* @param string $_name
* @eturn boolean true on success
*/
private function returnUserProfile($_name)
{
$user = \Federator\DIO\User::getUserByName(
$this->main->getDatabase(),
$_name,
$this->main->getConnector(),
$this->main->getCache()
);
if ($user === false || $user->id === null) {
throw new \Federator\Exceptions\FileNotFound();
}
$data = [
'iconMediaType' => $user->iconMediaType,
'iconURL' => $user->iconURL,
'imageMediaType' => $user->imageMediaType,
'imageURL' => $user->imageURL,
'fqdn' => $_SERVER['SERVER_NAME'],
'name' => $user->name,
'username' => $user->id,
'publickey' => str_replace("\n", "\\n", $user->publicKey),
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
'summary' => $user->summary,
'type' => $user->type
];
$this->response = $this->main->renderTemplate('user.json', $data);
return true;
}
/**
* 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

@ -20,6 +20,20 @@ class User
*/
public $externalid;
/**
* icon media type
*
* @var string $iconMediaType
*/
public $iconMediaType;
/**
* icon url
*
* @var string $iconURL
*/
public $iconURL;
/**
* user id
*
@ -27,9 +41,29 @@ class User
*/
public $id;
/**
* image media type
*
* @var string $imageMediaType
*/
public $imageMediaType;
/**
* image url
*
* @var string $imageURL
*/
public $imageURL;
/* @var string user language */
//public $lang;
/**
* user name
*
* @var string $name
*/
public $name = '';
/**
* user permissions
*
@ -38,12 +72,40 @@ class User
*/
public $permissions = [];
/**
* user public key
*
* @var string $publicKey
*/
public $publicKey;
/**
* registered unix timestamp
*
* @var int $registered
*/
public $registered = 0;
/**
* session id
*
* @var string $session
* */
public $session;
public $session = '';
/**
* summary for user/profile
*
* @var string $summary
*/
public $summary = '';
/**
* type of user (user/group)
*
* @var string $type
*/
public $type = 'group';
/**
* create new user object from json string
@ -58,10 +120,18 @@ class User
return false;
}
$user = new User();
$user->iconMediaType = $data['iconMediaType'];
$user->iconURL = $data['iconURL'];
$user->id = $data['id'];
$user->imageMediaType = $data['imageMediaType'];
$user->imageURL = $data['imageURL'];
$user->externalid = $data['externalid'];
$user->name = $data['name'];
/// TODO: replace with enums
$user->permissions = $data['permissions'];
$user->publicKey = $data['publicKey'];
$user->summary = $data['summary'];
$user->type = $data['type'];
return $user;
}
@ -86,9 +156,17 @@ class User
public function toJson()
{
$data = [
'iconMediaType' => $this->iconMediaType,
'iconURL' => $this->iconURL,
'id' => $this->id,
'imageMediaType' => $this->iconMediaType,
'imageURL' => $this->iconURL,
'externalid' => $this->externalid,
'permissions' => $this->permissions
'name' => $this->name,
'permissions' => $this->permissions,
'publicKey' => $this->publicKey,
'summary' => $this->summary,
'type' => $this->type
];
return json_encode($data) | '';
}

View File

@ -22,7 +22,21 @@ class User
*/
protected static function addLocalUser($dbh, $user, $_user)
{
// needed fields: RSA key pair, user name (handle)
// check if it is timed out user
$sql = 'select unix_timestamp(`validuntil`) from users where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
$ret = $stmt->bind_result($validuntil);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($validuntil == 0) {
$private_key = openssl_pkey_new();
if ($private_key === false) {
throw new \Federator\Exceptions\ServerError();
@ -30,19 +44,56 @@ class User
$public = openssl_pkey_get_details($private_key)['key'];
$private = '';
openssl_pkey_export($private_key, $private);
try {
$sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil)';
$sql .= ' values (?, ?, ?, ?, now() + interval 1 day)';
$sql .= ' on duplicate key update validuntil=now() + interval 1 day';
$sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil, type, name, summary, registered, iconmediatype, iconurl, imagemediatype, imageurl)';
$sql .= ' values (?, ?, ?, ?, now() + interval 1 day, ?, ?, ?, ?, ?, ?, ?, ?)';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("ssss", $_user, $user->externalid, $public, $private);
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
"ssssssssssss",
$_user,
$user->externalid,
$public,
$private,
$user->type,
$user->name,
$user->summary,
$registered,
$user->iconMediaType,
$user->iconURL,
$user->imageMediaType,
$user->imageURL
);
} else {
// update to existing user
$sql = 'update users set validuntil=now() + interval 1 day, type=?, name=?, summary=?, registered=?, iconmediatype=?, iconurl=?, imagemediatype=?, imageurl=? where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
"sssssssss",
$user->type,
$user->name,
$user->summary,
$registered,
$user->iconMediaType,
$user->iconURL,
$user->imageMediaType,
$user->imageURL,
$_user
);
}
try {
$stmt->execute();
$stmt->close();
$user->id = $_user;
} catch (\mysqli_sql_exception $e) {
error_log($sql);
error_log(print_r($user, true));
error_log($e->getMessage());
}
}
@ -55,20 +106,21 @@ class User
*/
protected static function extendUser(\mysqli $dbh, $user, $_user) : void
{
$sql = 'select id from users where id=?';
$sql = 'select id,unix_timestamp(`validuntil`) from users where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_user);
$ret = $stmt->bind_result($user->id);
$validuntil = 0;
$ret = $stmt->bind_result($user->id, $validuntil);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
// if a new user, create own database entry with additionally needed info
if ($user->id === null) {
if ($user->id === null || $validuntil < time()) {
self::addLocalUser($dbh, $user, $_user);
}
@ -99,14 +151,26 @@ class User
return $user;
}
// check our db
$sql = 'select id,externalid from users where id=? and validuntil>=now()';
$sql = 'select id,externalid,type,name,summary,unix_timestamp(registered),rsapublic,iconmediatype,iconurl,imagemediatype,imageurl from users where id=? and validuntil>=now()';
$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, $user->externalid);
$ret = $stmt->bind_result(
$user->id,
$user->externalid,
$user->type,
$user->name,
$user->summary,
$user->registered,
$user->publicKey,
$user->iconMediaType,
$user->iconURL,
$user->imageMediaType,
$user->imageURL,
);
$stmt->execute();
if ($ret) {
$stmt->fetch();

View File

@ -83,11 +83,16 @@ class ContentNation implements Connector
if ($r === false || $r === null || !is_array($r)) {
return false;
}
if (!array_key_exists('name', $r) || $r['name'] !== $_name) {
return false;
}
$user = new \Federator\Data\User();
$user->externalid = $_name;
$user->iconMediaType = $r['iconMediaType'];
$user->iconURL = $r['iconURL'];
$user->imageMediaType = $r['imageMediaType'];
$user->imageURL = $r['imageURL'];
$user->name = $r['name'];
$user->summary = $r['summary'];
$user->type = $r['type'];
$user->registered = intval($r['registered'], 10);
return $user;
}
@ -118,8 +123,8 @@ class ContentNation implements Connector
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
return false;
}
$user = new \Federator\Data\User();
$user->externalid = $_user;
$user = $this->getRemoteUserByName($_user);
// extend with permissions
$user->permissions = [];
$user->session = $_session;
foreach ($r[$_user] as $p) {

View File

@ -43,8 +43,6 @@ class DummyConnector implements Connector
// validate $_session and $user
$user = new \Federator\Data\User();
$user->externalid = $_name;
$user->permissions = [];
$user->session = '';
return $user;
}

9
sql/2024-07-23.sql Normal file
View File

@ -0,0 +1,9 @@
alter table users add `type` enum('person', 'group') default 'person';
alter table users add `name` varchar(255) default '';
alter table users add `summary` text default '';
alter table users add `registered` timestamp default 0;
alter table users add `iconmediatype` varchar(255) default '';
alter table users add `iconurl` varchar(255) default '';
alter table users add `imagemediatype` varchar(255) default '';
alter table users add `imageurl` varchar(255) default '';
update settings set `value`="2024-07-23" where `key`="database_version";

View File

@ -0,0 +1,104 @@
{ldelim}
"@context":[
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{ldelim}
"manuallyApprovesFollowers":"as:manuallyApprovesFollowers",
"toot":"http://joinmastodon.org/ns#",
"featured":{ldelim}
"@id":"toot:featured",
"@type":"@id"
{rdelim},
"featuredTags":{ldelim}
"@id":"toot:featuredTags",
"@type":"@id"
{rdelim},
"alsoKnownAs":{ldelim}
"@id":"as:alsoKnownAs",
"@type":"@id"
{rdelim},
"movedTo":{ldelim}
"@id":"as:movedTo",
"@type":"@id"
{rdelim},
"schema":"http://schema.org#",
"PropertyValue":"schema:PropertyValue",
"value":"schema:value",
"discoverable":"toot:discoverable",
"Device":"toot:Device",
"Ed25519Signature":"toot:Ed25519Signature",
"Ed25519Key":"toot:Ed25519Key",
"Curve25519Key":"toot:Curve25519Key",
"EncryptedMessage":"toot:EncryptedMessage",
"publicKeyBase64":"toot:publicKeyBase64",
"deviceId":"toot:deviceId",
"claim":{ldelim}
"@type":"@id",
"@id":"toot:claim"
{rdelim},
"fingerprintKey":{ldelim}
"@type":"@id",
"@id":"toot:fingerprintKey"
{rdelim},
"identityKey":{ldelim}
"@type":"@id",
"@id":"toot:identityKey"
{rdelim},
"devices":{ldelim}
"@type":"@id",
"@id":"toot:devices"
{rdelim},
"messageFranking":"toot:messageFranking",
"messageType":"toot:messageType",
"cipherText":"toot:cipherText",
"suspended":"toot:suspended",
"focalPoint":{ldelim}
"@container":"@list",
"@id":"toot:focalPoint"
{rdelim}
{rdelim}
],
"id":"https://{$fqdn}/users/{$username}",
"type":"{$type}",
"following":"https://{$fqdn}/users/{$username}/following",
"followers":"https://{$fqdn}/users/{$username}/followers",
"inbox":"https://{$fqdn}/users/{$username}/inbox",
"outbox":"https://{$fqdn}/users/{$username}/outbox",
"featured":"https://{$fqdn}/users/{$username}/collections/featured",
"featuredTags":"https://{$fqdn}/users/{$username}/collections/tags",
"preferredUsername":"{$username}",
"name":"{$name}",
"summary":"{$summary}",
"url":"https://{$fqdn}/@{$username}",
"manuallyApprovesFollowers":false,
"discoverable":true,
"published":"{$registered}",
"publicKey":{ldelim}
"id":"https://{$fqdn}/users/{$username}#main-key",
"owner":"https://{$fqdn}/users/{$username}",
"publicKeyPem":"{$publickey}"
{rdelim},
"tag":[],
"attachment":[
{if $type==='group'}{ldelim}
"type":"PropertyValue",
"name":"website",
"value":"\u003ca href=\"https://{$fqdn}/@{$username}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003e{$fqdn}/@{$username}\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e"
{rdelim}{/if}
],
"endpoints":{ldelim}
"sharedInbox":"https://{$fqdn}/inbox"
{rdelim},
{if $iconURL !== ''} "icon":{ldelim}
"type":"Image",
"mediaType":"{$iconMediaType}",
"url":"{$iconURL}"
{rdelim},
{/if}
{if $imageURL !== ''} "image":{ldelim}
"type":"Image",
"mediaType":"{$imageMediaType}",
"url":"{$imageURL}"
{rdelim}
{/if}
{rdelim}

View File

@ -1,6 +1,9 @@
{ldelim}
"subject": "acct:{$username}@{$domain}",
"aliases": ["https://{$domain}/@{$username}"],
"aliases": [
"https://{$domain}/@{$username}"
"https://{$domain}/users/{$username}"
],
"links": [
{ldelim}"rel": "self", "type": "application/activity+json", "href": "https://{$domain}/{$username}"{rdelim},
{if $type=='Group'}