Merge pull request #7377 from pixelfed/staging

Add FEP-044f: Consent-respecting quote posts
pull/7385/head
dansup 4 days ago committed by GitHub
commit d2fb9ec998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -62,6 +62,7 @@ use App\Services\MediaService;
use App\Services\NetworkTimelineService;
use App\Services\NotificationService;
use App\Services\PublicTimelineService;
use App\Services\QuoteService;
use App\Services\ReblogService;
use App\Services\RelationshipService;
use App\Services\SanitizeService;
@ -3758,6 +3759,7 @@ class ApiV1Controller extends Controller
'place_id' => 'sometimes|integer|min:1|max:128769',
'collection_ids' => 'sometimes|array|max:3',
'comments_disabled' => 'sometimes|boolean',
'quote_approval_policy' => 'sometimes|nullable|string|in:public,followers,nobody',
]);
if ($request->filled('visibility') && $request->input('visibility') === 'direct') {
@ -3835,6 +3837,8 @@ class ApiV1Controller extends Controller
$status = null;
$parent = null;
$quotePolicy = QuoteService::fromApiPolicy($request->input('quote_approval_policy'));
if ($in_reply_to_id) {
$parent = Status::findOrFail($in_reply_to_id);
@ -3867,6 +3871,7 @@ class ApiV1Controller extends Controller
$status->cw_summary = $spoilerText;
$status->in_reply_to_id = $parent->id;
$status->in_reply_to_profile_id = $parent->profile_id;
$status->quote_policy = $quotePolicy;
$status->save();
StatusService::del($parent->id);
Cache::forget('status:replies:all:'.$parent->id);
@ -3922,6 +3927,7 @@ class ApiV1Controller extends Controller
$status->comments_disabled = true;
}
$status->quote_policy = $quotePolicy;
$status->scope = $visibility;
$status->visibility = $visibility;
$status->type = StatusController::mimeTypeCheck($mimes);

@ -8,6 +8,7 @@ use App\Jobs\ProfilePipeline\ProfilePurgeFollowersByDomain;
use App\Jobs\ProfilePipeline\ProfilePurgeNotificationsByDomain;
use App\Models\UserDomainBlock;
use App\Services\FeaturedCollectionService;
use App\Services\QuoteService;
use App\Services\UserFilterService;
use App\Util\ActivityPub\Helpers;
use Illuminate\Http\JsonResponse;
@ -96,6 +97,7 @@ class DomainBlockController extends Controller
Cache::forget('profile:following:'.$pid);
UserFilterService::domainBlocks($pid, true);
FeaturedCollectionService::revokeForDomain($pid, $domain);
QuoteService::revokeForDomain($pid, $domain);
}
return $this->json([]);

@ -21,6 +21,7 @@ use App\Services\MediaPathService;
use App\Services\MediaStorageService;
use App\Services\MediaTagService;
use App\Services\PlaceService;
use App\Services\QuoteService;
use App\Services\SnowflakeService;
use App\Services\UserFilterService;
use App\Services\UserRoleService;
@ -535,6 +536,7 @@ class ComposeController extends Controller
'license' => 'nullable|integer|min:1|max:16',
'collections' => 'sometimes|array|min:1|max:5',
'spoiler_text' => 'nullable|string|max:140',
'quote_approval_policy' => 'sometimes|nullable|string|in:public,followers,nobody',
// 'optimize_media' => 'nullable'
]);
@ -624,6 +626,10 @@ class ComposeController extends Controller
$status->cw_summary = $request->input('spoiler_text');
}
if ($request->filled('quote_approval_policy')) {
$status->quote_policy = QuoteService::fromApiPolicy($request->input('quote_approval_policy'));
}
$defaultCaption = '';
$status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
$status->rendered = $defaultCaption;

@ -7,12 +7,14 @@ use App\Jobs\InboxPipeline\InboxValidator;
use App\Jobs\InboxPipeline\InboxWorker;
use App\Models\FeatureAuthorization;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\Status;
use App\Services\AccountService;
use App\Services\ActivityPubSignedFetchService;
use App\Services\FeaturedCollectionService;
use App\Services\FollowersSyncService;
use App\Services\InstanceService;
use App\Services\QuoteService;
use App\Util\Lexer\Nickname;
use App\Util\Site\Nodeinfo;
use App\Util\Webfinger\Webfinger;
@ -366,4 +368,31 @@ class FederationController extends Controller
->json(FeaturedCollectionService::stampObject($auth), 200, [], JSON_UNESCAPED_SLASHES)
->header('Content-Type', 'application/activity+json');
}
/**
* FEP-044f QuoteAuthorization stamp.
*
* Stamps are only ever issued for public and unlisted posts and carry
* nothing but ids, so they are publicly dereferenceable.
*/
public function userQuoteAuthorization(Request $request, $username, $id): JsonResponse
{
abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
abort_if(! ctype_digit((string) $id), 404);
$pid = AccountService::usernameToId($username);
abort_if(! $pid, 404);
$auth = QuoteAuthorization::with(['profile', 'status'])
->whereProfileId($pid)
->find((int) $id);
abort_if(! $auth || ! $auth->profile || $auth->profile->domain !== null, 404);
abort_if(! $auth->status || ! QuoteService::isQuotable($auth->status), 404);
abort_if($auth->isRevoked(), 410);
return response()
->json(QuoteService::stampObject($auth), 200, [], JSON_UNESCAPED_SLASHES)
->header('Content-Type', 'application/activity+json');
}
}

