Rework inbox processing, add stubs for account add/remove follower, create notice when processing follow, rework outbox (use envelope with single destination), add handlers for paths referenced in actor, add test to validate locally-created HTTP signature

master
teknomunk 1 year ago
parent b5a229267b
commit 0a95a70461

@ -1,2 +1,2 @@
FLAGS="-g -MP -MD -Os -Werror"
LDFLAGS = "-lcurl -lssl -lcrypto"
FLAGS="-g -MP -MD -Os -Werror -flto"
LDFLAGS = "-lcurl -lssl -lcrypto -flto -Os"

@ -8,8 +8,10 @@
// Model
#include "model/server.h"
#include "model/account.h"
#include "model/notification.h"
#include "model/ap/activity.h"
#include "model/ap/inbox_envelope.h"
#include "model/ap/outbox_envelope.h"
#include "model/crypto/http_sign.h"
// Stdlib
@ -35,43 +37,30 @@ bool route_inbox( struct http_request* req )
return true;
}
bool route_undo_activity( struct ap_activity* act )
static bool route_undo_follow( struct ap_activity* act )
{
if( act->object.tag != apaot_activity ) {
// Don't undo activities that are references
return false;
}
if( !act->object.ptr ) {
printf( "No object in activity\n" );
return false;
const char* target = act->object.ptr->object.ref;
struct account* a = account_from_uri( target );
if( !a || 0 != strcmp( a->server, g_server_name ) ) {
printf( "Unfollow not targeted at local account. Discarding.\n" );
return true;
}
switch( act->object.ptr->type ) {
case apat_follow:
const char* target = act->object.ptr->object.ref;
struct account* a = account_from_uri( target );
if( !a || 0 != strcmp( a->server, g_server_name ) ) {
printf( "Unfollow not targeted at local account. Discarding.\n" );
return true;
}
struct account* follower = account_from_uri( act->actor );
if( !follower ) {
printf( "Follower account not present local. Active follow not possible. Discarding undo\n" );
return true;
}
struct account* follower = account_from_uri( act->actor );
if( !follower ) {
printf( "Follower account not present local. Active follow not possible. Discarding undo\n" );
return true;
}
account_remove_follower( a, follower );
printf( "TODO: undo %s following %s\n", act->actor, target );
return false;
default:
printf( "Unhandled object activity type %d in undo\n", act->object.ptr->type );
return false;
};
// TODO: create a notification for unfollow
return false;
return true;
}
bool route_follow( struct ap_activity* act )
static bool route_follow( struct ap_activity* act )
{
struct account* follower = NULL;
bool res = false;
@ -80,11 +69,13 @@ bool route_follow( struct ap_activity* act )
const char* target = act->object.ref;
struct account* a = account_from_uri( target );
// Don't process follows for remote users
if( !a || 0 != strcmp( a->server, g_server_name ) ) {
printf( "Unfollow not targeted at local account. Discarding.\n" );
goto success;
}
// Get account for the follower
follower = account_from_uri( act->actor );
if( !follower ) {
follower = account_fetch_from_uri( act->actor );
@ -94,17 +85,26 @@ bool route_follow( struct ap_activity* act )
goto failed;
}
// Add the follower
account_add_follower( a, follower );
// Create Accept activity
accept = ap_activity_create_accept(act);
char filename[512]; snprintf( filename, 512, "data/outbox/%d.json", accept->local_id );
char tmp_filename[512]; snprintf( tmp_filename, 512, "%s.tmp", filename );
FILE* f = fopen(tmp_filename,"w");
fprintf( f, "to: %d\n", follower->id );
ap_activity_write_to_FILE( accept, f );
fclose(f);
rename( tmp_filename, filename );
ap_activity_save(accept);
struct outbox_envelope* env = outbox_envelope_new();
env->activity_id = accept->local_id;
env->account_id = follower->id;
outbox_envelope_save( env );
outbox_envelope_free( env );
// Create notification for follow
struct notification* note = notification_new();
note->type = nt_follow;
note->account_id = follower->id;
note->created_at = time(NULL);
notification_save( note );
notification_free( note );
success:
res = true;
@ -113,15 +113,33 @@ cleanup:
account_free(a);
account_free(follower);
exit(0);
return res;
failed:
res = false;
goto cleanup;
}
bool route_activity( struct ap_activity* act )
static bool route_undo_activity( struct ap_activity* act )
{
if( act->object.tag != apaot_activity ) {
// Don't undo activities that are references
return false;
}
if( !act->object.ptr ) {
printf( "No object in activity\n" );
return false;
}
switch( act->object.ptr->type ) {
case apat_follow: return route_undo_follow( act );
default:
printf( "Unhandled object activity type %d in undo\n", act->object.ptr->type );
return false;
};
return false;
}
static bool route_activity( struct ap_activity* act )
{
switch( act->type ) {
case apat_undo: return route_undo_activity(act);
@ -132,7 +150,7 @@ bool route_activity( struct ap_activity* act )
return false;
}
bool process_one()
static bool process_one()
{
// Items requiring cleanup
struct ap_activity* act = NULL;
@ -156,9 +174,6 @@ bool process_one()
return false;
}
// Validate signature
env->validated = http_signature_validate( env, "post /inbox" );
// Load activity
FILE* f = fmemopen( env->body, strlen(env->body), "r" );
act = ap_activity_from_FILE(f);
@ -167,16 +182,19 @@ bool process_one()
// Discard delete requests
if( act->type == apat_delete ) {
step_tail = true;
goto step;
goto discard;
}
// Validate signature
env->validated = http_signature_validate( env, "post /inbox" );
if( !env->validated ) { goto failed; }
printf( "Processing %d\n", id );
step_tail = route_activity( act );
finished:
printf( "handled: %c\n", step_tail ? 'T' : 'F' );
printf( "step_tail=%c\n", step_tail ? 'T' : 'F' );
if( step_tail ) {
fs_list_set( "data/inbox/TAIL", id );
result = true;
@ -184,16 +202,17 @@ finished:
ap_activity_free(act);
ap_envelope_free(env);
printf( "result=%c\n", result ? 'T' : 'F' );
return result;
failed:
result = false;
goto finished;
step:
discard:
result = true;
goto finished;
}
bool cleanup_inbox()
static bool cleanup_inbox()
{
int tail_pos = fs_list_get("data/inbox/TAIL");
int dead_pos = fs_list_get("data/inbox/DEAD");
@ -225,15 +244,11 @@ void process_inbox()
while( !terminate ) {
bool activity = false;
activity |= process_one();
if( !activity ) {
printf( "TODO: unhandled activity\n" );
exit(1);
}
activity |= cleanup_inbox();
if( !activity ) {
fflush(stdout);
sleep(10);
sleep(1);
}
}
}

@ -80,6 +80,7 @@ bool route_asset( struct http_request* req )
const char* where;
} fs_mounts[] = {
{ "assets/soapbox", "/" },
{ "data/config/assets", "/" },
{ NULL, NULL }
};

@ -11,6 +11,7 @@
#include "model/crypto/http_sign.h"
#include "model/account.h"
#include "model/ap/activity.h"
#include "model/ap/outbox_envelope.h"
#include "model/ap/activity/rsa_signature_2017.h"
// Stdlib
@ -25,71 +26,37 @@ static bool process_one( int id )
struct crypto_keys* keys = crypto_keys_new();
FILE* f = NULL;
struct ap_activity* act = NULL;
ARRAY_OF(char*) inboxes;
memset( &inboxes, 0, sizeof(inboxes) );
if( !crypto_keys_load_private( keys, "data/owner/private.pem" ) ) {
printf( "Failed to load private key\n" );
return false;
}
char buffer[512];
snprintf( buffer, 512, "data/outbox/%d.json", id );
f = fopen( buffer, "r" );
if( !f ) {
printf( "Unable to open file %s\n", buffer );
goto discard;
// Get next outbox item
struct outbox_envelope* env = outbox_envelope_load_next();
if( !env ) {
printf( "? No envelope\n" );
goto failed;
}
// TODO: REWORK to no longer have multiple target inboxes
printf( "account_id=%d\n", env->account_id );
printf( "activity_id=%d\n", env->activity_id );
char* toline = NULL;
size_t n;
if( -1 == getline( &toline, &n, f ) ) {
printf( "no to line" );
free(toline);
goto failed;
}
// Get outbox URL
struct account* to_account = account_from_id( env->account_id );
if( strlen(toline) < 5 ) {
printf( "too short.\n" );
goto failed;
// Load crypto keys
if( !crypto_keys_load_private( keys, "data/owner/private.pem" ) ) {
printf( "Failed to load private key\n" );
return false;
}
act = ap_activity_from_FILE(f);
// Load target account
act = ap_activity_from_local_id( env->activity_id );
if( !act ) {
printf( "No activity\n" );
goto failed;
}
f = NULL;
char* remainder = NULL;
char* iter = strtok_r( &toline[4],",",&remainder);
do
{
int id;
if( sscanf( iter, "%d", &id ) ) {
int compare( void* a, void* b ) { return strcmp( (char*)a, (char*)b ); }
struct account* to_account = account_from_id( id );
if( to_account ) {
if( to_account->inbox ) {
char* item_to_add = strdup(to_account->inbox);
array_append_unique( &inboxes, sizeof(item_to_add), &item_to_add, compare );
}
account_free(to_account);
}
}
iter = strtok_r( NULL,",",&remainder);
} while( iter );
free(toline);
if( inboxes.count > 1 ) {
printf( "! No activity\n" );
goto failed;
}
// Create signature
ap_activity_create_rsa_signature_2017( act, keys );
// Create post data
size_t size;
{
FILE* f2 = open_memstream( &postdata, &size );
@ -102,19 +69,16 @@ static bool process_one( int id )
postdata[size] = '\0';
printf( "post: %s\n", postdata );
int i = 0;
const char* inbox = inboxes.items[i];
printf( "item[%d] = %s\n", i, inbox );
struct http_signature hs;
if( !http_signature_make( inboxes.items[i], keys, &hs ) ) {
if( !http_signature_make( to_account->inbox, keys, &hs ) ) {
goto failed;
}
char date_line[512];
snprintf( date_line, sizeof(date_line), "Date: %s", hs.date );
char sign_line[512];
snprintf( sign_line, sizeof(sign_line), "Signature: keyId=\"https://%s/owner/actor\",headers=\"(request-target) host date\",signature=\"%s\"",
char date_header[512];
snprintf( date_header, sizeof(date_header), "Date: %s", hs.date );
char sign_header[512];
snprintf( sign_header, sizeof(sign_header), "Signature: keyId=\"https://%s/owner/actor#mainKey\",headers=\"(request-target) host date\",signature=\"%s\"",
g_server_name,
hs.signature
);
@ -124,10 +88,10 @@ static bool process_one( int id )
long status_code = -1;
const void* request[] = {
HTTP_REQ_URL, inbox,
HTTP_REQ_URL, to_account->inbox,
HTTP_REQ_HEADER, user_agent,
HTTP_REQ_HEADER, date_line,
HTTP_REQ_HEADER, sign_line,
HTTP_REQ_HEADER, date_header,
HTTP_REQ_HEADER, sign_header,
HTTP_REQ_HEADER, "Content-Type: application/activity+json",
HTTP_REQ_POSTDATA, postdata,
HTTP_REQ_RESULT_STATUS, &status_code,
@ -149,10 +113,9 @@ static bool process_one( int id )
goto failed;
cleanup:
void release( void* item ) { free( *(char**)item ); }
array_free( &inboxes, sizeof(char*), release );
ap_activity_free(act);
account_free(to_account);
outbox_envelope_free(env);
free(postdata);
crypto_keys_free(keys);
@ -177,6 +140,8 @@ void process_outbox()
printf( "Done with outbox/%d.json\n", tail );
fs_list_set( "data/outbox/TAIL", tail + 1 );
}
} else {
printf( "? No processing done\n" );
}
exit(0);

@ -22,13 +22,44 @@ static void write_public_key( FILE* f )
}
}
static bool handle_featured( struct http_request* req )
{
struct account* owner_account = account_from_id(0);
http_request_send_headers( req, 200, "application/activity+json", true );
FILE* f = http_request_get_response_body(req);
#include "src/view/owner/featured.json.inc"
return true;
}
static bool handle_followers( struct http_request* req )
{
struct account* owner_account = account_from_id(0);
http_request_send_headers( req, 200, "application/activity+json", true );
FILE* f = http_request_get_response_body(req);
#include "src/view/owner/followers.json.inc"
return true;
}
static bool handle_following( struct http_request* req )
{
struct account* owner_account = account_from_id(0);
http_request_send_headers( req, 200, "application/activity+json", true );
FILE* f = http_request_get_response_body(req);
#include "src/view/owner/following.json.inc"
return true;
}
static bool handle_owner_actor( struct http_request* req )
{
struct account* owner_account = account_from_id(0);
http_request_send_headers( req, 200, "application/activity+json", true );
FILE* f = http_request_get_response_body(req);
#include "src/view/owner_actor.json.inc"
#include "src/view/owner/actor.json.inc"
return true;
}
@ -49,16 +80,16 @@ static bool show_owner_profile_page( struct http_request* req )
bool route_owner( struct http_request* req )
{
if( http_request_route_term( req, "/actor" ) ) {
/*
const char* accept = http_request_get_header( req, "Accept" );
if( 0 == strcmp(accept,"application/ld+json")) {
if( http_request_route( req, "/actor" ) ) {
if( http_request_route_term( req, "" ) ) {
return handle_owner_actor(req);
} else if( http_request_route_term( req, "/following" ) ) {
return handle_following(req);
} else if( http_request_route_term( req, "/followers" ) ) {
return handle_followers(req);
}
return show_owner_profile_page(req);
*/
return handle_owner_actor(req);
} else if( http_request_route_term( req, "/collections/featured" ) ) {
return handle_featured(req);
} else if( http_request_route_term( req, "" ) ) {
return show_owner_profile_page(req);
}

@ -67,12 +67,49 @@ static bool test_http_signature()
return http_signature_validate( &env, "post /foo?param=value&pet=dog" );
}
static bool test_http_signature_2()
{
struct crypto_keys* keys = crypto_keys_new();
crypto_keys_load_private( keys, "assets/test.private.pem" );
struct http_signature hs;
if( !http_signature_make( "https://example.com/inbox", keys, &hs ) ) {
return false;
}
char signature_header[512];
snprintf( signature_header, sizeof(signature_header), "keyId=\"Test\",headers=\"(request-target) host date\",signature=\"%s\"", hs.signature );
//printf( "Signature: %s\n", signature_header );
struct http_header headers[] = {
{ .key = "Host", .value = "example.com" },
{ .key = "Date", .value = hs.date },
{ .key = "Content-Type", .value = "application/json" },
{ .key = "Digest", .value = "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" },
{ .key = "Content-Length", .value = "18" },
{ .key = "Signature", .value = signature_header },
};
struct ap_envelope env = {
.when = "1388957500000000000",
.headers = {
.items = headers,
.count = sizeof(headers) / sizeof(headers[0]),
},
.validated = false,
.body = "{\"hello\": \"world\"}",
};
return http_signature_validate( &env, "post /inbox" );
}
bool test_crypto()
{
bool result = true;
if( !test_signatures() ) { printf( "[FAIL] test_signatures()\n" ); return false; }
if( !test_http_signature() ) { printf( "[FAIL] test_http_signature()\n" ); return false; }
if( !test_http_signature_2() ) { printf( "[FAIL] test_http_signature_2()\n" ); return false; }
return true;
}

@ -1 +1 @@
Subproject commit 4ea04a07127532702b142e699d9e6a0c8559cd10
Subproject commit 452952bdf2befc174e2b342192fd5a2631011985

@ -76,7 +76,7 @@ int main( int argc, char* argv[] )
int section = 0;
sscanf(argv[1],"--section=%d",&section);
switch( section ) {
case 1: process_inbox(); goto exit;
//case 1: process_inbox(); goto exit;
case 2: process_outbox(); goto exit;
case 3: built_in_test(); goto exit;
@ -84,7 +84,6 @@ int main( int argc, char* argv[] )
}
}
/*
int inbox_handler_pid = -1;
// Process inbox
@ -93,6 +92,7 @@ int main( int argc, char* argv[] )
process_inbox();
}
/*
// Process outbox
if( !( inbox_handler_pid = fork() ) ) {
prctl(PR_SET_PDEATHSIG, SIGHUP);

@ -253,6 +253,16 @@ void account_save( struct account* a )
json_write_object_layout_to_file( filename, "\t", account_layout, a );
}
void account_add_follower( struct account* a, struct account* follower )
{
printf( "TODO: implement account_add_follower()\n" );
}
void account_remove_follower( struct account* a, struct account* follower )
{
printf( "TODO: implement account_remove_follower()\n" );
}
// TODO: move to controller/view
void account_write_as_json( struct account* a, FILE* f )
{

@ -109,8 +109,8 @@ void ap_activity_free_composite( struct ap_activity* act )
struct ap_activity* ap_activity_create_accept( struct ap_activity* act )
{
int id = fs_list_get("data/outbox/HEAD") + 1;
fs_list_set( "data/outbox/HEAD", id );
int id = fs_list_get("data/activities/HEAD") + 1;
fs_list_set( "data/activities/HEAD", id );
char* act_id; asprintf( &act_id,"https://%s/activity/%d", g_server_name, id );
char* actor; asprintf( &actor, "https://%s/owner/actor", g_server_name );

@ -116,10 +116,13 @@ extern struct json_object_field ap_activity_layout[];
struct ap_activity* ap_activity_new();
struct ap_activity* ap_activity_dup( struct ap_activity* act );
struct ap_activity* ap_activity_from_FILE( FILE* f );
struct ap_activity* ap_activity_from_local_id( int id );
void ap_activity_free( struct ap_activity* act );
void ap_activity_free_composite( struct ap_activity* act );
void ap_activity_write_to_FILE( struct ap_activity* act, FILE* f );
void ap_activity_save( struct ap_activity* act );
struct ap_activity* ap_activity_create_accept( struct ap_activity* act );

@ -94,6 +94,8 @@ static bool context_writer( struct json_writer* jw, const char* field_name, void
printf( "TODO: context_writer, handle array\n" );
exit(1);
}
return true;
}
struct json_field_type ap_activity_context_type = {

@ -143,4 +143,25 @@ void ap_activity_write_to_FILE( struct ap_activity* act, FILE* f )
};
json_write_pretty_object_layout( &jw, ap_activity_layout, act );
}
void ap_activity_save( struct ap_activity* act )
{
char filename[512];
snprintf( filename, sizeof(filename), "data/activities/%d.json", act->local_id );
json_write_object_layout_to_file( filename, "\t", ap_activity_layout, act );
}
struct ap_activity* ap_activity_from_local_id( int id )
{
struct ap_activity* act = ap_activity_new();
char filename[512];
snprintf( filename, sizeof(filename), "data/activities/%d.json", id );
if( !json_read_object_layout_from_file( filename, ap_activity_layout, act ) ) {
ap_activity_free(act);
return NULL;
}
return act;
}

@ -17,7 +17,7 @@
#include <stdlib.h>
#include <stddef.h>
extern struct rdf_enum_item ap_activity_type_enum[0];
extern struct rdf_enum_item ap_activity_type_enum[];
static char* type_filter( const char* name )
{
char* res;
@ -130,10 +130,7 @@ bool ap_activity_create_rsa_signature_2017( struct ap_activity* act, struct cryp
if( !calculate_hash_for_object( ap_activity_rdf, act, &raw_hash[0] ) ) { return NULL; }
if( !calculate_hash_for_object( ap_activity_signature_rdf, &act->signature, &raw_hash[32] ) ) { return NULL; }
char hash[32];
sha256_easy_hash( raw_hash, 64, &hash[0] );
char* sign = crypto_keys_sign( keys, &hash[0], 32 );
char* sign = crypto_keys_sign( keys, raw_hash, 64 );
act->signature.value = sign;
act->signature.type = apst_rsa_signature_2017;
printf( "act->signature = %s\n", sign );

@ -0,0 +1,70 @@
#include "outbox_envelope.h"
#include "json/layout.h"
#include "ffdb/fs_list.h"
#include <stdlib.h>
#include <stdio.h>
#include <stddef.h>
#include <string.h>
static struct json_object_field layout[] = {
{ "account", offsetof( struct outbox_envelope, account_id ), true, &json_field_integer },
{ "activity", offsetof( struct outbox_envelope, activity_id ), true, &json_field_integer },
{ NULL },
};
struct outbox_envelope* outbox_envelope_new()
{
struct outbox_envelope* env;
env = malloc(sizeof(*env));
if( !env ) { return NULL; }
memset(env,0,sizeof(*env));
}
void outbox_envelope_free( struct outbox_envelope* env )
{
free(env);
}
void outbox_envelope_save( struct outbox_envelope* env )
{
int id = fs_list_get( "data/outbox/HEAD" ) + 1;
fs_list_set( "data/outbox/HEAD", id );
char filename[512];
snprintf( filename, sizeof(filename), "data/outbox/%d.json", id );
json_write_object_layout_to_file( filename, "\t", layout, env );
}
struct outbox_envelope* outbox_envelope_load_next()
{
struct outbox_envelope* env;
if( !( env = outbox_envelope_new()) ) { return NULL; }
int id = fs_list_get( "data/outbox/TAIL" ) + 1;
env->id = id;
char filename[512];
snprintf( filename, sizeof(filename), "data/outbox/%d.json", id );
if( !json_read_object_layout_from_file( filename, layout, env ) ) {
outbox_envelope_free( env );
return NULL;
}
return env;
}
void outbox_envelope_delete( struct outbox_envelope* env )
{
char filename[512];
snprintf( filename, sizeof(filename), "data/outbox/%d.json", env->id );
remove(filename);
outbox_envelope_free(env);
}

@ -0,0 +1,17 @@
#pragma once
struct outbox_envelope
{
int id;
int activity_id;
int account_id;
};
struct outbox_envelope* outbox_envelope_new();
void outbox_envelope_free( struct outbox_envelope* env );
void outbox_envelope_save( struct outbox_envelope* env );
struct outbox_envelope* outbox_envelope_load_next();
void outbox_envelope_delete( struct outbox_envelope* env );

@ -46,14 +46,12 @@ bool http_signature_make( const char* inbox, struct crypto_keys* keys, struct ht
// Build hash line
char hash_line[512];
snprintf( hash_line, 512, "(request-target): post %s\nhost: %s\ndate: %s", path, host, date );
printf( "hash_line = %s\n", hash_line );
int size = snprintf( hash_line, 512, "(request-target): post %s\nhost: %s\ndate: %s", path, host, date );
//printf( "\nbuilding hash_line = %s|\n\n", hash_line );
// Hash and sign
char hash[32];
sha256_easy_hash( hash_line, strlen(hash_line), hash );
sign->signature = crypto_keys_sign( keys, hash, 32 );
printf( "Signature: %s\n", sign->signature );
// sign
sign->signature = crypto_keys_sign( keys, hash_line, strlen(hash_line) );
//printf( "Signature: %s\n", sign->signature );
return true;
}
@ -261,9 +259,6 @@ bool http_signature_validate( struct ap_envelope* env, const char* request_targe
hash_line[hash_line_size] = '\0';
//printf( "\nhash_line:\n%s|\n\n", hash_line );
char raw_hash[32];
sha256_easy_hash( hash_line, hash_line_size, raw_hash );
//result = crypto_keys_verify( keys, raw_hash, 32, signature );
result = crypto_keys_verify( keys, hash_line, hash_line_size, signature );
if( !result ) {

@ -48,7 +48,7 @@ struct notification* notification_new()
struct notification* note = malloc(sizeof(struct notification));
memset(note,0,sizeof(*note));
note->id = id;
fs_list_set( "data/notices/TAIL", id );
fs_list_set( "data/notices/HEAD", id );
return note;
}

@ -21,7 +21,7 @@
},
"discoverable":false,
"endpoints":{
"sharedInbox":"https://%s{g_server_name}/inbox",
"sharedInbox":"https://%s{g_server_name}/inbox"
},
"featured":"https://%s{g_server_name}/owner/collections/featured",
"followers":"https://%s{g_server_name}/owner/followers",

@ -0,0 +1,10 @@
{
"@context":[
"https://www.w3.org/ns/activitystreams",
{"@language":"und"}
],
"id":"https://%s{g_server_name}/owner/collections/featured",
"orderedItems":[],
"totalItems":0,
"type":"OrderedCollection"
}

@ -0,0 +1,10 @@
{
"@context":[
"https://www.w3.org/ns/activitystreams",
{"@language":"und"}
],
"orderedItems":[]
"id":"https://%s{g_server_name}/owner/actor/following",
"totalItems":0,
"type":"OrderedCollection"
}

@ -0,0 +1,10 @@
{
"@context":[
"https://www.w3.org/ns/activitystreams",
{"@language":"und"}
],
"orderedItems":[]
"id":"https://%s{g_server_name}/owner/actor/following",
"totalItems":0,
"type":"OrderedCollection"
}
Loading…
Cancel
Save