From ad450ccf23255bb4a64d31ad617539a58047270b Mon Sep 17 00:00:00 2001 From: Ghostie Date: Sat, 28 Dec 2024 14:40:53 -0500 Subject: [PATCH] added support for activitypub follows --- app/Http/Controllers/AP/APInboxController.php | 85 +++++++- app/Models/Activity.php | 27 +++ app/Models/Actor.php | 84 +------- app/Types/TypeActivity.php | 114 ++++++++++ app/Types/TypeActor.php | 200 ++++++++++++++++++ composer.json | 1 + composer.lock | 2 +- .../2024_12_27_002726_create_actors_table.php | 1 + ...4_12_28_014335_create_activities_table.php | 35 +++ 9 files changed, 470 insertions(+), 79 deletions(-) create mode 100644 app/Models/Activity.php create mode 100644 app/Types/TypeActivity.php create mode 100644 app/Types/TypeActor.php create mode 100644 database/migrations/2024_12_28_014335_create_activities_table.php diff --git a/app/Http/Controllers/AP/APInboxController.php b/app/Http/Controllers/AP/APInboxController.php index d1d693f..ab06aa3 100644 --- a/app/Http/Controllers/AP/APInboxController.php +++ b/app/Http/Controllers/AP/APInboxController.php @@ -4,6 +4,10 @@ namespace App\Http\Controllers\AP; use App\Models\User; use App\Models\Actor; +use App\Models\Activity; + +use App\Types\TypeActor; +use App\Types\TypeActivity; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -13,7 +17,86 @@ class APInboxController extends Controller { public function inbox (User $user) { + $request = request (); + $type = $request->get ("type"); + + switch ($type) { + case "Follow": + $this->handle_follow ($user, $request->all ()); + break; + + case "Undo": + $this->handle_undo ($user, $request->all ()); + break; + } + Log::info ("APInboxController@index"); - Log::info (json_encode (request ()->all ())); + Log::info (json_encode ($request->all ())); + } + + private function handle_follow (User $user, $activity) + { + if (TypeActivity::activity_exists ($activity ["id"])) + return response ()->json (["error" => "Activity already exists",], 409); + + $actor = TypeActor::actor_exists_or_obtain ($activity ["actor"]); + + $target = TypeActor::actor_get_local ($activity ["object"]); + if (!$target || !$target->user) + return response ()->json (["error" => "Target not found",], 404); + + $activity ["activity_id"] = $activity ["id"]; + + // there's no follows model, it'll be handled with the activity model + $act = Activity::create ($activity); + + // TODO: Users should be able to manually check this + $accept_activity = TypeActivity::craft_accept ($act); + $response = TypeActivity::post_activity ($accept_activity, $target, $actor); + if (!$response) + { + return response ()->json ([ + "error" => "Error posting activity", + ], 500); + } + + $target->user->friends += 1; + $target->user->save (); + } + + public function handle_undo (User $user, $activity) + { + if (TypeActivity::activity_exists ($activity ["id"])) + return response ()->json (["error" => "Activity already exists",], 409); + + $actor = TypeActor::actor_exists_or_obtain ($activity ["actor"]); + + $child_activity = $activity ["object"]; + if (!TypeActivity::activity_exists ($child_activity ["id"])) + return response ()->json (["error" => "Child activity not found",], 404); + + $child_activity = Activity::where ("activity_id", $child_activity ["id"])->first (); + switch ($child_activity->type) + { + case "Follow": + // TODO: Move this to its own function + // TODO: Should the accept activity be deleted? + $followed_user = TypeActor::actor_get_local ($child_activity ["object"]); + if (!$followed_user || !$followed_user->user) + return response ()->json (["error" => "Target not found",], 404); + + $followed_user->user->friends -= 1; + $followed_user->user->save (); + + $child_activity->delete (); + break; + + default: + Log::info ("Unknown activity type to Undo: " . $child_activity ["type"]); + break; + } + + // TODO: Should Undo create a new activity model? + return response ()->json (["error" => "Not implemented",], 501); } } diff --git a/app/Models/Activity.php b/app/Models/Activity.php new file mode 100644 index 0000000..e4644b4 --- /dev/null +++ b/app/Models/Activity.php @@ -0,0 +1,27 @@ + "array", + "target" => "array" + ]; + + public function actor () + { + return $this->belongsTo (Actor::class); + } +} diff --git a/app/Models/Actor.php b/app/Models/Actor.php index b5dc695..2db52c0 100644 --- a/app/Models/Actor.php +++ b/app/Models/Actor.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\User; +use App\Types\TypeActor; use Illuminate\Database\Eloquent\Model; @@ -28,6 +29,9 @@ class Actor extends Model "name", "summary", + "icon", + "image", + "public_key", "private_key" ]; @@ -39,86 +43,12 @@ class Actor extends Model public function create_from_user (User $user) { - $app_url = env ("APP_URL"); - - $config = [ - "private_key_bits" => 4096, - "private_key_type" => OPENSSL_KEYTYPE_RSA - ]; - - $res = openssl_pkey_new ($config); - openssl_pkey_export ($res, $private_key); - - $public_key = openssl_pkey_get_details ($res); - - return $this->create ([ - "user_id" => $user->id, - - "type" => "Person", - "actor_id" => $app_url . "/ap/v1/user/" . $user->name, - - "following" => $app_url . "/ap/v1/user/" . $user->name . "/following", - "followers" => $app_url . "/ap/v1/user/" . $user->name . "/followers", - - "liked" => $app_url . "/ap/v1/user/" . $user->name . "/liked", - - "inbox" => $app_url . "/ap/v1/user/" . $user->name . "/inbox", - "outbox" => $app_url . "/ap/v1/user/" . $user->name . "/outbox", - - "sharedInbox" => $app_url . "/ap/v1/inbox", - - "preferredUsername" => $user->name, - "name" => $user->name, - "summary" => "", - - "public_key" => $public_key["key"], - "private_key" => $private_key - ]); + $data = TypeActor::create_from_user ($user); + return $this->create ($data); } public static function build_response (Actor $actor) { - $response = [ - "@context" => [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ], - "id" => $actor->actor_id, - "type" => $actor->type, - - "following" => $actor->following, - "followers" => $actor->followers, - - "liked" => $actor->liked, - - "inbox" => $actor->inbox, - "outbox" => $actor->outbox, - - "sharedInbox" => $actor->sharedInbox, - - "preferredUsername" => $actor->preferredUsername, - "name" => $actor->name, - "summary" => $actor->summary, - - "icon" => [ - "type" => "Image", - "mediaType" => "image/jpeg", - "url" => $actor->icon - ], - - "image" => [ - "type" => "Image", - "mediaType" => "image/jpeg", - "url" => $actor->icon - ], - - "publicKey" => [ - "id" => $actor->actor_id . "#main-key", - "owner" => $actor->actor_id, - "publicKeyPem" => $actor->public_key - ] - ]; - - return $response; + return TypeActor::build_response ($actor); } } diff --git a/app/Types/TypeActivity.php b/app/Types/TypeActivity.php new file mode 100644 index 0000000..cff9579 --- /dev/null +++ b/app/Types/TypeActivity.php @@ -0,0 +1,114 @@ + "https://www.w3.org/ns/activitystreams", + "id" => $activity->activity_id, + "type" => $activity->type, + "actor" => $activity->actor, + "object" => $activity->object, + ]; + + if ($activity->target) + { + $crafted_activity["target"] = $activity->target; + } + + if ($activity->summary) + { + $crafted_activity["summary"] = $activity->summary; + } + + return $crafted_activity; + } + + public static function craft_accept (Activity $activity) + { + $accept_activity = new Activity (); + $accept_activity->activity_id = env ("APP_URL") . "/activity/" . uniqid (); + $accept_activity->type = "Accept"; + $accept_activity->actor = $activity->object; + $accept_activity->object = TypeActivity::craft_response ($activity); + $accept_activity->save (); + + return $accept_activity; + } + + public static function craft_signed_headers ($activity, Actor $source, Actor $target) + { + if (!$source->user) + { + Log::error ("Source not found"); + return null; + } + + $key_id = $source->actor_id . "#main-key"; + + $signer = openssl_get_privatekey ($source->private_key); + + $date = gmdate ("D, d M Y H:i:s \G\M\T"); + + // we suppose that the activity is already json encoded + $hash = hash ("sha256", $activity, true); + $digest = base64_encode ($hash); + + $url = parse_url ($target->inbox); + $string_to_sign = "(request-target): post ". $url["path"] . "\nhost: " . $url["host"] . "\ndate: " . $date . "\ndigest: SHA-256=" . $digest; + + openssl_sign ($string_to_sign, $signature, $signer, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode ($signature); + + $signature_header = 'keyId="' . $key_id . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; + + return [ + "Host" => $url["host"], + "Date" => $date, + "Digest" => "SHA-256=" . $digest, + "Signature" => $signature_header, + "Content-Type" => "application/activity+json", + "Accept" => "application/activity+json", + ]; + } + + public static function post_activity (Activity $activity, Actor $source, Actor $target) + { + $crafted_activity = TypeActivity::craft_response ($activity); + $activity_json = json_encode ($crafted_activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); + $activity_json = mb_convert_encoding ($activity_json, "UTF-8"); + + $headers = TypeActivity::craft_signed_headers ($activity_json, $source, $target); + + try { + $client = new Client (); + $response = $client->post ($target->inbox, [ + "headers" => $headers, + "body" => $activity_json, + "debug" => true + ]); + } + catch (RequestException $e) + { + Log::error ($e->getMessage ()); + return null; + } + + return $response; + } + + // some little functions + public static function activity_exists ($activity_id) + { + return Activity::where ("activity_id", $activity_id)->first (); + } +} diff --git a/app/Types/TypeActor.php b/app/Types/TypeActor.php new file mode 100644 index 0000000..085f0d4 --- /dev/null +++ b/app/Types/TypeActor.php @@ -0,0 +1,200 @@ + 4096, + "private_key_type" => OPENSSL_KEYTYPE_RSA + ]; + + $res = openssl_pkey_new ($config); + openssl_pkey_export ($res, $private_key); + + $public_key = openssl_pkey_get_details ($res); + + return [ + "public_key" => $public_key, + "private_key" => $private_key + ]; + } + + public static function create_from_user (User $user) + { + $keys = TypeActor::gen_keys (); + $app_url = env ("APP_URL"); + + return [ + "user_id" => $user->id, + + "type" => "Person", + "actor_id" => $app_url . "/ap/v1/user/" . $user->name, + + "following" => $app_url . "/ap/v1/user/" . $user->name . "/following", + "followers" => $app_url . "/ap/v1/user/" . $user->name . "/followers", + + "liked" => $app_url . "/ap/v1/user/" . $user->name . "/liked", + + "inbox" => $app_url . "/ap/v1/user/" . $user->name . "/inbox", + "outbox" => $app_url . "/ap/v1/user/" . $user->name . "/outbox", + + "sharedInbox" => $app_url . "/ap/v1/inbox", + + "preferredUsername" => $user->name, + "name" => $user->name, + "summary" => "", + + "public_key" => $keys["public_key"]["key"], + "private_key" => $keys["private_key"] + ]; + } + + public static function build_response (Actor $actor) + { + $response = [ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id" => $actor->actor_id, + "type" => $actor->type, + + "following" => $actor->following, + "followers" => $actor->followers, + + "liked" => $actor->liked, + + "inbox" => $actor->inbox, + "outbox" => $actor->outbox, + + "endpoints" => [ + "sharedInbox" => $actor->sharedInbox, + ], + + "preferredUsername" => $actor->preferredUsername, + "name" => $actor->name, + "summary" => $actor->summary, + + "icon" => [ + "type" => "Image", + "mediaType" => "image/png", + "url" => $actor->icon + ], + + "image" => [ + "type" => "Image", + "mediaType" => "image/png", + "url" => $actor->image + ], + + "publicKey" => [ + "id" => $actor->actor_id . "#main-key", + "owner" => $actor->actor_id, + "publicKeyPem" => $actor->public_key + ] + ]; + + return $response; + } + + public static function create_from_request ($request) + { + $actor = new Actor (); + + // Use null coalescing operator `??` for safety + $actor->actor_id = $request['id'] ?? ''; + $actor->type = $request['type'] ?? ''; + + $actor->following = $request['following'] ?? ''; + $actor->followers = $request['followers'] ?? ''; + + $actor->liked = $request['liked'] ?? ''; + + $actor->inbox = $request['inbox'] ?? ''; + $actor->outbox = $request['outbox'] ?? ''; + + $actor->sharedInbox = $request['endpoints']['sharedInbox'] ?? ''; + + $actor->preferredUsername = $request['preferredUsername'] ?? ''; + $actor->name = $request['name'] ?? ''; + $actor->summary = $request['summary'] ?? ''; + + // Handle nested keys with checks + $actor->icon = $request['icon']['url'] ?? ''; + $actor->image = $request['image']['url'] ?? ''; + + // Handle nested keys in `publicKey` + $actor->public_key = $request['publicKey']['publicKeyPem'] ?? ''; + + $actor->save (); + + return $actor; + } + + public static function obtain_actor_info ($actor_id) + { + $client = new Client (); + + $parsed_url = parse_url ($actor_id); + $url_instance = $parsed_url["scheme"] . "://" . $parsed_url["host"]; + $url_path = explode ("/", $parsed_url["path"]); + $actor_name = end ($url_path); + + $well_known_url = $url_instance . "/.well-known/webfinger?resource=acct:" . $actor_name . "@" . $parsed_url["host"]; + $res = $client->get ($well_known_url); + + $response = json_decode ($res->getBody ()->getContents ()); + + foreach ($response->links as $link) + { + if ($link->rel == "self") + { + $res = $client->request ("GET", $link->href, [ + "headers" => [ + "Accept" => "application/activity+json" + ] + ]); + $actor = json_decode ($res->getBody ()->getContents (), true); + + $result = TypeActor::create_from_request ($actor); + return $result; + } + } + + return null; + } + + // some little functions + public static function actor_exists ($actor_id) + { + $actor = Actor::where ("actor_id", $actor_id)->first (); + return $actor; + } + + public static function actor_exists_or_obtain ($actor_id) + { + $actor = TypeActor::actor_exists ($actor_id); + if (!$actor) + { + $actor = TypeActor::obtain_actor_info ($actor_id); + } + + return $actor; + } + + public static function actor_get_local ($actor_id) + { + $actor = Actor::where ("actor_id", $actor_id)->first (); + if (!$actor->user) + return null; + return $actor; + } +} diff --git a/composer.json b/composer.json index 645174e..b0c1727 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "guzzlehttp/guzzle": "^7.9", "intervention/image": "^3.10", "laravel/framework": "^11.31", "laravel/tinker": "^2.9" diff --git a/composer.lock b/composer.lock index d9db450..984aaac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "64b78dd438697d218ce81a8ea6afcbb3", + "content-hash": "067a260457c754c554a61fce0e741b0f", "packages": [ { "name": "brick/math", diff --git a/database/migrations/2024_12_27_002726_create_actors_table.php b/database/migrations/2024_12_27_002726_create_actors_table.php index 94ffdab..fcc5a62 100644 --- a/database/migrations/2024_12_27_002726_create_actors_table.php +++ b/database/migrations/2024_12_27_002726_create_actors_table.php @@ -37,6 +37,7 @@ return new class extends Migration $table->string ("private_key")->nullable (); $table->string ("icon")->nullable (); + $table->string ("image")->nullable (); $table->timestamps(); }); diff --git a/database/migrations/2024_12_28_014335_create_activities_table.php b/database/migrations/2024_12_28_014335_create_activities_table.php new file mode 100644 index 0000000..74db427 --- /dev/null +++ b/database/migrations/2024_12_28_014335_create_activities_table.php @@ -0,0 +1,35 @@ +id(); + + $table->string ("activity_id")->unique (); + $table->string ("actor"); + $table->string ("type"); + $table->json ("object"); + $table->json ("target")->nullable (); + $table->text ("summary")->nullable (); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('activities'); + } +};