@ -6,9 +6,11 @@ use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
use App\Models\FeatureAuthorization;
use App\Models\Follower;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\UserFilter;
use App\Services\AccountService;
use App\Services\FeaturedCollectionService;
use App\Services\QuoteService;
use App\Services\RelationshipService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@ -107,6 +109,13 @@ trait PrivacySettings
FeaturedCollectionService::forgetPolicy($pid);
}
$canQuote = $request->input('can_quote');
if (in_array($canQuote, QuoteService::POLICIES, true) && $canQuote !== $settings->can_quote) {
$settings->can_quote = $canQuote;
$settings->save();
QuoteService::forgetPolicy($pid);
}
Cache::forget('profile:settings:'.$pid);
Cache::forget('user:account:id:'.$profile->user_id);
Cache::forget('profile:follower_count:'.$pid);
@ -180,6 +189,33 @@ trait PrivacySettings
return redirect()->back()->with('status', 'You have been removed from the collection.');
}
public function quotes(Request $request)
{
$pid = $request->user()->profile->id;
$quotes = QuoteAuthorization::whereProfileId($pid)
->approved()
->with(['actor', 'status'])
->orderByDesc('id')
->simplePaginate(15);
return view('settings.privacy.quotes', ['quotes' => $quotes]);
}
public function quotesRevoke(Request $request)
{
$this->validate($request, [
'id' => 'required|integer|min:1',
]);
$pid = $request->user()->profile->id;
$auth = QuoteAuthorization::whereProfileId($pid)
->approved()
->findOrFail($request->input('id'));
QuoteService::revoke($auth);
return redirect()->back()->with('status', 'Quote approval revoked.');
}
public function blockedUsers(Request $request)
{
$pid = $request->user()->profile->id;

@ -6,10 +6,12 @@ use App\Http\Requests\Status\StoreStatusEditRequest;
use App\Jobs\StatusPipeline\StatusLocalUpdateActivityPubDeliverPipeline;
use App\Models\Status;
use App\Models\StatusEdit;
use App\Services\QuoteService;
use App\Services\Status\UpdateStatusService;
use App\Services\StatusService;
use App\Util\Lexer\Autolink;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class StatusEditController extends Controller
{
@ -32,6 +34,39 @@ class StatusEditController extends Controller
return $res;
}
/**
* PUT /api/v1/statuses/{id}/interaction_policy
*
* Mastodon 4.5 compatible. Sets the per-post canQuote override, or
* clears it (back to the account default) when the value is null.
* The policy is advisory and enforced here on every QuoteRequest, so
* no Update is federated, remote servers pick it up on their next
* fetch of the post.
*/
public function interactionPolicy(Request $request, $id)
{
abort_if(! $request->user(), 403);
$this->validate($request, [
'quote_approval_policy' => 'present|nullable|string|in:public,followers,nobody',
]);
$status = Status::whereProfileId($request->user()->profile_id)
->whereNull('reblog_of_id')
->whereIn('scope', ['public', 'unlisted', 'private'])
->findOrFail($id);
$status->quote_policy = QuoteService::fromApiPolicy($request->input('quote_approval_policy'));
$status->save();
Cache::forget('pf:status:ap:v1:sid:'.$status->id);
$res = StatusService::get($status->id, false);
$res['quote_approval_policy'] = QuoteService::toApiPolicy(QuoteService::statusFlags($status));
return $res;
}
public function history(Request $request, $id)
{
abort_if(! $request->user(), 403);

@ -29,6 +29,7 @@ use App\Models\Profile;
use App\Models\ProfileAlias;
use App\Models\ProfileMigration;
use App\Models\ProfileSponsor;
use App\Models\QuoteAuthorization;
use App\Models\RemoteAuth;
use App\Models\RemoteReport;
use App\Models\Report;
@ -195,6 +196,7 @@ class DeleteAccountPipeline implements ShouldQueue
UserDevice::whereUserId($user->id)->forceDelete();
UserFilter::whereUserId($user->id)->forceDelete();
FeatureAuthorization::whereProfileId($id)->delete();
QuoteAuthorization::whereProfileId($id)->delete();
UserSetting::whereUserId($user->id)->forceDelete();
Mention::whereProfileId($id)->forceDelete();

@ -16,6 +16,7 @@ use App\Models\Notification;
use App\Models\Poll;
use App\Models\PollVote;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\Report;
use App\Models\Status;
use App\Models\Story;
@ -123,6 +124,9 @@ class DeleteRemoteProfilePipeline implements ShouldQueue
// Delete mentions
Mention::whereProfileId($pid)->forceDelete();
// Delete quote approval stamps issued to this actor
QuoteAuthorization::whereActorId($pid)->delete();
// Delete notifications
Notification::whereProfileId($pid)
->orWhere('actor_id', $pid)

@ -0,0 +1,37 @@
<?php
namespace App\Jobs\QuotePipeline;
use App\Models\QuoteAuthorization;
use App\Services\QuoteService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RevokeQuoteAuthorizationPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 60;
public $tries = 3;
public $maxExceptions = 1;
public $backoff = [10, 60];
public function __construct(protected int $authorizationId) {}
public function handle(): void
{
$auth = QuoteAuthorization::with(['profile', 'actor', 'status'])->find($this->authorizationId);
if (! $auth || ! $auth->isRevoked()) {
return;
}
QuoteService::sendDelete($auth);
}
}

@ -12,6 +12,7 @@ use App\Models\Media;
use App\Models\MediaTag;
use App\Models\Mention;
use App\Models\Notification;
use App\Models\QuoteAuthorization;
use App\Models\Report;
use App\Models\Status;
use App\Models\StatusArchived;
@ -127,6 +128,8 @@ class StatusDelete implements ShouldQueue
Bookmark::whereStatusId($status->id)->delete();
QuoteAuthorization::whereStatusId($status->id)->delete();
CollectionItem::whereObjectType(Status::class)
->whereObjectId($status->id)
->get()

@ -0,0 +1,93 @@
<?php
namespace App\Models;
use App\HasSnowflakePrimary;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* FEP-044f approval stamp issued by a local author for a remote quote post.
*
* @property int $id
* @property int $profile_id
* @property int $status_id
* @property int $actor_id
* @property string $quote_url
* @property string|null $request_url
* @property string $state
*/
class QuoteAuthorization extends Model
{
use HasSnowflakePrimary;
public const STATE_APPROVED = 'approved';
public const STATE_REVOKED = 'revoked';
public $incrementing = false;
protected $keyType = 'int';
protected $guarded = [];
protected function casts(): array
{
return [
'revoked_at' => 'datetime',
];
}
/**
* The local profile whose post was quoted.
*/
public function profile(): BelongsTo
{
return $this->belongsTo(Profile::class, 'profile_id');
}
/**
* The local post that was quoted.
*/
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_id');
}
/**
* The remote profile that authored the quote post.
*/
public function actor(): BelongsTo
{
return $this->belongsTo(Profile::class, 'actor_id');
}
public function isApproved(): bool
{
return $this->state === self::STATE_APPROVED;
}
public function isRevoked(): bool
{
return $this->state === self::STATE_REVOKED;
}
/**
* Public URL of the stamp. Must share a host with the quoted author's
* actor id, which remote servers check during verification.
*/
public function permalink(): string
{
return $this->profile->permalink('/quote_authorizations/'.$this->id);
}
public function scopeApproved($query)
{
return $query->where('state', self::STATE_APPROVED);
}
public function scopeRevoked($query)
{
return $query->where('state', self::STATE_REVOKED);
}
}

@ -31,6 +31,7 @@ use Illuminate\Support\Str;
* @property string|null $visibility
* @property string|null $cw_summary
* @property bool $comments_disabled
* @property int|null $quote_policy FEP-044f per-post canQuote bitmask (see QuoteService), null = account default, 0 = nobody
* @property int $likes_count
* @property int $reblogs_count
* @property int $reply_count
@ -66,6 +67,7 @@ class Status extends Model
return [
'deleted_at' => 'datetime',
'edited_at' => 'datetime',
'quote_policy' => 'integer',
];
}

@ -7,6 +7,7 @@ use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
use App\Models\Profile;
use App\Models\UserFilter;
use App\Services\FeaturedCollectionService;
use App\Services\QuoteService;
use App\Services\UserFilterService;
class UserFilterObserver
@ -85,6 +86,7 @@ class UserFilterObserver
FeedUnfollowPipeline::dispatch($userFilter->user_id, $userFilter->filterable_id)->onQueue('feed');
// user_id is the blocking profile id, filterable_id the blocked profile
FeaturedCollectionService::revokeForActor($userFilter->user_id, $userFilter->filterable_id);
QuoteService::revokeForActor($userFilter->user_id, $userFilter->filterable_id);
break;
}
}

