Add optional rate limiting

This commit is contained in:
Tetrakern 2023-09-01 21:36:18 +02:00
parent 95a7a4b305
commit f63256326f
8 changed files with 84 additions and 2 deletions

View File

@ -821,6 +821,7 @@ define( 'CONSTANT_NAME', value );
| FICTIONEER_CHAPTER_FOLDING_THRESHOLD | integer | Threshold before and after folding in chapter lists. Default `5`.
| FICTIONEER_SHORTCODE_TRANSIENT_EXPIRATION | integer | Expiration duration for shortcode Transients in seconds. Default `300`.
| FICTIONEER_STORY_COMMENT_COUNT_TIMEOUT | integer | Timeout between comment count refreshes for stories in seconds. Default `900`.
| FICTIONEER_REQUESTS_PER_MINUTE | integer | Maximum requests per minute if the rate limit is enabled. Default `5`.
| FICTIONEER_CACHE_PURGE_ASSIST | boolean | Whether to call the cache purge assist function on post updates. Default `true`.
| FICTIONEER_RELATIONSHIP_PURGE_ASSIST | boolean | Whether to purge related post caches. Default `true`.
| FICTIONEER_CHAPTER_LIST_TRANSIENTS | boolean | Whether to cache chapter lists on story pages as Transients. Default `true`.

View File

@ -241,6 +241,11 @@ if ( ! defined( 'FICTIONEER_STORY_COMMENT_COUNT_TIMEOUT' ) ) {
define( 'FICTIONEER_STORY_COMMENT_COUNT_TIMEOUT', 900 );
}
// Integer: Requests per minute
if ( ! defined( 'FICTIONEER_REQUESTS_PER_MINUTE' ) ) {
define( 'FICTIONEER_REQUESTS_PER_MINUTE', 5 );
}
/*
* Booleans
*/

View File

@ -574,6 +574,56 @@ if ( ! function_exists( 'fictioneer_get_validated_ajax_user' ) ) {
}
}
// =============================================================================
// RATE LIMIT
// =============================================================================
/**
* Checks rate limit globally or for an action via the session
*
* @since Fictioneer 5.7.1
*
* @param string $action The action to check for rate-limiting.
* Defaults to 'fictioneer_global'.
*/
function fictioneer_check_rate_limit( $action = 'fictioneer_global' ) {
if ( ! get_option( 'fictioneer_enable_rate_limits' ) ) {
return;
}
// Start session if not already done
if ( session_status() == PHP_SESSION_NONE ) {
session_start();
}
// Initialize if not set
if ( ! isset( $_SESSION[ $action ]['request_times'] ) ) {
$_SESSION[ $action ]['request_times'] = [];
}
// Setup
$current_time = microtime( true );
$time_window = 60;
// Filter out old timestamps
$_SESSION[ $action ]['request_times'] = array_filter(
$_SESSION[ $action ]['request_times'],
function ( $time ) use ( $current_time, $time_window ) {
return ( $current_time - $time ) < $time_window;
}
);
// Limit exceeded?
if ( count( $_SESSION[ $action ]['request_times'] ) >= FICTIONEER_REQUESTS_PER_MINUTE ) {
http_response_code( 429 ); // Too many requests
exit;
}
// Record the current request time
$_SESSION[ $action ]['request_times'][] = $current_time;
}
// =============================================================================
// KEY/VALUE STRING REPLACEMENT
// =============================================================================

View File

