PluginProbe
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP / trunk
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP vtrunk
3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 3.0.0 2.4.13 2.4.12 2.4.11 2.4.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 110 releases
betterlinks / includes / API / QuickLink.php

QuickLink.php in BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP trunk, at includes/API/QuickLink.php

246 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BetterLinks\API;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; }
7
8 use BetterLinks\CLEToken;
9 use BetterLinks\Helper;
10 use BetterLinks\Link;
11
12 /**
13 * Quick Link Creation REST endpoint.
14 *
15 * The preferred transport for Quick Link Creation. Unlike the legacy front-end
16 * `?action=btl_cle&api_key=…` GET, the credential travels in an
17 * `Authorization: Bearer` header on a POST, so it never lands in browser
18 * history, referrers, proxy logs, server access logs or a shared screenshot.
19 */
20 class QuickLink {
21
22 /**
23 * REST namespace.
24 *
25 * @var string
26 */
27 private $namespace = BETTERLINKS_PLUGIN_SLUG . '/v1';
28
29 /**
30 * The token record authenticated for this request.
31 *
32 * @var array|false
33 */
34 private $token_record = false;
35
36 public function __construct() {
37 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
38 }
39
40 /**
41 * Register the route.
42 *
43 * @return void
44 */
45 public function register_routes() {
46 register_rest_route(
47 $this->namespace,
48 '/quick-link',
49 array(
50 array(
51 'methods' => \WP_REST_Server::CREATABLE,
52 'callback' => array( $this, 'create' ),
53 'permission_callback' => array( $this, 'permissions_check' ),
54 'args' => array(
55 'target_url' => array(
56 'required' => true,
57 'type' => 'string',
58 'sanitize_callback' => 'esc_url_raw',
59 ),
60 'title' => array(
61 'required' => false,
62 'type' => 'string',
63 'sanitize_callback' => 'sanitize_text_field',
64 ),
65 ),
66 ),
67 )
68 );
69 }
70
71 /**
72 * Authenticate the caller.
73 *
74 * Accepts a bearer token issued by {@see CLEToken}. A logged-in user with the
75 * usual capability is also accepted (nonce-authenticated admin UI calls), so
76 * the endpoint works from the dashboard without minting a token.
77 *
78 * @param \WP_REST_Request $request Request.
79 * @return bool|\WP_Error
80 */
81 public function permissions_check( $request ) {
82 if ( is_user_logged_in() && CLEToken::current_user_can_create() ) {
83 return true;
84 }
85
86 $token = $this->get_bearer_token( $request );
87
88 if ( '' === $token ) {
89 return new \WP_Error(
90 'betterlinks_cle_no_credentials',
91 __( 'A Quick Link Creation token is required.', 'betterlinks' ),
92 array( 'status' => 401 )
93 );
94 }
95
96 // Throttle credential guessing per peer before touching the token store.
97 if ( ! $this->within_rate_limit() ) {
98 return new \WP_Error(
99 'betterlinks_cle_rate_limited',
100 __( 'Too many requests.', 'betterlinks' ),
101 array( 'status' => 429 )
102 );
103 }
104
105 $record = CLEToken::authenticate( $token );
106
107 if ( ! $record ) {
108 return new \WP_Error(
109 'betterlinks_cle_invalid_token',
110 __( 'Invalid or expired Quick Link Creation token.', 'betterlinks' ),
111 array( 'status' => 401 )
112 );
113 }
114
115 $this->token_record = $record;
116
117 return true;
118 }
119
120 /**
121 * Create the short link.
122 *
123 * @param \WP_REST_Request $request Request.
124 * @return \WP_REST_Response|\WP_Error
125 */
126 public function create( $request ) {
127 global $betterlinks_settings;
128
129 if ( empty( $betterlinks_settings['cle']['enable_cle'] ) ) {
130 return new \WP_Error(
131 'betterlinks_cle_disabled',
132 __( 'Quick Link Creation is disabled.', 'betterlinks' ),
133 array( 'status' => 403 )
134 );
135 }
136
137 $target_url = (string) $request->get_param( 'target_url' );
138 $title = (string) $request->get_param( 'title' );
139
140 // Shortening an intranet or odd-port URL is legitimate, so the link itself
141 // only needs to be a well-formed http(s) URL.
142 $scheme = $target_url ? wp_parse_url( $target_url, PHP_URL_SCHEME ) : '';
143
144 if ( '' === $target_url || ! in_array( strtolower( (string) $scheme ), array( 'http', 'https' ), true ) ) {
145 return new \WP_Error(
146 'betterlinks_cle_invalid_url',
147 __( 'A valid http(s) target URL is required.', 'betterlinks' ),
148 array( 'status' => 400 )
149 );
150 }
151
152 if ( '' === trim( $title ) ) {
153 // Only the server-side title fetch needs the stricter SSRF check;
154 // fetch_target_url() uses wp_safe_remote_get() and enforces it again.
155 if ( ! wp_http_validate_url( $target_url ) ) {
156 return new \WP_Error(
157 'betterlinks_cle_no_title',
158 __( 'A title is required for this URL.', 'betterlinks' ),
159 array( 'status' => 422 )
160 );
161 }
162
163 $title = ( new Helper() )->fetch_target_url( $target_url );
164 }
165
166 if ( '' === trim( (string) $title ) ) {
167 return new \WP_Error(
168 'betterlinks_cle_no_title',
169 __( 'Could not determine a title for the target URL.', 'betterlinks' ),
170 array( 'status' => 422 )
171 );
172 }
173
174 $link = ( new Link() )->create_new_link( $title, $target_url, $betterlinks_settings, false );
175
176 if ( empty( $link ) ) {
177 return new \WP_Error(
178 'betterlinks_cle_create_failed',
179 __( 'Could not create the short link.', 'betterlinks' ),
180 array( 'status' => 500 )
181 );
182 }
183
184 return new \WP_REST_Response(
185 array(
186 'success' => true,
187 'data' => array(
188 'id' => isset( $link['id'] ) ? (int) $link['id'] : 0,
189 'title' => $title,
190 'target_url' => $target_url,
191 'short_url' => $link['permalink'],
192 ),
193 ),
194 201
195 );
196 }
197
198 /**
199 * Extract the bearer token from the request.
200 *
201 * @param \WP_REST_Request $request Request.
202 * @return string
203 */
204 private function get_bearer_token( $request ) {
205 $header = (string) $request->get_header( 'authorization' );
206
207 if ( '' === $header && isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
208 $header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
209 }
210
211 // Some Apache/CGI setups strip Authorization; WordPress mirrors it here.
212 if ( '' === $header && isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
213 $header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
214 }
215
216 if ( '' === $header || ! preg_match( '/^\s*Bearer\s+(\S+)\s*$/i', $header, $matches ) ) {
217 return '';
218 }
219
220 return $matches[1];
221 }
222
223 /**
224 * Per-peer throttle for token presentation.
225 *
226 * @return bool
227 */
228 private function within_rate_limit() {
229 $limit = (int) apply_filters( 'betterlinks/cle/rate_limit', 20 );
230
231 if ( $limit <= 0 ) {
232 return true;
233 }
234
235 $peer = isset( $_SERVER['REMOTE_ADDR'] )
236 ? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) )
237 : 'unknown';
238
239 return \BetterLinks\Services\CountryDetectionService::consume_bucket(
240 'btl_cle_rl_' . md5( $peer ),
241 $limit,
242 MINUTE_IN_SECONDS
243 );
244 }
245 }
246