Jelajahi Sumber

Add shared inbox

Daniel Supernault 4 tahun lalu
induk
melakukan
4733ca9fb9

+ 11 - 0
app/Http/Controllers/FederationController.php

@@ -108,6 +108,17 @@ class FederationController extends Controller
         return;
     }
 
+    public function sharedInbox(Request $request)
+    {
+        abort_if(!config('federation.activitypub.enabled'), 404);
+        abort_if(!config('federation.activitypub.sharedInbox'), 404);
+
+        $headers = $request->headers->all();
+        $payload = $request->getContent();
+        dispatch(new InboxWorker($headers, $payload))->onQueue('high');
+        return;
+    }
+
     public function userFollowing(Request $request, $username)
     {
         abort_if(!config('federation.activitypub.enabled'), 404);

+ 112 - 3
app/Jobs/InboxPipeline/InboxWorker.php

@@ -26,10 +26,9 @@ class InboxWorker implements ShouldQueue
      *
      * @return void
      */
-    public function __construct($headers, $profile, $payload)
+    public function __construct($headers, $payload)
     {
         $this->headers = $headers;
-        $this->profile = $profile;
         $this->payload = $payload;
     }
 
@@ -40,6 +39,116 @@ class InboxWorker implements ShouldQueue
      */
     public function handle()
     {
-        (new Inbox($this->headers, $this->profile, $this->payload))->handle();
+        $profile = null;
+        $headers = $this->headers;
+        $payload = json_decode($this->payload, true, 8);
+
+        if(!isset($headers['signature']) || !isset($headers['date'])) {
+            return;
+        }
+
+        if(empty($headers) || empty($payload)) {
+            return;
+        }
+
+        if($this->verifySignature($headers, $payload) == true) {
+            (new Inbox($headers, $profile, $payload))->handle();
+            return;
+        } else if($this->blindKeyRotation($headers, $payload) == true) {
+            (new Inbox($headers, $profile, $payload))->handle();
+            return;
+        } else {
+            return;
+        }
+    }
+
+    protected function verifySignature($headers, $payload)
+    {
+        $body = $this->payload;
+        $bodyDecoded = $payload;
+        $signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
+        $date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
+        if(!$signature) {
+            return;
+        }
+        if(!$date) {
+            return;
+        }
+        if(!now()->parse($date)->gt(now()->subDays(1)) || 
+           !now()->parse($date)->lt(now()->addDays(1))
+       ) {
+            return;
+        }
+        $signatureData = HttpSignature::parseSignatureHeader($signature);
+        $keyId = Helpers::validateUrl($signatureData['keyId']);
+        $id = Helpers::validateUrl($bodyDecoded['id']);
+        $keyDomain = parse_url($keyId, PHP_URL_HOST);
+        $idDomain = parse_url($id, PHP_URL_HOST);
+        if(isset($bodyDecoded['object']) 
+            && is_array($bodyDecoded['object'])
+            && isset($bodyDecoded['object']['attributedTo'])
+        ) {
+            if(parse_url($bodyDecoded['object']['attributedTo'], PHP_URL_HOST) !== $keyDomain) {
+                return;
+                abort(400, 'Invalid request');
+            }
+        }
+        if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
+            return;
+            abort(400, 'Invalid request');
+        }
+        $actor = Profile::whereKeyId($keyId)->first();
+        if(!$actor) {
+            $actorUrl = is_array($bodyDecoded['actor']) ? $bodyDecoded['actor'][0] : $bodyDecoded['actor'];
+            $actor = Helpers::profileFirstOrNew($actorUrl);
+        }
+        if(!$actor) {
+            return;
+        }
+        $pkey = openssl_pkey_get_public($actor->public_key);
+        $inboxPath = "/f/inbox";
+        list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
+        if($verified == 1) { 
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    protected function blindKeyRotation($headers, $payload)
+    {
+        $signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
+        $date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
+        if(!$signature) {
+            return;
+        }
+        if(!$date) {
+            return;
+        }
+        if(!now()->parse($date)->gt(now()->subDays(1)) || 
+           !now()->parse($date)->lt(now()->addDays(1))
+       ) {
+            return;
+        }
+        $signatureData = HttpSignature::parseSignatureHeader($signature);
+        $keyId = Helpers::validateUrl($signatureData['keyId']);
+        $actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
+        if(!$actor) {
+            return;
+        }
+        if(Helpers::validateUrl($actor->remote_url) == false) {
+            return;
+        }
+        $res = Zttp::timeout(5)->withHeaders([
+          'Accept'     => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+          'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
+        ])->get($actor->remote_url);
+        $res = json_decode($res->body(), true, 8);
+        if($res['publicKey']['id'] !== $actor->key_id) {
+            return;
+        }
+        $actor->public_key = $res['publicKey']['publicKeyPem'];
+        $actor->save();
+        return $this->verifySignature($headers, $payload);
     }
 }

+ 3 - 0
app/Transformer/ActivityPub/ProfileTransformer.php

@@ -44,6 +44,9 @@ class ProfileTransformer extends Fractal\TransformerAbstract
             'mediaType' => 'image/jpeg',
             'url'       => $profile->avatarUrl(),
           ],
