Repository navigation
Expand file tree
/
Copy pathRoute.php
More file actions
486 lines (433 loc) · 16.9 KB
/
Copy pathRoute.php
File metadata and controls
486 lines (433 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
<?php
/**
* The site's webhook endpoint and the account endpoint that points at it.
*
* @package Assinafy\WP
*/
declare(strict_types=1);
namespace Assinafy\WP\Webhook;
defined( 'ABSPATH' ) || exit;
use Assinafy\SDK\AssinafyClient;
use Assinafy\SDK\Exceptions\ApiException;
use Assinafy\SDK\Support\WebhookEventParser;
use Assinafy\WP\Settings;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
/**
* Registers `POST /wp-json/assinafy/v1/webhook/<token>` and owns everything about that URL:
* the token that makes it unguessable, the rotation of that token, and the account-side
* endpoint that tells Assinafy to deliver to it.
*
* Deliveries authenticate in two layers. The URL token is the baseline: `permission_callback`
* compares it with `hash_equals()` and is never `__return_true`. When a `whsec_` signing
* secret is configured — stored by registration or restored from a saved copy — the
* delivery's Standard Webhooks signature is verified on top,
* and an unsigned, badly signed or stale delivery fails with a 401. With no secret configured
* the token alone authenticates, exactly as before, so the body is treated as a hint rather
* than as truth either way (see {@see Handler}).
*
* An account has one endpoint, or up to three on paid plans, and every active endpoint
* subscribed to an event receives it. {@see self::subscribe()} therefore manages this site's
* own endpoint — found by URL match among the account's endpoints, created when absent —
* and never touches an endpoint belonging to another integration.
*/
final class Route {
/**
* REST namespace the endpoint lives in.
*/
public const REST_NAMESPACE = 'assinafy/v1';
/**
* Token length in characters. The route regex pins the same number.
*/
public const TOKEN_LENGTH = 32;
/**
* Random bytes behind one token. Hex doubles, so this is half {@see self::TOKEN_LENGTH}.
*/
private const TOKEN_BYTES = 16;
/**
* The documented document and signer events the receiver understands.
*
* Twenty document and signer events. `template_created`, `template_processed` and
* `template_processing_failed` exist as SDK constants and in the published event catalog
* but are not subscribable, so they are absent here. Registration also filters against
* the target environment catalog returned by `GET /webhooks/event-types`.
*
* @var array<int, string>
*/
public const EVENTS = array(
'document_uploaded',
'document_metadata_ready',
'document_prepared',
'assignment_created',
'signature_requested',
'document_ready',
'signer_created',
'signer_email_verified',
'signer_whatsapp_verified',
'signer_data_confirmed',
'signer_identity_verified',
'signer_identity_verification_failed',
'signer_identity_review_required',
'signer_identity_review_approved',
'signer_identity_review_rejected',
'signer_signed_document',
'signer_viewed_document',
'signer_rejected_document',
'user_rejected_document',
'document_processing_failed',
);
/**
* The events the plugin asks for when nothing more specific is requested.
*
* Every one of these changes what the document list shows, so each is worth a delivery.
* The rest are reachable through the hourly reconcile run.
*
* @var array<int, string>
*/
public const DEFAULT_EVENTS = array(
'document_metadata_ready',
'document_ready',
'signer_identity_verified',
'signer_identity_verification_failed',
'signer_identity_review_required',
'signer_identity_review_approved',
'signer_identity_review_rejected',
'signer_viewed_document',
'signer_signed_document',
'signer_rejected_document',
'user_rejected_document',
'document_processing_failed',
);
/**
* @param Handler $handler The delivery handler that backs the route callback.
*/
public function __construct( private readonly Handler $handler ) {
}
/**
* Attach the route to the REST API.
*/
public function register(): void {
add_action( 'rest_api_init', array( $this, 'register_route' ) );
}
/**
* Declare the endpoint.
*
* The token is matched by the URL pattern and checked again in `permission_callback`:
* the pattern only narrows the shape, `hash_equals()` is what authenticates. The route
* is registered even while deliveries are switched off: the endpoint lives on the
* Assinafy account rather than here, so an authenticated delivery still has to be
* answered, and {@see Handler::handle()} acknowledges it without doing any work.
*
* There is deliberately no `args` schema. Declaring one makes
* `WP_REST_Request::sanitize_params()` walk every parameter the request carries, and its
* JSON parameters are whatever `json_decode()` made of the body — for a body that is a
* bare JSON scalar, a string or an integer, which it then tries to `foreach`. That is an
* uncaught `TypeError`, so an endpoint declaring `args` answers a one-byte body with a
* 500. Nothing is lost by omitting it: the route pattern already fixes the token's
* length and alphabet, and {@see self::check_token()} reads the value the pattern
* captured rather than anything a schema would sanitise. The delivery body is parsed by
* {@see Handler::handle()} from the raw request, which is the only reader that should
* ever touch it.
*/
public function register_route(): void {
register_rest_route(
self::REST_NAMESPACE,
'/webhook/(?P<token>[A-Za-z0-9]{' . self::TOKEN_LENGTH . '})',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this->handler, 'handle' ),
'permission_callback' => array( $this, 'check_token' ),
'show_in_index' => false,
)
);
}
/**
* Authenticate the delivery.
*
* Returning a `WP_Error` rather than `false` keeps the status precise. The token gate
* compares in constant time and refuses outright when no token has been generated yet.
*
* The token is read from `get_url_params()`, never `get_param()`. `get_param()` resolves
* in the order the REST server declares — JSON body, then POST body, then query string,
* and only then the URL match — so a delivery carrying its own `token` key would decide
* its own authentication. The value this gate checks is the one the route pattern
* captured from the path and nothing else.
*
* When a signing secret is stored, the delivery must additionally carry a valid Standard
* Webhooks signature over the raw body with a fresh timestamp; an unsigned, badly signed
* or stale delivery fails closed. A stored secret that cannot be parsed fails the delivery
* rather than widening the gate.
*
* @param WP_REST_Request<array<string, mixed>> $request Incoming request.
* @return true|WP_Error True when the delivery authenticates, an error otherwise.
*/
public function check_token( WP_REST_Request $request ): bool|WP_Error {
$stored = self::token();
$given = $request->get_url_params()['token'] ?? null;
if ( '' === $stored || ! is_string( $given ) || ! hash_equals( $stored, $given ) ) {
return new WP_Error(
'assinafy_webhook_forbidden',
__( 'Invalid webhook endpoint.', 'assinafy' ),
array( 'status' => 403 )
);
}
if ( ! self::signature_valid( $request ) ) {
return new WP_Error(
'assinafy_webhook_bad_signature',
__( 'Invalid webhook signature.', 'assinafy' ),
array( 'status' => 401 )
);
}
return true;
}
/**
* Verify the delivery's Standard Webhooks signature when a secret is configured.
*
* With no secret stored the token alone authenticates. A stored secret that cannot be
* parsed makes the parser raise, which fails the delivery closed rather than widening
* the gate.
*
* @param WP_REST_Request<array<string, mixed>> $request Incoming request.
*/
private static function signature_valid( WP_REST_Request $request ): bool {
$secret = self::secret();
if ( '' === $secret ) {
return '' === (string) Settings::get( Settings::OPTION_WEBHOOK_SECRET_ENC );
}
try {
return ( new WebhookEventParser() )->verifySignature( $request->get_body(), $request->get_headers(), $secret );
} catch ( \Throwable $e ) {
return false;
}
}
/**
* The stored endpoint token, or an empty string before one is generated.
*/
public static function token(): string {
return (string) Settings::get( Settings::OPTION_WEBHOOK_TOKEN );
}
/**
* The decrypted `whsec_` signing secret, or an empty string when none is stored.
*/
public static function secret(): string {
return SigningSecret::get();
}
/**
* This site's delivery endpoint, or an empty string while no token exists.
*
* Example: `https://example.com/wp-json/assinafy/v1/webhook/<token>`.
*/
public static function url(): string {
$token = self::token();
if ( '' === $token ) {
return '';
}
return rest_url( self::REST_NAMESPACE . '/webhook/' . $token );
}
/**
* Issue a new token and return it.
*
* The old endpoint stops authenticating the moment this returns, so the caller must
* re-register with {@see self::subscribe()} or the account keeps delivering to a URL that
* now answers 403.
*
* The token is the whole of the endpoint's baseline authentication, so it comes straight
* from `random_bytes()`: 128 bits rendered as the hex the route pattern accepts.
* `wp_generate_password()` would route the value through the `random_password` filter,
* where any plugin on the site could shorten or fix it.
*
* @return string The new token.
*/
public static function rotate_token(): string {
$token = bin2hex( random_bytes( self::TOKEN_BYTES ) );
update_option( Settings::OPTION_WEBHOOK_TOKEN, $token, false );
return $token;
}
/**
* Whether an endpoint belongs to this site.
*
* Matches on the endpoint prefix rather than the full URL so that an endpoint still
* carrying a rotated-away token is recognised as this site's own and is updated in place.
* An endpoint without a URL is never this site's.
*
* @param array<string, mixed>|null $endpoint Endpoint as returned by the API.
*/
public static function is_ours( ?array $endpoint ): bool {
$url = is_string( $endpoint['url'] ?? null ) ? untrailingslashit( $endpoint['url'] ) : '';
if ( '' === $url ) {
return false;
}
$base = untrailingslashit( rest_url( self::REST_NAMESPACE . '/webhook' ) );
return $url === $base || str_starts_with( $url, $base . '/' );
}
/**
* Register or update this site's own webhook endpoint on the account.
*
* The account's endpoints are listed first and the one whose URL names this site —
* {@see self::is_ours()} matches on the endpoint prefix — is updated in place; when none
* matches, a new endpoint is created. Foreign endpoints coexist and are never touched,
* which is why a full plan fails with `assinafy_webhook_limit` rather than an offer to
* take one over.
*
* On success the endpoint id is stored and the creation secret encrypted. API-key sites
* can re-read the secret; existing OAuth endpoints keep their saved secret.
*
* @param AssinafyClient $client Configured API client.
* @param string $email Address Assinafy alerts when delivery fails.
* @param array<int, string> $events Events to add; the defaults when the merge is empty.
* @return array<string, mixed>|WP_Error The stored endpoint, or the failure.
*/
public static function subscribe( AssinafyClient $client, string $email, array $events = array() ): array|WP_Error {
if ( '' === self::url() ) {
return new WP_Error(
'assinafy_webhook_no_token',
__( 'No webhook endpoint has been generated for this site yet.', 'assinafy' )
);
}
$endpoints = self::endpoints( $client );
if ( $endpoints instanceof WP_Error ) {
return $endpoints;
}
$existing = self::own_endpoint( $endpoints );
try {
$available = array_column( $client->webhooks()->eventTypes(), 'id' );
} catch ( \Throwable $e ) {
return self::error( $e );
}
$events = array_values( array_intersect( self::merged_events( $existing['events'] ?? null, $events ), $available ) );
if ( array() === $events ) {
return new WP_Error( 'assinafy_webhook_events_unavailable', __( 'Assinafy returned no supported document webhook events. Try registering again later.', 'assinafy' ) );
}
return self::write_endpoint( $client, $email, $events, $existing );
}
/**
* The account's endpoints, oldest first, or the failure that prevented reading them.
*
* A list that cannot be read is reported rather than assumed empty: assuming absence
* would create a second endpoint for a site that already has one.
*
* @param AssinafyClient $client Configured API client.
* @return array<int, array<string, mixed>>|WP_Error
*/
private static function endpoints( AssinafyClient $client ): array|WP_Error {
try {
return $client->webhooks()->listEndpoints();
} catch ( \Throwable $e ) {
return self::error( $e );
}
}
/**
* The endpoint belonging to this site, when the account has one.
*
* @param array<int, array<string, mixed>> $endpoints The account's endpoints.
* @return array<string, mixed>|null
*/
private static function own_endpoint( array $endpoints ): ?array {
foreach ( $endpoints as $endpoint ) {
if ( self::is_ours( $endpoint ) ) {
return $endpoint;
}
}
return null;
}
/**
* Update the matching endpoint, or create this site's own when none matches.
*
* On success the endpoint id and creation secret are stored. API-key sites can also
* re-read the secret; existing OAuth endpoints keep their saved secret.
*
* @param AssinafyClient $client Configured API client.
* @param string $email Address Assinafy alerts when delivery fails.
* @param array<int, string> $events The merged event list.
* @param array<string, mixed>|null $existing This site's endpoint on file, or null.
* @return array<string, mixed>|WP_Error The stored endpoint, or the failure.
*/
private static function write_endpoint( AssinafyClient $client, string $email, array $events, ?array $existing ): array|WP_Error {
$id = null !== $existing && is_string( $existing['id'] ?? null ) ? $existing['id'] : '';
// A short label telling this site's endpoint apart from the account's others.
$host = (string) wp_parse_url( self::url(), PHP_URL_HOST );
try {
$endpoint = '' !== $id
? $client->webhooks()->updateEndpoint(
$id,
array(
'url' => self::url(),
'email' => $email,
'events' => $events,
'is_active' => true,
'signing_enabled' => true,
)
)
: $client->webhooks()->createEndpoint( self::url(), $email, $events, '' !== $host ? $host : 'WordPress', true, true );
} catch ( \Throwable $e ) {
return self::write_error( $e );
}
$id = is_string( $endpoint['id'] ?? null ) ? $endpoint['id'] : '';
if ( '' !== $id ) {
update_option( Settings::OPTION_WEBHOOK_ENDPOINT_ID, $id, false );
$error = SigningSecret::store( $client, $id, $endpoint );
if ( null !== $error ) {
return $error;
}
}
unset( $endpoint['secret'] );
return $endpoint;
}
/**
* Turn a failed endpoint write into a `WP_Error`.
*
* A 403 from the create route is the plan's endpoint limit, reported as such: foreign
* endpoints coexist with this site's, so there is nothing to take over — the operator
* removes one in the Assinafy dashboard first.
*
* @param \Throwable $e The failure.
*/
private static function write_error( \Throwable $e ): WP_Error {
if ( $e instanceof ApiException && 403 === $e->getStatusCode() ) {
return new WP_Error(
'assinafy_webhook_limit',
__( 'This Assinafy account already has the maximum number of webhook endpoints — paid plans allow up to three. Remove one in the Assinafy dashboard before registering this site.', 'assinafy' ),
array( 'status' => 403 )
);
}
return self::error( $e );
}
/**
* The events to ask for, keeping the ones already on the endpoint.
*
* The event list is merged rather than replaced: re-registering here can never stop an
* event this endpoint already received. Anything outside {@see self::EVENTS} is dropped,
* since the API rejects it.
*
* @param mixed $kept The endpoint's current events, when it has any.
* @param array<int, string> $events Events to add; the defaults when the merge is empty.
* @return array<int, string>
*/
private static function merged_events( mixed $kept, array $events ): array {
$kept = is_array( $kept ) ? $kept : array();
$merged = array_values(
array_intersect(
array_unique( array_merge( array_filter( $kept, 'is_string' ), $events ) ),
self::EVENTS
)
);
return array() === $merged ? self::DEFAULT_EVENTS : $merged;
}
/**
* Turn a thrown SDK failure into a `WP_Error` the admin screen can render.
*
* @param \Throwable $e The failure.
*/
private static function error( \Throwable $e ): WP_Error {
$status = $e instanceof \Assinafy\SDK\Exceptions\ApiException ? $e->getStatusCode() : 0;
return new WP_Error(
'assinafy_webhook_request_failed',
$e->getMessage(),
array(
'type' => get_debug_type( $e ),
'status' => $status > 0 ? $status : 500,
)
);
}
}