@ -0,0 +1,659 @@
<?php
namespace App\Services;
use App\Jobs\QuotePipeline\RevokeQuoteAuthorizationPipeline;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\Status;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* FEP-044f: Consent-respecting quote posts.
*
* Lets remote actors request approval to quote a local post, issues and
* serves QuoteAuthorization stamps, and revokes them. Only automatic
* approval is supported, there is no manual review queue.
*/
class QuoteService
{
public const POLICY_EVERYONE = 'everyone';
public const POLICY_FOLLOWERS = 'followers';
public const POLICY_NOBODY = 'nobody';
public const POLICIES = [
self::POLICY_EVERYONE,
self::POLICY_FOLLOWERS,
self::POLICY_NOBODY,
];
/**
* Audience flags for the per-post bitmask in statuses.quote_policy.
*
* The low byte holds the automaticApproval audiences, the high byte
* the manualApproval ones, so a policy like "followers automatically,
* everyone else after review" fits in one SMALLINT UNSIGNED. Local posts
* only ever use the automatic half, the rest is there for policies read
* off remote posts.
*
* 0 means nobody. NULL (no override, use the account default) is a
* different value: never test the column for truthiness, go through
* statusFlags().
*/
public const FLAG_PUBLIC = 1;
public const FLAG_FOLLOWERS = 2;
public const FLAG_FOLLOWING = 4;
/** An audience we could not map, e.g. a list of individual actors. */
public const FLAG_UNSUPPORTED = 8;
public const FLAGS_NOBODY = 0;
public const MANUAL_SHIFT = 8;
public const SUBPOLICY_MASK = 0xFF;
public const POLICY_FLAGS = [
self::POLICY_EVERYONE => self::FLAG_PUBLIC,
self::POLICY_FOLLOWERS => self::FLAG_FOLLOWERS,
self::POLICY_NOBODY => self::FLAGS_NOBODY,
];
/**
* Mastodon API `quote_approval_policy` values mapped to ours.
*/
public const API_POLICIES = [
'public' => self::POLICY_EVERYONE,
'followers' => self::POLICY_FOLLOWERS,
'nobody' => self::POLICY_NOBODY,
];
public const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
public const QUOTABLE_SCOPES = ['public', 'unlisted'];
/**
* Status types that are never quotable.
*/
public const UNQUOTABLE_TYPES = [
'share',
'story',
'story:reply',
'story:reaction',
'story:live',
];
/**
* Properties an inlined quote post may use to point at what it quotes.
*/
public const QUOTE_PROPERTIES = ['quote', 'quoteUrl', 'quoteUri', '_misskey_quote'];
private const string POLICY_CACHE_KEY = 'pf:services:quotes:policy:';
private const int POLICY_CACHE_TTL = 86400;
/**
* Term definitions merged into the inline @context of Note and
* Question objects so `interactionPolicy.canQuote` compacts the same
* way it does on Mastodon and GoToSocial.
*
* @var array<string, mixed>
*/
public const NOTE_CONTEXT_TERMS = [
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => [
'@id' => 'gts:interactionPolicy',
'@type' => '@id',
],
'canQuote' => [
'@id' => 'gts:canQuote',
'@type' => '@id',
],
'automaticApproval' => [
'@id' => 'gts:automaticApproval',
'@type' => '@id',
],
'manualApproval' => [
'@id' => 'gts:manualApproval',
'@type' => '@id',
],
];
/**
* @var array<int, mixed>
*/
public const STAMP_CONTEXT = [
'https://www.w3.org/ns/activitystreams',
[
'gts' => 'https://gotosocial.org/ns#',
'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization',
'interactingObject' => [
'@id' => 'gts:interactingObject',
'@type' => '@id',
],
'interactionTarget' => [
'@id' => 'gts:interactionTarget',
'@type' => '@id',
],
],
];
/**
* @var array<int, mixed>
*/
public const REQUEST_CONTEXT = [
'https://www.w3.org/ns/activitystreams',
[
'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest',
],
];
/**
* The account-wide default canQuote policy a local profile has chosen.
*/
public static function accountPolicy(Profile $profile): string
{
if ($profile->domain !== null) {
return self::POLICY_NOBODY;
}
return Cache::remember(
self::POLICY_CACHE_KEY.$profile->id,
self::POLICY_CACHE_TTL,
function () use ($profile) {
$policy = $profile->user?->settings?->can_quote;
return in_array($policy, self::POLICIES, true)
? $policy
: self::POLICY_EVERYONE;
}
);
}
public static function forgetPolicy(int $profileId): void
{
Cache::forget(self::POLICY_CACHE_KEY.$profileId);
}
/**
* The effective bitmask for one post: nobody when the post is not
* public or unlisted, otherwise the per-post override, otherwise the
* author's account default.
*/
public static function statusFlags(Status $status): int
{
if (! self::isQuotable($status)) {
return self::FLAGS_NOBODY;
}
// Strict null check on purpose, an override of 0 means "nobody"
if ($status->quote_policy !== null) {
return (int) $status->quote_policy & 0xFFFF;
}
return self::toFlags(self::accountPolicy($status->profile));
}
/**
* The effective policy for one post as a named policy, for the API and
* anything else that shows it to a person.
*/
public static function statusPolicy(Status $status): string
{
return self::fromFlags(self::statusFlags($status));
}
/**
* Audiences that are approved automatically.
*/
public static function automatic(int $flags): int
{
return $flags & self::SUBPOLICY_MASK;
}
/**
* Audiences that need the author's manual approval.
*/
public static function manual(int $flags): int
{
return ($flags >> self::MANUAL_SHIFT) & self::SUBPOLICY_MASK;
}
/**
* Bitmask for a named policy. Unknown names are treated as nobody.
*/
public static function toFlags(string $policy): int
{
return self::POLICY_FLAGS[$policy] ?? self::FLAGS_NOBODY;
}
/**
* Closest named policy for a bitmask, going by who is approved
* automatically.
*/
public static function fromFlags(int $flags): string
{
$automatic = self::automatic($flags);
if ($automatic & self::FLAG_PUBLIC) {
return self::POLICY_EVERYONE;
}
if ($automatic & self::FLAG_FOLLOWERS) {
return self::POLICY_FOLLOWERS;
}
return self::POLICY_NOBODY;
}
/**
* Structural check, independent of who is asking.
*/
public static function isQuotable(Status $status): bool
{
if ($status->reblog_of_id !== null) {
return false;
}
if (in_array($status->type, self::UNQUOTABLE_TYPES, true)) {
return false;
}
if (! in_array($status->scope, self::QUOTABLE_SCOPES, true)) {
return false;
}
$profile = $status->profile;
return $profile
&& $profile->domain === null
&& $profile->status === null
&& ! $profile->deleted_at;
}
/**
* Translate a Mastodon API `quote_approval_policy` value into the
* bitmask stored on the post. Returns null for anything unknown, which
* means "no override, use the account default". Note that "nobody" is
* a valid override and comes back as 0, not null.
*/
public static function fromApiPolicy(mixed $value): ?int
{
if (! is_string($value) || ! isset(self::API_POLICIES[$value])) {
return null;
}
return self::toFlags(self::API_POLICIES[$value]);
}
public static function toApiPolicy(int $flags): string
{
$key = array_search(self::fromFlags($flags), self::API_POLICIES, true);
return is_string($key) ? $key : 'nobody';
}
/**
* The actors and collections a set of audience flags stands for.
*
* @return array<int, string>
*/
public static function audienceUris(int $subpolicy, Profile $profile): array
{
$uris = [];
if ($subpolicy & self::FLAG_PUBLIC) {
$uris[] = self::AS_PUBLIC;
}
if ($subpolicy & self::FLAG_FOLLOWERS) {
$uris[] = $profile->permalink('/followers');
}
if ($subpolicy & self::FLAG_FOLLOWING) {
$uris[] = $profile->permalink('/following');
}
return $uris;
}
/**
* interactionPolicy fragment for a Note or Question.
*
* Per the FEP an empty array is equivalent to a missing property under
* JSON-LD canonicalization, so "nobody" is expressed as the author's
* own id. manualApproval is only emitted when it has something in it.
*
* @return array<string, mixed>
*/
public static function interactionPolicy(Status $status): array
{
$profile = $status->profile;
$flags = self::statusFlags($status);
$automatic = self::audienceUris(self::automatic($flags), $profile);
$manual = self::audienceUris(self::manual($flags), $profile);
$policy = [
'automaticApproval' => $automatic ?: [$profile->permalink()],
];
if ($manual) {
$policy['manualApproval'] = $manual;
}
return [
'canQuote' => $policy,
];
}
/**
* May $actor quote $status, based on policy and blocks?
*
* Previously revoked quotes are handled by the caller, since that
* decision is per quote post rather than per actor.
*/
public static function canQuote(Status $status, Profile $actor): bool
{
if (! self::isQuotable($status)) {
return false;
}
if ($actor->domain === null || $actor->status !== null) {
return false;
}
$target = $status->profile;
if (AccountService::blocksDomain($target->id, $actor->domain)) {
return false;
}
$blocks = UserFilterService::blocks($target->id);
if ($blocks && in_array($actor->id, $blocks)) {
return false;
}
// Only automatic approval is acted on. There is no manual review
// queue yet, so an audience listed under manualApproval is refused.
$automatic = self::automatic(self::statusFlags($status));
if ($automatic & self::FLAG_PUBLIC) {
return true;
}
if (($automatic & self::FLAG_FOLLOWERS) && FollowerService::follows($actor->id, $target->id)) {
return true;
}
if (($automatic & self::FLAG_FOLLOWING) && FollowerService::follows($target->id, $actor->id)) {
return true;
}
return false;
}
public static function find(Status $status, string $quoteUrl): ?QuoteAuthorization
{
return QuoteAuthorization::whereStatusId($status->id)
->whereQuoteUrl($quoteUrl)
->first();
}
/**
* Issue (or re-use) the stamp for a quote post.
*/
public static function authorize(
Status $status,
Profile $actor,
string $quoteUrl,
?string $requestUrl = null
): QuoteAuthorization {
$auth = self::find($status, $quoteUrl);
if ($auth instanceof QuoteAuthorization) {
return $auth;
}
$auth = new QuoteAuthorization;
$auth->id = SnowflakeService::next();
$auth->profile_id = $status->profile_id;
$auth->status_id = $status->id;
$auth->actor_id = $actor->id;
$auth->quote_url = $quoteUrl;
$auth->request_url = $requestUrl;
$auth->state = QuoteAuthorization::STATE_APPROVED;
$auth->save();
return $auth;
}
/**
* Withdraw consent. The row is kept (state revoked) so a repeat request
* for the same quote post is auto-rejected, and the stamp URL serves
* 410 Gone. The Delete activity is sent from a queued job.
*/
public static function revoke(QuoteAuthorization $auth, bool $notify = true): void
{
if ($auth->isRevoked()) {
return;
}
$auth->state = QuoteAuthorization::STATE_REVOKED;
$auth->revoked_at = now();
$auth->save();
if ($notify) {
RevokeQuoteAuthorizationPipeline::dispatch($auth->id)->onQueue('high');
}
}
/**
* Revoke every stamp a profile issued to a specific remote actor.
* Used when the profile blocks that actor.
*/
public static function revokeForActor(int $profileId, int $actorId): void
{
QuoteAuthorization::whereProfileId($profileId)
->whereActorId($actorId)
->approved()
->chunkById(100, function ($auths) {
$auths->each(fn (QuoteAuthorization $auth) => self::revoke($auth));
});
}
/**
* Revoke every stamp a profile issued to actors on a domain.
* Used when the profile blocks that domain.
*/
public static function revokeForDomain(int $profileId, string $domain): void
{
$domain = strtolower($domain);
QuoteAuthorization::whereProfileId($profileId)
->approved()
->whereIn('actor_id', Profile::where('domain', $domain)->select('id'))
->chunkById(100, function ($auths) {
$auths->each(fn (QuoteAuthorization $auth) => self::revoke($auth));
});
}
/**
* Drop the stamp for a quote post whose author deleted it. Nothing is
* federated, the quote is already gone on their side.
*/
public static function forgetQuote(int $actorId, string $quoteUrl): void
{
QuoteAuthorization::whereActorId($actorId)
->whereQuoteUrl($quoteUrl)
->delete();
}
/**
* The QuoteAuthorization object served at the stamp URL and embedded
* in the revocation Delete. References only, per the FEP neither the
* quote post nor the quoted post may be inlined here.
*
* @return array<string, mixed>
*/
public static function stampObject(QuoteAuthorization $auth): array
{
return [
'@context' => self::STAMP_CONTEXT,
'id' => $auth->permalink(),
'type' => 'QuoteAuthorization',
'attributedTo' => $auth->profile->permalink(),
'interactingObject' => $auth->quote_url,
'interactionTarget' => $auth->status->url(),
];
}
/**
* The QuoteRequest as embedded in our Accept or Reject.
*
* @return array<string, mixed>
*/
public static function requestObject(Profile $actor, Status $status, string $quoteUrl, string $requestUrl): array
{
return [
'id' => $requestUrl,
'type' => 'QuoteRequest',
'actor' => $actor->permalink(),
'object' => $status->url(),
'instrument' => $quoteUrl,
];
}
/**
* Accept for a QuoteRequest, with the stamp as its `result`.
*
* @return array<string, mixed>
*/
public static function acceptActivity(QuoteAuthorization $auth, string $requestUrl): array
{
$target = $auth->profile;
return [
'@context' => self::REQUEST_CONTEXT,
'id' => $target->permalink('#accepts/quotes/'.$auth->id),
'type' => 'Accept',
'actor' => $target->permalink(),
'to' => $auth->actor->permalink(),
'object' => self::requestObject($auth->actor, $auth->status, $auth->quote_url, $requestUrl),
'result' => $auth->permalink(),
];
}
/**
* Reject for a QuoteRequest.
*
* @return array<string, mixed>
*/
public static function rejectActivity(Status $status, Profile $actor, string $quoteUrl, string $requestUrl): array
{
$target = $status->profile;
return [
'@context' => self::REQUEST_CONTEXT,
'id' => $target->permalink('#rejects/quotes/'.hash('xxh3', $requestUrl)),
'type' => 'Reject',
'actor' => $target->permalink(),
'to' => $actor->permalink(),
'object' => self::requestObject($actor, $status, $quoteUrl, $requestUrl),
];
}
/**
* Delete that revokes a stamp.
*
* @return array<string, mixed>
*/
public static function deleteActivity(QuoteAuthorization $auth): array
{
$object = self::stampObject($auth);
$context = $object['@context'];
unset($object['@context']);
return [
'@context' => $context,
'id' => $auth->permalink().'#delete',
'type' => 'Delete',
'actor' => $auth->profile->permalink(),
'to' => $auth->actor->permalink(),
'object' => $object,
];
}
public static function sendAccept(QuoteAuthorization $auth, string $requestUrl): void
{
self::deliver($auth->profile, $auth->actor, self::acceptActivity($auth, $requestUrl));
}
public static function sendReject(Status $status, Profile $actor, string $quoteUrl, string $requestUrl): void
{
self::deliver($status->profile, $actor, self::rejectActivity($status, $actor, $quoteUrl, $requestUrl));
}
public static function sendDelete(QuoteAuthorization $auth): void
{
if (! $auth->profile || ! $auth->actor || ! $auth->status) {
return;
}
self::deliver($auth->profile, $auth->actor, self::deleteActivity($auth));
}
/**
* @param array<string, mixed> $activity
*/
private static function deliver(Profile $from, Profile $to, array $activity): void
{
$inbox = $to->sharedInbox ?? $to->inbox_url;
if (! $inbox) {
Log::info('QuoteService: remote actor has no inbox', [
'profile_id' => $from->id,
'actor_id' => $to->id,
]);
return;
}
Helpers::sendSignedObject($from, $inbox, $activity);
}
/**
* Same URL, ignoring scheme/host case and a trailing slash.
*/
public static function sameUrl(?string $a, ?string $b): bool
{
if (! is_string($a) || ! is_string($b)) {
return false;
}
$norm = function (string $url): ?string {
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['host'])) {
return null;
}
$path = rtrim($parts['path'] ?? '/', '/');
return strtolower($parts['scheme'] ?? 'https').'://'.strtolower($parts['host']).$path;
};
$na = $norm($a);
return $na !== null && $na === $norm($b);
}
}

