You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
674 lines
17 KiB
C
674 lines
17 KiB
C
#define _GNU_SOURCE
|
|
#include "account.h"
|
|
|
|
// Submodules
|
|
#include "json/json.h"
|
|
#include "json/layout.h"
|
|
#include "http/client/client.h"
|
|
#include "ffdb/fs_list.h"
|
|
#include "ffdb/hash_index.h"
|
|
#include "ffdb/trie.h"
|
|
#include "format.h"
|
|
#include "collections/array.h"
|
|
|
|
// Model
|
|
#include "model/server.h"
|
|
#include "model/status.h"
|
|
#include "model/ap/account.h"
|
|
#include "model/ap/activity.h"
|
|
#include "model/ap/outbox_envelope.h"
|
|
#include "model/crypto/keys.h"
|
|
#include "model/notification.h"
|
|
|
|
#include "view/api/Relationship.h"
|
|
|
|
// Stdlib
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stddef.h>
|
|
#include <sys/stat.h>
|
|
#include <time.h>
|
|
|
|
static const char* safe( const char* value, const char* other )
|
|
{
|
|
if( !value ) { return other; }
|
|
return value;
|
|
}
|
|
static const char* b(bool value)
|
|
{
|
|
return value ? "true" : "false";
|
|
}
|
|
|
|
static struct json_enum account_types_enum[] = {
|
|
{ "owner", at_owner },
|
|
{ "bot", at_bot },
|
|
{ "activity_pub", at_remote_activity_pub },
|
|
{ "rss", at_rss_feed },
|
|
{ NULL },
|
|
};
|
|
|
|
#define OBJ_TYPE struct account
|
|
static struct json_object_field account_layout[] = {
|
|
JSON_FIELD_STRING( handle, true ),
|
|
JSON_FIELD_STRING( server, true ),
|
|
JSON_FIELD_STRING( display_name, true ),
|
|
JSON_FIELD_STRING( avatar, false ),
|
|
{
|
|
.key = "avatar",
|
|
.offset = offsetof( struct account, avatar.url ),
|
|
.required = false,
|
|
.type = &json_field_string
|
|
},
|
|
{
|
|
.key = "avatar_static",
|
|
.offset = offsetof( struct account, avatar.static_url ),
|
|
.required = false,
|
|
.type = &json_field_string
|
|
},
|
|
JSON_FIELD_ARRAY_OF_STRINGS( aliases, false ),
|
|
JSON_FIELD_ENUM( account_type, account_types_enum, true ),
|
|
JSON_FIELD_STRING( inbox, false ),
|
|
JSON_FIELD_STRING( shared_inbox, false ),
|
|
JSON_FIELD_STRING( note, false ),
|
|
{
|
|
.key = "followers",
|
|
.offset = offsetof( struct account, followers_count ),
|
|
.required = false,
|
|
.type = &json_field_integer
|
|
},
|
|
{
|
|
.key = "following",
|
|
.offset = offsetof( struct account, following_count ),
|
|
.required = false,
|
|
.type = &json_field_integer
|
|
},
|
|
JSON_FIELD_INTEGER( follow_activity, false ),
|
|
JSON_FIELD_STRING( account_url, true ),
|
|
JSON_FIELD_END,
|
|
};
|
|
|
|
static struct account* new_system_account()
|
|
{
|
|
struct account* a;
|
|
a = malloc(sizeof(*a));
|
|
memset(a,0,sizeof(*a));
|
|
|
|
a->id = -1;
|
|
a->handle = strdup("system");
|
|
a->display_name = strdup("Apogee System");
|
|
a->account_type = at_bot,
|
|
a->bot = true;
|
|
asprintf( &a->avatar.url, "https://%s/system-account.png", g_server_name );
|
|
a->avatar.static_url = strdup(a->avatar.url);
|
|
|
|
return a;
|
|
}
|
|
static bool index_uri_to_account_id( const char* uri, int account_id );
|
|
void account_reindex()
|
|
{
|
|
int max_account_id = fs_list_get( "data/accounts/HEAD" );
|
|
for( int i = 0; i < max_account_id+1; ++i ) {
|
|
struct account* a = account_from_id(i);
|
|
if( !a ) { continue; }
|
|
|
|
if( a->account_url ) {
|
|
index_uri_to_account_id( a->account_url, a->id );
|
|
}
|
|
|
|
account_index_webfinger( a );
|
|
|
|
account_free(a);
|
|
}
|
|
}
|
|
|
|
struct account* account_from_id( int id )
|
|
{
|
|
switch( id ) {
|
|
case -1:
|
|
// System account
|
|
return new_system_account();
|
|
}
|
|
|
|
char filename[512];
|
|
snprintf( filename, 512, "data/accounts/%d.json", id );
|
|
|
|
struct account* a = malloc(sizeof(struct account));
|
|
memset( a, 0, sizeof(struct account) );
|
|
a->id = id;
|
|
|
|
if( !json_read_object_layout_from_file( filename, account_layout, a ) ) {
|
|
account_free(a);
|
|
return NULL;
|
|
}
|
|
|
|
return a;
|
|
}
|
|
|
|
static bool index_uri_to_account_id( const char* uri, int account_id )
|
|
{
|
|
return hash_index_set( "data/accounts/uri_index/", uri, account_id );
|
|
}
|
|
static int lookup_account_id_from_uri( const char* uri )
|
|
{
|
|
int result = 0;
|
|
if( !hash_index_get( "data/accounts/uri_index/", uri, &result ) ) {
|
|
return -1;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
struct account* account_from_uri( const char* uri )
|
|
{
|
|
struct account* result = NULL;
|
|
|
|
// Handle owner as special case
|
|
char buffer[512];
|
|
snprintf( buffer, 512, "https://%s/owner/actor", g_server_name );
|
|
if( 0 == strcmp(buffer,uri) ) {
|
|
return account_from_id(0);
|
|
}
|
|
|
|
// TODO: handle bots
|
|
|
|
int account_id = lookup_account_id_from_uri( uri );
|
|
if( account_id == -1 ) {
|
|
printf( "Failed to lookup local account id for %s\n", uri );
|
|
return NULL;
|
|
}
|
|
|
|
return account_from_id( account_id );
|
|
}
|
|
struct account* account_from_uri_or_fetch( const char* uri )
|
|
{
|
|
struct account* res = account_from_uri(uri);
|
|
if( res ) { return res; }
|
|
|
|
return account_fetch_from_uri( uri );
|
|
}
|
|
void account_index_webfinger( struct account* a )
|
|
{
|
|
char webfinger_name[512];
|
|
snprintf( webfinger_name, 512, "%s@%s", a->handle, a->server );
|
|
hash_index_set( "data/accounts/webfinger", webfinger_name, a->id );
|
|
}
|
|
struct account* account_from_webfinger( const char* handle )
|
|
{
|
|
//printf( "account_from_webfinger( %s )\n", handle );
|
|
int id;
|
|
if( !hash_index_get( "data/accounts/webfinger", handle, &id ) ) {
|
|
printf( "! No account id for %s\n", handle );
|
|
printf( "! TODO: implement webfinger lookup\n" );
|
|
return NULL;
|
|
}
|
|
|
|
return account_from_id(id);
|
|
}
|
|
|
|
struct ap_account* account_activity_pub_data( struct account* a )
|
|
{
|
|
char filename[512];
|
|
return ap_account_from_file(
|
|
format( filename, 512, "data/accounts/%d/ap.json", a->id )
|
|
);
|
|
}
|
|
|
|
bool account_sync_from_activity( struct account* a, struct ap_activity* act )
|
|
{
|
|
for( int i = 0; i < a->aliases.count; ++i ) {
|
|
free( a->aliases.items[i] );
|
|
}
|
|
a->aliases.count = 0;
|
|
|
|
for( int i = 0; i < act->also_known_as.count; ++i ) {
|
|
char* str = strdup(act->also_known_as.items[i]);
|
|
array_append( &a->aliases, sizeof(str), &str );
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool account_sync_from_activity_pub( unsigned int account_id )
|
|
{
|
|
char filename[512];
|
|
struct ap_account* ap = ap_account_from_file(
|
|
format( filename, 512, "data/accounts/%d/ap.json", account_id )
|
|
);
|
|
if( !ap ) {
|
|
printf( "! Failed to sync account %d from %s\n", account_id, filename );
|
|
return false;
|
|
}
|
|
printf( "ap = " ); ap_account_debug_dump(ap);
|
|
|
|
struct account* a = malloc(sizeof(struct account));
|
|
memset(a,0,sizeof(*a));
|
|
a->id = account_id;
|
|
a->handle = strdup(ap->preferredUsername);
|
|
if( ap->name ) {
|
|
a->display_name = strdup(ap->name);
|
|
} else {
|
|
a->display_name = strdup(ap->preferredUsername);
|
|
}
|
|
if( ap->avatar ) {
|
|
a->avatar.url = strdup(ap->avatar);
|
|
a->avatar.static_url = strdup(ap->avatar);
|
|
}
|
|
a->bot = ( ap->type != apacct_Person );
|
|
a->account_type = at_remote_activity_pub;
|
|
a->account_url = strdup(ap->url);
|
|
a->inbox = strdup(ap->inbox);
|
|
if( ap->shared_inbox ) {
|
|
a->shared_inbox = strdup(ap->shared_inbox);
|
|
}
|
|
|
|
if( 0 == strncmp( ap->id, "https://", 8 ) ) {
|
|
char* server_name = strdup(&ap->id[8]);
|
|
char* discard;
|
|
strtok_r(server_name,"/",&discard);
|
|
|
|
a->server = server_name;
|
|
}
|
|
|
|
// Extract out the public key
|
|
char* id = strdup(ap->public_key.id);
|
|
char* key_id = NULL;
|
|
strtok_r( id, "#", &key_id );
|
|
FILE* key_pem = fopen( format(filename,sizeof(filename),"data/accounts/%d/%s.pem", a->id, key_id), "w" );
|
|
if( !key_pem ) {
|
|
printf( "Unable to save public key to %s\n", filename );
|
|
} else {
|
|
printf( "Writing public key to %s\n", filename );
|
|
fprintf( key_pem, "%s", ap->public_key.pem );
|
|
fclose(key_pem);
|
|
}
|
|
|
|
account_save(a);
|
|
account_index_webfinger(a);
|
|
|
|
ap_account_free(ap);
|
|
account_free(a);
|
|
|
|
return true;
|
|
}
|
|
struct crypto_keys* account_get_public_key( struct account* a, const char* key_name )
|
|
{
|
|
char filename[512];
|
|
FILE* f = fopen( format( filename, sizeof(filename), "data/accounts/%d/%s.pem", a->id, key_name ), "r" );
|
|
if( !f ) {
|
|
printf( "Failed to open file %s\n", filename );
|
|
return NULL;
|
|
}
|
|
fclose(f);
|
|
|
|
struct crypto_keys* keys = crypto_keys_new();
|
|
if( crypto_keys_load_public( keys, filename ) ) {
|
|
return keys;
|
|
}
|
|
|
|
printf( "Failed to load public key from %s\n", filename );
|
|
crypto_keys_free(keys);
|
|
return NULL;
|
|
}
|
|
struct crypto_keys* account_get_private_key( struct account* a )
|
|
{
|
|
return NULL;
|
|
}
|
|
|
|
static void create_account_skeleton( int account_id )
|
|
{
|
|
char b[512];
|
|
|
|
// Make sure the account directory exists
|
|
mkdir( format( b, 512, "data/accounts/%d", account_id ), 0755 );
|
|
mkdir( format( b, 512, "data/accounts/%d/timeline", account_id ), 0755 );
|
|
|
|
fs_list_set( format( b, 512, "data/accounts/%d/timeline/HEAD", account_id ), 0 );
|
|
}
|
|
|
|
struct account* account_fetch_from_uri( const char* uri )
|
|
{
|
|
int account_id = lookup_account_id_from_uri( uri );
|
|
|
|
if( -1 == account_id ) {
|
|
account_id = fs_list_get( "data/accounts/HEAD" ) + 1;
|
|
fs_list_set( "data/accounts/HEAD", account_id );
|
|
|
|
index_uri_to_account_id( uri, account_id );
|
|
}
|
|
|
|
create_account_skeleton(account_id);
|
|
|
|
// Fetch the ActivityPub actor data if we don't already have it
|
|
char filename[512];
|
|
FILE* f = fopen( format( filename, 512, "data/accounts/%d/ap.json", account_id ), "r" );
|
|
if( !f ) {
|
|
char tmp_filename[512];
|
|
snprintf( tmp_filename, 512, "%s.tmp", filename );
|
|
printf( "tmp_filename = %s\n", tmp_filename );
|
|
|
|
FILE* tmp = fopen(tmp_filename,"w");
|
|
if( !tmp ) {
|
|
printf( "! Unable to open %s\n", tmp_filename );
|
|
return NULL;
|
|
}
|
|
long status_code = -1;
|
|
const void* request[] = {
|
|
HTTP_REQ_URL, uri,
|
|
HTTP_REQ_HEADER, "Accept: application/ld+json",
|
|
HTTP_REQ_OUTFILE, tmp,
|
|
HTTP_REQ_RESULT_STATUS, &status_code,
|
|
NULL,
|
|
};
|
|
if( !http_client_do( request ) ) {
|
|
printf( "! Unable to fetch %s\n", uri );
|
|
return NULL;
|
|
}
|
|
fflush(tmp);
|
|
printf( "status_code = %d\n", status_code );
|
|
|
|
rename(tmp_filename,filename);
|
|
}
|
|
|
|
// Fail if we can't sync
|
|
if( !account_sync_from_activity_pub( account_id ) ) {
|
|
return NULL;
|
|
}
|
|
|
|
return account_from_id(account_id);
|
|
}
|
|
|
|
void account_free( struct account* a )
|
|
{
|
|
if( !a ) { return; }
|
|
|
|
free(a->handle);
|
|
free(a->server);
|
|
free(a->inbox);
|
|
free(a->shared_inbox);
|
|
free(a->display_name);
|
|
free(a->account_url);
|
|
free(a->avatar.url);
|
|
free(a->avatar.static_url);
|
|
|
|
for( int i = 0; i < a->aliases.count; ++i ) {
|
|
free( a->aliases.items[i] );
|
|
}
|
|
free(a->aliases.items);
|
|
|
|
free(a->note);
|
|
|
|
free(a);
|
|
}
|
|
|
|
void account_save( struct account* a )
|
|
{
|
|
char filename[512];
|
|
snprintf( filename, 512, "data/accounts/%d.json", a->id );
|
|
|
|
json_write_object_layout_to_file( filename, "\t", account_layout, a );
|
|
}
|
|
|
|
|
|
void account_add_follower( struct account* a, struct account* follower )
|
|
{
|
|
// Insert an entry for this follower (only does something if not already set)
|
|
char filename[512];
|
|
char key[32];
|
|
ffdb_trie_set(
|
|
format( filename, sizeof(filename), "data/accounts/%d/followers", a->id ),
|
|
format(key,sizeof(key),"%d", follower->id),
|
|
"T"
|
|
);
|
|
a->followers_count = ffdb_trie_count(filename);
|
|
account_save(a);
|
|
|
|
ffdb_trie_set(
|
|
format( filename, sizeof(filename), "data/accounts/%d/following", follower->id ),
|
|
format(key,sizeof(key),"%d", a->id),
|
|
"T"
|
|
);
|
|
follower->following_count = ffdb_trie_count(filename);
|
|
account_save(follower);
|
|
|
|
// Create notification for follow
|
|
struct notification* note = notification_new();
|
|
note->debug = 4;
|
|
note->type = nt_follow;
|
|
note->account_id = follower->id;
|
|
note->created_at = time(NULL);
|
|
notification_save( note );
|
|
notification_free( note );
|
|
}
|
|
void account_remove_follower( struct account* a, struct account* follower )
|
|
{
|
|
// Remove the follow
|
|
char filename[512];
|
|
char key[32];
|
|
ffdb_trie_remove(
|
|
format( filename, sizeof(filename), "data/accounts/%d/followers", a->id ),
|
|
format( key, sizeof(key), "%d", follower->id )
|
|
);
|
|
ffdb_trie_remove(
|
|
format( filename, sizeof(filename), "data/accounts/%d/following", follower->id ),
|
|
format( key, sizeof(key), "%d", a->id )
|
|
);
|
|
|
|
// Update follower count
|
|
a->followers_count = ffdb_trie_count(filename);
|
|
|
|
// create a notification for unfollow
|
|
struct notification* note = notification_new();
|
|
note->debug = 5;
|
|
note->type = nt_unfollow;
|
|
note->account_id = -1;
|
|
note->ref_account_id = follower->id;
|
|
note->created_at = time(NULL);
|
|
notification_save( note );
|
|
notification_free( note );
|
|
}
|
|
void account_follow( struct account* a, struct account* to_follow )
|
|
{
|
|
char index[512];
|
|
char key[32];
|
|
char value[32];
|
|
|
|
// Federate Follow Activity
|
|
int act_id = ap_activity_follow( a, to_follow );
|
|
|
|
// Update following list
|
|
ffdb_trie_set(
|
|
format(index,512,"data/accounts/%d/following", a->id),
|
|
format(key,32,"%d", to_follow->id ),
|
|
format(value,32,"%d", act_id )
|
|
);
|
|
a->following_count = ffdb_trie_count(index);
|
|
|
|
// Save account data
|
|
account_save(a);
|
|
}
|
|
void account_announce( struct account* a, struct status* s )
|
|
{
|
|
struct ap_activity* act = ap_activity_new();
|
|
int id = fs_list_get("data/activities/HEAD") + 1;
|
|
|
|
act->id = aformat( "https://%s/activity/%d", g_server_name, id );
|
|
act->local_id = id;
|
|
act->published = time(NULL);
|
|
act->type = apat_announce;
|
|
act->actor = strdup(a->account_url);
|
|
act->object.tag = apaot_ref;
|
|
act->object.ref = strdup(s->url);
|
|
|
|
// Create To: list
|
|
char* str = strdup("https://www.w3.org/ns/activitystreams#Public");
|
|
array_append( &act->to, sizeof(str), &str );
|
|
|
|
str = aformat("https://%s/owner/followers", g_server_name );
|
|
array_append( &act->to, sizeof(str), &str );
|
|
|
|
struct account* origin_post_account = account_from_id(s->account_id);
|
|
str = strdup(origin_post_account->account_url);
|
|
array_append( &act->to, sizeof(str), &str );
|
|
|
|
struct outbox_envelope_list oel;
|
|
memset(&oel,0,sizeof(oel));
|
|
|
|
account_deliver_activity_to_followers( a, act, &oel );
|
|
account_deliver_activity( origin_post_account, act, &oel );
|
|
|
|
printf( "Delivering to %s inboxes\n", oel.count );
|
|
outbox_envelope_list_save(&oel);
|
|
outbox_envelope_list_free_composite(&oel);
|
|
|
|
ap_activity_save(act);
|
|
ap_activity_write_to_FILE( act, stdout );
|
|
|
|
cleanup:
|
|
account_free(origin_post_account);
|
|
ap_activity_free(act);
|
|
}
|
|
|
|
void account_deliver_activity( struct account* a, struct ap_activity* act, struct outbox_envelope_list* oel )
|
|
{
|
|
printf( "Delivering activity %s to account %s\n", act->id, a->account_url );
|
|
|
|
if( a->shared_inbox ) {
|
|
// Check if the sh
|
|
for( int i = 0; i < oel->count; ++i ) {
|
|
struct outbox_envelope* e = oel->items[i];
|
|
if( e->shared_inbox && a->shared_inbox && 0 == strcmp(e->shared_inbox,a->shared_inbox) ) {
|
|
printf( "\tUsing shared inbox %s already in delivery list\n", a->shared_inbox );
|
|
// This account will get the message delivered thru the shared inbox
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
struct outbox_envelope* env = outbox_envelope_new();
|
|
env->activity_id = act->local_id;
|
|
if( a->shared_inbox ) {
|
|
env->shared_inbox = strdup(a->shared_inbox);
|
|
}
|
|
env->account_id = a->id;
|
|
array_append( oel, sizeof(env), &env );
|
|
}
|
|
void account_deliver_activity_to_followers( struct account* a, struct ap_activity* act, struct outbox_envelope_list* oel )
|
|
{
|
|
printf( "Delivering activity %s to followers of account %s\n", act->id, a->account_url );
|
|
struct {
|
|
char** items;
|
|
int count;
|
|
} keys;
|
|
memset( &keys, 0, sizeof(keys) );
|
|
|
|
char filename[512];
|
|
snprintf( filename, sizeof(filename), "data/accounts/%d/followers", a->id );
|
|
|
|
// TODO: handle shared inbox delivery
|
|
int pages = (a->followers_count+31) / 32;
|
|
printf( "\tpages = %d, followers = %d\n", pages, a->followers_count );
|
|
for( int i = 0; i < pages; ++i ) {
|
|
keys.count = 0;
|
|
ffdb_trie_list( filename, i * 32, 32, &keys, NULL );
|
|
printf( "keys.count = %d\n", keys.count );
|
|
for( int j = 0; j < keys.count; ++j ) {
|
|
int account_id = atoi(keys.items[j]);
|
|
printf( "account_id = %d\n", account_id );
|
|
struct account* follower_account = account_from_id(account_id);
|
|
account_deliver_activity( follower_account, act, oel );
|
|
account_free(follower_account);
|
|
}
|
|
}
|
|
free(keys.items);
|
|
}
|
|
|
|
void account_unfollow( struct account* a, struct account* to_unfollow )
|
|
{
|
|
char index[512];
|
|
char key[32];
|
|
|
|
// Make sure the account to unfollow has previously been followed
|
|
char* res = ffdb_trie_get(
|
|
format(index,512,"data/accounts/%d/following", a->id),
|
|
format(key,32,"%d", to_unfollow->id)
|
|
);
|
|
if( !res ) {
|
|
printf( "%s is not following %s\n", a->account_url, to_unfollow->account_url );
|
|
return;
|
|
}
|
|
|
|
// Lookup the Activity used to federate following this account
|
|
struct ap_activity* act = ap_activity_from_local_id( atoi(res) );
|
|
free(res);
|
|
|
|
// Federate Undo Activity
|
|
ap_activity_undo(act,to_unfollow->id);
|
|
ap_activity_free(act);
|
|
|
|
// Update following list
|
|
ffdb_trie_remove( index, key );
|
|
a->following_count = ffdb_trie_count(index);
|
|
|
|
// Save account data
|
|
account_save(a);
|
|
}
|
|
|
|
struct int_array {
|
|
int* items;
|
|
int count;
|
|
};
|
|
|
|
static void account_list( const char* filename, int offset, int limit, struct int_array* array )
|
|
{
|
|
struct {
|
|
char** items;
|
|
int count;
|
|
} keys;
|
|
memset( &keys, 0, sizeof(keys) );
|
|
|
|
ffdb_trie_list( filename, offset, limit, &keys, NULL );
|
|
|
|
array->items = malloc( sizeof(int) * keys.count );
|
|
for( int i = 0; i < keys.count; ++i ) {
|
|
char* str = keys.items[i];
|
|
array->items[i] = atoi( str );
|
|
free( str );
|
|
}
|
|
free(keys.items);
|
|
array->count = keys.count;
|
|
}
|
|
|
|
void account_list_followers( struct account* a, int offset, int limit, void* id_array )
|
|
{
|
|
char filename[512];
|
|
account_list( format( filename, sizeof(filename), "data/accounts/%d/followers", a->id ), offset, limit, id_array );
|
|
}
|
|
void account_list_following( struct account* a, int offset, int limit, void* id_array )
|
|
{
|
|
char filename[512];
|
|
account_list( format( filename, sizeof(filename), "data/accounts/%d/following", a->id ), offset, limit, id_array );
|
|
}
|
|
void account_move( struct account* a, const char* new_uri )
|
|
{
|
|
printf( "TODO: implement account move\n" );
|
|
}
|
|
bool account_does_follow( struct account* a, int account_id )
|
|
{
|
|
char index[512];
|
|
char key[32];
|
|
|
|
// Make sure the account to unfollow has previously been followed
|
|
char* value = ffdb_trie_get(
|
|
format(index,512,"data/accounts/%d/following", a->id),
|
|
format(key,32,"%d", account_id)
|
|
);
|
|
|
|
//printf( "account_does_follow( %d, %d ) => %s (%c)\n", a->id, account_id, value, !!value ? 'T' : 'F' );
|
|
|
|
bool result = !!value;
|
|
free(value);
|
|
return result;
|
|
}
|
|
|