Browse Source

feat: add serve_file / serve_model_file views and semantic URL routing (RFC §6.4, §9)

Implements the URL decoupling layer described in the RFC:

  sapl/base/views.py
    serve_file(request, file_uuid)
      Resolves UUID → FileMetadata → storage_name; issues X-Accel-Redirect
      to nginx with dual-param Content-Disposition (ASCII + UTF-8 encoded).
      Currently passes all files unconditionally; the permission-check hook
      is in place for when DocumentoAdministrativo.nivel_restricao is wired.

    serve_model_file(request, app_label, model_name, pk, field_name)
      Semantic alias handler: validates (app, model, field) against an explicit
      SERVE_FILE_FIELDS allowlist, fetches the parent instance, resolves the
      _metadata FK, then delegates to serve_file.  Allowlist prevents the
      generic URL from accidentally exposing arbitrary model fields.

  sapl/base/fields.py
    MetadataFieldFile(FieldFile) — overrides .url:
      • saved instance  → /<app>/<model>/<pk>/<field>/download (semantic alias)
      • unsaved instance → /documentos/<uuid>/ (canonical fallback)
      • no metadata row → raw storage URL (pre-backfill fallback, nothing breaks)
    MetadataFileField.attr_class = MetadataFieldFile

  sapl/urls.py
    path('documentos/<uuid:file_uuid>/', serve_file, name='serve_file')
    path('<app>/<model>/<pk>/<field>/download', serve_model_file, ...)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
file-metafields
Edward Ribeiro 2 weeks ago
parent
commit
8e81281ea9
  1. 53
      sapl/base/fields.py
  2. 95
      sapl/base/views.py
  3. 9
      sapl/urls.py

53
sapl/base/fields.py

@ -1,8 +1,55 @@
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from django.core.files import File
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db import models 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 /<app>/<model>/<pk>/<field>/download instead of exposing disk paths.
Falls back to /documentos/<uuid>/ for unsaved instances (pk is None) or
when the _metadata FK has not been set yet. The canonical /documentos/<uuid>/
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): def _compute_size_and_hash(field_file):
@ -24,6 +71,10 @@ class MetadataFileField(models.FileField):
""" """
Drop-in replacement for models.FileField. Drop-in replacement for models.FileField.
Uses MetadataFieldFile as its descriptor so that .url returns the semantic
alias /<app>/<model>/<pk>/<field>/download for saved instances, and falls
back to /documentos/<uuid>/ for unsaved instances.
In addition to normal FileField behaviour, this field: In addition to normal FileField behaviour, this field:
1. Injects a companion ForeignKey '<fieldname>_metadata' pointing to 1. Injects a companion ForeignKey '<fieldname>_metadata' pointing to
base.FileMetadata on the owning model class at class-definition time. 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. Case 4 no-op re-save : nothing touched.
""" """
attr_class = MetadataFieldFile
def contribute_to_class(self, cls, name): def contribute_to_class(self, cls, name):
super().contribute_to_class(cls, name) super().contribute_to_class(cls, name)
# Inject companion FK: e.g. texto_original → texto_original_metadata_id # Inject companion FK: e.g. texto_original → texto_original_metadata_id

95
sapl/base/views.py

@ -1569,3 +1569,98 @@ def pesquisa_textual(request):
json_dict['resultados'].append(sec_dict) json_dict['resultados'].append(sec_dict)
return JsonResponse(json_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: /<app>/<model>/<pk>/<field>/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)

9
sapl/urls.py

@ -21,6 +21,8 @@ from django.urls import path
from django.views.generic.base import RedirectView, TemplateView from django.views.generic.base import RedirectView, TemplateView
from django.views.static import serve as view_static_server 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.api.urls
import sapl.audiencia.urls import sapl.audiencia.urls
import sapl.base.urls import sapl.base.urls
@ -79,6 +81,13 @@ urlpatterns += [
# Monitoring # Monitoring
path(r'', include('django_prometheus.urls')), path(r'', include('django_prometheus.urls')),
# File-serving routes (RFC §6.4, §9)
# /documentos/<uuid>/ — canonical stable URL (API, emails, bookmarks)
path('documentos/<uuid:file_uuid>/', serve_file, name='serve_file'),
# /<app>/<model>/<pk>/<field>/download — semantic alias (templates, browser bar)
path('<str:app_label>/<str:model_name>/<int:pk>/<str:field_name>/download',
serve_model_file, name='serve_model_file'),
] ]

Loading…
Cancel
Save