+          'endpoints' => [
+            'sharedInbox' => config('app.url') . '/f/inbox'
+          ]
       ];
     }
 }

+ 50 - 35
app/Util/ActivityPub/Helpers.php

@@ -135,41 +135,49 @@ class Helpers {
 		if(is_array($url)) {
 			$url = $url[0];
 		}
-		
-		$localhosts = [
-			'127.0.0.1', 'localhost', '::1'
-		];
 
-		if(mb_substr($url, 0, 8) !== 'https://') {
-			return false;
-		}
+		$hash = hash('sha256', $url);
+		$key = "helpers:url:valid:sha256-{$hash}";
+		$ttl = now()->addMinutes(5);
 
-		$valid = filter_var($url, FILTER_VALIDATE_URL);
+		$valid = Cache::remember($key, $ttl, function() use($url) {
+			$localhosts = [
+				'127.0.0.1', 'localhost', '::1'
+			];
 
-		if(!$valid) {
-			return false;
-		}
+			if(mb_substr($url, 0, 8) !== 'https://') {
+				return false;
+			}
 
-		$host = parse_url($valid, PHP_URL_HOST);
+			$valid = filter_var($url, FILTER_VALIDATE_URL);
 
-		if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) {
-			return false;
-		}
+			if(!$valid) {
+				return false;
+			}
 
-		if(config('costar.enabled') == true) {
-			if(
-				(config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) || 
-				(config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true)
-			) {
+			$host = parse_url($valid, PHP_URL_HOST);
+
+			if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) {
 				return false;
 			}
-		}
 
-		if(in_array($host, $localhosts)) {
-			return false;
-		}
+			if(config('costar.enabled') == true) {
+				if(
+					(config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) || 
+					(config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true)
+				) {
+					return false;
+				}
+			}
 
-		return $valid;
+			if(in_array($host, $localhosts)) {
+				return false;
+			}
+
+			return true;
+		});
+
+		return (bool) $valid;
 	}
 
 	public static function validateLocalUrl($url)
@@ -194,19 +202,25 @@ class Helpers {
 		];
 	}
 
-	public static function fetchFromUrl($url)
+	public static function fetchFromUrl($url = false)
 	{
-		$url = self::validateUrl($url);
-		if($url == false) {
+		if(self::validateUrl($url) == false) {
 			return;
 		}
-		$res = Zttp::withHeaders(self::zttpUserAgent())->get($url);
-		$res = json_decode($res->body(), true, 8);
-		if(json_last_error() == JSON_ERROR_NONE) {
-			return $res;
-		} else {
-			return false;
-		}
+
+		$hash = hash('sha256', $url);
+		$key = "helpers:url:fetcher:sha256-{$hash}";
+		$ttl = now()->addMinutes(5);
+
+		return Cache::remember($key, $ttl, function() use($url) {
+			$res = Zttp::withoutVerifying()->withHeaders(self::zttpUserAgent())->get($url);
+			$res = json_decode($res->body(), true, 8);
+			if(json_last_error() == JSON_ERROR_NONE) {
+				return $res;
+			} else {
+				return false;
+			}
+		});
 	}
 
 	public static function fetchProfileFromUrl($url)
@@ -444,6 +458,7 @@ class Helpers {
 				$profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user';
 				$profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null;
 				$profile->last_fetched_at = now();
+				$profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) && Helpers::validateUrl($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
 				$profile->save();
 			}
 		}

+ 48 - 47
app/Util/ActivityPub/Inbox.php

