O que vamos fazer
O PDF (contrato, comprovante, nota) vai para um bucket privado: a chave pública não abre, e nenhuma URL sem assinatura funciona. Upload e download passam pelo seu servidor, que confere quem está logado; para baixar, o servidor gera um link assinado que vence em 1 hora.
Fluxo:
- Bucket
proj_meuapp_documentos, privado. - Upload: o formulário manda o arquivo para uma Server Action, que confere o usuário e grava com a chave de servidor na pasta dele.
- Download: outra Server Action confere que o caminho é do usuário e chama
createSignedUrl(path, 3600). - O link volta para o navegador e abre numa aba nova.
Limite de hoje: o Storage ainda não separa arquivos por usuário — todo usuário logado do projeto tem acesso de leitura e escrita aos buckets privados. Até isso mudar, não guarde aqui documento que um usuário do seu app não pode ver de outro.
Pré-requisitos
- Projeto SuperDB com Auth funcionando no seu app.
- Variável de ambiente
SUPERDB_SERVICE_ROLE_KEYdisponível só no servidor. - Tabela
arquivos(user_id,path,nome) para listar os documentos de cada um.
Chave de servidor nunca no navegador: ela escreve em qualquer bucket do projeto. Vive em variável de ambiente do servidor, nunca em NEXT_PUBLIC_*.
Passo a passo
1. Criar o bucket privado
No painel, Storage → Novo bucket. Nome documentos, visibilidade Privado e limite de 20 MB. O bucket nasce como proj_meuapp_documentos — é esse nome que vai no código.
2. Cliente do servidor
import 'server-only'
import { createClient } from '@superdb/client'
// Com a chave de servidor, o SDK troca por um token de storage que escreve
export const admin = createClient(
'https://auth.superdb.com.br',
process.env.SUPERDB_SERVICE_ROLE_KEY!,
{ project: 'meuapp' }
)
export const BUCKET = 'proj_meuapp_documentos'
3. Upload pela Server Action
'use server'
import { admin, BUCKET } from '@/lib/superdb-admin'
import { getServerUser } from '@/lib/auth-server'
export async function enviarDocumento(form: FormData) {
const user = await getServerUser()
if (!user) throw new Error('Não autenticado')
const file = form.get('arquivo')
if (!(file instanceof File) || file.type !== 'application/pdf') {
throw new Error('Envie um PDF')
}
const path = `${user.id}/${crypto.randomUUID()}.pdf`
const { error } = await admin.storage.from(BUCKET)
.upload(path, file, { contentType: 'application/pdf' })
if (error) throw new Error('Upload falhou')
await admin.from('arquivos').insert({ user_id: user.id, path, nome: file.name })
}
import { enviarDocumento } from '@/app/actions/documentos'
export function UploadDocumento() {
return (
<form action={enviarDocumento}>
<input type="file" name="arquivo" accept="application/pdf" required />
<button>Enviar</button>
</form>
)
}
PDF acima de 1 MB: Server Actions aceitam 1 MB por padrão. Aumente em next.config: experimental: { serverActions: { bodySizeLimit: '20mb' } }.
4. Download: link assinado gerado no servidor
O servidor confere que o caminho pedido pertence ao usuário logado antes de assinar. Sem isso, quem descobre o caminho de outro gera o link dele.
'use server'
import { admin, BUCKET } from '@/lib/superdb-admin'
import { getServerUser } from '@/lib/auth-server'
export async function linkDeDownload(path: string) {
const user = await getServerUser()
if (!user) throw new Error('Não autenticado')
// O caminho tem que ser da pasta do usuário logado
if (!path.startsWith(`${user.id}/`)) throw new Error('Acesso negado')
const { data, error } = await admin.storage
.from(BUCKET)
.createSignedUrl(path, 3600) // 1 hora
if (error || !data) throw new Error('Não foi possível gerar o link')
return data.signedUrl
}
5. Botão de download
'use client'
import { linkDeDownload } from '@/app/actions/download'
export function DownloadBtn({ path, nome }: { path: string; nome: string }) {
async function baixar() {
window.open(await linkDeDownload(path), '_blank')
}
return <button onClick={baixar}>Baixar {nome}</button>
}
Resultado
O que você tem:
- PDFs num bucket privado: a chave pública não abre, e URL sem assinatura não funciona.
- A chave de servidor fica no servidor; o navegador só recebe o link assinado.
- Link de download que vence em 1 hora.
Variações
Prazo do link
O prazo vai em segundos: 3600 = 1 hora, 86400 = 1 dia. Para mandar um contrato a alguém de fora, gere com prazo maior e envie por e-mail — mas prefira poucos dias: quem recebe o link pode repassar.
Registro de quem acessou
Antes de chamar createSignedUrl, grave numa tabela pdf_access_log:
create table pdf_access_log (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
path text not null,
ip text,
user_agent text,
acessado_em timestamptz default now()
);
Erros comuns
Nome curto do bucket: com 'documentos' no lugar de 'proj_meuapp_documentos', o Storage não acha o bucket. Use o nome como aparece no painel.
Não conferir o dono no servidor: se a Server Action só repassa path para o createSignedUrl, qualquer usuário que descubra o caminho de outro baixa o arquivo dele.
Prazo longo demais: createSignedUrl(path, 90 * 86400) é um link público por 90 dias — quem receber pode redistribuir.
Bucket público por engano: em bucket público, a URL /object/public/… abre sem assinatura. No painel, a visibilidade precisa ser Privado.