@ -621,6 +621,13 @@ define( 'FICTIONEER_OPTIONS', array(
'sanitize_callback' => 'fictioneer_sanitize_checkbox',
'label' => __( 'Enable fast AJAX for comments', 'fictioneer' ),
'default' => false
),
'fictioneer_enable_rate_limits' => array(
'name' => 'fictioneer_enable_rate_limits',
'group' => 'fictioneer-settings-general-group',
'sanitize_callback' => 'fictioneer_sanitize_checkbox',
'label' => __( 'Enable rate limiting for AJAX requests', 'fictioneer' ),
'default' => false
)
),
'integers' => array(

View File

@ -758,6 +758,19 @@
</div>
</label>
<label for="fictioneer_enable_rate_limits" class="label-wrapped-checkbox row">
<input name="fictioneer_enable_rate_limits" type="checkbox" id="fictioneer_enable_rate_limits" <?php echo checked( 1, get_option( 'fictioneer_enable_rate_limits' ), false ); ?> value="1">
<div>
<span><?php echo FICTIONEER_OPTIONS['booleans']['fictioneer_enable_rate_limits']['label']; ?></span>
<p class="sub-label"><?php
printf(
__( 'Simple session-based rate limiting, allowing <code>%s</code> requests per minute for selected actions (per action).', 'fictioneer' ),
FICTIONEER_REQUESTS_PER_MINUTE
);
?></p>
</div>
</label>
<label for="fictioneer_disable_application_passwords" class="label-wrapped-checkbox row">
<input name="fictioneer_disable_application_passwords" type="checkbox" id="fictioneer_disable_application_passwords" <?php echo checked( 1, get_option( 'fictioneer_disable_application_passwords' ), false ); ?> value="1">
<div>

View File

@ -258,10 +258,14 @@ if ( ! function_exists( 'fictioneer_query_followed_chapters' ) ) {
* Sends the HTML for Follows notifications via AJAX
*
* @since Fictioneer 4.3
* @see fictioneer_check_rate_limit()
* @see fictioneer_get_validated_ajax_user()
*/
function fictioneer_ajax_get_follows_notifications() {
// Rate limit
fictioneer_check_rate_limit( 'fictioneer_ajax_get_follows_notifications' );
// Setup and validations
$user = fictioneer_get_validated_ajax_user();

2
js/follows.min.js vendored
View File

@ -1 +1 @@
const fcn_desktopFollowList=_$$$("follow-menu-scroll"),fcn_mobileFollowList=_$$$("mobile-menu-follows-list"),fcn_followsMenuItem=_$$$("follow-menu-button");var fcn_userFollowsTimeout,fcn_follows;function fcn_initializeFollows(o){const t=o.detail.data.follows;!1!==t&&(fcn_follows=t,fcn_updateFollowsView(),localStorage.removeItem("fcnBookshelfContent"))}function fcn_toggleFollow(o){const t=fcn_getUserData();if(fcn_follows&&t.follows){if(localStorage.removeItem("fcnBookshelfContent"),JSON.stringify(fcn_follows.data[o])!==JSON.stringify(t.follows.data[o]))return fcn_follows=t.follows,fcn_showNotification(__("Follows re-synchronized.","fictioneer")),void fcn_updateFollowsView();fcn_follows.data.hasOwnProperty(o)?delete fcn_follows.data[o]:fcn_follows.data[o]={story_id:parseInt(o),timestamp:Date.now()},t.follows.data[o]=fcn_follows.data[o],t.lastLoaded=0,fcn_setUserData(t),fcn_updateFollowsView(),clearTimeout(fcn_userFollowsTimeout),fcn_userFollowsTimeout=setTimeout((()=>{fcn_ajaxPost({action:"fictioneer_ajax_toggle_follow",fcn_fast_ajax:1,story_id:o,set:fcn_follows.data.hasOwnProperty(o)}).then((o=>{o.data.error&&fcn_showNotification(o.data.error,5,"warning")})).catch((o=>{o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning")}))}),fictioneer_ajax.post_debounce_rate)}}function fcn_updateFollowsView(){const o=fcn_getUserData();if(!fcn_follows||!o.follows)return;_$$(".button-follow-story").forEach((o=>{o.classList.toggle("_followed",fcn_follows?.data.hasOwnProperty(o.dataset.storyId))})),_$$(".card").forEach((o=>{o.classList.toggle("has-follow",fcn_follows?.data.hasOwnProperty(o.dataset.storyId))}));const t=parseInt(fcn_follows.new)>0;_$$(".mark-follows-read, .follows-alert-number, .mobile-menu-button").forEach((o=>{o.classList.toggle("_new",t),t>0&&(o.dataset.newCount=fcn_follows.new)}))}function fcn_setupFollowsHTML(){fcn_followsMenuItem.classList.contains("_loaded")||fcn_ajaxGet({action:"fictioneer_ajax_get_follows_notifications",fcn_fast_ajax:1}).then((o=>{o.data.html&&(fcn_desktopFollowList.innerHTML=o.data.html,fcn_mobileFollowList.innerHTML=o.data.html)})).catch((o=>{o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning"),fcn_desktopFollowList.remove(),fcn_mobileFollowList.remove()})).then((()=>{fcn_followsMenuItem.classList.add("_loaded")}))}function fcn_markFollowsRead(){if(!fcn_followsMenuItem.classList.contains("_new")||!fcn_followsMenuItem.classList.contains("_loaded"))return;_$$(".mark-follows-read, .follows-alert-number, .follow-item, .mobile-menu-button").forEach((o=>{o.classList.remove("_new")}));const o=fcn_getUserData();o.new=0,o.lastLoaded=0,fcn_setUserData(o),fcn_ajaxPost({action:"fictioneer_ajax_mark_follows_read",fcn_fast_ajax:1}).catch((o=>{o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning")}))}document.addEventListener("fcnUserDataReady",(o=>{fcn_initializeFollows(o)})),fcn_followsMenuItem?.addEventListener("mouseover",(()=>{fcn_setupFollowsHTML()}),{once:!0}),fcn_followsMenuItem?.addEventListener("focus",(()=>{fcn_setupFollowsHTML()}),{once:!0}),_$('.mobile-menu__frame-button[data-frame-target="follows"]')?.addEventListener("click",(()=>{fcn_setupFollowsHTML()}),{once:!0}),_$$(".button-follow-story").forEach((o=>{o.addEventListener("click",(o=>{fcn_toggleFollow(o.currentTarget.dataset.storyId)}))})),_$$(".mark-follows-read").forEach((o=>{o.addEventListener("click",(()=>{fcn_markFollowsRead()}))}));
const fcn_desktopFollowList=_$$$("follow-menu-scroll"),fcn_mobileFollowList=_$$$("mobile-menu-follows-list"),fcn_followsMenuItem=_$$$("follow-menu-button");var fcn_userFollowsTimeout,fcn_follows;function fcn_initializeFollows(o){const t=o.detail.data.follows;!1!==t&&(fcn_follows=t,fcn_updateFollowsView(),localStorage.removeItem("fcnBookshelfContent"))}function fcn_toggleFollow(o){const t=fcn_getUserData();if(fcn_follows&&t.follows){if(localStorage.removeItem("fcnBookshelfContent"),JSON.stringify(fcn_follows.data[o])!==JSON.stringify(t.follows.data[o]))return fcn_follows=t.follows,fcn_showNotification(__("Follows re-synchronized.","fictioneer")),void fcn_updateFollowsView();fcn_follows.data.hasOwnProperty(o)?delete fcn_follows.data[o]:fcn_follows.data[o]={story_id:parseInt(o),timestamp:Date.now()},t.follows.data[o]=fcn_follows.data[o],t.lastLoaded=0,fcn_setUserData(t),fcn_updateFollowsView(),clearTimeout(fcn_userFollowsTimeout),fcn_userFollowsTimeout=setTimeout((()=>{fcn_ajaxPost({action:"fictioneer_ajax_toggle_follow",fcn_fast_ajax:1,story_id:o,set:fcn_follows.data.hasOwnProperty(o)}).then((o=>{o.data.error&&fcn_showNotification(o.data.error,5,"warning")})).catch((o=>{o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning")}))}),fictioneer_ajax.post_debounce_rate)}}function fcn_updateFollowsView(){const o=fcn_getUserData();if(!fcn_follows||!o.follows)return;_$$(".button-follow-story").forEach((o=>{o.classList.toggle("_followed",fcn_follows?.data.hasOwnProperty(o.dataset.storyId))})),_$$(".card").forEach((o=>{o.classList.toggle("has-follow",fcn_follows?.data.hasOwnProperty(o.dataset.storyId))}));const t=parseInt(fcn_follows.new)>0;_$$(".mark-follows-read, .follows-alert-number, .mobile-menu-button").forEach((o=>{o.classList.toggle("_new",t),t>0&&(o.dataset.newCount=fcn_follows.new)}))}function fcn_setupFollowsHTML(){fcn_followsMenuItem.classList.contains("_loaded")||fcn_ajaxGet({action:"fictioneer_ajax_get_follows_notifications",fcn_fast_ajax:1}).then((o=>{o.data.html&&(fcn_desktopFollowList.innerHTML=o.data.html,fcn_mobileFollowList.innerHTML=o.data.html)})).catch((o=>{429===o.status?fcn_showNotification(__("Slow down.","fcnl"),3,"warning"):o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning"),fcn_desktopFollowList.remove(),fcn_mobileFollowList.remove()})).then((()=>{fcn_followsMenuItem.classList.add("_loaded")}))}function fcn_markFollowsRead(){if(!fcn_followsMenuItem.classList.contains("_new")||!fcn_followsMenuItem.classList.contains("_loaded"))return;_$$(".mark-follows-read, .follows-alert-number, .follow-item, .mobile-menu-button").forEach((o=>{o.classList.remove("_new")}));const o=fcn_getUserData();o.new=0,o.lastLoaded=0,fcn_setUserData(o),fcn_ajaxPost({action:"fictioneer_ajax_mark_follows_read",fcn_fast_ajax:1}).catch((o=>{o.status&&o.statusText&&fcn_showNotification(`${o.status}: ${o.statusText}`,5,"warning")}))}document.addEventListener("fcnUserDataReady",(o=>{fcn_initializeFollows(o)})),fcn_followsMenuItem?.addEventListener("mouseover",(()=>{fcn_setupFollowsHTML()}),{once:!0}),fcn_followsMenuItem?.addEventListener("focus",(()=>{fcn_setupFollowsHTML()}),{once:!0}),_$('.mobile-menu__frame-button[data-frame-target="follows"]')?.addEventListener("click",(()=>{fcn_setupFollowsHTML()}),{once:!0}),_$$(".button-follow-story").forEach((o=>{o.addEventListener("click",(o=>{fcn_toggleFollow(o.currentTarget.dataset.storyId)}))})),_$$(".mark-follows-read").forEach((o=>{o.addEventListener("click",(()=>{fcn_markFollowsRead()}))}));

View File

@ -193,7 +193,9 @@ function fcn_setupFollowsHTML() {
})
.catch(error => {
// Show server error
if (error.status && error.statusText) {
if (error.status === 429) {
fcn_showNotification(__( 'Slow down.', 'fcnl' ), 3, 'warning');
} else if (error.status && error.statusText) {
fcn_showNotification(`${error.status}: ${error.statusText}`, 5, 'warning');
}