@ -5,6 +5,7 @@ namespace App\Transformer\ActivityPub\Verb;
use App\Models\CustomEmoji;
use App\Models\Status;
use App\Services\MediaService;
use App\Services\QuoteService;
use App\Util\Lexer\Autolink;
use Illuminate\Support\Str;
use League\Fractal;
@ -87,6 +88,7 @@ class CreateNote extends Fractal\TransformerAbstract
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
'blurhash' => 'toot:blurhash',
...QuoteService::NOTE_CONTEXT_TERMS,
],
],
'id' => $status->permalink(),
@ -110,6 +112,7 @@ class CreateNote extends Fractal\TransformerAbstract
'attachment' => MediaService::activitypub($status->id, true),
'tag' => $tags,
'commentsEnabled' => (bool) ! $status->comments_disabled,
'interactionPolicy' => QuoteService::interactionPolicy($status),
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
'like' => 'https://www.w3.org/ns/activitystreams#Public',

@ -3,6 +3,7 @@
namespace App\Transformer\ActivityPub\Verb;
use App\Models\Status;
use App\Services\QuoteService;
use League\Fractal;
class CreateQuestion extends Fractal\TransformerAbstract
@ -44,6 +45,7 @@ class CreateQuestion extends Fractal\TransformerAbstract
],
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
...QuoteService::NOTE_CONTEXT_TERMS,
],
],
'id' => $status->permalink(),

@ -5,6 +5,7 @@ namespace App\Transformer\ActivityPub\Verb;
use App\Models\CustomEmoji;
use App\Models\Status;
use App\Services\MediaService;
use App\Services\QuoteService;
use App\Util\Lexer\Autolink;
use Illuminate\Support\Str;
use League\Fractal;
@ -88,6 +89,7 @@ class Note extends Fractal\TransformerAbstract
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
'blurhash' => 'toot:blurhash',
...QuoteService::NOTE_CONTEXT_TERMS,
],
],
'id' => $status->url(),
@ -104,6 +106,7 @@ class Note extends Fractal\TransformerAbstract
'attachment' => MediaService::activitypub($status->id),
'tag' => $tags,
'commentsEnabled' => (bool) ! $status->comments_disabled,
'interactionPolicy' => QuoteService::interactionPolicy($status),
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
'like' => 'https://www.w3.org/ns/activitystreams#Public',

@ -3,6 +3,7 @@
namespace App\Transformer\ActivityPub\Verb;
use App\Models\Status;
use App\Services\QuoteService;
use App\Util\Lexer\Autolink;
use Illuminate\Support\Str;
use League\Fractal;
@ -65,6 +66,7 @@ class Question extends Fractal\TransformerAbstract
],
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
...QuoteService::NOTE_CONTEXT_TERMS,
],
],
'id' => $status->url(),
@ -81,6 +83,7 @@ class Question extends Fractal\TransformerAbstract
'attachment' => [],
'tag' => $tags,
'commentsEnabled' => (bool) ! $status->comments_disabled,
'interactionPolicy' => QuoteService::interactionPolicy($status),
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
'like' => 'https://www.w3.org/ns/activitystreams#Public',

