Skip to content

Commit 17461f2

Browse files
committed
feat: Custom rate limit factor per user
1 parent 8976dfd commit 17461f2

17 files changed

Lines changed: 702 additions & 246 deletions

website/app-templates/smarty/admin/pages/api_rate_limit.tpl

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
{block name=title}{t}Api Rate Limits{/t}{/block}
44

5-
{assign var=RATES_LIMITS value=constant('GK_RATE_LIMITS')}
65
{block name=content}
76
<h1>
87
{t}Api Rate Limits{/t}
@@ -15,20 +14,24 @@
1514
<thead>
1615
<tr>
1716
<th>Group</th>
18-
<th>ID</th>
19-
<th class="text-right">Count</th>
17+
<th>Key</th>
18+
<th class="text-center">Tier</th>
19+
<th class="text-right">Used</th>
2020
<th>Limit</th>
21+
<th class="text-right">Left</th>
2122
<th class="text-right">Actions</th>
2223
</tr>
2324
</thead>
2425
<tbody>
25-
{foreach from=$current key=group item=item}
26-
{foreach from=$item key=key item=value}
26+
{foreach from=$current key=group item=rows}
27+
{foreach from=$rows key=wire_key item=row}
2728
<tr>
28-
<td>{$group} ({$RATES_LIMITS[$group][1]})</td>
29-
<td>{$key}</td>
30-
<td class="text-right">{$value}</td>
31-
<td>/{$RATES_LIMITS[$group][0]}</td>
29+
<td>{$group} ({$row.period})</td>
30+
<td>{$wire_key}</td>
31+
<td class="text-center">{$row.tier|default:'-'}</td>
32+
<td class="text-right">{$row.used}</td>
33+
<td>/{$row.limit}</td>
34+
<td class="text-right">{$row.left}</td>
3235
<td class="text-right">
3336
{block user_actions}{/block}
3437
</td>
@@ -41,7 +44,14 @@
4144

4245
{block user_actions}
4346
<div class="btn-group" role="group" aria-label="...">
44-
<button type="button" class="btn btn-warning btn-xs" title="{t}Reset{/t}" data-toggle="modal" data-target="#modal" data-type="admin-rate-limit-reset" data-key="{$key}" data-NAME="{$group}">
47+
<button type="button"
48+
class="btn btn-warning btn-xs"
49+
title="{t}Reset{/t}"
50+
data-toggle="modal"
51+
data-target="#modal"
52+
data-type="admin-rate-limit-reset"
53+
data-key="{$wire_key}"
54+
data-name="{$group}">
4555
{fa icon="refresh"}
4656
</button>
4757
</div>

website/app-templates/smarty/dialog/admin_dialog_rate_limit_reset.tpl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
<h4 class="modal-title" id="modalLabel">{t}Do you really want to reset this rate-limit?{/t}</h4>
55
</div>
66

7-
<form name="ResetRateLimitForm" action="{'admin_rate_limit_reset'|alias:sprintf('name=%s,key=%s', $name, $key)}" method="post">
7+
<form name="ResetRateLimitForm" action="{'admin_rate_limit_reset'|alias}" method="post">
88
<div class="modal-body">
9-
{t escape=no name=$name key=$key}Name=%1 ID=%2{/t}
9+
{t escape=no name=$name key=$key}Name=%1 KEY=%2{/t}
10+
<input type="hidden" name="name" value="{$name|escape}">
11+
<input type="hidden" name="key" value="{$key|escape}">
1012
</div>
1113
<div class="modal-footer">
1214
{call csrf}

website/app-templates/smarty/pages/help_api.tpl

Lines changed: 134 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -459,39 +459,151 @@ var_dump($gk);
459459
</div>
460460
</div>
461461

