A partir da v0.4.0, o módulo de anexos passou por endurecimento e ganhou um fluxo dedicado para contratos com versionamento.
CONTRACT, INVOICE, RECEIPT,
PROPOSAL, ID_DOC, PHOTO, OTHER.CONTRACT (cada
upload novo é uma versão).Arquivos vivem fora de public/ em <repo>/uploads/. Estrutura:
uploads/{ownerType}/{ownerId}/{hash16}_{filename} # padrão
uploads/contract/{contractId}/v{n}/{hash16}_{filename} # versionado
hash16 são os primeiros 16 caracteres de SHA-256 do conteúdo. O hash
completo é armazenado em Attachment.sha256Full para detecção de
integridade.
Toda Server Action de upload (uploadAttachment, replaceContractFile)
passa por:
auth(). Falha → 401.canUploadAttachmentKind(role, kind).ownerType, ownerId, kind.detectMagic(buffer) em src/lib/file-validation.ts
— PDF, PNG, JPEG, WEBP, HEIC.assertMagicMatchesMime(detected, file.type) — bloqueia
conteúdo que não bate com o MIME declarado pelo cliente.assertAllowedForKind(kind, file.type) — restringe formatos por
kind. CONTRACT só aceita application/pdf.assertSizeForKind(kind, bytes). CONTRACT
tem teto de 8 MB; demais kinds até 10 MB.path.resolve + startsWith(UPLOADS_ROOT) —
path traversal robusto.uploadedById preenchido com session.user.id.UPLOAD.| Ação | ADMIN | GROOM | BRIDE | PLANNER | FAMILY | VIEWER |
|---|---|---|---|---|---|---|
| Upload CONTRACT | ✓ | ✓ | ✓ | ✓ | – | – |
| Ver / baixar CONTRACT | ✓ | ✓ | ✓ | ✓ | – | – |
| Substituir CONTRACT (nova versão) | ✓ | ✓ | ✓ | ✓ | – | – |
| Excluir CONTRACT (soft) | ✓ | ✓ | ✓ | – | – | – |
| Marcar como SIGNED_* | ✓ | ✓ | ✓ | – | – | – |
| Upload PHOTO/PROPOSAL/OTHER | ✓ | ✓ | ✓ | ✓ | – | – |
| Ver PHOTO/PROPOSAL/OTHER | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Implementado em src/lib/permissions.ts:
canUploadContract, canViewContract, canManageContract,
canSignContract, canViewAttachmentKind, canUploadAttachmentKind.
/api/files/[id]auth() e canViewAttachmentKind(role, attachment.kind).20/min por usuário+anexo, 120/min por IP.X-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGIN (impede embedding cross-origin do arquivo)Content-Security-Policy:
application/pdf): frame-ancestors 'self'. O sandbox
foi removido porque o visualizador interno do Chrome depende de
executar scripts próprios para renderizar — o sandbox sem
allow-scripts produzia tela cinza com “página bloqueada”.default-src 'none'; sandbox; style-src 'unsafe-inline'; frame-ancestors 'self'
como defesa em profundidade.Referrer-Policy: no-referrerCache-Control: private, no-store para contratos / max-age=60 para os demaisaudit("Attachment", id, "DOWNLOAD") a cada acesso.Regra: o primeiro PDF enviado a um contrato é gravado na versão atual do contrato (ex.: contrato criado em v1 + primeiro upload = attachment v1). Substituições posteriores incrementam (v2, v3…).
Fluxo de replaceContractFile:
nextVersion:
nextVersion = contract.version.nextVersion = contract.version + 1.prisma.$transaction):
Attachment.updateMany setando deletedAt = now()
no CONTRACT ativo do contrato.Attachment.create com version: nextVersion, kind: CONTRACT,
subdir: v{nextVersion}.Contract.update setando version = nextVersion.audit("Contract", id, "UPLOAD" | "REPLACE", { fromVersion, toVersion }).
UPLOAD no primeiro envio (fromVersion === toVersion).REPLACE quando houve troca de versão.Arquivos da versão antiga não são removidos do disco imediatamente — ficam soft-deletados por 30 dias.
Endpoint cron GET /api/cron/cleanup-files (Bearer CRON_SECRET):
deletedAt < now - 30d.Configure no orquestrador externo (cron de sistema, GitHub Actions, Cloudflare Cron Workers etc.) para rodar 1× ao dia.
/dashboard/vendors/[id] ganhou bloco “Arquivo do contrato” embutido em
cada contrato, com:
<object data="/api/files/{id}#toolbar=1&navpanes=0" type="application/pdf">
para preview do PDF. (Antes usávamos <iframe sandbox="allow-same-origin">,
mas o sandbox sem allow-scripts bloqueava o visualizador interno do
Chrome. Como o arquivo é servido same-origin com X-Frame-Options:
SAMEORIGIN, não há regressão de superfície.)canUploadContract) com confirm dialog.canSignContract).canManageContract) collapsible.X-Frame-Options: SAMEORIGIN + frame-ancestors 'self' (impede embedding
externo) e o navegador roda o visualizador interno em seu próprio sandbox.