Merge pull request #4455 from pixelfed/staging

Handle Update.Person activities
This commit is contained in:
daniel 2023-06-06 06:14:03 -06:00 committed by GitHub
commit 61346b104c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 502 additions and 6 deletions

View file

@ -60,7 +60,7 @@ class RemoteAvatarFetch implements ShouldQueue
{ {
$profile = $this->profile; $profile = $this->profile;
if(config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) { if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
return 1; return 1;
} }
@ -108,7 +108,7 @@ class RemoteAvatarFetch implements ShouldQueue
$avatar->remote_url = $icon['url']; $avatar->remote_url = $icon['url'];
$avatar->save(); $avatar->save();
MediaStorageService::avatar($avatar, config_cache('pixelfed.cloud_storage') == false); MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false);
return 1; return 1;
} }

View file

@ -0,0 +1,97 @@
<?php
namespace App\Jobs\AvatarPipeline;
use App\Avatar;
use App\Profile;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Str;
use Zttp\Zttp;
use App\Http\Controllers\AvatarController;
use Cache;
use Storage;
use Log;
use Illuminate\Http\File;
use App\Services\AccountService;
use App\Services\MediaStorageService;
use App\Services\ActivityPubFetchService;
class RemoteAvatarFetchFromUrl implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile;
protected $url;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 1;
public $timeout = 300;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Profile $profile, $url)
{
$this->profile = $profile;
$this->url = $url;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = $this->profile;
Cache::forget('avatar:' . $profile->id);
AccountService::del($profile->id);
if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
return 1;
}
if($profile->domain == null || $profile->private_key) {
return 1;
}
$avatar = Avatar::whereProfileId($profile->id)->first();
if(!$avatar) {
$avatar = new Avatar;
$avatar->profile_id = $profile->id;
$avatar->is_remote = true;
$avatar->remote_url = $this->url;
$avatar->save();
} else {
$avatar->remote_url = $this->url;
$avatar->is_remote = true;
$avatar->save();
}
MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
return 1;
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Jobs\ProfilePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Avatar;
use App\Profile;
use App\Util\ActivityPub\Helpers;
use Cache;
use Purify;
use App\Jobs\AvatarPipeline\RemoteAvatarFetchFromUrl;
use App\Util\Lexer\Autolink;
class HandleUpdateActivity implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $payload;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($payload)
{
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
$payload = $this->payload;
if(empty($payload) || !isset($payload['actor'])) {
return;
}
$profile = Profile::whereRemoteUrl($payload['actor'])->first();
if(!$profile || $profile->domain === null || $profile->private_key) {
return;
}
if($profile->sharedInbox == null || $profile->sharedInbox != $payload['object']['endpoints']['sharedInbox']) {
$profile->sharedInbox = $payload['object']['endpoints']['sharedInbox'];
}
if($profile->public_key !== $payload['object']['publicKey']['publicKeyPem']) {
$profile->public_key = $payload['object']['publicKey']['publicKeyPem'];
}
if($profile->bio !== $payload['object']['summary']) {
$len = strlen(strip_tags($payload['object']['summary']));
if($len) {
if($len > 500) {
$updated = strip_tags($payload['object']['summary']);
$updated = substr($updated, 0, config('pixelfed.max_bio_length'));
$profile->bio = Autolink::create()->autolink($updated);
} else {
$profile->bio = Purify::clean($payload['object']['summary']);
}
} else {
$profile->bio = null;
}
}
if($profile->name !== $payload['object']['name']) {
$profile->name = Purify::clean(substr($payload['object']['name'], 0, config('pixelfed.max_name_length')));
}
if($profile->isDirty()) {
$profile->save();
}
if(isset($payload['object']['icon']) && isset($payload['object']['icon']['url'])) {
RemoteAvatarFetchFromUrl::dispatch($profile, $payload['object']['icon']['url'])->onQueue('low');
} else {
$profile->avatar->update(['remote_url' => null]);
Cache::forget('avatar:' . $profile->id);
}
return;
}
}

