diff --git a/sapl/base/fields.py b/sapl/base/fields.py index c521cef5c..dc8526e90 100644 --- a/sapl/base/fields.py +++ b/sapl/base/fields.py @@ -1,8 +1,55 @@ import hashlib from pathlib import Path +from django.core.files import File from django.core.files.storage import default_storage from django.db import models +from django.db.models.fields.files import FieldFile + + +class MetadataFieldFile(FieldFile): + """ + FieldFile subclass that returns human-readable URLs via the semantic alias + pattern /////download instead of exposing disk paths. + + Falls back to /documentos// for unsaved instances (pk is None) or + when the _metadata FK has not been set yet. The canonical /documentos// + form is always stable across model/field renames and is what API serializers + must use (see RFC §10). + """ + + @property + def url(self): + if not self: + raise ValueError("The '%s' attribute has no file associated with it." % self.field.name) + + instance = self.instance + meta_attr = f'{self.field.name}_metadata' + meta = getattr(instance, meta_attr, None) + + # Fallback: no metadata row yet (pre-backfill existing file or first save + # before commit) → return the raw storage URL so nothing breaks. + if meta is None: + return self.storage.url(self.name) + + pk = getattr(instance, 'pk', None) + + if pk is not None: + # Saved instance — return the semantic alias. + # Lazy import avoids a circular dependency at module load time. + from django.urls import reverse + return reverse( + 'serve_model_file', + kwargs={ + 'app_label': instance._meta.app_label, + 'model_name': instance._meta.model_name, + 'pk': pk, + 'field_name': self.field.name, + }, + ) + + # Unsaved instance — return canonical UUID form. + return f'/documentos/{meta.uuid}/' def _compute_size_and_hash(field_file): @@ -24,6 +71,10 @@ class MetadataFileField(models.FileField): """ Drop-in replacement for models.FileField. + Uses MetadataFieldFile as its descriptor so that .url returns the semantic + alias /////download for saved instances, and falls + back to /documentos// for unsaved instances. + In addition to normal FileField behaviour, this field: 1. Injects a companion ForeignKey '_metadata' pointing to base.FileMetadata on the owning model class at class-definition time. @@ -42,6 +93,8 @@ class MetadataFileField(models.FileField): Case 4 — no-op re-save : nothing touched. """ + attr_class = MetadataFieldFile + def contribute_to_class(self, cls, name): super().contribute_to_class(cls, name) # Inject companion FK: e.g. texto_original → texto_original_metadata_id diff --git a/sapl/base/views.py b/sapl/base/views.py index 2b53aa57c..d9e159fa0 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -1569,3 +1569,98 @@ def pesquisa_textual(request): json_dict['resultados'].append(sec_dict) return JsonResponse(json_dict) + + +# --------------------------------------------------------------------------- +# File-serving views (RFC §6.4, §9) +# --------------------------------------------------------------------------- + +from urllib.parse import quote # noqa: E402 — kept near usage site + +from django.http import HttpResponse # noqa: E402 + +SERVE_FILE_FIELDS = frozenset({ + ('materia', 'materialegislativa', 'texto_original'), + ('materia', 'documentoacessorio', 'arquivo'), + ('materia', 'proposicao', 'texto_original'), + ('protocoloadm', 'documentoadministrativo', 'texto_integral'), + ('protocoloadm', 'documentoacessorioadministrativo', 'arquivo'), + ('norma', 'normajuridica', 'texto_integral'), + ('norma', 'anexonormajuridica', 'anexo_arquivo'), + ('comissoes', 'reuniao', 'upload_pauta'), + ('comissoes', 'reuniao', 'upload_ata'), + ('comissoes', 'reuniao', 'upload_anexo'), + ('comissoes', 'documentoacessorio', 'arquivo'), + ('audiencia', 'audienciapublica', 'upload_pauta'), + ('audiencia', 'audienciapublica', 'upload_ata'), + ('audiencia', 'audienciapublica', 'upload_anexo'), + ('audiencia', 'anexoaudienciapublica', 'arquivo'), + ('sessao', 'sessaoplenaria', 'upload_pauta'), + ('sessao', 'sessaoplenaria', 'upload_ata'), + ('sessao', 'sessaoplenaria', 'upload_anexo'), + ('sessao', 'justificativaausencia', 'upload_anexo'), +}) + + +def serve_file(request, file_uuid): + """ + Secure file-serving view — the single chokepoint for all document downloads. + + Resolves uuid → FileMetadata → storage_name, performs a permission check + (currently public files pass unconditionally), then delegates the actual + byte transfer to nginx via X-Accel-Redirect. Django never reads file bytes + into Python memory; nginx's sendfile delivers them zero-copy. + + The /media/ location in nginx must be marked 'internal' so that clients + cannot bypass this view and fetch files directly. + """ + from sapl.base.models import FileMetadata + from django.shortcuts import get_object_or_404 as _get_or_404 + + meta = _get_or_404(FileMetadata, uuid=file_uuid) + + # Permission check — currently unconditional for public files. + # When DocumentoAdministrativo.restrito / nivel_restricao is wired, + # insert the per-file check here (RFC §6.4). + + # Build the nginx internal redirect path. + # storage_name is relative to MEDIA_ROOT (e.g. "sapl/public/norma/…/file.pdf"). + internal_path = f'/media/{meta.storage_name}' + + response = HttpResponse() + response['X-Accel-Redirect'] = internal_path + + # RFC 6266 — dual filename parameter: ASCII fallback + UTF-8 encoded. + filename_ascii = meta.original_filename.encode('ascii', 'replace').decode() + filename_encoded = quote(meta.original_filename, safe='') + response['Content-Disposition'] = ( + f'inline; filename="{filename_ascii}"' + f"; filename*=UTF-8''{filename_encoded}" + ) + return response + + +def serve_model_file(request, app_label, model_name, pk, field_name): + """ + Semantic alias for file downloads: /////download + + Validates the (app_label, model_name, field_name) triple against an explicit + allowlist, fetches the parent model instance, resolves the _metadata FK, then + delegates to serve_file. All permission logic lives in serve_file only. + """ + from django.shortcuts import get_object_or_404 as _get_or_404 + + if (app_label, model_name, field_name) not in SERVE_FILE_FIELDS: + raise Http404 + + try: + model = apps.get_model(app_label, model_name) + except LookupError: + raise Http404 + + instance = _get_or_404(model, pk=pk) + meta = getattr(instance, f'{field_name}_metadata', None) + if meta is None: + raise Http404 + + return serve_file(request, file_uuid=meta.uuid) diff --git a/sapl/urls.py b/sapl/urls.py index d2967e927..51788a55a 100644 --- a/sapl/urls.py +++ b/sapl/urls.py @@ -21,6 +21,8 @@ from django.urls import path from django.views.generic.base import RedirectView, TemplateView from django.views.static import serve as view_static_server +from sapl.base.views import serve_file, serve_model_file + import sapl.api.urls import sapl.audiencia.urls import sapl.base.urls @@ -79,6 +81,13 @@ urlpatterns += [ # Monitoring path(r'', include('django_prometheus.urls')), + # File-serving routes (RFC §6.4, §9) + # /documentos// — canonical stable URL (API, emails, bookmarks) + path('documentos//', serve_file, name='serve_file'), + # /////download — semantic alias (templates, browser bar) + path('////download', + serve_model_file, name='serve_model_file'), + ]