added support for activitypub follows
This commit is contained in:
parent
0bc0eb23e5
commit
ad450ccf23
@ -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
27
app/Models/Activity.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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
114
app/Types/TypeActivity.php
Normal 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
200
app/Types/TypeActor.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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
2
composer.lock
generated
@ -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",
|
||||
|
@ -37,6 +37,7 @@ return new class extends Migration
|
||||
$table->string ("private_key")->nullable ();
|
||||
|
||||
$table->string ("icon")->nullable ();
|
||||
$table->string ("image")->nullable ();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
@ -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');
|
||||
}
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user