Skip to content

Commit ee50457

Browse files
committed
Add QuotePost request handling
1 parent f9c0809 commit ee50457

13 files changed

Lines changed: 646 additions & 19 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
namespace App\Federation\ActivityBuilders;
4+
5+
use App\Models\Profile;
6+
use App\Models\QuoteAuthorization;
7+
use App\Services\SnowflakeService;
8+
9+
class QuoteRequestActivityBuilder
10+
{
11+
/**
12+
* Build a QuoteRequest activity (for requesting permission to quote)
13+
*
14+
* @param Profile $actor The local profile requesting to quote
15+
* @param string $quotedObjectUrl The URL of the object being quoted
16+
* @param array $quotePost The post that contains the quote
17+
* @return array The ActivityPub QuoteRequest activity
18+
*/
19+
public static function buildRequest(Profile $actor, string $quotedObjectUrl, array $quotePost): array
20+
{
21+
$activityId = $actor->getActorId('#quote-requests/'.SnowflakeService::next());
22+
23+
return [
24+
'@context' => [
25+
'https://www.w3.org/ns/activitystreams',
26+
[
27+
'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest',
28+
'quote' => [
29+
'@id' => 'https://w3id.org/fep/044f#quote',
30+
'@type' => '@id',
31+
],
32+
],
33+
],
34+
'type' => 'QuoteRequest',
35+
'id' => $activityId,
36+
'actor' => $actor->getActorId(),
37+
'object' => $quotedObjectUrl,
38+
'instrument' => $quotePost,
39+
];
40+
}
41+
42+
/**
43+
* Build an Accept activity for a QuoteRequest
44+
*
45+
* @param array $quoteRequest The original QuoteRequest activity
46+
* @param Profile $actor The local profile accepting the quote
47+
* @param QuoteAuthorization $authorization The authorization stamp
48+
* @return array The ActivityPub Accept activity
49+
*/
50+
public static function buildAccept(array $quoteRequest, Profile $actor, QuoteAuthorization $authorization): array
51+
{
52+
$activityId = $actor->getActorId('#accepts/quote-requests/'.SnowflakeService::next());
53+
54+
return [
55+
'@context' => [
56+
'https://www.w3.org/ns/activitystreams',
57+
[
58+
'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest',
59+
],
60+
],
61+
'type' => 'Accept',
62+
'id' => $activityId,
63+
'actor' => $actor->getActorId(),
64+
'to' => [$quoteRequest['actor']],
65+
'object' => $quoteRequest,
66+
'result' => $authorization->ap_url,
67+
'published' => now()->toIso8601String(),
68+
];
69+
}
70+
71+
/**
72+
* Build a Reject activity for a QuoteRequest
73+
*
74+
* @param array $quoteRequest The original QuoteRequest activity
75+
* @param Profile $actor The local profile rejecting the quote
76+
* @return array The ActivityPub Reject activity
77+
*/
78+
public static function buildReject(array $quoteRequest, Profile $actor): array
79+
{
80+
$activityId = $actor->getActorId('#rejects/quote-requests/'.SnowflakeService::next());
81+
82+
return [
83+
'@context' => [
84+
'https://www.w3.org/ns/activitystreams',
85+
[
86+
'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest',
87+
],
88+
],
89+
'type' => 'Reject',
90+
'id' => $activityId,
91+
'actor' => $actor->getActorId(),
92+
'to' => [$quoteRequest['actor']],
93+
'object' => $quoteRequest,
94+
'published' => now()->toIso8601String(),
95+
];
96+
}
97+
}
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
<?php
2+
3+
namespace App\Federation\Handlers;
4+
5+
use App\Federation\ActivityBuilders\QuoteRequestActivityBuilder;
6+
use App\Models\Comment;
7+
use App\Models\CommentReply;
8+
use App\Models\Profile;
9+
use App\Models\QuoteAuthorization;
10+
use App\Models\Video;
11+
use App\Services\DeliveryService;
12+
use App\Services\HashidService;
13+
use App\Services\SanitizeService;
14+
use Illuminate\Support\Facades\Log;
15+
16+
class QuoteRequestHandler
17+
{
18+
/**
19+
* Handle an incoming QuoteRequest activity.
20+
* This processes a request to quote one of our posts.
21+
*
22+
* @param array $activity The QuoteRequest activity
23+
* @param Profile $actor The remote profile requesting to quote
24+
* @param Profile|null $target The local profile being quoted
25+
*/
26+
public function handle(array $activity, Profile $actor, ?Profile $target = null): void
27+
{
28+
$quotedObjectUrl = $activity['object'];
29+
$quotePostUrl = is_array($activity['instrument'])
30+
? ($activity['instrument']['id'] ?? null)
31+
: $activity['instrument'];
32+
33+
if (! $quotePostUrl) {
34+
if (config('logging.dev_log')) {
35+
Log::warning('QuoteRequest handler: Missing quote post URL', [
36+
'activity_id' => $activity['id'],
37+
'actor' => $actor->uri,
38+
]);
39+
}
40+
41+
return;
42+
}
43+
44+
$quotable = $this->findQuotableByUrl($quotedObjectUrl);
45+
46+
if (! $quotable) {
47+
if (config('logging.dev_log')) {
48+
Log::warning('QuoteRequest handler: Quoted object not found', [
49+
'activity_id' => $activity['id'],
50+
'quoted_url' => $quotedObjectUrl,
51+
'actor' => $actor->uri,
52+
]);
53+
}
54+
55+
return;
56+
}
57+
58+
if (! $target) {
59+
$target = $quotable->profile;
60+
}
61+
62+
if ($this->shouldAutoApproveQuote($quotable, $actor)) {
63+
$this->approveQuoteRequest($activity, $quotable, $quotePostUrl, $actor);
64+
} else {
65+
$this->rejectQuoteRequest($activity, $quotable, $actor);
66+
}
67+
}
68+
69+
/**
70+
* Find a quotable object by its ActivityPub URL
71+
*/
72+
protected function findQuotableByUrl(string $url)
73+
{
74+
$statusMatch = app(SanitizeService::class)->matchUrlTemplate(
75+
url: $url,
76+
templates: [
77+
'/ap/users/{userId}/video/{videoId}',
78+
],
79+
useAppHost: true,
80+
constraints: ['userId' => '\d+', 'videoId' => '\d+']
81+
);
82+
83+
if ($statusMatch && isset($statusMatch['userId'], $statusMatch['videoId'])) {
84+
return Video::published()->where('visibility', 1)->whereProfileId($statusMatch['userId'])->whereKey($statusMatch['videoId'])->first();
85+
}
86+
87+
$videoHashIdMatch = app(SanitizeService::class)->matchUrlTemplate(
88+
url: $url,
89+
templates: ['/v/{hashId}'],
90+
useAppHost: true,
91+
constraints: ['hashId' => '[0-9a-zA-Z_-]{1,11}']
92+
);
93+
94+
if ($videoHashIdMatch && isset($videoHashIdMatch['hashId'])) {
95+
$decodedId = HashidService::safeDecode($videoHashIdMatch['hashId']);
96+
if ($decodedId !== null) {
97+
return Video::published()->where('visibility', 1)->whereKey($decodedId)->first();
98+
}
99+
}
100+
101+
$commentMatch = app(SanitizeService::class)->matchUrlTemplate(
102+
url: $url,
103+
templates: [
104+
'/ap/users/{userId}/comment/{replyId}',
105+
],
106+
useAppHost: true,
107+
constraints: ['userId' => '\d+', 'replyId' => '\d+']
108+
);
109+
110+
if ($commentMatch && isset($commentMatch['userId'], $commentMatch['replyId'])) {
111+
return Comment::whereProfileId($commentMatch['userId'])->whereKey($commentMatch['replyId'])->first();
112+
}
113+
114+
$commentReplyMatch = app(SanitizeService::class)->matchUrlTemplate(
115+
url: $url,
116+
templates: [
117+
'/ap/users/{userId}/reply/{commentReplyId}',
118+
],
119+
useAppHost: true,
120+
constraints: ['userId' => '\d+', 'commentReplyId' => '\d+']
121+
);
122+
123+
if ($commentReplyMatch && isset($commentReplyMatch['userId'], $commentReplyMatch['commentReplyId'])) {
124+
return CommentReply::whereProfileId($commentReplyMatch['userId'])->whereKey($commentReplyMatch['commentReplyId'])->first();
125+
}
126+
127+
return false;
128+
}
129+
130+
/**
131+
* Check if a quote should be auto-approved based on interaction policy
132+
*/
133+
protected function shouldAutoApproveQuote($quotable, Profile $quoterProfile): bool
134+
{
135+
$policy = $quotable->interaction_policy ?? [];
136+
$canQuote = $policy['canQuote'] ?? [];
137+
$automaticApproval = $canQuote['automaticApproval'] ?? [];
138+
139+
if (in_array('https://www.w3.org/ns/activitystreams#Public', $automaticApproval)) {
140+
return true;
141+
}
142+
143+
// $quotedProfile = $quotable->profile;
144+
145+
// $followersUrl = $quotedProfile->getFollowersUrl();
146+
// if (in_array($followersUrl, $automaticApproval)) {
147+
// if ($quotedProfile->followedBy($quoterProfile)) {
148+
// return true;
149+
// }
150+
// }
151+
152+
// $followingUrl = $quotedProfile->getFollowingUrl();
153+
// if (in_array($followingUrl, $automaticApproval)) {
154+
// if ($quotedProfile->following($quoterProfile)) {
155+
// return true;
156+
// }
157+
// }
158+
159+
return false;
160+
}
161+
162+
/**
163+
* Approve a quote request
164+
*/
165+
protected function approveQuoteRequest(
166+
array $activity,
167+
$quotable,
168+
string $quotePostUrl,
169+
Profile $quoterProfile
170+
): void {
171+
$existing = QuoteAuthorization::where('quote_post_url', $quotePostUrl)
172+
->where('quotable_type', get_class($quotable))
173+
->where('quotable_id', $quotable->id)
174+
->first();
175+
176+
if ($existing) {
177+
if (config('logging.dev_log')) {
178+
Log::info('QuoteRequest handler: Authorization already exists', [
179+
'authorization_id' => $existing->id,
180+
'quote_post_url' => $quotePostUrl,
181+
]);
182+
}
183+
184+
return;
185+
}
186+
187+
$authorization = QuoteAuthorization::create([
188+
'quotable_id' => $quotable->id,
189+
'quotable_type' => get_class($quotable),
190+
'quote_post_url' => $quotePostUrl,
191+
'quoted_profile_id' => $quotable->profile_id,
192+
'quoter_profile_id' => $quoterProfile->id,
193+
]);
194+
195+
$accept = QuoteRequestActivityBuilder::buildAccept(
196+
$activity,
197+
$quotable->profile,
198+
$authorization
199+
);
200+
201+
app(DeliveryService::class)->deliverToInbox(
202+
$quotable->profile,
203+
$quoterProfile,
204+
$accept,
205+
);
206+
207+
if (config('logging.dev_log')) {
208+
Log::info('QuoteRequest approved', [
209+
'authorization_id' => $authorization->id,
210+
'quote_post_url' => $quotePostUrl,
211+
'quotable_type' => get_class($quotable),
212+
'quotable_id' => $quotable->id,
213+
'quoter' => $quoterProfile->uri,
214+
]);
215+
}
216+
}
217+
218+
/**
219+
* Reject a quote request
220+
*/
221+
protected function rejectQuoteRequest(array $activity, $quotable, Profile $quoterProfile): void
222+
{
223+
$reject = QuoteRequestActivityBuilder::buildReject(
224+
$activity,
225+
$quotable->profile
226+
);
227+
228+
app(DeliveryService::class)->deliverToInbox(
229+
$quotable->profile,
230+
$quoterProfile,
231+
$reject,
232+
);
233+
234+
if (config('logging.dev_log')) {
235+
Log::info('QuoteRequest rejected', [
236+
'activity_id' => $activity['id'],
237+
'quote_post_url' => is_array($activity['instrument'])
238+
? ($activity['instrument']['id'] ?? 'unknown')
239+
: $activity['instrument'],
240+
'quotable_type' => get_class($quotable),
241+
'quotable_id' => $quotable->id,
242+
'quoter' => $quoterProfile->uri,
243+
]);
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)