@ -5,6 +5,7 @@ namespace App\Transformer\ActivityPub\Verb;
use App\Models\CustomEmoji;
use App\Models\Status;
use App\Services\MediaService;
use App\Services\QuoteService;
use App\Util\Lexer\Autolink;
use Illuminate\Support\Str;
use League\Fractal;
@ -88,6 +89,7 @@ class UpdateNote extends Fractal\TransformerAbstract
],
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
...QuoteService::NOTE_CONTEXT_TERMS,
],
],
'id' => $status->permalink('#updates/'.$latestEdit->id),
@ -111,6 +113,7 @@ class UpdateNote extends Fractal\TransformerAbstract
'attachment' => MediaService::activitypub($status->id, true),
'tag' => $tags,
'commentsEnabled' => (bool) ! $status->comments_disabled,
'interactionPolicy' => QuoteService::interactionPolicy($status),
'updated' => $latestEdit->created_at->toAtomString(),
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',

@ -10,6 +10,7 @@ use App\Util\ActivityPub\Inbox\HandlesFlags;
use App\Util\ActivityPub\Inbox\HandlesFollows;
use App\Util\ActivityPub\Inbox\HandlesLikes;
use App\Util\ActivityPub\Inbox\HandlesMoves;
use App\Util\ActivityPub\Inbox\HandlesQuoteRequests;
use App\Util\ActivityPub\Inbox\HandlesStories;
use App\Util\ActivityPub\Inbox\HandlesUndos;
use App\Util\ActivityPub\Inbox\HandlesUpdates;
@ -20,6 +21,7 @@ use App\Util\ActivityPub\Validator\FeatureRequestValidator;
use App\Util\ActivityPub\Validator\Follow as FollowValidator;
use App\Util\ActivityPub\Validator\Like as LikeValidator;
use App\Util\ActivityPub\Validator\MoveValidator;
use App\Util\ActivityPub\Validator\QuoteRequestValidator;
use App\Util\ActivityPub\Validator\RejectValidator;
use Illuminate\Support\Facades\Log;
@ -33,6 +35,7 @@ class Inbox
use HandlesFollows;
use HandlesLikes;
use HandlesMoves;
use HandlesQuoteRequests;
use HandlesStories;
use HandlesUndos;
use HandlesUpdates;
@ -137,6 +140,13 @@ class Inbox
$this->handleFeatureRequestActivity();
break;
case 'QuoteRequest':
if (QuoteRequestValidator::validate($this->payload) == false) {
return;
}
$this->handleQuoteRequestActivity();
break;
case 'Update':
$this->handleUpdateActivity();
break;

@ -10,6 +10,7 @@ use App\Models\Notification;
use App\Models\Profile;
use App\Models\Status;
use App\Models\Story;
use App\Services\QuoteService;
use App\Util\ActivityPub\Helpers;
trait HandlesDeletes
@ -107,6 +108,9 @@ trait HandlesDeletes
return;
}
// FEP-044f: if this post was an approved quote, its stamp goes with it
QuoteService::forgetQuote($profile->id, $objectId);
$status = Status::where('object_url', $objectId)->first();
if (! $status) {
$status = Status::where('url', $objectId)->first();

@ -0,0 +1,246 @@
<?php
namespace App\Util\ActivityPub\Inbox;
use App\Models\Profile;
use App\Models\Status;
use App\Services\QuoteService;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Facades\Log;
/**
* FEP-044f: a remote actor asks permission to quote a local post.
*
* Anything we cannot attribute to a real remote actor and a quotable local
* post is dropped silently. Once both are known, the answer is always an
* explicit Accept (with a QuoteAuthorization stamp) or Reject.
*/
trait HandlesQuoteRequests
{
public function handleQuoteRequestActivity(): void
{
$requestUrl = $this->payload['id'];
$actor = $this->quoteRequestActor();
if (! $actor) {
return;
}
if (! $this->hostsMatch($requestUrl, $actor->remote_url)) {
return;
}
$quoteUrl = $this->quoteRequestInstrumentUrl($actor);
if (! $quoteUrl) {
return;
}
$status = $this->quoteRequestTarget();
if (! $status) {
return;
}
$existing = QuoteService::find($status, $quoteUrl);
if ($existing && ($existing->isRevoked() || $existing->actor_id !== $actor->id)) {
QuoteService::sendReject($status, $actor, $quoteUrl, $requestUrl);
return;
}
if ($existing && $existing->isApproved()) {
QuoteService::sendAccept($existing, $requestUrl);
return;
}
if (
! QuoteService::canQuote($status, $actor) ||
! $this->quoteRequestInstrumentMatches($actor, $status)
) {
Log::info('HandlesQuoteRequests: rejected', [
'actor_id' => $actor->id,
'status_id' => $status->id,
'quote' => $quoteUrl,
]);
QuoteService::sendReject($status, $actor, $quoteUrl, $requestUrl);
return;
}
$auth = QuoteService::authorize($status, $actor, $quoteUrl, $requestUrl);
QuoteService::sendAccept($auth, $requestUrl);
}
/**
* The requester. Must be a remote actor and must be the one that signed
* the request. The user inbox only checks that the key and the actor
* share a host, so the exact match is enforced here.
*/
protected function quoteRequestActor(): ?Profile
{
$claimed = $this->quoteRequestUrlField($this->payload['actor'] ?? null);
if (! $claimed) {
return null;
}
$actor = $this->validateAndFetchActor($claimed);
if (! $actor || $actor->domain === null) {
return null;
}
$signer = $this->signingProfile();
if ($signer && $signer->id !== $actor->id) {
return null;
}
return $actor;
}
/**
* The id of the quote post. `instrument` may be the bare id or the
* inlined object. It has to live on the requester's host, and it is
* returned exactly as sent because the stamp's `interactingObject` is
* compared byte for byte by the quoting server.
*/
protected function quoteRequestInstrumentUrl(Profile $actor): ?string
{
$raw = $this->quoteRequestNode($this->payload['instrument'] ?? null);
if (is_array($raw)) {
$raw = $raw['id'] ?? null;
}
if (! is_string($raw)) {
return null;
}
$raw = trim($raw);
if ($raw === '' || strlen($raw) > 500) {
return null;
}
if (! Helpers::validateUrl($raw) || ! $this->hostsMatch($raw, $actor->remote_url)) {
return null;
}
return $raw;
}
/**
* When the quote post is inlined, make sure it is what the request
* says it is: written by the requester, and quoting this post. A bare
* id passes, the FEP only says we MAY inspect the instrument and the
* requester's server is authoritative for its own objects either way.
*/
protected function quoteRequestInstrumentMatches(Profile $actor, Status $status): bool
{
$instrument = $this->quoteRequestNode($this->payload['instrument'] ?? null);
if (! is_array($instrument)) {
return true;
}
if (isset($instrument['attributedTo'])) {
$author = $this->quoteRequestUrlField($instrument['attributedTo']);
if (! QuoteService::sameUrl($author, $actor->remote_url)) {
return false;
}
}
foreach (QuoteService::QUOTE_PROPERTIES as $property) {
if (! isset($instrument[$property])) {
continue;
}
$quoted = $this->quoteRequestUrlField($instrument[$property]);
if (! $quoted || ! Helpers::validateLocalUrl($quoted)) {
return false;
}
$found = Helpers::findExistingStatus($quoted);
if (! $found || (int) $found->id !== (int) $status->id) {
return false;
}
}
return true;
}
/**
* The local post being quoted. Anything that is not a public or
* unlisted post by an active local account is ignored without a
* Reject, so a request cannot be used to probe for private posts.
*/
protected function quoteRequestTarget(): ?Status
{
$objectUrl = $this->quoteRequestUrlField($this->payload['object'] ?? null);
if (! $objectUrl) {
return null;
}
$localUrl = Helpers::validateLocalUrl($objectUrl);
if (! $localUrl) {
return null;
}
$status = Helpers::findExistingStatus($localUrl);
if (! $status || ! QuoteService::isQuotable($status)) {
return null;
}
return $status;
}
/**
* Unwrap a JSON-LD value that may be a single node or a list of them.
* Helpers::pluckval() is not used here because it calls head() on
* embedded objects too, which returns their first property.
*
* @return string|array<string, mixed>|null
*/
protected function quoteRequestNode(mixed $value): string|array|null
{
if (is_array($value) && array_is_list($value)) {
$value = $value[0] ?? null;
}
return is_string($value) || is_array($value) ? $value : null;
}
/**
* Pluck a URL out of a value that may be a string, an array of
* strings, or an embedded object with an id.
*/
protected function quoteRequestUrlField(mixed $value): ?string
{
$value = $this->quoteRequestNode($value);
if (is_array($value)) {
$value = $value['id'] ?? null;
}
if (! is_string($value) || $value === '') {
return null;
}
$url = Helpers::validateUrl($value);
return is_string($url) ? $url : null;
}
}

@ -7,6 +7,7 @@ use App\Models\Profile;
use App\Services\AccountService;
use App\Services\UserFilterService;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use Illuminate\Support\Facades\Cache;
trait InboxHelpers
@ -24,6 +25,37 @@ trait InboxHelpers
return Helpers::profileFetch($actorUrl);
}
/**
* The known profile that owns the key this request was signed with.
* The inbox validators already verified the signature against that key.
*/
public function signingProfile(): ?Profile
{
$signature = $this->headers['signature'] ?? null;
if (is_array($signature)) {
$signature = $signature[0] ?? null;
}
if (! is_string($signature) || $signature === '') {
return null;
}
$data = HttpSignature::parseSignatureHeader($signature);
if (isset($data['error']) || empty($data['keyId'])) {
return null;
}
$keyId = Helpers::validateUrl($data['keyId']);
if (! $keyId) {
return null;
}
return Profile::whereKeyId($keyId)->first();
}
/**
* Check if a profile has blocked the given domain.
*/

