Browse Source

Phase 0 hardening: nginx GeoIP2, rate limits, Gunicorn tuning, N+1 fix

- nginx: sendfile on, tcp_nopush, reduced keepalive/proxy timeouts
- nginx: GeoIP2 ASN-based bot blocking (cloud providers + known scrapers)
- nginx: UA blocklist (GPTBot, ClaudeBot, Chrome/98.0.4758 impersonator, etc.)
- nginx: rate-limit zones (30r/m general, 10r/m heavy/relatorios), 429/500 error pages
- nginx: proper ETags + Cache-Control on /media/ to stop 30GB logo re-transfers
- Dockerfile: install libnginx-mod-http-geoip2; download GeoLite2-ASN.mmdb via
  BuildKit secret (key never baked into image layers); ARG GEOIP_CACHE_BUST for
  forced re-download without --no-cache
- Gunicorn: workers 3->2, threads 8->4, timeout 300->120, max_memory 300->400MB
- Django: FILE_UPLOAD_MAX_MEMORY_SIZE=2MB, FILE_UPLOAD_TEMP_DIR for large uploads
- relatorios/views.py: fix N+1 in get_etiqueta_protocolos with bulk-fetch
  MateriaLegislativa + DocumentoAdministrativo using select_related + dict lookups
- Add robots.txt, 429.html, 500.html static pages
- docker-compose.yaml: use sapl:local for local dev
- docker/README.md: build instructions with MAXMIND_LICENSE_KEY
- rate-limiter-v2.md: canonical planning document (Architecture through Phase 5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 3 weeks ago
parent
commit
eaf4a8405a
  1. 39
      docker/Dockerfile
  2. 107
      docker/README.md
  3. 82
      docker/config/nginx/nginx.conf
  4. 114
      docker/config/nginx/sapl.conf
  5. 2
      docker/docker-compose.yaml
  6. 10
      docker/startup_scripts/gunicorn.conf.py
  7. 1231
      rate-limiter-v2.md
  8. 43
      sapl/relatorios/views.py
  9. 4
      sapl/settings.py
  10. 42
      sapl/static/429.html
  11. 42
      sapl/static/500.html
  12. 19
      sapl/static/robots.txt

39
docker/Dockerfile

@ -57,7 +57,7 @@ RUN set -eux; \
if [ "$WITH_GRAPHVIZ" = "1" ]; then apt-get install -y --no-install-recommends graphviz; fi; \
if [ "$WITH_POPPLER" = "1" ]; then apt-get install -y --no-install-recommends poppler-utils; fi; \
if [ "$WITH_PSQL_CLIENT" = "1" ]; then apt-get install -y --no-install-recommends postgresql-client; fi; \
if [ "$WITH_NGINX" = "1" ]; then apt-get install -y --no-install-recommends nginx; fi; \
if [ "$WITH_NGINX" = "1" ]; then apt-get install -y --no-install-recommends nginx libnginx-mod-http-geoip2 libmaxminddb0; fi; \
rm -rf /var/lib/apt/lists/*
# Usuários/grupos (idempotente)
@ -67,7 +67,13 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin sapl || true \
&& usermod -aG nginx sapl || true
# Estrutura de diretórios
RUN mkdir -p /var/interlegis/sapl /var/interlegis/sapl/data /var/interlegis/sapl/media /var/interlegis/sapl/run \
RUN mkdir -p \
/var/interlegis/sapl \
/var/interlegis/sapl/data \
/var/interlegis/sapl/media \
/var/interlegis/sapl/run \
/var/interlegis/sapl/tmp \
/etc/nginx/geoip \
&& chown -R root:nginx /var/interlegis/sapl /var/interlegis/sapl/run \
&& chmod -R g+rwX /var/interlegis/sapl \
&& chmod 2775 /var/interlegis/sapl /var/interlegis/sapl/run \
@ -88,6 +94,35 @@ RUN if [ "$WITH_NGINX" = "1" ]; then \
cp docker/config/nginx/nginx.conf /etc/nginx/nginx.conf; \
fi
# GeoLite2-ASN database for nginx ASN-based bot blocking.
# The key is injected via BuildKit secret — it is NEVER stored in any image layer.
#
# Build command:
# DOCKER_BUILDKIT=1 docker build \
# --secret id=maxmind_key,src=.env \
# -f docker/Dockerfile .
#
# .env must contain: MAXMIND_LICENSE_KEY=your_key
# The weekly host cron (/etc/cron.weekly/update-geoip) refreshes the db in production.
#
# Pass --build-arg GEOIP_CACHE_BUST=$(date +%s) to force re-download.
ARG GEOIP_CACHE_BUST=0
RUN --mount=type=secret,id=maxmind_key \
if [ "$WITH_NGINX" = "1" ]; then \
MAXMIND_LICENSE_KEY=$(grep -E '^MAXMIND_LICENSE_KEY=' /run/secrets/maxmind_key 2>/dev/null | cut -d= -f2- | tr -d '[:space:]' || true); \
if [ -n "$MAXMIND_LICENSE_KEY" ]; then \
tmpdir=$(mktemp -d) \
&& curl -fsSL \
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
| tar -xz --strip-components=1 -C "$tmpdir" \
&& mv "$tmpdir"/*.mmdb /etc/nginx/geoip/GeoLite2-ASN.mmdb \
&& rm -rf "$tmpdir" \
&& echo "GeoLite2-ASN.mmdb downloaded successfully."; \
else \
echo "MAXMIND_LICENSE_KEY not set in secret — GeoLite2-ASN.mmdb skipped. Add it to .env and rebuild."; \
fi; \
fi
# Scripts + gunicorn.conf no diretório da app
RUN install -m 755 docker/startup_scripts/start.sh /var/interlegis/sapl/start.sh \
&& install -m 755 docker/startup_scripts/wait-for-pg.sh /var/interlegis/sapl/wait-for-pg.sh \

107
docker/README.md

@ -0,0 +1,107 @@
# SAPL Docker Build
## Building locally
### 1. Prerequisites
- Docker 23+ with BuildKit enabled (default since Docker 23)
- A free [MaxMind account](https://www.maxmind.com/en/geolite2/signup) with a license key
### 2. Set your MaxMind license key
Add the key to the project root `.env` file (already gitignored):
```
MAXMIND_LICENSE_KEY=your_key_here
```
The key is used **only at build time** to download the `GeoLite2-ASN.mmdb` database for
nginx ASN-based bot blocking. It is injected via a BuildKit secret and is **never stored
in any image layer** — it will not appear in `docker history` or any registry push.
### 3. Build the image
```bash
docker build \
--secret id=maxmind_key,src=.env \
-f docker/Dockerfile \
-t sapl:local \
.
```
Run from the **project root** (not from inside `docker/`), so the build context includes
the full source tree.
#### Optional build args
| Arg | Default | Description |
|---|---|---|
| `WITH_NGINX` | `1` | Include nginx in the image |
| `WITH_GRAPHVIZ` | `1` | Include Graphviz |
| `WITH_POPPLER` | `1` | Include Poppler (PDF utilities) |
| `WITH_PSQL_CLIENT` | `1` | Include `psql` client |
Example — build without Graphviz:
```bash
docker build \
--secret id=maxmind_key,src=.env \
--build-arg WITH_GRAPHVIZ=0 \
-f docker/Dockerfile \
-t sapl:local \
.
```
### 4. If the MaxMind key is not provided
The build will succeed but nginx will log an error on startup because
`/etc/nginx/geoip/GeoLite2-ASN.mmdb` will be missing. ASN-based bot blocking will
be inactive. All other Phase 0 mitigations (UA blocklist, rate limits, ETags) still apply.
You can mount the database file at runtime as a workaround:
```bash
docker run \
-v /path/to/GeoLite2-ASN.mmdb:/etc/nginx/geoip/GeoLite2-ASN.mmdb:ro \
sapl:local
```
---
## Production — Harbor
Official images are built and pushed through **Harbor**. Before the next release, configure
the MaxMind license key as a build secret in the Harbor / CI pipeline:
1. Add `MAXMIND_LICENSE_KEY` as a **masked CI/CD secret** in the Harbor build project
(do not put it in any Helm values file or ConfigMap).
2. Pass it to the build step:
```bash
docker build \
--secret id=maxmind_key,env=MAXMIND_LICENSE_KEY \
-f docker/Dockerfile \
-t harbor.your-registry/sapl/sapl:$VERSION \
.
```
Note: `env=` variant reads the secret from an environment variable instead of a file —
useful in CI where `.env` files are not present.
3. Push as normal — the key will not be present in the pushed image.
### Keeping GeoLite2-ASN up to date
MaxMind updates the database every Tuesday. On production hosts, install the weekly refresh
cron (run as root):
```bash
cat > /etc/cron.weekly/update-geoip << 'EOF'
#!/bin/bash
MAXMIND_KEY="$(kubectl get secret sapl-build-secrets -n interlegis-infra \
-o jsonpath='{.data.MAXMIND_LICENSE_KEY}' | base64 -d)"
curl -fsSL \
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_KEY}&suffix=tar.gz" \
| tar -xz -C /tmp --wildcards '*.mmdb'
mv /tmp/GeoLite2-ASN_*/GeoLite2-ASN.mmdb /etc/nginx/geoip/GeoLite2-ASN.mmdb
nginx -s reload
EOF
chmod +x /etc/cron.weekly/update-geoip
```

82
docker/config/nginx/nginx.conf

@ -1,3 +1,5 @@
load_module modules/ngx_http_geoip2_module.so;
user www-data nginx;
worker_processes 1;
@ -14,20 +16,88 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ----------------------------------------------------------------
# Real client IP extracted from X-Forwarded-For set by K8s Ingress.
# ----------------------------------------------------------------
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.1;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time';
access_log /var/log/nginx/access.log main;
sendfile off;
#tcp_nopush on;
# ----------------------------------------------------------------
# FIX: kernel bypass was off (disables zero-copy file serving)
# ----------------------------------------------------------------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# ----------------------------------------------------------------
# Timeouts reduced from 300s to prevent bots holding threads.
# Per-location overrides in sapl.conf handle legitimate slow ops.
# ----------------------------------------------------------------
keepalive_timeout 75; # was 300
proxy_connect_timeout 10s;
proxy_read_timeout 120s; # was 300s overridden per-location
proxy_send_timeout 120s;
# ----------------------------------------------------------------
# Rate limiting zones (effective once real_ip is resolved).
# sapl_general : 30 req/min for most traffic
# sapl_heavy : 10 req/min for PDF/report endpoints
# ----------------------------------------------------------------
limit_req_zone $binary_remote_addr zone=sapl_general:20m rate=30r/m;
limit_req_zone $binary_remote_addr zone=sapl_heavy:20m rate=10r/m;
keepalive_timeout 300;
# ----------------------------------------------------------------
# ASN-Based Blocking (datacenter / scraper ASNs).
# Requires libnginx-mod-http-geoip2 and GeoLite2-ASN.mmdb.
# See rate-limiter-v2.md Phase 0 §3.4 for install instructions.
# ----------------------------------------------------------------
geoip2 /etc/nginx/geoip/GeoLite2-ASN.mmdb {
$geoip2_asn_number autonomous_system_number;
$geoip2_asn_org autonomous_system_organization;
}
proxy_connect_timeout 75s;
proxy_read_timeout 300s;
map $geoip2_asn_number $bot_asn {
default 0;
16509 1; # Amazon AWS
14618 1; # Amazon AWS us-east
8075 1; # Microsoft Azure
396982 1; # Google Cloud
20473 1; # Vultr
24940 1; # Hetzner
16276 1; # OVH
36352 1; # ColoCrossing
63949 1; # Linode / Akamai
}
# ----------------------------------------------------------------
# Bot blocking by User-Agent.
# Chrome/98.0.4758 is a confirmed scraper (no real user runs a
# 2022 browser version in 2026). Googlebot excluded for SEO.
# ----------------------------------------------------------------
map $http_user_agent $bot_ua_blocked {
default 0;
"~*GPTBot" 1;
"~*ClaudeBot" 1;
"~*PerplexityBot" 1;
"~*Bytespider" 1;
"~*AhrefsBot" 1;
"~*SemrushBot" 1;
"~*DotBot" 1;
"~*meta-externalagent" 1;
"~*OAI-SearchBot" 1;
"~*Chrome/98\.0\.4758" 1;
}
gzip on;
gzip_disable "MSIE [1-6]\\.(?!.*SV1)";

114
docker/config/nginx/sapl.conf

@ -1,10 +1,8 @@
upstream sapl_server {
server unix:/var/interlegis/sapl/run/gunicorn.sock fail_timeout=0;
}
# Se o cliente já manda X-Request-ID, reaproveita; senão, usa $request_id (nginx)
# Reuse X-Request-ID from ingress if present; otherwise generate one.
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
@ -18,14 +16,101 @@ server {
client_max_body_size 4G;
# ----------------------------------------------------------------
# Block known scraper ASNs (datacenter traffic) — zero Python cost.
# ----------------------------------------------------------------
if ($bot_asn = 1) {
return 429 "Too Many Requests";
}
# ----------------------------------------------------------------
# Block known bots by User-Agent — zero Python cost.
# ----------------------------------------------------------------
if ($bot_ua_blocked = 1) {
return 429 "Too Many Requests";
}
# ----------------------------------------------------------------
# robots.txt served directly by nginx.
# ----------------------------------------------------------------
location = /robots.txt {
alias /var/interlegis/sapl/collected_static/robots.txt;
}
# ----------------------------------------------------------------
# Static files — no rate limiting, no proxy.
# ----------------------------------------------------------------
location /static/ {
alias /var/interlegis/sapl/collected_static/;
}
# ----------------------------------------------------------------
# Media files — FIX: add ETags and Cache-Control headers.
# sendfile on + etag on converts repeat bot requests to 304s.
# ----------------------------------------------------------------
location /media/ {
alias /var/interlegis/sapl/media/;
sendfile on;
etag on;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=3600";
add_header X-Robots-Tag "noindex" always;
}
# Private documents — X-Accel-Redirect after auth check in Django.
location /media/documentos_privados/ {
internal;
alias /var/interlegis/sapl/media/documentos_privados/;
}
# ----------------------------------------------------------------
# /relatorios/ — heaviest endpoint (PDF generation).
# Tighter rate limit; extended timeout for uncached generation.
# ----------------------------------------------------------------
location /relatorios/ {
limit_req zone=sapl_heavy burst=5 nodelay;
limit_req_status 429;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://sapl_server;
}
# ----------------------------------------------------------------
# Upload endpoints — nginx buffers the full upload before forwarding.
# Protects workers from slow municipal-link clients uploading 150 MB.
# ----------------------------------------------------------------
location ~* ^/(protocoloadm/criar-protocolo|materia/.*upload|norma/.*upload) {
proxy_request_buffering on;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://sapl_server;
}
# ----------------------------------------------------------------
# /api/ — rate limited, CORS maintained from original config.
# ----------------------------------------------------------------
location /api/ {
limit_req zone=sapl_general burst=30 nodelay;
limit_req_status 429;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, HEAD, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Access-Control-Allow-Origin,XMLHttpRequest,Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With';
add_header 'Access-Control-Expose-Headers' 'Access-Control-Allow-Origin,XMLHttpRequest,Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With';
# handle the browser's preflight steps
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, HEAD, OPTIONS';
@ -45,15 +130,13 @@ server {
proxy_pass http://sapl_server;
}
location /static/ {
alias /var/interlegis/sapl/collected_static/;
}
location /media/ {
alias /var/interlegis/sapl/media/;
}
# ----------------------------------------------------------------
# General traffic — moderate rate limit.
# ----------------------------------------------------------------
location / {
limit_req zone=sapl_general burst=20 nodelay;
limit_req_status 429;
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@ -62,8 +145,15 @@ server {
proxy_pass http://sapl_server;
}
error_page 429 /429.html;
location = /429.html {
root /var/interlegis/sapl/sapl/static/;
internal;
}
error_page 500 502 503 504 /500.html;
location = /500.html {
root /var/interlegis/sapl/sapl/static/;
internal;
}
}

2
docker/docker-compose.yaml

@ -33,7 +33,7 @@ services:
networks:
- sapl-net
sapl:
image: interlegis/sapl:3.1.165-RC2
image: sapl:local
# build:
# context: ../
# dockerfile: ./docker/Dockerfile

10
docker/startup_scripts/gunicorn.conf.py

@ -10,9 +10,9 @@ DJANGODIR = "/var/interlegis/sapl"
SOCKFILE = f"unix:{DJANGODIR}/run/gunicorn.sock"
USER = "sapl"
GROUP = "nginx"
NUM_WORKERS = int(os.getenv("WEB_CONCURRENCY", "3"))
THREADS = int(os.getenv("GUNICORN_THREADS", "8"))
TIMEOUT = int(os.getenv("GUNICORN_TIMEOUT", "300"))
NUM_WORKERS = int(os.getenv("WEB_CONCURRENCY", "2")) # was 3
THREADS = int(os.getenv("GUNICORN_THREADS", "4")) # was 8
TIMEOUT = int(os.getenv("GUNICORN_TIMEOUT", "120")) # was 300
MAX_REQUESTS = 1000
WORKER_CLASS = "gthread"
DJANGO_SETTINGS = "sapl.settings"
@ -36,7 +36,7 @@ chdir = DJANGODIR
wsgi_app = WSGI_APP
# Logs
loglevel = "debug"
loglevel = "info" # was debug — reduces log I/O
accesslog = "/var/log/sapl/access.log"
errorlog = "/var/log/sapl/error.log"
# errorlog = "-" # send to stderr (so you see it in docker logs or terminal)
@ -53,7 +53,7 @@ keepalive = 10
backlog = 2048
max_requests = MAX_REQUESTS
max_requests_jitter = 200
worker_max_memory_per_child = 300 * 1024 * 1024 # 300 MB cap
worker_max_memory_per_child = 400 * 1024 * 1024 # 400 MB — was 300 MB
# Environment (same as exporting before running)
raw_env = [

1231
rate-limiter-v2.md

File diff suppressed because it is too large

43
sapl/relatorios/views.py

@ -1141,8 +1141,29 @@ def relatorio_etiqueta_protocolo(request, nro, ano):
def get_etiqueta_protocolos(prots):
prot_list = list(prots)
if not prot_list:
return []
# Pre-fetch MateriaLegislativa for all protocols in one query.
materia_query = Q()
for p in prot_list:
materia_query |= Q(numero_protocolo=p.numero, ano=p.ano)
materias_map = {
(m.numero_protocolo, m.ano): m
for m in MateriaLegislativa.objects.filter(
materia_query).select_related('tipo')
}
# Pre-fetch DocumentoAdministrativo for all protocols in one query.
documentos_map = {
doc.protocolo_id: doc
for doc in DocumentoAdministrativo.objects.filter(
protocolo__in=prot_list).select_related('tipo')
}
protocolos = []
for p in prots:
for p in prot_list:
dic = {}
dic['titulo'] = str(p.numero) + '/' + str(p.ano)
@ -1159,11 +1180,11 @@ def get_etiqueta_protocolos(prots):
dic['nom_autor'] = str(p.autor or ' ')
dic['num_materia'] = ''
for materia in MateriaLegislativa.objects.filter(
numero_protocolo=p.numero, ano=p.ano):
dic['num_materia'] = materia.tipo.sigla + ' ' + \
str(materia.numero) + '/' + str(materia.ano)
materia = materias_map.get((p.numero, p.ano))
dic['num_materia'] = (
materia.tipo.sigla + ' ' + str(materia.numero) + '/' + str(materia.ano)
if materia else ''
)
dic['natureza'] = ''
if p.tipo_processo == 0:
@ -1171,11 +1192,11 @@ def get_etiqueta_protocolos(prots):
if p.tipo_processo == 1:
dic['natureza'] = 'Legislativo'
dic['num_documento'] = ''
for documento in DocumentoAdministrativo.objects.filter(
protocolo=p):
dic['num_documento'] = documento.tipo.sigla + ' ' + \
str(documento.numero) + '/' + str(documento.ano)
documento = documentos_map.get(p.pk)
dic['num_documento'] = (
documento.tipo.sigla + ' ' + str(documento.numero) + '/' + str(documento.ano)
if documento else ''
)
dic['ident_processo'] = dic['num_materia'] or dic['num_documento']

4
sapl/settings.py

@ -315,6 +315,10 @@ WAFFLE_ENABLE_ADMIN_PAGES = True
MAX_DOC_UPLOAD_SIZE = 150 * 1024 * 1024 # 150MB
MAX_IMAGE_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
# Files above 2 MB are streamed to a temp file on disk rather than held in
# worker RAM. Critical for large upload support without memory blowup.
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 # 2MB
FILE_UPLOAD_TEMP_DIR = '/var/interlegis/sapl/tmp'
RATE_LIMITER_RATE = config('RATE_LIMITER_RATE', default='35/m')

42
sapl/static/429.html

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>429 – Muitas Requisições</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: #fff;
border-top: 4px solid #e74c3c;
border-radius: 4px;
padding: 2.5rem 3rem;
max-width: 480px;
width: 90%;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.code { font-size: 4rem; font-weight: 700; color: #e74c3c; line-height: 1; }
h1 { font-size: 1.25rem; margin: .75rem 0 1rem; }
p { font-size: .95rem; line-height: 1.6; color: #555; }
.retry { margin-top: 1.5rem; font-size: .85rem; color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="code">429</div>
<h1>Muitas Requisições</h1>
<p>Você realizou muitas requisições em um curto período. Aguarde um momento e tente novamente.</p>
<p class="retry">Se o problema persistir, entre em contato com o suporte da sua Câmara Municipal.</p>
</div>
</body>
</html>

42
sapl/static/500.html

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 – Erro Interno do Servidor</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: #fff;
border-top: 4px solid #e67e22;
border-radius: 4px;
padding: 2.5rem 3rem;
max-width: 480px;
width: 90%;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.code { font-size: 4rem; font-weight: 700; color: #e67e22; line-height: 1; }
h1 { font-size: 1.25rem; margin: .75rem 0 1rem; }
p { font-size: .95rem; line-height: 1.6; color: #555; }
.retry { margin-top: 1.5rem; font-size: .85rem; color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="code">500</div>
<h1>Erro Interno do Servidor</h1>
<p>Ocorreu um erro inesperado. Nossa equipe foi notificada e trabalhará para resolver o problema.</p>
<p class="retry">Tente novamente em alguns instantes. Se o problema persistir, entre em contato com o suporte da sua Câmara Municipal.</p>
</div>
</body>
</html>

19
sapl/static/robots.txt

@ -0,0 +1,19 @@
User-agent: GPTBot
Disallow: /
Crawl-delay: 10
User-agent: ClaudeBot
Disallow: /
Crawl-delay: 10
User-agent: meta-externalagent
Disallow: /
Crawl-delay: 10
User-agent: OAI-SearchBot
Disallow: /
Crawl-delay: 10
User-agent: *
Disallow: /relatorios/
Crawl-delay: 10
Loading…
Cancel
Save