Docs›Gestão de Documentos (GED)›Upload e versionamento
Gestão de Documentos (GED)

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():

  1. Sanitiza o nome vindo cru de $_FILES — normaliza \ → /, reduz a basename() (mata traversal ../) e remove separadores/controles residuais. Nome vazio/./.. vira file.
  2. Calcula o próximo version_number (MAX(version_number) + 1 do documento).
  3. 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_MAP por extensão quando o finfo falha.
  4. MadUploadStorage::put($tmpPath, "files/ged/{$id}/v{$n}_{$nome}") — lança RuntimeException em falha de escrita (os discos têm 'throw' => false; sem isso o path iria pro banco sem objeto atrás). O tmp é consumido depois.
  5. Só então abre a transação em DB::connection('ged'): grava a linha de DocumentVersion e atualiza Document.current_version_id.
  6. Fora da transação: pruneVersions() (abaixo) e a atividade version_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\Config e a tabela mad_ged_config não existem mais — foram substituídos por essas preferências. Código antigo que chamava Config::get()/set() do GED precisa migrar para GedSettingsService::get() (leitura) e para a aba GED do PreferenceForm (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