Browse Source

Rename nginx internal media path and simplify file serving in views

Renames /private/media/ to /internal/media/ in nginx and serve_media().
Adds Content-Type and Content-Disposition to the X-Accel-Redirect response.
Replaces manual file reads in proposicao_texto and doc_texto_integral with
redirects to the media URL, removing the unused get_mime_type_from_file_extension helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 2 weeks ago
parent
commit
ff8eaf171d
  1. 2
      docker/config/nginx/sapl.conf
  2. 8
      plan/RATE-LIMITER-PLAN.md
  3. 10
      sapl/base/media.py
  4. 14
      sapl/materia/views.py
  5. 30
      sapl/protocoloadm/views.py
  6. 1
      sapl/utils.py

2
docker/config/nginx/sapl.conf

@ -64,7 +64,7 @@ server {
# Internal location used exclusively by X-Accel-Redirect responses
# from serve_media(). Not reachable by external clients.
location /private/media/ {
location /internal/media/ {
internal;
alias /var/interlegis/sapl/media/;
sendfile on;

8
plan/RATE-LIMITER-PLAN.md

@ -720,11 +720,11 @@ GET /media/foo.pdf
serve_media(request, path='foo.pdf')
returns HttpResponse with X-Accel-Redirect: /private/media/foo.pdf
returns HttpResponse with X-Accel-Redirect: /internal/media/foo.pdf
nginx sees X-Accel-Redirect header
/private/media/ internal location → reads file from disk → sends to client
/internal/media/ internal location → reads file from disk → sends to client
```
nginx does no routing beyond picking a `location` block. The mapping from
@ -757,7 +757,7 @@ location /media/ {
}
# Internal — only reachable via X-Accel-Redirect, not by external clients.
location /private/media/ {
location /internal/media/ {
internal;
alias /var/interlegis/sapl/media/;
sendfile on;
@ -776,7 +776,7 @@ Per-request steps:
1. **Path traversal guard**`os.path.abspath` check; raises 404 on escape.
2. **Auth gate**`documentos_privados/` paths require an authenticated session; redirects to login otherwise.
3. **Path counter** — increments `rl:{ns}:path:{sha256}:reqs` in Redis DB 1 (TTL = `MEDIA_PATH_COUNTER_TTL`).
4. **Serve** — in DEBUG: `django.views.static.serve` directly. In production: `X-Accel-Redirect: /private/media/<path>`. Nginx sets `Content-Type` from its own `mime.types`.
4. **Serve** — in DEBUG: `django.views.static.serve` directly. In production: `X-Accel-Redirect: /internal/media/<path>`. Nginx sets `Content-Type` from its own `mime.types`.
### Settings

10
sapl/base/media.py

@ -15,7 +15,9 @@ Redis side-effects per request (DB 1, TTL=MEDIA_PATH_COUNTER_TTL):
"""
import hashlib
import mimetypes
import os
from urllib.parse import quote
from django.conf import settings
from django.http import Http404, HttpResponse
@ -72,9 +74,11 @@ def serve_media(request, path):
return serve(request, path, document_root=settings.MEDIA_ROOT)
# Production: tell nginx to serve the file from the internal location.
# Nginx sets Content-Type from its own mime.types when serving the file.
response = HttpResponse()
response['X-Accel-Redirect'] = f'/private/media/{path}'
filename = os.path.basename(path)
content_type, _ = mimetypes.guess_type(filename)
response = HttpResponse(content_type=content_type or 'application/octet-stream')
response['X-Accel-Redirect'] = f'/internal/media/{path}'
response['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(filename)}"
response['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600'
response['X-Robots-Tag'] = 'noindex'
return response

14
sapl/materia/views.py

@ -55,7 +55,7 @@ from sapl.parlamentares.models import Legislatura
from sapl.protocoloadm.models import Protocolo
from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT
from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url,
get_mime_type_from_file_extension, lista_anexados,
lista_anexados,
mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO,
show_results_filter_set, get_tempfile_dir,
google_recaptcha_configured, MultiFormatOutputMixin)
@ -122,17 +122,7 @@ def proposicao_texto(request, pk):
return redirect(reverse('sapl.materia:proposicao_detail',
kwargs={'pk': pk}))
arquivo = proposicao.texto_original
mime = get_mime_type_from_file_extension(arquivo.name)
with open(arquivo.path, 'rb') as f:
data = f.read()
response = HttpResponse(data, content_type='%s' % mime)
response['Content-Disposition'] = (
'inline; filename="%s"' % arquivo.name.split('/')[-1])
return response
return redirect(proposicao.texto_original.url)
logger.error('user=' + username +
'. Objeto Proposicao com pk={} não encontrado.'.format(pk))
raise Http404

30
sapl/protocoloadm/views.py

@ -46,7 +46,7 @@ from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\
from sapl.relatorios.views import relatorio_doc_administrativos
from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate
from sapl.utils import (create_barcode, get_base_url,
get_mime_type_from_file_extension, lista_anexados,
lista_anexados,
show_results_filter_set, mail_service_configured, from_date_to_datetime_utc,
google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin)
@ -102,28 +102,10 @@ def recuperar_materia_protocolo(request):
def doc_texto_integral(request, pk):
can_see = True
if not request.user.is_authenticated:
app_config = AppConfig.objects.last()
if app_config and app_config.documentos_administrativos == 'R':
can_see = False
if can_see:
documento = DocumentoAdministrativo.objects.get(pk=pk)
if documento.texto_integral:
arquivo = documento.texto_integral
mime = get_mime_type_from_file_extension(arquivo.name)
with open(arquivo.path, 'rb') as f:
data = f.read()
response = HttpResponse(data, content_type='%s' % mime)
response['Content-Disposition'] = (
'inline; filename="%s"' % arquivo.name.split('/')[-1])
return response
documento = get_object_or_404(DocumentoAdministrativo, pk=pk)
if not documento.texto_integral:
raise Http404
return redirect(documento.texto_integral.url)
def get_pdf_docacessorios(request, pk):
@ -516,9 +498,7 @@ class DocumentoAdministrativoCrud(Crud):
def urlize(self, obj, fieldname):
a = '<a href="%s">%s</a>' % (
reverse(
'sapl.protocoloadm:doc_texto_integral',
kwargs={'pk': obj.pk}),
obj.texto_integral.url,
obj.texto_integral.name.split('/')[-1])
return obj.texto_integral.field.verbose_name, a

1
sapl/utils.py

@ -78,7 +78,6 @@ def is_weak_password(password):
return len(password) < MIN_PASSWORD_LENGTH or not (pwd_has_lowercase and pwd_has_uppercase
and pwd_has_number and pwd_has_special_char)
def groups_remove_user(user, groups_name):
from django.contrib.auth.models import Group

Loading…
Cancel
Save