support for user discovery endpoint
parent
4c8e765a9e
commit
6b3d2c133b
15
README.md
15
README.md
|
@ -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.
|
||||
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
'id' => $this->id,
|
||||
'externalid' => $this->externalid,
|
||||
'permissions' => $this->permissions
|
||||
'iconMediaType' => $this->iconMediaType,
|
||||
'iconURL' => $this->iconURL,
|
||||
'id' => $this->id,
|
||||
'imageMediaType' => $this->iconMediaType,
|
||||
'imageURL' => $this->iconURL,
|
||||
'externalid' => $this->externalid,
|
||||
'name' => $this->name,
|
||||
'permissions' => $this->permissions,
|
||||
'publicKey' => $this->publicKey,
|
||||
'summary' => $this->summary,
|
||||
'type' => $this->type
|
||||
];
|
||||
return json_encode($data) | '';
|
||||
}
|
||||
|
|
|
@ -22,27 +22,78 @@ class User
|
|||
*/
|
||||
protected static function addLocalUser($dbh, $user, $_user)
|
||||
{
|
||||
// needed fields: RSA key pair, user name (handle)
|
||||
$private_key = openssl_pkey_new();
|
||||
if ($private_key === false) {
|
||||
// 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();
|
||||
}
|
||||
$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';
|
||||
$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();
|
||||
}
|
||||
$public = openssl_pkey_get_details($private_key)['key'];
|
||||
$private = '';
|
||||
openssl_pkey_export($private_key, $private);
|
||||
$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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
|
@ -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}
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in New Issue