Pular para o conteúdo
📖 COOKBOOK

Upload de PDF com signed URL.

Bucket privado e download via URL assinada que expira. Use pra contratos, comprovantes, NF-e. Mostra como gerar URL temporária no server e revogar acesso.

O que vamos fazer

PDF (contrato, comprovante, NF-e) vai pra bucket privado. O arquivo nunca é acessível por URL pública — quando o user pede pra baixar, o server gera uma URL assinada que expira em 1 hora. Depois do TTL, link morre. Sem replay attacks, sem vazamento por URL compartilhada.

Fluxo:

  • Bucket documentos privado, com policy "só dono lê".
  • Upload pelo client autenticado (RLS valida).
  • Quando user clica "baixar": Next.js server action chama createSignedUrl(path, 3600) com service-role.
  • Server valida que path pertence ao user antes de assinar.
  • URL volta pro client, abre em nova aba.

Pré-requisitos

  • Projeto SuperDB com Auth configurada.
  • Variável de ambiente SUPERDB_SERVICE_ROLE_KEY disponível só no server.
  • Bucket documentos criado privado (não marcar "Public").
⚠️

Service-role nunca no client: a chave service_role dá poderes de admin (bypassa RLS). Ela vive em variável de ambiente do server. Nunca em NEXT_PUBLIC_*, nunca em código que vai pro bundle.

Passo a passo

1. Criar bucket privado

Studio → Storage → New bucket. Nome documentos. NÃO marque "Public bucket". File size limit 25 MB (PDF cabe folgado). Allowed MIME: application/pdf.

2. Policy: dono só vê o próprio

schema.sql
-- Upload: só no próprio diretório
create policy doc_own_insert
  on storage.objects for insert to authenticated
  with check (
    bucket_id = 'documentos'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

-- Leitura via SDK autenticado: só o próprio (a signed URL bypassa isso)
create policy doc_own_select
  on storage.objects for select to authenticated
  using (
    bucket_id = 'documentos'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

3. Upload no client

Para upload, use HTTP direto contra storage.superdb.com.br com o token do usuário autenticado — a policy de INSERT valida o path.

⚠️

Não existe @superdb/superdb-js. @superdb/auth-js é apenas auth — sem .storage ou .from(). Use HTTP direto ou @supabase/supabase-js com storage.superdb.com.br e api.superdb.com.br.

src/components/UploadContrato.tsx
'use client'
import { useUser } from '@/lib/auth'

const ANON_KEY = process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!

export function UploadContrato() {
  const user = useUser()

  async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file || !user) return

    const path = `${user.id}/${crypto.randomUUID()}.pdf`

    // Upload via HTTP direto — storage.superdb.com.br
    const storageRes = await fetch(
      `https://storage.superdb.com.br/object/documentos/${path}`,
      {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${ANON_KEY}`, 'apikey': ANON_KEY,
                   'Content-Type': 'application/pdf' },
        body: file,
      }
    )
    if (!storageRes.ok) return alert('Upload falhou')

    // Persistir referência — api.superdb.com.br (sem /rest/v1/)
    await fetch('https://api.superdb.com.br/arquivos', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${ANON_KEY}`, 'apikey': ANON_KEY,
                 'Content-Type': 'application/json', 'Prefer': 'return=minimal' },
      body: JSON.stringify({ user_id: user.id, bucket: 'documentos', path, nome: file.name }),
    })
  }

  return <input type="file" accept="application/pdf" onChange={onFile} />
}

4. Server action: gerar signed URL

O segredo é gerar a signed URL no server e validar que o path pedido pertence ao usuário logado. Caso contrário, qualquer user logado conseguia gerar URL pro PDF de outro.

src/app/actions/download.ts
'use server'
import { getServerUser } from '@/lib/auth-server'

const SERVICE_KEY = process.env.SUPERDB_SERVICE_ROLE_KEY!

export async function getDownloadUrl(path: string) {
  const user = await getServerUser()
  if (!user) throw new Error('Não autenticado')

  // CRÍTICO: validar que o path é do user logado
  if (!path.startsWith(`${user.id}/`)) {
    throw new Error('Acesso negado')
  }

  // Signed URL via HTTP direto — storage.superdb.com.br
  const res = await fetch(
    `https://storage.superdb.com.br/object/sign/documentos/${path}`,
    {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${SERVICE_KEY}`, 'apikey': SERVICE_KEY,
                 'Content-Type': 'application/json' },
      body: JSON.stringify({ expiresIn: 3600 }),  // 1 hora
    }
  )
  if (!res.ok) throw new Error('Erro ao gerar signed URL')
  const { signedURL } = await res.json()
  return `https://storage.superdb.com.br${signedURL}`
}

5. Botão de download no client

src/components/DownloadBtn.tsx
'use client'
import { getDownloadUrl } from '@/app/actions/download'

export function DownloadBtn({ path, nome }: { path: string; nome: string }) {
  async function baixar() {
    const url = await getDownloadUrl(path)
    window.open(url, '_blank')
  }
  return <button onClick={baixar}>Baixar {nome}</button>
}
💡

Dica: precisa baixar vários de uma vez (boleto + NF-e + recibo)? Use createSignedUrls(paths, 3600) em batch — 1 round-trip pro server, N URLs assinadas.

Resultado

O que você tem:

  • PDFs guardados em bucket privado — invisíveis sem assinatura.
  • Link de download válido por 1 hora, depois expira sozinho.
  • Cada usuário só baixa os PDFs dele — validação no server, não confia no client.
  • Funciona pra contratos, comprovantes, NF-e, recibos, holerites.

Variações

TTL customizado

TTL em segundos: 3600 = 1h, 86400 = 24h, 604800 = 7d. Pra "compartilhar contrato com cliente externo": gere com TTL maior e mande por email. Mas não passe de 7d — se precisar de mais, o caso é outro (ver erros comuns).

Forçar download (Content-Disposition)

Por padrão o navegador tenta abrir o PDF inline. Pra forçar "Salvar como":

com download flag
const { data } = await admin.storage
  .from('documentos')
  .createSignedUrl(path, 3600, {
    download: 'contrato-2026.pdf',  // força Content-Disposition
  })

Audit log de quem acessou

Antes de chamar createSignedUrl, insira 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

⚠️

Chamar createSignedUrl no client: só funciona se o user tem policy SELECT no objeto — o que vaza permissão. Sempre gere no server com service-role + validação manual de ownership.

⚠️

TTL muito longo: createSignedUrl(path, 90 * 86400) vira link público de fato — quem receber pode redistribuir, e o link continua funcionando por 90 dias. Pra "compartilhar permanente", use bucket público com policy de leitura ou implemente reverse proxy.

⚠️

Não validar ownership no server: se o server só repassa path pro createSignedUrl sem checar que começa com auth.uid(), qualquer user logado descobre o ID de outro (vaza em URL, log, etc) e gera URL pro PDF alheio. Sempre path.startsWith(user.id + '/').

⚠️

Bucket público em vez de privado: se marcou "Public bucket" por engano, todo URL /object/public/... funciona sem assinatura — vaza tudo. Veja em Studio → bucket → Configuration.

Essa página ajudou?