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).
Criando o link pela tela admin
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
- Permissões — ACL interna (usuário/grupo), complementar a este link anônimo.
- Roteamento — rotas públicas
- Upload e versionamento — de onde vem o
storage_pathservido aqui.