@@ -323,7 +323,7 @@ class Inbox
     public function handleFollowActivity()
     {
         $actor = $this->actorFirstOrCreate($this->payload['actor']);
-        $target = $this->profile;
+        $target = $this->actorFirstOrCreate($this->payload['object']);
         if(!$actor || $actor->domain == null || $target->domain !== null) {
             return;
         }
@@ -470,55 +470,56 @@ class Inbox
             $profile->statuses()->delete();
             $profile->delete();
             return;
-        }
-        $type = $this->payload['object']['type'];
-        $typeCheck = in_array($type, ['Person', 'Tombstone']);
-        if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) {
-            return;
-        }
-        if(parse_url($obj['id'], PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
-            return;
-        }
-        $id = $this->payload['object']['id'];
-        switch ($type) {
-            case 'Person':
-                    $profile = Profile::whereRemoteUrl($actor)->first();
-                    if(!$profile || $profile->private_key != null) {
-                        return;
-                    }
-                    Notification::whereActorId($profile->id)->delete();
-                    $profile->avatar()->delete();
-                    $profile->followers()->delete();
-                    $profile->following()->delete();
-                    $profile->likes()->delete();
-                    $profile->media()->delete();
-                    $profile->hashtags()->delete();
-                    $profile->statuses()->delete();
-                    $profile->delete();
+        } else {
+            $type = $this->payload['object']['type'];
+            $typeCheck = in_array($type, ['Person', 'Tombstone']);
+            if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) {
                 return;
-                break;
-
-            case 'Tombstone':
-                    $profile = Helpers::profileFetch($actor);
-                    $status = Status::whereProfileId($profile->id)
-                        ->whereUri($id)
-                        ->orWhere('url', $id)
-                        ->orWhere('object_url', $id)
-                        ->first();
-                    if(!$status) {
+            }
+            if(parse_url($obj['id'], PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
+                return;
+            }
+            $id = $this->payload['object']['id'];
+            switch ($type) {
+                case 'Person':
+                        $profile = Profile::whereRemoteUrl($actor)->first();
+                        if(!$profile || $profile->private_key != null) {
+                            return;
+                        }
+                        Notification::whereActorId($profile->id)->delete();
+                        $profile->avatar()->delete();
+                        $profile->followers()->delete();
+                        $profile->following()->delete();
+                        $profile->likes()->delete();
+                        $profile->media()->delete();
+                        $profile->hashtags()->delete();
+                        $profile->statuses()->delete();
+                        $profile->delete();
+                    return;
+                    break;
+
+                case 'Tombstone':
+                        $profile = Helpers::profileFetch($actor);
+                        $status = Status::whereProfileId($profile->id)
+                            ->whereUri($id)
+                            ->orWhere('url', $id)
+                            ->orWhere('object_url', $id)
+                            ->first();
+                        if(!$status) {
+                            return;
+                        }
+                        $status->directMessage()->delete();
+                        $status->media()->delete();
+                        $status->likes()->delete();
+                        $status->shares()->delete();
+                        $status->delete();
                         return;
-                    }
-                    $status->directMessage()->delete();
-                    $status->media()->delete();
-                    $status->likes()->delete();
-                    $status->shares()->delete();
-                    $status->delete();
+                    break;
+                
+                default:
                     return;
-                break;
-            
-            default:
-                return;
-                break;
+                    break;
+            }
         }
     }
 

+ 1 - 0
app/Util/Lexer/RestrictedNames.php

@@ -177,6 +177,7 @@ class RestrictedNames
 		'help-center_',
 		'help_center-',
 		'i',
+		'inbox',
 		'img',
 		'imgs',
 		'image',

+ 1 - 1
config/federation.php

@@ -20,7 +20,7 @@ return [
 		'remoteFollow' => env('AP_REMOTE_FOLLOW', false),
 
 		'delivery' => [
-			'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),
+			'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0),
 			'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10),
 			'logger' => [
 				'enabled' => env('AP_LOGGER_ENABLED', false),

+ 1 - 0
routes/api.php

@@ -4,6 +4,7 @@ use Illuminate\Http\Request;
 
 $middleware = ['auth:api','twofactor','validemail','localization', 'throttle:60,1'];
 
+Route::post('/f/inbox', 'FederationController@sharedInbox');
 Route::post('/users/{username}/inbox', 'FederationController@userInbox');
 
 Route::group(['prefix' => 'api'], function() use($middleware) {