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. 32
      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 # Internal location used exclusively by X-Accel-Redirect responses
# from serve_media(). Not reachable by external clients. # from serve_media(). Not reachable by external clients.
location /private/media/ { location /internal/media/ {
internal; internal;
alias /var/interlegis/sapl/media/; alias /var/interlegis/sapl/media/;
sendfile on; sendfile on;

8
plan/RATE-LIMITER-PLAN.md

@ -720,11 +720,11 @@ GET /media/foo.pdf
serve_media(request, path='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 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 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. # Internal — only reachable via X-Accel-Redirect, not by external clients.
location /private/media/ { location /internal/media/ {
internal; internal;
alias /var/interlegis/sapl/media/; alias /var/interlegis/sapl/media/;
sendfile on; sendfile on;
@ -776,7 +776,7 @@ Per-request steps:
1. **Path traversal guard**`os.path.abspath` check; raises 404 on escape. 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. 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`). 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 ### 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 hashlib
import mimetypes
import os import os
from urllib.parse import quote
from django.conf import settings from django.conf import settings
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
@ -72,9 +74,11 @@ def serve_media(request, path):
return serve(request, path, document_root=settings.MEDIA_ROOT) return serve(request, path, document_root=settings.MEDIA_ROOT)
# Production: tell nginx to serve the file from the internal location. # Production: tell nginx to serve the file from the internal location.
# Nginx sets Content-Type from its own mime.types when serving the file. filename = os.path.basename(path)
response = HttpResponse() content_type, _ = mimetypes.guess_type(filename)
response['X-Accel-Redirect'] = f'/private/media/{path}' 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['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600'
response['X-Robots-Tag'] = 'noindex' response['X-Robots-Tag'] = 'noindex'
return response 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.protocoloadm.models import Protocolo
from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT
from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, 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, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO,
show_results_filter_set, get_tempfile_dir, show_results_filter_set, get_tempfile_dir,
google_recaptcha_configured, MultiFormatOutputMixin) google_recaptcha_configured, MultiFormatOutputMixin)
@ -122,17 +122,7 @@ def proposicao_texto(request, pk):
return redirect(reverse('sapl.materia:proposicao_detail', return redirect(reverse('sapl.materia:proposicao_detail',
kwargs={'pk': pk})) kwargs={'pk': pk}))
arquivo = proposicao.texto_original return redirect(proposicao.texto_original.url)
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
logger.error('user=' + username + logger.error('user=' + username +
'. Objeto Proposicao com pk={} não encontrado.'.format(pk)) '. Objeto Proposicao com pk={} não encontrado.'.format(pk))
raise Http404 raise Http404

32
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.relatorios.views import relatorio_doc_administrativos
from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate
from sapl.utils import (create_barcode, get_base_url, 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, show_results_filter_set, mail_service_configured, from_date_to_datetime_utc,
google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin) google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin)
@ -102,28 +102,10 @@ def recuperar_materia_protocolo(request):
def doc_texto_integral(request, pk): def doc_texto_integral(request, pk):
can_see = True documento = get_object_or_404(DocumentoAdministrativo, pk=pk)
if not documento.texto_integral:
if not request.user.is_authenticated: raise Http404
app_config = AppConfig.objects.last() return redirect(documento.texto_integral.url)
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
raise Http404
def get_pdf_docacessorios(request, pk): def get_pdf_docacessorios(request, pk):
@ -516,9 +498,7 @@ class DocumentoAdministrativoCrud(Crud):
def urlize(self, obj, fieldname): def urlize(self, obj, fieldname):
a = '<a href="%s">%s</a>' % ( a = '<a href="%s">%s</a>' % (
reverse( obj.texto_integral.url,
'sapl.protocoloadm:doc_texto_integral',
kwargs={'pk': obj.pk}),
obj.texto_integral.name.split('/')[-1]) obj.texto_integral.name.split('/')[-1])
return obj.texto_integral.field.verbose_name, a 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 return len(password) < MIN_PASSWORD_LENGTH or not (pwd_has_lowercase and pwd_has_uppercase
and pwd_has_number and pwd_has_special_char) and pwd_has_number and pwd_has_special_char)
def groups_remove_user(user, groups_name): def groups_remove_user(user, groups_name):
from django.contrib.auth.models import Group from django.contrib.auth.models import Group

Loading…
Cancel
Save