462-
<h3>Rate Limits</h3>
462+
<h3>Rate limits</h3>
463463
<a class="anchor" id="apiratelimit"></a>
464+
464465
<div class="panel panel-default">
465466
<div class="panel-body">
466-
<p>In order to protect our service from abuse/misbehaving client… We have rate limiting in place.</p>
467-
<p>If you hit a rate limit, we wil respond with the usual http code <code>429</code>.</p>
468-
<p>A first set of rule limit the request rate per minutes, for any pages.</p>
469-
<p>A second set of rule limit the API calls over a period of time. We're using the <a href="https://en.wikipedia.org/wiki/Leaky_bucket" target="_blank">Leaky Bucket Algorithm</a>.</p>
470-
<blockquote cite="https://en.wikipedia.org/wiki/Leaky_bucket">
471-
The leaky bucket analogy. Water can be added intermittently to the bucket, which leaks out at a constant
472-
rate until empty, and will also overflow when full.
473-
<img src="https://upload.wikimedia.org/wikipedia/commons/7/77/Leaky_bucket_analogy.svg" class="img-responsive" width="170" height="240">
467+
468+
<div class="alert alert-info" role="alert" style="margin-bottom:16px">
469+
<b>Quick summary</b>
470+
<ul style="margin-bottom:0">
471+
<li><b>Two layers:</b> (1) a global <i>burst</i> limit per minute across endpoints; (2) per-endpoint <i>quota</i> over a longer period (leaky bucket).</li>
472+
<li><b>Who’s counted:</b> anonymous traffic is counted per <b>IP address</b>; authenticated traffic is counted per <b>account</b>.</li>
473+
<li><b>Exceeding a limit:</b> we return <code>HTTP 429</code> and standard rate-limit headers.</li>
474+
</ul>
475+
</div>
476+
477+
<p>To keep the service reliable and abuse-resistant, we apply rate limiting.</p>
478+
<p>If you hit a limit, we respond with <code>429 Too Many Requests</code>.</p>
479+
480+
<h4>How it works</h4>
481+
<ul>
482+
<li><b>Layer 1 – Burst (RPM):</b> caps short spikes across all endpoints per minute.</li>
483+
<li><b>Layer 2 – Endpoint quota:</b> each API endpoint also has a longer-period allowance enforced with the
484+
<a href="https://en.wikipedia.org/wiki/Leaky_bucket" target="_blank" rel="noopener">Leaky Bucket algorithm</a>.
485+
</li>
486+
</ul>
487+
488+
<blockquote cite="https://en.wikipedia.org/wiki/Leaky_bucket">
489+
The leaky bucket analogy: water can be added intermittently, leaks at a constant rate, and overflows when full.
490+
<img src="{GK_CDN_IMAGES_URL}/help/Leaky_bucket_analogy.svg" class="img-responsive" width="170" height="240" alt="Leaky bucket analogy">
474491
</blockquote>
475-
<p>The limits are set per IP or per secid depending if the call is authenticated or not.</p>
492+
493+
<h5>Check usage</h5>
476494
<p>
477-
Your current API usage is available in the headers of each API call. You can also get your current Rate Limit usage using this endpoint:
495+
Your current usage is exposed in the API response headers and via a dedicated endpoint:
478496
</p>
479497
<ul>
480498
<li>Anonymous: <a href="{'api_v1_rate_limit_usage'|alias}">{'api_v1_rate_limit_usage'|alias}</a></li>
481499
<li>Authenticated: <a href="{'api_v1_rate_limit_usage'|alias}?secid=&lt;secid_here&gt;">{'api_v1_rate_limit_usage'|alias}?secid=&lt;secid_here&gt;</a></li>
482500
</ul>
501+
483502
<pre><code class="language-xml">{$rate_limit_usage}</code></pre>
503+
504+
<h5>Example: 429 response</h5>
505+
<pre><code>HTTP/1.1 429 Too Many Requests
506+
Ratelimit-Limit: 750
507+
Ratelimit-Remaining: 0
508+
X-RateLimit-Limit: 750
509+
X-RateLimit-Remaining: 0
510+
X-RateLimit-Reset: 1725100800
511+
X-Ratelimit-Resource: API_V1_EXPORT2
512+
X-GK-Rate-Limit: API_V1_EXPORT2 0/750 (86400)
513+
X-GK-Rate-Limit-Exceeded: true
514+
Content-Type: application/xml</code></pre>
515+
<pre><code class="language-xml">{$gk_xml_rate_limit_error}</code></pre>
516+
517+
<h4>Current API rate limits</h4>
518+
<p>
519+
Your effective limit is the base multiplied by your tier multiplier (the <b>period</b> does not change):
520+
<code>effective_limit = floor(base_limit × tier_multiplier)</code>
521+
</p>
522+
523+
<table class="table table-striped table-sm">
524+
<caption class="text-muted" style="caption-side: bottom">
525+
Endpoint quotas per tier (leaky bucket).
526+
</caption>
527+
<thead>
528+
<tr>
529+
<th>endpoint</th>
530+
<th>period_s</th>
531+
<th>base_limit</th>
532+
{foreach from=$smarty.const.RATE_LIMIT_LEVEL_MULTIPLIER key=tier item=mult}
533+
<th>{$tier}{$mult})</th>
534+
{/foreach}
535+
</tr>
536+
</thead>
537+
<tbody>
538+
{foreach GK_RATE_LIMITS_DEFAULT as $limit => $values}
539+
{assign var=base value=$values[0]}
540+
{assign var=period value=$values[1]}
541+
<tr>
542+
<td>{$limit}</td>
543+
<td>{$period}</td>
544+
<td>{$base}</td>
545+
{foreach from=$smarty.const.RATE_LIMIT_LEVEL_MULTIPLIER key=tier item=mult}
546+
<td>{math equation="floor(x*y)" x=$base y=$mult}</td>
547+
{/foreach}
548+
</tr>
549+
{/foreach}
550+
</tbody>
551+
</table>
552+
553+
<p class="text-muted">
554+
<em>Note: some endpoints (e.g. username changes) are account-only; anonymous rows are kept for consistency.</em>
555+
</p>
556+
557+
<h4>Tiers and multipliers</h4>
558+
<ul>
559+
<li><b>Anonymous</b> — requests without an account or secure identifier; <b>counted by IP address</b> (×0.5).</li>
560+
<li><b>User</b> — any logged-in account (×1).</li>
561+
<li><b>Contributor</b> — merged code, documentation, translations, or issue triage (×2).</li>
562+
<li><b>Donor</b> — active project sponsor (×2).</li>
563+
<li><b>Recurring donor</b> — regular project sponsor (×3).</li>
564+
<li><b>Maintainer</b> — core team and operational tasks (×3).</li>
565+
</ul>
566+
567+
<h4>Tips to avoid 429s</h4>
568+
<ul>
569+
<li>Authenticate when possible to avoid sharing anonymous (IP-based) limits behind NAT.</li>
570+
<li>Batch and paginate requests; add small client-side delays or jitter.</li>
571+
<li>Honor <code>Retry-After</code> and <code>X-RateLimit-*</code> headers when present.</li>
572+
</ul>
573+
574+
<h4>How to earn higher limits</h4>
575+
<ul>
576+
<li><b>Code &amp; QA:</b> bug fixes, features, security reports.</li>
577+
<li><b>Community:</b> translations, documentation, user support, issue triage.</li>
578+
<li><b>Sponsorship:</b> optional donations help cover hosting and maintenance.</li>
579+
</ul>
580+
<p>Once confirmed, your tier updates automatically (a short cache delay may apply). Non-financial contributions are valued equally.</p>
581+
582+
<h4>Transparency</h4>
583+
<ul>
584+
<li>The table lists <b>base limits</b> per endpoint and <b>multipliers</b> per tier.</li>
585+
<li>Endpoints may expose your current allowance and tier so you can monitor usage.</li>
586+
</ul>
587+
588+
<h4>Abuse &amp; fairness</h4>
589+
<ul>
590+
<li>Automated abuse, credential sharing, or bypass attempts may lead to temporary or permanent restrictions.</li>
591+
<li>We aim to notify and work with users to resolve accidental overuse.</li>
592+
</ul>
593+
594+
<h4>Privacy</h4>
595+
<ul>
596+
<li>We store minimal counters in Redis keyed to anonymized identifiers.</li>
597+
<li>Anonymous usage is keyed by IP; authenticated usage is keyed to your account.</li>
598+
<li>Short-lived caches keep the service responsive.</li>
599+
</ul>
600+
601+
<h4>Change management</h4>
602+
<ul>
603+
<li>Base limits or multipliers may be adjusted to keep the service healthy; material changes are announced in the changelog/release notes.</li>
604+
<li>If you believe your tier is incorrect, please contact us for a review.</li>
605+
</ul>
606+
484607
</div>
485-
<p>Current API rate limits are:</p>
486-
<ul>
487-
{foreach GK_RATE_LIMITS as $limit => $values}
488-
<li>{$limit}
489-
<ul>
490-
<li>max requests: {$values[0]}</li>
491-
<li>period: {$values[1]}s</li>
492-
</ul>
493-
</li>
494-
{/foreach}
495-
</ul>
496608
</div>
497609
{/block}

