Docs›Gestão de Documentos (GED)›Compartilhamento público
Gestão de Documentos (GED)

Compartilhamento público

Link tokenizado anônimo: GedSharedLinkService + GedPublicLinkController, senha, expiração, níveis view/download.

Compartilhamento público

Além da ACL interna (ver Permissões), qualquer documento pode gerar um link público tokenizado — uma URL anônima, sem login, que qualquer pessoa com o link consegue abrir. É o padrão "compartilhar link" de qualquer produto de armazenamento de arquivos: token opaco, expiração opcional, senha opcional, e dois níveis de acesso.

O modelo

// app/Models/Ged/SharedLink.php — tabela mad_ged_shared_link, conexão `ged`
$link->document_id;
$link->token;             // 64 hex chars
$link->level;              // 'view' | 'download'
$link->password_hash;      // bcrypt, ou null
$link->expires_at;         // datetime, ou null = nunca expira
$link->max_access_count;   // limite de acessos, ou null = ilimitado
$link->access_count;       // contador, incrementado a cada acesso válido
$link->is_active;          // desativação manual (sem apagar a linha)

$link->is_expired;   // acessor: true se passou de expires_at OU access_count >= max_access_count
$link->url;           // acessor: url('/public/ged/' . token)

level controla o que o visitante pode fazer: view só permite pré-visualizar (preview embutido); download permite pré-visualizar e baixar o arquivo.

Criar, validar e registrar acesso

App\Service\Ged\GedSharedLinkService é a API:

use App\Service\Ged\GedSharedLinkService;

$link = GedSharedLinkService::create(
    documentId:     $doc->id,
    level:          'download',
    password:       null,                  // ou uma senha em texto puro — é hasheada aqui
    expiresAt:      '2026-12-31 23:59:59',  // ou null
    maxAccessCount: null,
    createdBy:      (int) session('userid'),
);

echo $link->url; // https://seuapp.com/public/ged/3f9a...64-hex-chars

O token é gerado com bin2hex(random_bytes(32)) — 64 caracteres hexadecimais, criptograficamente aleatórios. validateToken() é o que a rota pública chama para checar se um token ainda é válido (existe, ativo, não expirado, dentro do limite de acessos, e a senha bate, se houver):

$link = GedSharedLinkService::validateToken($token, $password); // null se inválido em qualquer critério
GedSharedLinkService::logAccess($link->id, $request->ip());     // incrementa access_count, loga atividade
GedSharedLinkService::deactivate($link->id);                    // desativa sem apagar (histórico preservado)

As rotas públicas — GedPublicLinkController

Este controller é um bom exemplo de como expor algo do admin para o público sem reaproveitar o MadComponent/MadForm: é um controller Laravel comum, registrado fora do grupo autenticado em routes/web.php:

Route::get('/public/ged/{token}',     [GedPublicLinkController::class, 'show'])
    ->where('token', '[A-Fa-f0-9]{64}')->name('ged.public');

Route::get('/public/ged/{token}/raw', [GedPublicLinkController::class, 'file'])
    ->where('token', '[A-Fa-f0-9]{64}')->name('ged.public.file');

// Unlock (senha) é anônimo → throttle anti brute-force: 8 tentativas/min por IP.
Route::post('/public/ged/{token}',    [GedPublicLinkController::class, 'unlock'])
    ->where('token', '[A-Fa-f0-9]{64}')->middleware('throttle:8,1')->name('ged.public.unlock');

A constraint de rota ([A-Fa-f0-9]{64}) já filtra formato antes de tocar no banco — defesa em profundidade, redundante com a checagem que o controller faz de qualquer forma (strlen($token) === 64 && ctype_xdigit($token)).

Rota Método O que faz
ged.public GET /public/ged/{token} Landing page — não baixa o arquivo direto. Mostra nome/tipo/tamanho, preview embutido (PDF via <iframe>, imagem via <img>) e botões Visualizar/Baixar.
ged.public.file GET /public/ged/{token}/raw Stream do arquivo de fato — inline (preview) ou attachment (?dl=1, só se level==='download').
ged.public.unlock POST /public/ged/{token} Recebe a senha, valida, e se OK marca o token como desbloqueado na sessão. Rate-limited (throttle:8,1) — é a única superfície de brute-force do link.

Fluxo de senha (sem re-POST no F5)

Quando o link tem password_hash, o GET em show() não pede a senha direto no form — ele checa se o token já foi desbloqueado nesta sessão:

private const SESSION_UNLOCKED = 'ged_public_unlocked';

private function isUnlocked(Request $request, string $token): bool
{
    return in_array($token, (array) $request->session()->get(self::SESSION_UNLOCKED, []), true);
}

unlock() valida a senha via GedSharedLinkService::validateToken() e, se certa, dá push() do token nesse array de sessão e redireciona de volta para show() (GET) — assim um F5 na landing nunca reenvia o POST da senha.

