Pular para o conteúdo
📖 COOKBOOK

Upload de PDF com link assinado.

Bucket privado, upload pelo seu servidor e download por link assinado que vence. Para contratos, comprovantes e notas.

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_KEY disponí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

src/lib/superdb-admin.ts
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

src/app/actions/documentos.ts
'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 })
}
src/components/UploadDocumento.tsx
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.

src/app/actions/download.ts
'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

src/components/DownloadBtn.tsx
'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:

schema.sql
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.

Essa página ajudou?