website/app-templates/smarty/pages/privacy_statement.tpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
<li>
111111
<p>
112112
{t}Rate limit counters are used to prevent service usage abuse. Activity is kept for:{/t}<br>
113-
{foreach from=GK_RATE_LIMITS key=key item=value}
113+
{foreach from=GK_RATE_LIMITS_DEFAULT key=key item=value}
114114
{$key} => {'seconds'|print_interval_for_humans:$value[1]}
115115
<br>
116116
{/foreach}

website/app/GeoKrety/Controller/Admin/RateLimits.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
class RateLimits extends Base {
1010
public function render_results(\Base $f3) {
11-
$current = RateLimit::get_rates_limits_usage();
11+
$current = RateLimit::get_rates_limits_usage_detailed();
1212
Smarty::assign('current', $current);
1313
Smarty::render('admin/pages/api_rate_limit.tpl');
1414
}

website/app/GeoKrety/Controller/Pages/HelpApi.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,20 @@ public function get($f3) {
6565
Smarty::assign('modified_since', date('YmdHis', time() - (1 * 60 * 60)));
6666

6767
$xml = new Xml\RateLimits(false);
68-
foreach (GK_RATE_LIMITS as $name => $values) {
69-
$xml->addLimit($name, $values[0], $values[1]);
70-
$xml->addUsage('xxx', 0);
68+
foreach (GK_RATE_LIMITS_DEFAULT as $name => $values) {
69+
$xml->addLimit($name, $values[1]);
70+
$xml->addUsage('xxx', 0, $values[0], RATE_LIMIT_LEVEL_ANONYMOUS);
7171
$xml->endElement();
7272
}
7373
$xml->end();
7474
Smarty::assign('rate_limit_usage', $xml->asXMLPretty());
7575

76+
// Render ratelimit error
77+
$xml = new Xml\Error();
78+
$xml->addError(_('Rate limit exceeded'));
79+
$xml->end();
80+
Smarty::assign('gk_xml_rate_limit_error', $xml->asXMLPretty());
81+
7682
Smarty::render('pages/help_api.tpl');
7783
}
7884

website/app/GeoKrety/Controller/Pages/RateLimitXML.php

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace GeoKrety\Controller;
44

5+
use GeoKrety\Model\User;
56
use GeoKrety\Service\RateLimit;
67
use GeoKrety\Service\StorageException;
78
use GeoKrety\Service\Xml\RateLimits;
@@ -14,50 +15,30 @@ public function beforeRoute(\Base $f3) {
1415
$this->xml = new RateLimits(true, $f3->get('GET.compress'));
1516
}
1617

17-
public function authenticate(): array {
18-
$keys = [];
19-
if ($this->f3->exists('GET.secid')) {
20-
$login = new Login();
21-
22-
// TODO can we use that to load user limits?
23-
$user = $login->secidAuth($this->f3, $this->f3->get('GET.secid'), false);
24-
if (!is_null($user)) {
25-
$keys[] = $user->secid;
26-
Login::disconnectUser($this->f3);
27-
// return $keys;
28-
}
29-
}
30-
31-
$keys[] = \Base::instance()->get('IP');
32-
33-
return $keys;
34-
}
35-
3618
public function get(\Base $f3) {
37-
$id = $this->f3->exists('GET.secid') ? $this->f3->get('GET.secid') : \Base::instance()->get('IP');
38-
RateLimit::check_rate_limit_xml('API_V1_CHECK_RATE_LIMIT', $id);
19+
// Pass secid if present; otherwise null and RateLimit will fallback to IP internally
20+
$secid = $f3->exists('GET.secid') ? (string) $f3->get('GET.secid') : null;
21+
RateLimit::check_rate_limit_xml('API_V1_CHECK_RATE_LIMIT', $secid);
3922

4023
$xml = $this->xml;
4124
try {
42-
$keys = $this->authenticate();
43-
44-
$usages = RateLimit::get_usage_for_identities($keys);
45-
$originalByNorm = [];
46-
foreach ($keys as $k) {
47-
$originalByNorm[strtr($k, [':' => '_'])] = $k;
48-
}
49-
foreach (GK_RATE_LIMITS as $name => $values) {
50-
$this->xml->addLimit($name, $values[0], $values[1]);
51-
if (!isset($usages[$name])) {
52-
foreach ($keys as $k) {
53-
$this->xml->addUsage($k, 0);
54-
}
55-
$this->xml->endElement();
56-
continue;
25+
$identities = [];
26+
if ($secid !== null && !empty($secid)) {
27+
$user = User::get_user_by_secid($secid);
28+
if (!is_null($user)) {
29+
$identities[] = $user->id;
5730
}
58-
foreach ($usages[$name] as $normKey => $count) {
59-
$displayId = $originalByNorm[$normKey] ?? $normKey;
60-
$this->xml->addUsage($displayId, $count);
31+
}
32+
$ip = \Base::instance()->get('IP');
33+
if ($ip) {
34+
$identities[] = $ip;
35+
}
36+
37+
$usages = RateLimit::get_usage_for_identities($identities);
38+
foreach ($usages as $limitName => $info) {
39+
$this->xml->addLimit($limitName, GK_RATE_LIMITS_DEFAULT[$limitName][1]);
40+
foreach ($info as $key => $details) {
41+
$this->xml->addUsage($key, $details['usage'], $details['limit'], $details['tier']);
6142
}
6243
$this->xml->endElement();
6344
}

website/app/GeoKrety/Controller/Pages/UserUpdateUsername.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public function post(\Base $f3) {
3434
RateLimit::incr('USERNAME_CHANGE', $this->currentUser->id);
3535
} catch (RateLimitExceeded $e) {
3636
register_shutdown_function('GeoKrety\Model\AuditPost::AmendAuditPostWithErrors', 'Rate limit exceeded');
37-
\Flash::instance()->addMessage(sprintf(_('You can only change your username %d times per month'), GK_RATE_LIMITS['USERNAME_CHANGE'][0]), 'danger');
37+
[$limit] = RateLimitPolicy::resolve('USERNAME_CHANGE', $this->currentUser->id);
38+
\Flash::instance()->addMessage(sprintf(_('You can only change your username %d times per month'), (int) $limit), 'danger');
3839
$f3->get('DB')->rollback();
3940
$this->get($f3);
4041
exit;

0 commit comments

Comments
 (0)