O que mais é checado antes de servir

Token válido não basta. show() e file() ainda exigem:

Checagem Falha
is_active no SharedLink 404
$link->is_expired (expiração ou teto de acessos) 410
Senha, quando há password_hash e o token não está desbloqueado na sessão tela de senha (show) / 403 (file)
Document.status === 'active' 404 — documento na lixeira ou arquivado não é servido por link público, mesmo com link ativo
?dl=1 num link de nível view 403
GedStorage::exists($version->storage_path) 404

Documento e versão são resolvidos com withoutGlobalScope('mad_unit') — o link público é uma capability (o token de 64 hex é a autorização), então o escopo de unidade não pode participar; sem isso um usuário logado de outra unidade receberia 404 no próprio link que recebeu. O escopo de tenant continua aplicado.

Contagem de acesso: uma vez, na landing

GedSharedLinkService::logAccess() é chamado só em show(), nunca em file(). O motivo é concreto: o preview embutido da landing carrega /raw sozinho — contar ali queimaria um link de uso único (max_access_count = 1) antes do visitante clicar em nada. logAccess() incrementa access_count e, ao bater max_access_count, seta is_active = false.

Containment do arquivo

file() nunca confia direto em DocumentVersion.storage_path. O path é uma chave relativa no disco de uploads, e App\Service\Ged\GedStorage valida a forma (string-level, antes de qualquer acesso a disco) e a existência:

if (!GedStorage::exists($version->storage_path)) {
    abort(404);
}

GedStorage::isValidKey() exige prefixo files/ged/ e rejeita null byte, \, .. e segmento vazio ou começando com .. Uma chave malformada nunca chega no Storage. Ver Upload e versionamento.

Servindo o arquivo

O stream sai por MadUploadStorage::response() (StreamedResponse via Flysystem — funciona igual em disco local e S3), e o Content-Type é derivado só da extensão, contra um allowlist canônico:

private const INLINE_MIME = [
    'pdf'  => 'application/pdf',
    'png'  => 'image/png',
    'jpg'  => 'image/jpeg',
    'jpeg' => 'image/jpeg',
    'gif'  => 'image/gif',
    'webp' => 'image/webp',
];

Nunca do mime_type gravado no banco — um .pdf cujos bytes são HTML teria mime_type = text/html e seria servido inline como HTML: XSS anônimo same-origin. Só as extensões acima servem inline; qualquer outra vai como attachment + application/octet-stream, com o browser proibido de renderizar. Os headers da resposta:

Header Valor
Content-Type do allowlist (inline) ou application/octet-stream
X-Content-Type-Options nosniff
Content-Security-Policy default-src 'none' (sem sandbox — quebraria o viewer de PDF no <iframe> da landing)
Content-Length DocumentVersion.file_size, quando conhecido — evita um HEAD extra no S3

A resposta é marcada setPrivate().

Preview embutido na landing

private const PREVIEW_PDF   = ['pdf'];
private const PREVIEW_IMAGE = ['png', 'jpg', 'jpeg', 'gif', 'webp'];

previewKind() retorna 'pdf', 'image' ou null — a view ged.public.show monta um <iframe>, um <img> ou nenhum preview a partir disso. SVG ficou de fora de propósito (conteúdo ativo), e nem chega a existir: svg está na lista de extensões bloqueadas no upload.

As views públicas ficam em resources/views/ged/public/ (show.blade.php, password.blade.php, error.blade.php).

A aba "Compartilhamento" do DocumentDetail tem um segundo <mad-db-blocks>, agora sobre SharedLink, com presets de expiração (never/1d/7d/30d):

public function onCreateLinkBlock(SharedLink $pivot)
{
    $data = (array) $this->form->getData();

    $expiresAt = match ($data['link_expires'] ?? 'never') {
        '1d'  => date('Y-m-d H:i:s', strtotime('+1 day')),
        '7d'  => date('Y-m-d H:i:s', strtotime('+7 days')),
        '30d' => date('Y-m-d H:i:s', strtotime('+30 days')),
        default => null,
    };

    $pivot->token         = bin2hex(random_bytes(32));
    $pivot->level         = in_array($data['link_level'] ?? '', ['view','download'], true) ? $data['link_level'] : 'view';
    $pivot->password_hash = !empty(trim($data['link_password'] ?? '')) ? password_hash(trim($data['link_password']), PASSWORD_BCRYPT) : null;
    $pivot->expires_at    = $expiresAt;
    $pivot->is_active     = true;
    $pivot->access_count  = 0;
    $pivot->created_by    = (int) session('userid');
}

"Remover" um link na lista não apaga a linha — onDeactivateLinkBlock() seta is_active = false e retorna false para cancelar o delete do mad-db-blocks, preservando o histórico de acessos.

Ver também