View file

@ -178,6 +178,14 @@ class Profile extends Model
return url('/storage/avatars/default.jpg'); return url('/storage/avatars/default.jpg');
} }
if( $avatar->is_remote &&
$avatar->remote_url &&
boolval(config_cache('pixelfed.cloud_storage')) == false &&
boolval(config_cache('federation.avatars.store_local')) == true
) {
return $avatar->remote_url;
}
if($path === 'public/avatars/default.jpg') { if($path === 'public/avatars/default.jpg') {
return url('/storage/avatars/default.jpg'); return url('/storage/avatars/default.jpg');
} }

View file

@ -191,7 +191,7 @@ class MediaStorageService {
unlink($tmpName); unlink($tmpName);
} }
protected function fetchAvatar($avatar, $local = false) protected function fetchAvatar($avatar, $local = false, $skipRecentCheck = false)
{ {
$url = $avatar->remote_url; $url = $avatar->remote_url;
$driver = $local ? 'local' : config('filesystems.cloud'); $driver = $local ? 'local' : config('filesystems.cloud');
@ -215,9 +215,14 @@ class MediaStorageService {
$mime = $head['mime']; $mime = $head['mime'];
$max_size = (int) config('pixelfed.max_avatar_size') * 1000; $max_size = (int) config('pixelfed.max_avatar_size') * 1000;
if(!$skipRecentCheck) {
if($avatar->last_fetched_at && $avatar->last_fetched_at->gt(now()->subDay())) { if($avatar->last_fetched_at && $avatar->last_fetched_at->gt(now()->subDay())) {
return; return;
} }
}
Cache::forget('avatar:' . $avatar->profile_id);
AccountService::del($avatar->profile_id);
// handle pleroma edge case // handle pleroma edge case
if(Str::endsWith($mime, '; charset=utf-8')) { if(Str::endsWith($mime, '; charset=utf-8')) {
@ -266,7 +271,7 @@ class MediaStorageService {
$avatar->save(); $avatar->save();
Cache::forget('avatar:' . $avatar->profile_id); Cache::forget('avatar:' . $avatar->profile_id);
Cache::forget(AccountService::CACHE_KEY . $avatar->profile_id); AccountService::del($avatar->profile_id);
unlink($tmpName); unlink($tmpName);
} }

View file

@ -29,6 +29,7 @@ use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
use App\Jobs\StoryPipeline\StoryExpire; use App\Jobs\StoryPipeline\StoryExpire;
use App\Jobs\StoryPipeline\StoryFetch; use App\Jobs\StoryPipeline\StoryFetch;
use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline; use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline;
use App\Jobs\ProfilePipeline\HandleUpdateActivity;
use App\Util\ActivityPub\Validator\Accept as AcceptValidator; use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
use App\Util\ActivityPub\Validator\Add as AddValidator; use App\Util\ActivityPub\Validator\Add as AddValidator;
@ -36,6 +37,7 @@ use App\Util\ActivityPub\Validator\Announce as AnnounceValidator;
use App\Util\ActivityPub\Validator\Follow as FollowValidator; use App\Util\ActivityPub\Validator\Follow as FollowValidator;
use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\Like as LikeValidator;
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
use App\Util\ActivityPub\Validator\UpdatePersonValidator;
use App\Services\PollService; use App\Services\PollService;
use App\Services\FollowerService; use App\Services\FollowerService;
@ -1217,10 +1219,18 @@ class Inbox
return; return;
} }
if(!Helpers::validateUrl($activity['id'])) {
return;
}
if($activity['type'] === 'Note') { if($activity['type'] === 'Note') {
if(Status::whereObjectUrl($activity['id'])->exists()) { if(Status::whereObjectUrl($activity['id'])->exists()) {
StatusRemoteUpdatePipeline::dispatch($activity); StatusRemoteUpdatePipeline::dispatch($activity);
} }
} else if ($activity['type'] === 'Person') {
if(UpdatePersonValidator::validate($this->payload)) {
HandleUpdateActivity::dispatch($this->payload)->onQueue('low');
}
} }
} }
} }