@ -0,0 +1,24 @@
<?php
namespace App\Util\ActivityPub\Validator;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class QuoteRequestValidator
{
public static function validate($payload)
{
return Validator::make($payload, [
'@context' => 'required',
'id' => 'required|string|url|max:500',
'type' => [
'required',
Rule::in(['QuoteRequest']),
],
'actor' => 'required',
'object' => 'required',
'instrument' => 'required',
])->passes();
}
}

@ -0,0 +1,36 @@
<?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('quote_authorizations', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('profile_id')->index();
$table->unsignedBigInteger('status_id')->index();
$table->unsignedBigInteger('actor_id')->index();
$table->string('quote_url', 500);
$table->string('request_url', 500)->nullable();
$table->string('state', 20)->default('approved')->index();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->unique(['status_id', 'quote_url']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('quote_authorizations');
}
};

@ -0,0 +1,28 @@
<?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::table('user_settings', function (Blueprint $table) {
$table->string('can_quote', 20)->default('everyone');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('can_quote');
});
}
};

@ -0,0 +1,36 @@
<?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
{
if (Schema::hasColumn('statuses', 'quote_policy')) {
return;
}
Schema::table('statuses', function (Blueprint $table) {
$table->unsignedSmallInteger('quote_policy')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('statuses', 'quote_policy')) {
return;
}
Schema::table('statuses', function (Blueprint $table) {
$table->dropColumn('quote_policy');
});
}
};

@ -593,6 +593,59 @@
</div>
</div>
{{-- Quote posts --}}
<div class="privacy-section">
<div class="privacy-section-header">
<h5>Quote Posts</h5>
<p>Control whether people on other servers can quote your posts.</p>
</div>
<div class="privacy-select">
<label for="can_quote">
Who can quote your posts
</label>
<p class="privacy-description mb-0">
This is the default for your public and unlisted posts. Apps can
set a different choice on individual posts. Followers-only posts
can never be quoted by anyone else.
</p>
<select
class="form-control"
name="can_quote"
id="can_quote">
<option
value="everyone"
{{ ($settings->can_quote ?? 'everyone') === 'everyone' ? 'selected' : '' }}>
Everyone
</option>
<option
value="followers"
{{ ($settings->can_quote ?? 'everyone') === 'followers' ? 'selected' : '' }}>
People who follow you
</option>
<option
value="nobody"
{{ ($settings->can_quote ?? 'everyone') === 'nobody' ? 'selected' : '' }}>
Nobody
</option>
</select>
<p class="privacy-description mt-2 mb-0">
You can review quotes of your posts and revoke any of them from
<a
href="{{ route('settings.privacy.quotes') }}"
class="font-weight-bold">
Quotes of your posts
</a>.
</p>
</div>
</div>
<div class="privacy-save">
<button
type="submit"

@ -0,0 +1,55 @@
@extends('settings.template')
@section('section')
<div class="d-flex justify-content-between align-items-center">
<div class="title d-flex align-items-center" style="gap: 1rem;">
<p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
<h3 class="font-weight-bold mb-0">Quotes of your posts</h3>
</div>
</div>
<hr />
@if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif
<p class="text-muted small">Posts on other servers that quote yours. Revoking tells the other server to stop showing your post inside that quote, and that quote cannot be approved again.</p>
@if($quotes->count() > 0)
<div class="list-group">
@foreach($quotes as $quote)
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="text-truncate pr-3">
<a href="{{ $quote->quote_url }}" class="text-decoration-none text-dark font-weight-bold" target="_blank" rel="noopener nofollow">
@if($quote->actor)
Quote by {{ $quote->actor->username }}
@else
Quote post
@endif
<i class="far fa-external-link ml-1 text-muted" style="opacity: 0.5"></i>
</a>
<div class="small text-muted">
@if($quote->status)
of <a href="{{ $quote->status->url() }}" class="text-muted">your post</a>
&middot;
@endif
approved {{ $quote->created_at->diffForHumans() }}
</div>
</div>
<span class="btn-group">
<form method="post" onsubmit="return confirm('Revoke approval for this quote?');">
@csrf
<input type="hidden" name="id" value="{{ $quote->id }}">
<button type="submit" class="btn btn-link btn-sm px-3 font-weight-bold text-danger">Revoke</button>
</form>
</span>
</div>
</div>
@endforeach
</div>
<div class="d-flex justify-content-center mt-3 font-weight-bold">
{{ $quotes->links() }}
</div>
@else
<p class="lead text-center font-weight-bold">Nobody has quoted your posts yet.</p>
@endif
@endsection

