commit bd928fe801c584c66b1125379b3207cc603595df
parent 56de16893e084bb15fa42f875fdca2c87b4fa81b
Author: claude-bot <noreply@anthropic.com>
Date: Thu, 8 Oct 2026 10:39:13 +0000
New rate limits: uploads 500 MB/h, links 50 MB/h, shortener 10/min + 50/h, no limit on GitHub sync
- file uploads (/api/put): 500 MB per hour per IP, no per-minute limit; a single file over 500 MB gets 413
- uploads from a link (/api/put-url): own allowance of 50 MB per hour per IP; fetches stop at 50 MB
- shortener: 10 per minute (unchanged) and 50 per hour (was 60)
- GitHub sync: rate limiter removed (it was silently the 50 MB/300 MB upload limiter); the unused 2 GB constant is gone
- limiter is now one hourly byte window with a name, so the error says which allowance was hit
- docs: Rate limits section rewritten, sync/upload/shortener sections and both page notices updated;
notes that a network in front of the CDN (e.g. Cloudflare free plan, 100 MB per request) can cap one request
Diffstat:
5 files changed, 69 insertions(+), 71 deletions(-)
diff --git a/app/main.py b/app/main.py
@@ -220,44 +220,40 @@ async def download_file(path: str, request: Request):
)
-UPLOAD_LIMIT_PER_MINUTE = 50 * 1024 * 1024
-UPLOAD_LIMIT_PER_HOUR = 300 * 1024 * 1024
+UPLOAD_LIMIT_PER_HOUR = 500 * 1024 * 1024 # file uploads (/api/put), per IP
+URL_UPLOAD_LIMIT_PER_HOUR = 50 * 1024 * 1024 # uploads from a link (/api/put-url), per IP
class _UploadRateLimiter:
- def __init__(self):
- self._usage: dict[str, deque] = {}
+ """Sliding one-hour window of uploaded bytes, per key (the client IP). No per-minute limit."""
- def _prune(self, ip: str, now: float):
- dq = self._usage.get(ip)
- if not dq:
- return
- while dq and now - dq[0][0] > 3600:
- dq.popleft()
+ def __init__(self, per_hour: int, name: str = "Upload"):
+ self.per_hour = per_hour
+ self.name = name
+ self._usage: dict[str, deque] = {}
- def check_and_record(self, ip: str, size: int):
+ def check_and_record(self, key: str, size: int):
now = time.time()
- dq = self._usage.setdefault(ip, deque())
- self._prune(ip, now)
- minute_used = sum(s for t, s in dq if now - t <= 60)
- hour_used = sum(s for t, s in dq)
-
- if minute_used + size > UPLOAD_LIMIT_PER_MINUTE:
+ if size > self.per_hour: # can never fit, so don't say "try again later"
raise HTTPException(
- status_code=429,
- detail=f"Upload rate limit exceeded: {format_size(UPLOAD_LIMIT_PER_MINUTE)}/minute. Try again shortly.",
+ status_code=413,
+ detail=f"File too large (limit {format_size(self.per_hour)} per file).",
)
-
- if hour_used + size > UPLOAD_LIMIT_PER_HOUR:
+ dq = self._usage.setdefault(key, deque())
+ while dq and now - dq[0][0] > 3600:
+ dq.popleft()
+ if sum(s for _, s in dq) + size > self.per_hour:
raise HTTPException(
status_code=429,
- detail=f"Upload rate limit exceeded: {format_size(UPLOAD_LIMIT_PER_HOUR)}/hour. Try again later.",
+ detail=f"{self.name} rate limit exceeded: {format_size(self.per_hour)}/hour. Try again later.",
)
-
dq.append((now, size))
+ if len(self._usage) > 10000: # drop idle clients so the table can't grow forever
+ self._usage = {k: v for k, v in self._usage.items() if v and now - v[-1][0] <= 3600}
-_upload_limiter = _UploadRateLimiter()
+_upload_limiter = _UploadRateLimiter(UPLOAD_LIMIT_PER_HOUR, "Upload")
+_url_upload_limiter = _UploadRateLimiter(URL_UPLOAD_LIMIT_PER_HOUR, "URL upload")
class _CountRateLimiter:
@@ -288,7 +284,7 @@ class _CountRateLimiter:
self._hits = {k: v for k, v in self._hits.items() if v and now - v[-1] <= 3600}
-_shorten_limiter = _CountRateLimiter(per_minute=10, per_hour=60)
+_shorten_limiter = _CountRateLimiter(per_minute=10, per_hour=50)
SHORTEN_MAX_URL_LENGTH = 2048
BATCH_MIN_FILES = 2
ALLOWED_UPLOAD_FOLDERS = {"uploads", "third-party"}
@@ -356,10 +352,6 @@ async def handle_upload(
return response
-GH_SYNC_LIMIT_PER_HOUR = 2 * 1024 * 1024 * 1024
-_gh_sync_limiter = _UploadRateLimiter()
-
-
def _safe_extract_tar(tar: tarfile.TarFile, dest: str):
dest_real = os.path.realpath(dest)
@@ -413,11 +405,7 @@ async def gh_sync(
raise HTTPException(status_code=403, detail="You are banned from this CDN.")
await gh_check(owner, claims["owner_id"])
- contents = await archive.read()
- _gh_sync_limiter.check_and_record(
- f"gh-sync:{owner}/{repo}:{client_ip}",
- len(contents),
- )
+ contents = await archive.read() # no size/rate limit on GitHub sync (identity, approval, bans and the video rule apply)
with tempfile.TemporaryDirectory() as tmp_upload, tempfile.TemporaryDirectory() as tmp_extract:
archive_path = os.path.join(tmp_upload, "payload.tar.gz")
@@ -508,7 +496,7 @@ async def mask_redirect(request: Request, short_id: str):
)
-_MAX_URL_UPLOAD_BYTES = UPLOAD_LIMIT_PER_HOUR
+_MAX_URL_UPLOAD_BYTES = URL_UPLOAD_LIMIT_PER_HOUR
def _assert_public_url(url: str):
@@ -644,7 +632,7 @@ async def handle_upload_from_url(
folder,
"url",
)
- _upload_limiter.check_and_record(
+ _url_upload_limiter.check_and_record(
client_ip,
downloaded,
)
diff --git a/app/templates/documentation.html b/app/templates/documentation.html
@@ -139,7 +139,7 @@
<li>Verifies the token's signature against GitHub's own public keys (<code>token.actions.githubusercontent.com/.well-known/jwks</code>), checks it isn't expired, and checks the <code>aud</code> claim matches this service.</li>
<li>Reads <code>repository_owner</code>, <code>repository</code> and the numeric <code>repository_owner_id</code> straight out of the <em>verified</em> token claims — this is the destination folder and the identity of the sender, and it is never taken from anything else in the request. A workflow file cannot forge these claims because GitHub itself stamps them for the run.</li>
<li>Refuses banned visitors and banned GitHub accounts, then applies the <strong>approval rule</strong>: the <em>first</em> sync from a GitHub account is accepted automatically; after that, the account must have been approved by the CDN owner or the sync is refused with <code>403</code>. Accounts are tracked by GitHub's numeric owner ID, so renaming an account or re-registering an old username does not bypass this. The CDN owner's own account is always allowed.</li>
- <li>Runs the rate check on the size of the compressed archive, per repo and IP (50 MB/minute, 300 MB/hour — see <a href="#rate-limits">Rate limits</a>).</li>
+
<li>Extracts the tar archive into a temp directory, rejecting anything that tries to write outside that directory (path traversal) or that is a symlink.</li>
<li>Rejects the <strong>whole sync</strong> if any file is a video, detected by extension <em>and</em> by file signature (so renaming a video does not help). Nothing is uploaded in that case.</li>
<li>Uploads that folder to the Hugging Face storage bucket with the destination and the delete scope both limited to exactly <code><owner>/<repo>/</code> — so the call can create, update, or delete files only inside that one folder, never anywhere else. Files deleted from the source repo are removed from the CDN folder too (this is a mirror, not an accumulate-forever upload). The bucket keeps no version history, so those removals are permanent.</li>
@@ -160,13 +160,15 @@
<li><span class="err-code">400</span> Archive isn't a readable <code>.tar.gz</code>, or it contains an unsafe path / a symlink.</li>
<li><span class="err-code">403</span> The sender's IP or GitHub account is banned, <em>or</em> this account's first sync was already accepted and further syncs need approval from the CDN owner. The message says which.</li>
<li><span class="err-code">415</span> The archive contains a video file. The message names the file and nothing is synced.</li>
- <li><span class="err-code">429</span> The archive is over the per-repo/IP limit (50 MB/minute or 300 MB/hour). An archive larger than 50 MB is always refused. See <a href="#rate-limits">Rate limits</a>.</li>
+
</ul>
<h3>Limits, currently</h3>
- <p>Any file type except video, and no limit on the number of files. The only size limit is the rate limit on the
- compressed archive (50 MB/minute, 300 MB/hour per repo and IP), so keep the archive under 50 MB, for example by
- excluding build folders with <code>tar --exclude</code>. Details in <a href="#rate-limits">Rate limits</a>.</p>
+ <p>Any file type except video. <strong>GitHub sync has no rate limit</strong> and no limit on the size or number of files:
+ what protects it instead is the sign-in identity, the approval rule, bans and the video rule above. Separately, the network in front of the
+ CDN can cap a single request (for example, Cloudflare's Free and Pro plans refuse requests over 100 MB, and that happens before the
+ request reaches this app), so a very large archive can still fail with a <code>413</code> that isn't one of the messages on this page.
+ If that happens, shrink the archive with <code>tar --exclude</code>. See <a href="#rate-limits">Rate limits</a>.</p>
</div>
</section>
@@ -187,7 +189,7 @@
<h3>How it works</h3>
<p>Each file is streamed to a temporary file named only by a random ID (your filename is never used in a filesystem path),
then checked in this order: banned IP, VPN/proxy address, video (by extension or file signature), and the
- rate limiter (50 MB/minute, 300 MB/hour per client IP). It is then stored in the bucket at
+ upload limit (500 MB per hour per client IP). It is then stored in the bucket at
<code><folder>/<8-char-uuid>-<filename></code>. Any filename is accepted: directory parts are dropped,
accents are simplified, and anything other than letters, digits, <code>.</code>, <code>_</code> and <code>-</code>
becomes an underscore (<code>my file (1).png</code> is stored as <code>ab12cd34-my_file_1.png</code>).
@@ -207,14 +209,16 @@
<li><span class="err-code">400</span> <code>folder</code> isn't <code>uploads</code> or <code>third-party</code>.</li>
<li><span class="err-code">403</span> Your IP is banned, or the upload comes from a VPN/proxy address.</li>
<li><span class="err-code">415</span> The file is a video (<code>.mp4</code>, <code>.mov</code>, <code>.webm</code>, <code>.mkv</code> and similar, or recognised by its contents). Videos are not accepted on this CDN.</li>
- <li><span class="err-code">429</span> Per-IP rate limit exceeded (50 MB/min or 300 MB/hour). A single file over 50 MB is always refused. See <a href="#rate-limits">Rate limits</a>.</li>
+ <li><span class="err-code">413</span> A single file is larger than 500 MB (waiting won't help; split it).</li>
+ <li><span class="err-code">429</span> You have used your 500 MB for the hour. See <a href="#rate-limits">Rate limits</a>.</li>
</ul>
</div>
<div class="endpoint">
<div class="route-row"><span class="method post">POST</span><span class="route-path">/api/put-url</span></div>
<p>Uploads a file the server fetches from a URL you give it, instead of one you send directly. Same destination
- rules, checks (ban, VPN/proxy, video) and rate limiter as <code>/api/put</code>. The file name is taken from the URL or the
+ rules and checks (ban, VPN/proxy, video) as <code>/api/put</code>, but with its own smaller allowance: <strong>50 MB per hour per IP</strong>,
+ separate from your file-upload allowance. The file name is taken from the URL or the
response's <code>Content-Disposition</code> and sanitised the same way.</p>
<h3>Request</h3>
<p>JSON body:</p>
@@ -232,9 +236,9 @@
<ul class="err-list">
<li><span class="err-code">400</span> Not <code>https://</code>, hostname doesn't resolve, resolves to a private/internal address, or the source returned a non-200.</li>
<li><span class="err-code">403</span> Your IP is banned, or the request comes from a VPN/proxy address.</li>
- <li><span class="err-code">413</span> File exceeds the fetch size limit (currently the same 300 MB/hour figure used as the upload cap).</li>
+ <li><span class="err-code">413</span> The file is larger than 50 MB, the most that can be fetched from a link. The download is stopped as soon as it passes the limit.</li>
<li><span class="err-code">415</span> The downloaded file is a video.</li>
- <li><span class="err-code">429</span> Per-IP rate limit exceeded. See <a href="#rate-limits">Rate limits</a>.</li>
+ <li><span class="err-code">429</span> You have used your 50 MB for the hour for uploads from a link. See <a href="#rate-limits">Rate limits</a>.</li>
</ul>
</div>
</section>
@@ -255,7 +259,7 @@
<h3>How it works / safety</h3>
<p>Only <code>https://</code> URLs of at most 2048 characters are accepted. The hostname is resolved and every
address is checked — private, loopback, link-local and other internal addresses are rejected, the same SSRF
- guard used by <code>/api/put-url</code>. Each client IP is limited to <strong>10 requests per minute and 60 per hour</strong>;
+ guard used by <code>/api/put-url</code>. Each client IP is limited to <strong>10 requests per minute and 50 per hour</strong>;
the limit is counted before the hostname lookup, so rejected attempts count too. The destination is stored as a small
object in the bucket under a random 8-character ID.</p>
<h3>Response <code>200</code></h3>
@@ -267,7 +271,7 @@
<ul class="err-list">
<li><span class="err-code">400</span> Not <code>https://</code>, longer than 2048 characters, invalid, host doesn't resolve, or it points to a private/internal address.</li>
<li><span class="err-code">403</span> Your IP is banned.</li>
- <li><span class="err-code">429</span> More than 10 requests in a minute or 60 in an hour from your IP. See <a href="#rate-limits">Rate limits</a>.</li>
+ <li><span class="err-code">429</span> More than 10 requests in a minute or 50 in an hour from your IP. See <a href="#rate-limits">Rate limits</a>.</li>
</ul>
</div>
@@ -285,41 +289,46 @@
<section id="rate-limits">
<h2>Rate limits</h2>
<p class="lede">Limits keep the storage free for everyone and stop one visitor from filling it. They apply only to things that
- <em>write</em>: uploads, fetching a file from a URL, shortening a link, and GitHub sync. Browsing, search, viewing,
- downloads and zips are not rate-limited by this app.</p>
+ <em>write</em>: uploading files, uploading from a link, and shortening a link. Browsing, search, viewing, downloads, zips and
+ GitHub sync are not rate-limited by this app.</p>
<h3>At a glance</h3>
<table>
<tr><th>What</th><th class="desc">Limit</th></tr>
- <tr><td style="white-space:nowrap"><code>/api/put</code><br><code>/api/put-url</code></td><td class="desc"><strong>50 MB per minute</strong> and <strong>300 MB per hour</strong>, counted in bytes, per IP. A single file can be at most 50 MB.</td></tr>
- <tr><td style="white-space:nowrap"><code>/api/shorten</code></td><td class="desc"><strong>10 per minute</strong> and <strong>60 per hour</strong>, counted in requests, per IP.</td></tr>
- <tr><td style="white-space:nowrap"><code>/api/gh-sync</code></td><td class="desc"><strong>50 MB per minute</strong> and <strong>300 MB per hour</strong> of compressed archive, per repo and IP. An archive over 50 MB is refused.</td></tr>
+ <tr><td style="white-space:nowrap"><code>/api/put</code></td><td class="desc"><strong>500 MB per hour</strong>, counted in bytes, per IP. No per-minute limit. A single file can be at most 500 MB.</td></tr>
+ <tr><td style="white-space:nowrap"><code>/api/put-url</code></td><td class="desc"><strong>50 MB per hour</strong>, counted in bytes, per IP. This is a separate allowance from file uploads. A single file can be at most 50 MB.</td></tr>
+ <tr><td style="white-space:nowrap"><code>/api/shorten</code></td><td class="desc"><strong>10 per minute</strong> and <strong>50 per hour</strong>, counted in requests, per IP.</td></tr>
+ <tr><td style="white-space:nowrap"><code>/api/gh-sync</code></td><td class="desc"><strong>No rate limit.</strong></td></tr>
</table>
<h3>How the limits work</h3>
<ul style="color:var(--muted)">
- <li><strong>Sliding window.</strong> Your usage is totalled over the <em>last 60 seconds</em> and the <em>last 60 minutes</em>. There is no clock-time reset: old uploads age out of the window one by one, so your allowance returns gradually.</li>
+ <li><strong>Sliding window.</strong> Your usage is totalled over the <em>last 60 minutes</em> (the shortener also looks at the last 60 seconds). There is no clock-time reset: old uploads age out one by one, so your allowance returns gradually.</li>
<li><strong>Per IP.</strong> The limit follows the address your request really comes from. People on the same network (office, school, mobile carrier) share one allowance. Uploads from VPN or proxy addresses are refused outright, whatever your usage.</li>
<li><strong>Bytes, not files.</strong> Ten 1 MB files use the same allowance as one 10 MB file. Each file is checked when it arrives.</li>
- <li><strong>The 50 MB file ceiling.</strong> A file larger than the per-minute allowance can never pass, even on a fresh IP, so 50 MB is the largest single file you can upload. <code>/api/put-url</code> will download up to 300 MB from the source before this check applies, then refuse anything over 50 MB.</li>
+ <li><strong>Two separate allowances.</strong> Uploading from a link never uses up your 500 MB of file uploads, and file uploads never use up your 50 MB for links.</li>
+ <li><strong>Biggest single file.</strong> 500 MB for file uploads and 50 MB from a link. A file over that is refused with <code>413</code> rather than <code>429</code>, because waiting would not help. From a link, the download stops as soon as it passes 50 MB.</li>
<li><strong>Batches stop at the limit.</strong> If you upload several files in one request and the limit is reached partway, the request fails with <code>429</code>. Files already stored earlier in that request stay stored.</li>
<li><strong>What does not count.</strong> Uploads refused before the limit check (banned IP, VPN/proxy address, video) do not use your allowance. Shortener requests <em>do</em> count even when the URL turns out to be invalid, because the limit is checked before the link is examined.</li>
+ <li><strong>GitHub sync is not rate-limited.</strong> It is protected by the sign-in identity, the approval rule, bans and the video rule instead (see <a href="#gh-sync">GitHub Actions sync</a>).</li>
<li><strong>In memory.</strong> Counters are kept in the server's memory, so a restart or redeploy clears them.</li>
+ <li><strong>Your network may add its own cap.</strong> A single request can also be limited by the network in front of the CDN, separately from the limits above. For example, Cloudflare's Free and Pro plans refuse any request over 100 MB before it reaches this app. If a large upload fails with a <code>413</code> that doesn't match the messages below, split it into smaller uploads.</li>
</ul>
<h3>When you hit a limit</h3>
- <p>The response is <code>429</code> with a JSON message. There is no <code>Retry-After</code> header, so use the message to tell which window you hit:</p>
- <pre>{ "detail": "Upload rate limit exceeded: 50.00 MB/minute. Try again shortly." }
-{ "detail": "Upload rate limit exceeded: 300.00 MB/hour. Try again later." }
+ <p>The response is <code>429</code> (or <code>413</code> for a file that is too big) with a JSON message. There is no <code>Retry-After</code> header, so use the message to see which limit you hit:</p>
+ <pre>{ "detail": "Upload rate limit exceeded: 500.00 MB/hour. Try again later." }
+{ "detail": "URL upload rate limit exceeded: 50.00 MB/hour. Try again later." }
{ "detail": "Rate limit exceeded: 10 requests/minute. Try again shortly." }
-{ "detail": "Rate limit exceeded: 60 requests/hour. Try again later." }</pre>
- <p>After a <em>minute</em> message, wait up to a minute. After an <em>hour</em> message, wait until earlier uploads are an hour old. Waiting is the only fix: retrying immediately just fails again.</p>
+{ "detail": "Rate limit exceeded: 50 requests/hour. Try again later." }
+{ "detail": "File too large (limit 500.00 MB per file)." }</pre>
+ <p>After a <em>minute</em> message, wait up to a minute. After an <em>hour</em> message, wait until earlier uploads are an hour old. For <em>file too large</em>, waiting doesn't help: make the file smaller or split it.</p>
<h3>Staying under the limits</h3>
<ul style="color:var(--muted)">
- <li>Upload big batches in smaller groups and leave a minute between them.</li>
- <li>Compress or resize large files before uploading, and keep each one under 50 MB.</li>
- <li>For GitHub sync, exclude build output and dependency folders from the archive (<code>tar --exclude='node_modules'</code>) so it stays under 50 MB.</li>
+ <li>Upload big batches in smaller groups, and leave time between them if you are close to 500 MB in an hour.</li>
+ <li>Compress or resize large files before uploading.</li>
+ <li>Have a whole project to move? <a href="#gh-sync">GitHub sync</a> has no rate limit.</li>
</ul>
</section>
diff --git a/app/templates/gh_sync_docs.html b/app/templates/gh_sync_docs.html
@@ -201,9 +201,10 @@ jobs:
extension and by file contents (renaming won't help). If the repo contains one, the whole sync is
rejected and nothing is uploaded. Leave videos out of the archive, for example by adding
<code>--exclude='*.mp4'</code> to the <code>tar</code> command.</li>
- <li>There's no limit on file count, but the <em>compressed archive</em> is rate-limited: 50 MB per minute and
- 300 MB per hour per repo, so an archive over 50 MB is refused. Keep it small by excluding build output,
- for example <code>--exclude='node_modules'</code> in the <code>tar</code> command.
+ <li><strong>No rate limit.</strong> There's no limit on file count, archive size or how often you sync. What protects it instead is your
+ sign-in identity, the approval rule, bans and the no-video rule. One caveat outside this service: the network in front of the CDN can cap a
+ single request (Cloudflare's free plan refuses requests over 100 MB), so a very large archive may still be refused. Keep it small with
+ <code>--exclude='node_modules'</code> in the <code>tar</code> command.
<a href="/documentation#rate-limits">How rate limits work</a>.</li>
<li>Each sync fully replaces the contents of <em>your own</em> <code>username/repo</code> folder only
(delete-then-upload), so removed files disappear from the CDN too. No other folder is ever touched.
@@ -225,9 +226,9 @@ jobs:
or exclude it from the archive.</li>
<li><code>401</code> — the identity token was missing, expired or not for this service. Check that
<code>permissions: id-token: write</code> is set and that the audience is <code>cdn.amit.is-a.dev</code>.</li>
- <li><code>429</code> — the archive is over the rate limit (50 MB per minute, 300 MB per hour). An archive larger than
- 50 MB can never pass, so shrink it, or wait if you've just synced. See
- <a href="/documentation#rate-limits">rate limits</a>.</li>
+ <li><code>413</code> with a message that isn't from the CDN (for example a plain "Request Entity Too Large") — the request was refused before it
+ reached the CDN because the archive is too big for the network in front of it (100 MB on Cloudflare's free plan). Exclude build output and
+ dependency folders from the archive. Sync itself has no <a href="/documentation#rate-limits">rate limit</a>.</li>
</ul>
<p>The full request/response details are in the <a href="/documentation#gh-sync">documentation</a>.</p>
diff --git a/app/templates/index.html b/app/templates/index.html
@@ -1047,7 +1047,7 @@
<button type="button" class="folder-option" data-folder="third-party">third-party</button>
</div>
- <div class="rate-note" role="note"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limits apply.</strong> Each IP can upload <strong>50 MB per minute</strong> and <strong>300 MB per hour</strong>, and a single file can be at most <strong>50 MB</strong>. Going over returns an error until your allowance comes back. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
+ <div class="rate-note" role="note"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limits apply.</strong> Each IP can upload <strong>500 MB per hour</strong> (a single file can be at most <strong>500 MB</strong>). Uploading from a link is limited to <strong>50 MB per hour</strong>. Going over returns an error until your allowance comes back. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
<div style="display:flex; gap:0.75rem; margin-bottom: 1.5rem;">
<input type="text" id="url-input" class="url-input" placeholder="Paste a link to fetch and upload…">
diff --git a/app/templates/url_shortener.html b/app/templates/url_shortener.html
@@ -240,7 +240,7 @@
The destination is never exposed until someone visits the masked URL.
</blockquote>
- <div class="rate-note" role="note"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limit:</strong> each IP can create <strong>10 links per minute</strong> and <strong>60 per hour</strong>. Failed attempts count too. Only <code>https://</code> URLs up to 2048 characters are accepted. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
+ <div class="rate-note" role="note"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limit:</strong> each IP can create <strong>10 links per minute</strong> and <strong>50 per hour</strong>. Failed attempts count too. Only <code>https://</code> URLs up to 2048 characters are accepted. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
<div class="input-group" id="shorten-group">
<input type="url" id="url-input" class="url-input" placeholder="Paste a URL to shorten (e.g. https://example.com)" autocomplete="off" spellcheck="false" required>