View file

@ -0,0 +1,119 @@
<?php
namespace App\Util\ActivityPub\Validator;
use Validator;
use Closure;
use Illuminate\Validation\Rule;
use \App\Rules\SameHostDomain;
class UpdatePersonValidator
{
public static function validate($payload)
{
$valid = Validator::make($payload, [
'@context' => 'required',
'id' => 'required|string|url',
'type' => [
'required',
Rule::in(['Update'])
],
'actor' => 'required|url',
'object' => 'required',
'object.id' => [
'required',
'url',
'same:actor',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.type' => [
'required',
Rule::in(['Person'])
],
'object.publicKey' => 'required',
'object.publicKey.id' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.publicKey.owner' => [
'required',
'url',
'same:actor',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.publicKey.publicKeyPem' => 'required|string',
'object.url' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.summary' => 'required|string|nullable',
'object.preferredUsername' => 'required|string',
'object.name' => 'required|string|nullable',
'object.inbox' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.outbox' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.following' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.followers' => [
'required',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
],
'object.manuallyApprovesFollowers' => 'required',
'object.icon' => 'sometimes|nullable',
'object.icon.type' => 'sometimes|required_with:object.icon.url,object.icon.mediaType|in:Image',
'object.icon.url' => 'sometimes|required_with:object.icon.type,object.icon.mediaType|url',
'object.icon.mediaType' => 'sometimes|required_with:object.icon.url,object.icon.type|in:image/jpeg,image/png,image/jpg',
'object.endpoints' => 'sometimes',
'object.endpoints.sharedInbox' => [
'sometimes',
'url',
function (string $attribute, mixed $value, Closure $fail) use($payload) {
self::sameHost($attribute, $value, $fail, $payload['actor']);
},
]
])->passes();
return $valid;
}
public static function sameHost(string $attribute, mixed $value, Closure $fail, string $comparedHost)
{
if(empty($value)) {
$fail('The ' . $attribute . ' is invalid or empty');
}
$host = parse_url($value, PHP_URL_HOST);
$idHost = parse_url($comparedHost, PHP_URL_HOST);
if ($host !== $idHost) {
$fail('The ' . $attribute . ' is invalid');
}
}
}

View file