@ -212,6 +212,7 @@ Route::prefix('api')->group(function () use ($middleware) {
Route::get('tags/{id}', [TagsController::class, 'getHashtag'])->middleware($middleware);
Route::get('statuses/{id}/history', [StatusEditController::class, 'history'])->middleware($middleware);
Route::put('statuses/{id}/interaction_policy', [StatusEditController::class, 'interactionPolicy'])->middleware($middleware);
Route::put('statuses/{id}', [StatusEditController::class, 'store'])->middleware($middleware);
Route::prefix('admin')->group(function () use ($middleware) {

@ -311,6 +311,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->grou
Route::get('privacy/blocked-keywords', [SettingsController::class, 'blockedKeywords'])->name('settings.privacy.blocked-keywords');
Route::get('privacy/featured-collections', [SettingsController::class, 'featuredCollections'])->name('settings.privacy.featured-collections');
Route::post('privacy/featured-collections', [SettingsController::class, 'featuredCollectionsRemove']);
Route::get('privacy/quotes', [SettingsController::class, 'quotes'])->name('settings.privacy.quotes');
Route::post('privacy/quotes', [SettingsController::class, 'quotesRevoke']);
Route::post('privacy/account', [SettingsController::class, 'privateAccountOptions'])->name('settings.privacy.account')->middleware('dangerzone');
Route::prefix('remove')->middleware('dangerzone')->group(function () {
Route::get('request/temporary', [SettingsController::class, 'removeAccountTemporary'])->name('settings.remove.temporary');
@ -478,6 +480,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->grou
Route::get('{username}/followers', [FederationController::class, 'userFollowers']);
Route::get('{username}/following', [FederationController::class, 'userFollowing']);
Route::get('{username}/stamps/{id}', [FederationController::class, 'userFeatureAuthorization'])->where('id', '[0-9]+');
Route::get('{username}/quote_authorizations/{id}', [FederationController::class, 'userQuoteAuthorization'])->where('id', '[0-9]+');
Route::get('{username}', [ProfileController::class, 'permalinkRedirect']);
});

@ -0,0 +1,736 @@
<?php
use App\Jobs\QuotePipeline\RevokeQuoteAuthorizationPipeline;
use App\Models\Profile;
use App\Models\QuoteAuthorization;
use App\Models\Status;
use App\Models\User;
use App\Models\UserFilter;
use App\Services\QuoteService;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\Note;
use App\Util\ActivityPub\Inbox;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Laravel\Passport\Passport;
use League\Fractal;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| FEP-044f: Consent-respecting quote posts (target side)
|--------------------------------------------------------------------------
*/
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
config([
'instance.enable_cc' => false,
'federation.activitypub.enabled' => true,
]);
});
function quoteLocalUser(string $canQuote = 'everyone'): User
{
$user = User::factory()->create();
$user->refresh();
if ($canQuote !== 'everyone') {
$settings = $user->settings;
$settings->can_quote = $canQuote;
$settings->save();
}
return $user;
}
function quoteLocalStatus(Profile $profile, array $attributes = []): Status
{
return Status::factory()->photo()->create(array_merge([
'profile_id' => $profile->id,
], $attributes));
}
function quoteRemoteProfile(string $domain = 'remote.example', string $username = 'bob', array $attributes = []): Profile
{
$actor = "https://{$domain}/users/{$username}";
return Profile::factory()->remote()->create(array_merge([
'domain' => $domain,
'username' => "@{$username}@{$domain}",
'remote_url' => $actor,
'key_id' => "{$actor}#main-key",
'inbox_url' => "{$actor}/inbox",
'sharedInbox' => "https://{$domain}/inbox",
'last_fetched_at' => now(),
], $attributes));
}
/**
* Seed the DNS and banned-domain caches so URL validation passes without a
* network lookup. Call after factories, the lazy refresh can flush the cache.
*/
function quoteSeedHosts(array $hosts = ['remote.example', 'other.example']): void
{
$hosts[] = config('pixelfed.domain.app');
foreach ($hosts as $host) {
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.40'], 3600);
}
Cache::put('instances:banned:domains', [], 1209600);
}
function quoteInProduction(callable $fn): mixed
{
$app = app();
$previous = $app['env'];
$app['env'] = 'production';
try {
return $fn();
} finally {
$app['env'] = $previous;
}
}
function quoteRequestPayload(Profile $actor, Status $status, array $overrides = []): array
{
$quoteUrl = $actor->remote_url.'/statuses/1';
return array_merge([
'@context' => [
'https://www.w3.org/ns/activitystreams',
['QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest'],
],
'id' => $quoteUrl.'/quote',
'type' => 'QuoteRequest',
'actor' => $actor->remote_url,
'object' => $status->url(),
'instrument' => $quoteUrl,
], $overrides);
}
function quoteDeliver(Profile $actor, array $payload, ?Profile $signer = null): void
{
$keyId = ($signer ?? $actor)->key_id;
$headers = [
'signature' => ['keyId="'.$keyId.'",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="dGVzdA=="'],
'date' => [now()->toRfc7231String()],
];
quoteInProduction(fn () => (new Inbox($headers, null, $payload))->handle());
}
function quoteSentActivities(): array
{
return collect(Http::recorded())
->filter(fn ($pair) => $pair[0]->method() === 'POST')
->map(fn ($pair) => json_decode($pair[0]->body(), true))
->filter()
->values()
->all();
}
function quoteNoteObject(Status $status): array
{
$fractal = new Fractal\Manager;
return $fractal->createData(new Fractal\Resource\Item($status, new Note))->toArray()['data'];
}
describe('advertised policy', function () {
it('advertises public automatic approval by default', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$note = quoteNoteObject($status);
expect($note['interactionPolicy']['canQuote']['automaticApproval'])
->toBe(['https://www.w3.org/ns/activitystreams#Public']);
$terms = $note['@context'][2];
expect($terms['canQuote']['@id'])->toBe('gts:canQuote')
->and($terms['interactionPolicy']['@id'])->toBe('gts:interactionPolicy')
->and($terms['gts'])->toBe('https://gotosocial.org/ns#');
});
it('advertises the followers collection for the followers policy', function () {
$user = quoteLocalUser('followers');
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink('/followers')]);
});
it('advertises the author alone for the nobody policy', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink()]);
});
it('lets a per-post override win over the account default', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAG_PUBLIC]);
expect(QuoteService::statusPolicy($status))->toBe('everyone');
});
it('never lets followers-only posts be quoted, whatever the override says', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, [
'scope' => 'private',
'visibility' => 'private',
'quote_policy' => QuoteService::FLAG_PUBLIC,
]);
expect(QuoteService::statusPolicy($status))->toBe('nobody')
->and(quoteNoteObject($status)['interactionPolicy']['canQuote']['automaticApproval'])
->toBe([$user->profile->permalink()]);
});
it('keeps an override of nobody apart from having no override', function () {
$user = quoteLocalUser();
$inherits = quoteLocalStatus($user->profile);
$nobody = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAGS_NOBODY]);
expect($inherits->fresh()->quote_policy)->toBeNull()
->and($nobody->fresh()->quote_policy)->toBe(0)
->and(QuoteService::statusPolicy($inherits->fresh()))->toBe('everyone')
->and(QuoteService::statusPolicy($nobody->fresh()))->toBe('nobody')
->and(QuoteService::fromApiPolicy('nobody'))->toBe(0)
->and(QuoteService::fromApiPolicy(null))->toBeNull()
->and(QuoteService::fromApiPolicy('bogus'))->toBeNull();
});
it('advertises every audience in a combined bitmask', function () {
$user = quoteLocalUser('nobody');
$profile = $user->profile;
$status = quoteLocalStatus($profile, [
'quote_policy' => QuoteService::FLAG_FOLLOWERS
| QuoteService::FLAG_FOLLOWING
| (QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT),
]);
$policy = quoteNoteObject($status)['interactionPolicy']['canQuote'];
expect($policy['automaticApproval'])->toBe([
$profile->permalink('/followers'),
$profile->permalink('/following'),
])->and($policy['manualApproval'])->toBe([
'https://www.w3.org/ns/activitystreams#Public',
])->and(QuoteService::statusPolicy($status))->toBe('followers');
});
it('leaves manualApproval out when nothing needs review', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
expect(quoteNoteObject($status)['interactionPolicy']['canQuote'])
->not->toHaveKey('manualApproval');
});
it('puts the policy on the object of a Create', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$fractal = new Fractal\Manager;
$create = $fractal->createData(new Fractal\Resource\Item($status, new CreateNote))->toArray()['data'];
expect($create['object']['interactionPolicy']['canQuote']['automaticApproval'])
->toBe(['https://www.w3.org/ns/activitystreams#Public'])
->and($create['@context'][2])->toHaveKey('canQuote');
});
});
describe('QuoteRequest', function () {
it('accepts with a stamp when the policy allows it', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$payload = quoteRequestPayload($bob, $status);
quoteDeliver($bob, $payload);
$auth = QuoteAuthorization::first();
expect($auth)->not->toBeNull()
->and($auth->isApproved())->toBeTrue()
->and((int) $auth->status_id)->toBe((int) $status->id)
->and((int) $auth->actor_id)->toBe((int) $bob->id)
->and($auth->quote_url)->toBe($payload['instrument']);
$sent = quoteSentActivities();
expect($sent)->toHaveCount(1);
$accept = $sent[0];
expect($accept['type'])->toBe('Accept')
->and($accept['actor'])->toBe($user->profile->permalink())
->and($accept['to'])->toBe($bob->remote_url)
->and($accept['result'])->toBe($auth->permalink())
->and($accept['object']['type'])->toBe('QuoteRequest')
->and($accept['object']['id'])->toBe($payload['id'])
->and($accept['object']['actor'])->toBe($bob->remote_url)
->and($accept['object']['object'])->toBe($status->url())
->and($accept['object']['instrument'])->toBe($payload['instrument']);
Http::assertSent(fn ($request) => $request->url() === 'https://remote.example/inbox');
});
it('rejects when the account policy is nobody', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
$sent = quoteSentActivities();
expect(QuoteAuthorization::count())->toBe(0)
->and($sent)->toHaveCount(1)
->and($sent[0]['type'])->toBe('Reject')
->and($sent[0]['object']['type'])->toBe('QuoteRequest')
->and($sent[0])->not->toHaveKey('result');
});
it('honours a per-post nobody override on an otherwise open account', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAGS_NOBODY]);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('only accepts followers under the followers policy', function () {
$user = quoteLocalUser('followers');
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
DB::table('followers')->insert([
'profile_id' => $carol->id,
'following_id' => $user->profile->id,
'created_at' => now(),
'updated_at' => now(),
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($carol, quoteRequestPayload($carol, $status));
$sent = quoteSentActivities();
expect($sent[0]['type'])->toBe('Reject')
->and($sent[1]['type'])->toBe('Accept')
->and(QuoteAuthorization::count())->toBe(1)
->and((int) QuoteAuthorization::first()->actor_id)->toBe((int) $carol->id);
});
it('accepts accounts the author follows under a following bitmask', function () {
$user = quoteLocalUser('nobody');
$status = quoteLocalStatus($user->profile, ['quote_policy' => QuoteService::FLAG_FOLLOWING]);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
DB::table('followers')->insert([
'profile_id' => $user->profile->id,
'following_id' => $carol->id,
'created_at' => now(),
'updated_at' => now(),
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($carol, quoteRequestPayload($carol, $status));
$sent = quoteSentActivities();
expect($sent[0]['type'])->toBe('Reject')
->and($sent[1]['type'])->toBe('Accept');
});
it('does not act on audiences that only have manual approval', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, [
'quote_policy' => QuoteService::FLAG_PUBLIC << QuoteService::MANUAL_SHIFT,
]);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('rejects an actor the author has blocked', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
UserFilter::create([
'user_id' => $user->profile->id,
'filterable_id' => $bob->id,
'filterable_type' => Profile::class,
'filter_type' => 'block',
]);
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('ignores requests for followers-only posts without answering', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile, ['scope' => 'private', 'visibility' => 'private']);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('ignores a quote post hosted somewhere other than the requester', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => 'https://other.example/users/mallory/statuses/9',
]));
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('ignores a request signed by a different actor than it claims', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$mallory = quoteRemoteProfile('remote.example', 'mallory');
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status), $mallory);
expect(QuoteAuthorization::count())->toBe(0);
expect(quoteSentActivities())->toBeEmpty();
});
it('accepts an inlined quote post whose id is not its first property', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$quoteUrl = $bob->remote_url.'/statuses/1';
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $quoteUrl,
'attributedTo' => $bob->remote_url,
'content' => 'look at this',
'quote' => $status->url(),
'quoteUrl' => $status->url(),
'_misskey_quote' => $status->url(),
],
]));
expect(QuoteAuthorization::first()?->quote_url)->toBe($quoteUrl)
->and(quoteSentActivities()[0]['type'])->toBe('Accept');
});
it('rejects an inlined quote post written by someone else', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $bob->remote_url.'/statuses/1',
'attributedTo' => 'https://remote.example/users/carol',
'quote' => $status->url(),
],
]));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('rejects an inlined quote post that quotes a different post', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$other = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status, [
'instrument' => [
'type' => 'Note',
'id' => $bob->remote_url.'/statuses/1',
'attributedTo' => $bob->remote_url,
'quote' => $other->url(),
],
]));
expect(QuoteAuthorization::count())->toBe(0)
->and(quoteSentActivities()[0]['type'])->toBe('Reject');
});
it('re-sends the same stamp for a repeated request', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
quoteDeliver($bob, quoteRequestPayload($bob, $status, ['id' => $bob->remote_url.'/statuses/1/quote-again']));
$sent = quoteSentActivities();
expect(QuoteAuthorization::count())->toBe(1)
->and($sent)->toHaveCount(2)
->and($sent[1]['type'])->toBe('Accept')
->and($sent[1]['result'])->toBe($sent[0]['result']);
});
it('keeps rejecting a quote whose stamp was revoked', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
quoteDeliver($bob, quoteRequestPayload($bob, $status));
QuoteService::revoke(QuoteAuthorization::first());
quoteDeliver($bob, quoteRequestPayload($bob, $status));
$sent = quoteSentActivities();
expect($sent[1]['type'])->toBe('Reject')
->and(QuoteAuthorization::approved()->count())->toBe(0);
});
});
describe('stamp', function () {
it('serves a QuoteAuthorization that satisfies the FEP verification rules', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$quoteUrl = $bob->remote_url.'/statuses/1';
$auth = QuoteService::authorize($status, $bob, $quoteUrl);
$this->get("/users/{$user->profile->username}/quote_authorizations/{$auth->id}")
->assertOk()
->assertHeader('Content-Type', 'application/activity+json')
->assertJson([
'id' => $auth->permalink(),
'type' => 'QuoteAuthorization',
'attributedTo' => $user->profile->permalink(),
'interactingObject' => $quoteUrl,
'interactionTarget' => $status->url(),
]);
expect(parse_url($auth->permalink(), PHP_URL_HOST))
->toBe(parse_url($user->profile->permalink(), PHP_URL_HOST));
});
it('is gone once revoked and unknown under another username', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->get("/users/{$stranger->profile->username}/quote_authorizations/{$auth->id}")
->assertNotFound();
QuoteService::revoke($auth);
$this->get("/users/{$user->profile->username}/quote_authorizations/{$auth->id}")
->assertStatus(410);
});
});
describe('revocation', function () {
it('queues a Delete that references the stamp without inlining either post', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
quoteSeedHosts();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::revoke($auth);
Queue::assertPushed(RevokeQuoteAuthorizationPipeline::class, 1);
quoteInProduction(fn () => (new RevokeQuoteAuthorizationPipeline($auth->id))->handle());
$delete = quoteSentActivities()[0];
expect($delete['type'])->toBe('Delete')
->and($delete['actor'])->toBe($user->profile->permalink())
->and($delete['object']['id'])->toBe($auth->permalink())
->and($delete['object']['type'])->toBe('QuoteAuthorization')
->and($delete['object']['interactingObject'])->toBeString()
->and($delete['object']['interactionTarget'])->toBeString();
});
it('revokes every stamp issued to an actor when the author blocks them', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$carol = quoteRemoteProfile('remote.example', 'carol');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/2');
QuoteService::authorize($status, $carol, $carol->remote_url.'/statuses/1');
QuoteService::revokeForActor($user->profile->id, $bob->id);
expect(QuoteAuthorization::approved()->count())->toBe(1)
->and((int) QuoteAuthorization::approved()->first()->actor_id)->toBe((int) $carol->id);
Queue::assertPushed(RevokeQuoteAuthorizationPipeline::class, 2);
});
it('revokes every stamp issued to a domain when the author blocks it', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile('remote.example', 'bob');
$dave = quoteRemoteProfile('other.example', 'dave');
QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
QuoteService::authorize($status, $dave, $dave->remote_url.'/statuses/1');
QuoteService::revokeForDomain($user->profile->id, 'REMOTE.example');
expect(QuoteAuthorization::approved()->count())->toBe(1)
->and((int) QuoteAuthorization::approved()->first()->actor_id)->toBe((int) $dave->id);
});
});
describe('settings', function () {
it('saves the account default from the privacy page', function () {
$user = quoteLocalUser();
$this->actingAs($user)
->get('/settings/privacy')
->assertOk()
->assertSee('Who can quote your posts');
// A fresh model, the GET above decorates the cached settings
// relation with view-only attributes that must not be saved.
$this->actingAs($user->fresh())
->post('/settings/privacy', ['can_quote' => 'followers'])
->assertRedirect();
expect($user->settings()->first()->can_quote)->toBe('followers')
->and(QuoteService::accountPolicy($user->profile))->toBe('followers');
});
it('lists approved quotes and revokes one', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->actingAs($user)
->get('/settings/privacy/quotes')
->assertOk()
->assertSee($bob->remote_url.'/statuses/1');
$this->actingAs($user)
->post('/settings/privacy/quotes', ['id' => $auth->id])
->assertRedirect();
expect($auth->fresh()->isRevoked())->toBeTrue();
Queue::assertPushed(RevokeQuoteAuthorizationPipeline::class, 1);
});
it('does not let someone revoke a stamp that is not theirs', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
$bob = quoteRemoteProfile();
$auth = QuoteService::authorize($status, $bob, $bob->remote_url.'/statuses/1');
$this->actingAs($stranger)
->post('/settings/privacy/quotes', ['id' => $auth->id])
->assertNotFound();
expect($auth->fresh()->isApproved())->toBeTrue();
});
});
describe('api', function () {
it('sets and clears the per-post override through interaction_policy', function () {
$user = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
Passport::actingAs($user, ['read', 'write']);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => 'nobody',
])->assertOk()->assertJsonPath('quote_approval_policy', 'nobody');
expect($status->fresh()->quote_policy)->toBe(QuoteService::FLAGS_NOBODY);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => null,
])->assertOk()->assertJsonPath('quote_approval_policy', 'public');
expect($status->fresh()->quote_policy)->toBeNull();
});
it('does not let someone else change the policy of a post', function () {
$user = quoteLocalUser();
$stranger = quoteLocalUser();
$status = quoteLocalStatus($user->profile);
Passport::actingAs($stranger, ['read', 'write']);
$this->putJson("/api/v1/statuses/{$status->id}/interaction_policy", [
'quote_approval_policy' => 'nobody',
])->assertNotFound();
expect($status->fresh()->quote_policy)->toBeNull();
});
});
Loading…
Cancel
Save