added support for activitypub follows

This commit is contained in:
Ghostie 2024-12-28 14:40:53 -05:00
parent 0bc0eb23e5
commit ad450ccf23
9 changed files with 470 additions and 79 deletions

View File

@ -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);
}
}

27
app/Models/Activity.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
protected $fillable = [
"activity_id",
"actor",
"type",
"object",
"target",
"summary"
];
protected $casts = [
"object" => "array",
"target" => "array"
];
public function actor ()
{
return $this->belongsTo (Actor::class);
}
}

View File

@ -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);
}
}

114
app/Types/TypeActivity.php Normal file
View File

@ -0,0 +1,114 @@
<?php
namespace App\Types;
use App\Models\Actor;
use App\Models\Activity;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class TypeActivity {
public static function craft_response (Activity $activity)
{
$crafted_activity = [
"@context" => "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 ();
}
}

200
app/Types/TypeActor.php Normal file
View File

@ -0,0 +1,200 @@
<?php
namespace App\Types;
use App\Models\User;
use App\Models\Actor;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class TypeActor {
public static function gen_keys ()
{
$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 [
"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;
}
}

View File

@ -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"

2
composer.lock generated
View File

@ -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",

View File

@ -37,6 +37,7 @@ return new class extends Migration
$table->string ("private_key")->nullable ();
$table->string ("icon")->nullable ();
$table->string ("image")->nullable ();
$table->timestamps();
});

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('activities', function (Blueprint $table) {
$table->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');
}
};