@ -7,6 +7,8 @@ use PHPUnit\Framework\TestCase;
class StoryValidationTest extends TestCase class StoryValidationTest extends TestCase
{ {
public $activity;
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();

View file

@ -0,0 +1,161 @@
<?php
namespace Tests\Unit\ActivityPub;
use App\Util\ActivityPub\Validator\UpdatePersonValidator;
use PHPUnit\Framework\TestCase;
class UpdatePersonValidationTest extends TestCase
{
public $activity;
public function setUp(): void
{
parent::setUp();
$this->activity = json_decode('{"type":"Update","object":{"url":"http://mastodon.example.org/@gargron","type":"Person","summary":"<p>Some bio</p>","publicKey":{"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0gs3VnQf6am3R+CeBV4H\nlfI1HZTNRIBHgvFszRZkCERbRgEWMu+P+I6/7GJC5H5jhVQ60z4MmXcyHOGmYMK/\n5XyuHQz7V2Ssu1AxLfRN5Biq1ayb0+DT/E7QxNXDJPqSTnstZ6C7zKH/uAETqg3l\nBonjCQWyds+IYbQYxf5Sp3yhvQ80lMwHML3DaNCMlXWLoOnrOX5/yK5+dedesg2\n/HIvGk+HEt36vm6hoH7bwPuEkgA++ACqwjXRe5Mta7i3eilHxFaF8XIrJFARV0t\nqOu4GID/jG6oA+swIWndGrtR2QRJIt9QIBFfK3HG5M0koZbY1eTqwNFRHFL3xaD\nUQIDAQAB\n-----END PUBLIC KEY-----\n","owner":"http://mastodon.example.org/users/gargron","id":"http://mastodon.example.org/users/gargron#main-key"},"preferredUsername":"gargron","outbox":"http://mastodon.example.org/users/gargron/outbox","name":"gargle","manuallyApprovesFollowers":false,"inbox":"http://mastodon.example.org/users/gargron/inbox","id":"http://mastodon.example.org/users/gargron","following":"http://mastodon.example.org/users/gargron/following","followers":"http://mastodon.example.org/users/gargron/followers","endpoints":{"sharedInbox":"http://mastodon.example.org/inbox"},"attachment":[{"type":"PropertyValue","name":"foo","value":"updated"},{"type":"PropertyValue","name":"foo1","value":"updated"}],"icon":{"type":"Image","mediaType":"image/jpeg","url":"https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg"},"image":{"type":"Image","mediaType":"image/png","url":"https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png"}},"id":"http://mastodon.example.org/users/gargron#updates/1519563538","actor":"http://mastodon.example.org/users/gargron","@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"toot":"http://joinmastodon.org/ns#","sensitive":"as:sensitive","ostatus":"http://ostatus.org#","movedTo":"as:movedTo","manuallyApprovesFollowers":"as:manuallyApprovesFollowers","inReplyToAtomUri":"ostatus:inReplyToAtomUri","conversation":"ostatus:conversation","atomUri":"ostatus:atomUri","Hashtag":"as:Hashtag","Emoji":"toot:Emoji"}]}', true);
}
/** @test */
public function schemaTest()
{
$this->assertTrue(UpdatePersonValidator::validate($this->activity));
}
/** @test */
public function invalidContext()
{
$activity = $this->activity;
unset($activity['@context']);
$activity['@@context'] = 'https://www.w3.org/ns/activitystreams';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function missingContext()
{
$activity = $this->activity;
unset($activity['@context']);
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function missingId()
{
$activity = $this->activity;
unset($activity['id']);
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function missingType()
{
$activity = $this->activity;
unset($activity['type']);
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidType()
{
$activity = $this->activity;
$activity['type'] = 'Create';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidObjectType()
{
$activity = $this->activity;
$activity['object']['type'] = 'Note';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorMatchingObjectId()
{
$activity = $this->activity;
$activity['object']['id'] = 'https://example.org/@user';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorUrlMatchingObjectId()
{
$activity = $this->activity;
$activity['object']['id'] = $activity['object']['id'] . 'test';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function missingActorPublicKey()
{
$activity = $this->activity;
unset($activity['object']['publicKey']);
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorPublicKey()
{
$activity = $this->activity;
$activity['object']['publicKey'] = null;
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorPublicKeyId()
{
$activity = $this->activity;
$activity['object']['publicKey']['id'] = null;
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorPublicKeyIdHost()
{
$activity = $this->activity;
$activity['object']['publicKey']['id'] = 'https://example.org/test';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorAvatar()
{
$activity = $this->activity;
$activity['object']['icon']['type'] = 'TikTok';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function invalidActorAvatarMediaType()
{
$activity = $this->activity;
$activity['object']['icon']['mediaType'] = 'video/mp4';
$this->assertFalse(UpdatePersonValidator::validate($activity));
}
/** @test */
public function validActorAvatarMediaTypePng()
{
$activity = $this->activity;
$activity['object']['icon']['mediaType'] = 'image/png';
$this->assertTrue(UpdatePersonValidator::validate($activity));
}
/** @test */
public function validActorAvatarMediaTypeJpeg()
{
$activity = $this->activity;
$activity['object']['icon']['mediaType'] = 'image/jpeg';
$this->assertTrue(UpdatePersonValidator::validate($activity));
}
/** @test */
public function validActorAvatarMediaUrl()
{
$activity = $this->activity;
$activity['object']['icon']['url'] = 'http://example.org/avatar.png';
$this->assertTrue(UpdatePersonValidator::validate($activity));
}
}