Upload e versionamento
GedUploadService: storage físico, validação, poda de versões antigas, configuração via GedConfig.
Upload e versionamento
Cada Document é só metadado — o arquivo físico vive em DocumentVersion.
Um documento sempre tem zero ou mais versões, e current_version_id aponta
para a que está "ativa" no momento. Subir um arquivo novo nunca sobrescreve o
anterior: cria uma versão nova e troca o ponteiro.
Onde o arquivo fica
Todo o file-IO do GED passa pela API de Filesystem do Laravel (facade
Storage/Flysystem), via Mad\Service\MadUploadStorage — nunca por
fopen/rename/unlink em caminho absoluto. storage_path no banco é
sempre uma chave relativa:
files/ged/{document_id}/v{N}_{nome-original}
Um "diretório" (prefixo) por documento, uma chave por versão, prefixada com o
número da versão para nunca colidir. O disco é
config('mad.uploads.disk') (env MAD_UPLOAD_DISK); vazio ⇒ o disco local
default mad_uploads, com root em storage/app/mad. Trocar para S3/MinIO
(MAD_UPLOAD_DISK=s3) não muda uma linha do GED nem o valor gravado no banco —
a mesma chave relativa vale nos dois.
App\Service\Ged\GedStorage é o guarda dessa chave: valida a forma antes
de qualquer acesso a disco (prefixo files/ged/ obrigatório, sem null byte,
sem \, sem .., sem segmento vazio ou começando com .):
use App\Service\Ged\GedStorage;
GedStorage::isValidKey($version->storage_path); // bool — só validação de forma
GedStorage::exists($version->storage_path); // forma válida E objeto no disco
GedStorage::delete($version->storage_path); // chave inválida = no-op
É GedStorage::exists() que o GedPublicLinkController chama antes de servir
um link público (ver Compartilhamento público) —
uma storage_path corrompida/fora do prefixo nunca chega no Storage.
use App\Service\Ged\GedUploadService;
GedUploadService::validateFile($tmpPath, $originalName); // lança \Exception se inválido
$version = GedUploadService::storeVersion(
documentId: $doc->id,
tmpPath: $tmpPath,
originalName: $originalName,
changeNote: 'Revisão após aprovação jurídica',
uploadedBy: (int) session('userid'),
);
Ordem real de storeVersion():
- Sanitiza o nome vindo cru de
$_FILES— normaliza\→/, reduz abasename()(mata traversal../) e remove separadores/controles residuais. Nome vazio/./..virafile. - Calcula o próximo
version_number(MAX(version_number) + 1do documento). - Mede mime e tamanho no arquivo tmp local, antes de mover — o destino pode
ser remoto e S3 não tem
finfo. O mime cai no$MIME_MAPpor extensão quando ofinfofalha. MadUploadStorage::put($tmpPath, "files/ged/{$id}/v{$n}_{$nome}")— lançaRuntimeExceptionem falha de escrita (os discos têm'throw' => false; sem isso o path iria pro banco sem objeto atrás). O tmp é consumido depois.- Só então abre a transação em
DB::connection('ged'): grava a linha deDocumentVersione atualizaDocument.current_version_id. - Fora da transação:
pruneVersions()(abaixo) e a atividadeversion_upload.
A escrita no disco acontece antes da transação, propositalmente — um rollback do banco pode deixar um objeto órfão no storage, mas nunca o contrário (linha apontando para arquivo inexistente).
Como a tela usa isso
DocumentForm (o drawer de criar/editar documento) não usa o padrão
declarativo <mad-file-field storage="disk"> para o upload em si — ele lê o
arquivo bruto de $_FILES['arquivo'] e chama GedUploadService diretamente
dentro do onSave(), porque a lógica de versionamento (calcular o próximo
número, trocar o ponteiro current_version_id, podar versões antigas) é
específica do módulo e não cabe no fluxo genérico de upload do MadForm. O
campo no Blade declara o accept/tamanho só para validação client-side:
<mad-file-field name="arquivo" label="Arquivo"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.png,.jpg,.zip"
max-size="50MB" :required="$isNew" />
O mesmo drawer tem 3 modos (new, edit, version) controlados pela prop
$mode — só o modo version (e new, se um arquivo foi anexado) chama
GedUploadService; edit só atualiza título/descrição/pasta.
Validação
validateFile() roda antes de mover qualquer coisa para o disco:
private static array $BLOCKED_EXTENSIONS = [
'php', 'php3', 'php4', 'phtml', 'pl', 'py', 'jsp', 'asp',
'htm', 'html', 'shtml', 'xhtml', 'xht', 'svg', 'svgz', 'xml', 'mathml', 'js',
'sh', 'cgi', 'htaccess',
];
A lista cobre duas famílias: executáveis do servidor (php*, pl, py,
jsp, asp, sh, cgi, htaccess) e conteúdo ativo servível inline
(html, xhtml, svg, xml, mathml, js) — este segundo grupo existe por
causa do link público anônimo: um SVG ou HTML servido same-origin seria XSS.
É defesa em profundidade; o GedPublicLinkController também força attachment
para tudo fora do allowlist de preview (ver
Compartilhamento público).
| Verificação | Regra |
|---|---|
| Extensão bloqueada | As extensões acima são sempre rejeitadas, mesmo que estejam na lista de permitidas. |
| Lista de permitidas | Se ged_allowed_extensions (preferência, ver abaixo) não estiver vazia, a extensão precisa estar nela. A comparação é case-insensitive dos dois lados (o admin digita PDF,DOCX à vontade). Vazio = qualquer extensão fora da lista bloqueada. |
| Tamanho | filesize($tmpPath) comparado contra ged_max_file_size_mb × 1MB. 0 = sem teto. |
| MIME type | Não é validado contra allowlist — é só detectado (finfo no tmp, com fallback por extensão) e gravado em DocumentVersion.mime_type. Note que esse valor não é usado como Content-Type no link público, justamente porque não é confiável. Se seu caso exige checagem de MIME real, valide antes de chamar storeVersion(). |
| Traversal no nome | storeVersion() reduz o nome a basename() e remove separadores/controles — ../../etc/passwd nunca vira caminho. |
Configuração (preferências do sistema, não .env)
Diferente da maioria das configurações do MAD, os limites do GED não ficam
em config/mad.php nem em variáveis de ambiente — ficam nas preferências do
sistema (mad_sys_preference), com chaves prefixadas ged_, editadas na aba
"GED" do PreferenceForm. A leitura é App\Service\Ged\GedSettingsService:
use App\Service\Ged\GedSettingsService;
GedSettingsService::get('max_file_size_mb', '50'); // string — sempre cast no call site
GedSettingsService::get('max_versions', '0');
GedSettingsService::get('x') lê a preferência ged_x, sem cache (leitura
fresh por id) — o valor novo já vale no mesmo request em que foi salvo.
| Preferência | Chave lida via get() |
Default | Efeito |
|---|---|---|---|
ged_max_file_size_mb |
max_file_size_mb |
50 |
Teto de tamanho por upload, em MB. 0 = sem teto. |
ged_allowed_extensions |
allowed_extensions |
'' (sem restrição) |
CSV de extensões, ex. pdf,doc,xlsx. Case-insensitive. |
ged_max_versions |
max_versions |
0 (ilimitado) |
Quantas versões manter por documento — acima disso, as mais antigas são removidas (objeto no storage + linha). |
ged_trash_retention_days |
trash_retention_days |
30 |
Ver Lixeira. |
O model
App\Models\Ged\Confige a tabelamad_ged_confignão existem mais — foram substituídos por essas preferências. Código antigo que chamavaConfig::get()/set()do GED precisa migrar paraGedSettingsService::get()(leitura) e para a aba GED doPreferenceForm(escrita).
Poda de versões antigas
public static function pruneVersions(int $documentId): void
Chamado automaticamente ao fim de todo storeVersion(). Se max_versions
for 0, não faz nada. Caso contrário, mantém as N mais recentes
(version_number desc) e apaga o resto — o objeto no storage
(GedStorage::delete(), ou seja Storage::delete()) e a linha — dentro de uma
transação.
Tamanho legível
DocumentVersion expõe um acessor Eloquent pronto:
$version->file_size_formatted; // "2.3 MB", "850 KB", "1.1 GB"...
Ver também
- Visão geral do GED
- Lixeira — o que acontece com as versões quando um documento é excluído permanentemente.
- Upload de arquivos (mad-file-field) — o padrão declarativo genérico do framework, para comparar com a abordagem manual do GED.