mirror of https://github.com/pixelfed/pixelfed
Add FEP-044f: Consent-respecting quote posts
parent
fc856fd1f3
commit
823efcaad7
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
·
|
||||
@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
|
||||
@ -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…
Reference in New Issue