Skip to content

Upload de ficheiros ​

@basaltkit/files é o pipeline de upload que assenta sobre @basaltkit/storage: valida o content type e o tamanho, impõe uma quota por tenant, escreve os bytes delimitados por tenant, regista a metadata e emite hooks para que a análise e as miniaturas aconteçam fora de banda. Está desacoplado do transporte — o parsing multipart fica no teu handler — e do backend, porque cada byte passa por um Disk de armazenamento.

Modelo mental ​

Um ficheiro são duas coisas que não devem ser confundidas:

PeçaVive emPertence a
Os bytesum Disk de armazenamento (local, S3, GCS, Azure) em files/<uuid>@basaltkit/storage
O registo (nome, tamanho, content type, SHA-256, quem carregou, resultado da análise)um FileStore@basaltkit/files

Files.upload() é a única coisa que escreve ambos, por esta ordem: validar tamanho → validar content type → verificar quota → escrever bytes → gravar registo → emitir file:uploaded. Se a validação ou a quota rejeitarem, nada é escrito — nem bytes nem registo.

Numa app multi-tenant, todas as operações são delimitadas por tenant. O tenant vem do argumento explícito tenantId, ou de ctx().tenant.id, e não há terceira opção: sem nenhum dos dois, a chamada lança FileTenantRequiredError (400 FILE_TENANT_REQUIRED) em vez de cair para um namespace global. O acesso ao armazenamento é depois embrulhado no contexto desse tenant, por isso o prefixo tenants/<id>/ do disco aplica-se mesmo quando o upload corre a partir de um job em background sem pedido ambiente.

Numa app single-tenant — sem tenancyPlugin — não existe dimensão de tenant, logo não há nada a fechar: upload/list/get/download/delete funcionam sem tenantId, os registos ficam numa única chave interna, SINGLE_TENANT_SCOPE ('@single' — fora da gramática de ids de tenant, logo nenhum tenant pode receber esses registos), e os caminhos de armazenamento ficam sem prefixo, tal como se usasses o @basaltkit/storage diretamente. Indica aí um disco do storagePlugin, ou passa um Disk construído com scope: null: um disco construído à mão com o scope predefinido recusa correr sem tenant. Vê Para além do SaaS.

Atualizar dados single-tenant

Antes do @basaltkit/files 5.0 a chave single-tenant era 'default' — um id de tenant válido, logo um tenant chamado default lia (e, ao apagar, deixava órfãos) os ficheiros single-tenant. Uma app single-tenant com registos persistidos muda-lhes a chave uma vez: UPDATE files SET "tenantId" = '@single' WHERE "tenantId" = 'default' (e o mesmo em file_versions com @basaltkit/files-versions). Salta este passo se default alguma vez foi um tenant real nessa base de dados.

Arranque rápido ​

O filesPlugin precisa de um disco. Regista primeiro o storagePlugin, aponta o filesPlugin a um disco pelo nome (ou passa uma instância Disk) e monta as rotas de leitura/gestão através do teu adaptador:

ts
// src/app.ts
import { createApp } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'
import { authPlugin, MemoryUserSource } from '@basaltkit/auth'
import { storagePlugin } from '@basaltkit/storage'
import { FILES, filesPlugin, fileRoutes } from '@basaltkit/files'

export const app = await createApp({
  plugins: [
    // ... o teu plugin de tenancy, que define ctx().tenant ...
    authPlugin({ users: new MemoryUserSource(), secret: process.env.AUTH_SECRET! }),
    storagePlugin({ default: 'uploads', disks: { uploads: { driver: 'local', root: './storage' } } }),
    filesPlugin({
      disk: 'uploads',                                   // um nome de disco ou uma instância Disk
      // Os uploads têm um limite de 25 MiB (DEFAULT_MAX_FILE_SIZE) mesmo sem
      // qualquer `validate`; define o teu limite e uma allowlist:
      validate: { maxSize: 5_000_000, allowedTypes: ['image/*', 'application/pdf'] },
      maxTotalBytes: 1_000_000_000,                      // quota por tenant (1 GB)
    }),
    fastifyPlugin({ routes: [...fileRoutes()] }),
  ],
}).boot()

await app.container.get(FASTIFY).listen({ port: 3000 })

Todas as rotas de fileRoutes() declaram meta: { auth: true }, por isso o authPlugin tem de estar registado — caso contrário o adaptador recusa arrancar com UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META) em vez de servir os teus ficheiros sem autenticação. Vê o guia de adaptadores.

Fazer upload ​

O caminho mais curto é a rota opcional: fileRoutes({ upload: { maxBytes: 25 * 1024 * 1024, maxFiles: 1, allowedTypes: ['application/pdf'] } }) monta POST /files — um upload em stream diretamente para o armazenamento, com uploadedBy do utilizador que chama, o tenant do contexto do pedido e as mesmas regras de validação e quota de qualquer outro upload. Está desligada por predefinição: os limites são teus, por isso a rota só existe depois de os declarares.

Escreve a tua quando precisares de outra forma (campos extra, o teu próprio registo, outro URL). Declara o body da rota com upload() do @basaltkit/http: um body multipart/form-data em stream que funciona igual em Fastify, Express e Hono. É uma route() normal, por isso o pipeline inteiro (rate limit, enrichers de tenant e utilizador, guards auth/can) corre antes de ser lido um único byte do body, e um upload não autenticado é recusado sem ser recebido. Cada ficheiro chega ao handler como stream; entrega-o a FILES.upload, que trata da validação, da quota, do armazenamento delimitado por tenant, do checksum e do hook file:uploaded:

ts
import { route, upload, HttpError } from '@basaltkit/http'
import { FILES, toPublicFile } from '@basaltkit/files'
import { ctx } from '@basaltkit/core'
import { app } from './app.js'

const files = app.container.get(FILES)

export const uploadFile = route({
  method: 'POST',
  url: '/files/upload',
  body: upload({
    maxBytes: 25 * 1024 * 1024,                           // o pedido inteiro; 413 acima disso
    maxFiles: 1,                                          // 400 TOO_MANY_FILES acima disso
    allowedTypes: ['application/pdf', 'image/*'],         // tipo declarado; 415 caso contrário
  }),
  meta: { auth: true },
  async handler({ body, reply }) {
    for await (const file of body.files) {
      const record = await files.upload(file.stream, {
        name: file.filename,                              // basename sanitizado, nunca um caminho
        contentType: file.declaredType,                   // o que o cliente afirma, por isso liga o validate.sniff
        uploadedBy: ctx().user?.id,                       // o tenantId vem de ctx().tenant
        metadata: { source: 'web', title: body.fields['title'] }, // campos de texto enviados antes do ficheiro
      })
      return reply.code(201).send(toPublicFile(record))   // nunca o FileRecord cru
    }
    throw new HttpError(400, 'FILE_REQUIRED', 'Attach a file.')
  },
})

O body é analisado enquanto o handler o lê (nunca vai para um buffer), e todos os limites são aplicados aos bytes efetivamente recebidos. O guia de adaptadores lista todas as opções e erros. Continuas a poder passar um Buffer a FILES.upload.

Passa contentLength: file.declaredLength quando existir e o S3 transmite o ficheiro num único PutObject. declaredLength é o cabeçalho Content-Length da própria parte; o RFC 7578 não o exige e nenhum browser o envia, por isso normalmente é undefined — e o Content-Length do pedido (body.contentLength) cobre todas as partes mais o enquadramento multipart, por isso é um limite superior para um ficheiro, nunca o seu tamanho — nunca o passes como contentLength. Um tamanho declarado é verificado contra os bytes (uma divergência dá 400 STORAGE_CONTENT_LENGTH_MISMATCH e não guarda nada). Sem ele, a escrita é limitada por validate.maxSize.

O FileRecord devolvido é { id, tenantId, name, contentType, size, path, checksum, uploadedBy?, metadata?, scannedAt?, createdAt }. O path é a chave dentro do disco (files/<uuid>); o disco acrescenta o prefixo do tenant em todas as operações, por isso o objeto aterra realmente em tenants/<tenantId>/files/<uuid>. É por isso que uma rota responde com toPublicFile(record) — { id, name, contentType, size, createdAt, scannedAt?, scan?: { clean }, metadata? } — e guarda path, checksum, tenantId, uploadedBy e o detail do scanner no servidor, como faz o fileRoutes() (vê Rotas).

Uploads em stream ​

upload() também aceita o ficheiro como stream: um Readable do Node, qualquer AsyncIterable<Uint8Array>, ou um ReadableStream web. O stream é lido uma vez, e o validate.maxSize é aplicado à medida que chega — no instante em que passa o limite a origem é destruída/cancelada e é lançado 413 FILE_TOO_LARGE, sem nada escrito. O tamanho e o checksum SHA-256 são calculados em andamento, e o validate.sniff (abaixo) inspeciona os primeiros 64 KiB, por isso um ficheiro disfarçado é recusado antes de o resto ser lido.

ts
// Fastify + @fastify/multipart: part.file é um Readable — sem toBuffer()
const part = await request.file()
const record = await files.upload(part.file, { name: part.filename, contentType: part.mimetype, uploadedBy: ctx().user?.id })

// Qualquer runtime web-standard (Hono, um body PUT cru): o body é um ReadableStream
const record = await files.upload(request.body!, { name, contentType: request.headers.get('content-type') ?? 'application/octet-stream' })

// O body neutro upload() de rota do @basaltkit/http dá { stream } por ficheiro — em todos os adaptadores
const record = await files.upload(file.stream, { name: file.filename, contentType: file.declaredType })

Diretamente para o backend quando o driver consegue transmitir

Num disco cujo driver implementa putStream — local, s3, azure, gcs (vê Ficheiros grandes) — os bytes vão diretamente para o armazenamento. Este módulo só segura a janela de sniffing de 64 KiB; o driver segura o que o seu protocolo de upload precisa — uma parte de cada vez no multipart do S3 (partSizeBytes × queueSize, 20 MiB por omissão), os buffers de blocos do SDK no Azure — e nunca até maxSize. Passa contentLength quando o cliente declarou o tamanho exato do ficheiro; o S3 transmite-o então num único PutObject.

ts
// Um PUT cru cujo corpo É o ficheiro: o seu Content-Length é o tamanho do ficheiro.
// (Num pedido multipart não é — vê declaredLength acima.)
const declared = request.headers['content-length']
await files.upload(request.raw, {
  name,
  contentType: request.headers['content-type'] ?? 'application/octet-stream',
  ...(declared !== undefined ? { contentLength: Number(declared) } : {}),
})

Um contentLength declarado tem de cumprir o que promete: se não for um inteiro não negativo → 400 STORAGE_CONTENT_LENGTH_INVALID antes de se ler seja o que for; acima de maxSize → 413 FILE_TOO_LARGE; um corpo com mais ou menos bytes → 400 STORAGE_CONTENT_LENGTH_MISMATCH, sem objeto nem registo.

O caminho com buffer — no máximo maxSize em memória e depois disk.put — mantém-se como alternativa para um driver sem putStream, um validate.maxSize sem limite e sem contentLength declarado, ou um checkQuota próprio (a quem é pedido que aprove um tamanho que o stream ainda não tem). O registo é idêntico nos dois casos, e um upload falhado não deixa nem registo nem objeto parcial.

O contentType é a alegação do cliente

Sem sniffing, allowedTypes compara com o content type que passas, que num upload de browser é o que o browser disser: uma página HTML enviada como application/pdf passa. Liga o validate.sniff (abaixo) — e mantém a passagem de antivírus/moderação e a predefinição attachment dos URLs assinados (vê Armazenamento) para que um HTML ou SVG mal rotulado não possa renderizar na origem do armazenamento.

Limites de tamanho e de tipo ​

validate.maxSize tem por predefinição DEFAULT_MAX_FILE_SIZE — 25 MiB — e é aplicado mesmo quando não passas qualquer validate. Algo maior lança FileTooLargeError (413 FILE_TOO_LARGE) antes de escrever um único byte:

ts
import { DEFAULT_MAX_FILE_SIZE } from '@basaltkit/files'

filesPlugin({ disk: 'uploads', validate: { maxSize: 50 * 1024 * 1024 } })  // aumentar
filesPlugin({ disk: 'uploads', validate: { maxSize: Number.POSITIVE_INFINITY } }) // desligar

allowedTypes é uma allowlist com wildcard type/*: ['image/*', 'application/pdf'] aceita image/png e application/pdf e rejeita tudo o resto com FileTypeNotAllowedError (415 FILE_TYPE_NOT_ALLOWED). Sem allowedTypes, todos os content types são aceites.

Sniffing de conteúdo (validate.sniff) ​

validate: { sniff: true } verifica o que os bytes são em vez de confiar no que o cliente declarou. O sniffer embutido lê a assinatura do ficheiro (magic bytes, sem dependências) e reconhece PDF, PNG, JPEG, GIF, WebP, TIFF (as duas ordens de bytes), ZIP e os formatos Office (docx/xlsx/pptx, pelos nomes das entradas do ZIP), mais os formatos perigosos quando disfarçados: texto HTML, SVG e XML, e executáveis (PE/MZ, ELF, Mach-O, scripts #!). Com ele ligado:

  • conteúdo que contradiz o tipo declarado é recusado com FileTypeMismatchError (415 FILE_TYPE_MISMATCH) — uma página HTML enviada como application/pdf, um .exe enviado como image/jpeg, um SVG enviado como image/png, um Word enviado como PDF;
  • um tipo PDF/PNG/JPEG/GIF/WebP/TIFF/ZIP/Office declarado cujos bytes não trazem essa assinatura (um ficheiro renomeado ou truncado) é recusado da mesma forma;
  • allowedTypes julga o tipo detetado, o contentType do registo é o tipo detetado, e a alegação do cliente fica em metadata.declaredType;
  • conteúdo para o qual o sniffer não tem assinatura (texto simples, CSV, …) mantém o tipo declarado. application/octet-stream (ou nenhum tipo) é aceite como "desconhecido" e guardado como aquilo que os bytes forem só quando isso é um formato inerte — PDF, uma imagem raster, áudio, vídeo, ZIP/Office. HTML, SVG, XML, scripts e executáveis enviados como "uns bytes" são recusados com FileTypeMismatchError, nunca rerotulados para um tipo que o browser renderiza ou executa; guardar um desses exige declará-lo, e o allowedTypes julga então essa declaração.
ts
filesPlugin({
  disk: 'uploads',
  validate: { allowedTypes: ['image/*', 'application/pdf'], sniff: true },
})

// ou o teu próprio detetor (recebe os primeiros 64 KiB; devolve um MIME type ou null)
filesPlugin({ disk: 'uploads', validate: { sniff: (head) => myDetector(head) } })

O sniffing está desligado por predefinição (muda o que fica guardado), mas considera ligá-lo em qualquer app cujos utilizadores carregam ficheiros que outros utilizadores abrem. sniffContentType(bytes) é exportado se quiseres o mesmo detetor noutro sítio.

O limite do pipeline é distinto do maxBytes / allowedContentTypes por put da fachada de armazenamento — vê a referência de opções do armazenamento. Não precisas dos dois; o filesPlugin é o sítio certo para a política de upload.

Quotas ​

maxTotalBytes é o limite embutido por tenant. Antes de cada upload, o totalSize(tenantId) do store é somado ao tamanho a entrar; acima da linha lança StorageQuotaExceededError (402 FILE_QUOTA_EXCEEDED).

Para ligar o armazenamento a um plano, liga antes o checkQuota — um hook assíncrono que lança para rejeitar — a @basaltkit/subscriptions:

ts
filesPlugin({
  disk: 'uploads',
  checkQuota: (tenantId, size) =>
    subscriptions.features(tenantId).consume('storage_bytes', size),
})

Ambos correm quando ambos estão definidos: primeiro maxTotalBytes, depois checkQuota. Nota que consume() regista o consumo, por isso um checkQuota construído sobre ele tem de ser compensado libertando as unidades quando um ficheiro é apagado (ouve file:deleted) — caso contrário a quota do plano de um tenant só desce.

Servir e descarregar ​

Três formas de devolver bytes a um cliente, por ordem de preferência:

ts
// 1. Um URL assinado direto para o objeto — nenhum byte passa pela tua app.
const url = await files.temporaryUrl(id, '15m')

// 2. Os bytes, para ficheiros pequenos ou quando tens mesmo de fazer proxy.
const { record, content } = await files.download(id)

// 3. Só metadata.
const record = await files.get(id)          // FileRecord | null
const all = await files.list()              // FileRecord[] do tenant

temporaryUrl herda a predefinição do armazenamento: Content-Disposition: attachment, para que um HTML ou SVG carregado nunca renderize ao nível de topo na origem do armazenamento. Passa { disposition: 'inline' } (quarto argumento) quando a renderização no browser for deliberada — usos embebidos em <img>/<video> renderizam de qualquer forma. A justificação completa está em Armazenamento.

ts
await files.temporaryUrl(id, '15m', undefined, { disposition: 'inline' })

Download em stream ​

Quando tens mesmo de servir um ficheiro grande pela app, o downloadStream() espelha o download() — mesmo scope de tenant, mesma quarentena — sem o carregar para memória:

Devolve-a com stream() do @basaltkit/http — a resposta em stream neutra — e o adaptador trata do envio em Fastify, Express e Hono por igual:

ts
import { route, stream } from '@basaltkit/http'

route({
  method: 'GET',
  url: '/documents/:id',
  meta: { auth: true },
  async handler({ params }) {
    const { record, stream: body } = await files.downloadStream(params.id)
    return stream(body, { contentType: record.contentType, contentLength: record.size, filename: record.name })
  },
})

A partir daí a stream é do adaptador: um cliente que se desliga a meio do download destrói a fonte (sem socket S3 nem descritor de ficheiro pendurado), um cliente lento abranda a leitura em vez de encher a memória, e o HEAD responde apenas com os cabeçalhos. O filename é sanitizado e codificado segundo o RFC 5987, por isso um nome vindo do utilizador não consegue injetar um cabeçalho.

Consome a stream ou faz destroy() — uma stream abandonada mantém uma ligação (S3, Azure, GCS) ou um descritor de ficheiro (local) aberto; entregá-la a stream() passa essa responsabilidade ao adaptador. Com requireScan, um ficheiro em quarentena lança 423 FILE_NOT_SCANNED / 403 FILE_INFECTED antes de a stream sequer ser aberta; o { bypassQuarantine: true } é só para o scanner. O files.canStreamDownloads() diz-te se o driver do disco consegue fazer stream — um que não consiga lança STORAGE_GET_STREAM_UNSUPPORTED, por isso volta ao download() nesse caso (é o que o fileRoutes() faz).

Quarentena até à análise (requireScan) ​

markScanned(id, { clean: false }) regista uma análise falhada, mas por si só não impede que o ficheiro seja servido. filesPlugin({ requireScan: true }) torna a análise uma barreira: download(), downloadStream() e temporaryUrl() (e portanto GET /files/:id/content e POST /files/:id/url) lançam FileNotScannedError (423 FILE_NOT_SCANNED) até uma análise reportar o ficheiro limpo, e FileInfectedError (403 FILE_INFECTED) depois de uma o reportar não limpo — até nova análise limpa. Um instante de análise sem veredicto limpo conta como não analisado (fail closed). GET /files e GET /files/:id continuam a listar o ficheiro, com scannedAt e o veredicto como scan: { clean } (o detail do scanner fica no servidor), para uma UI poder mostrar "a analisar…" ou "bloqueado". Os erros trazem o seu status, por isso Fastify, Express e Hono respondem igual.

O próprio scanner tem de ler os bytes em quarentena: passa { bypassQuarantine: true } ao download aí — e em nenhum sítio que sirva utilizadores.

ts
filesPlugin({ disk: 'uploads', requireScan: true })

// no job de análise
const { content } = await files.download(id, tenantId, { bypassQuarantine: true })
await files.markScanned(id, { clean: await antivirus.check(content) }, tenantId)

Os URLs assinados precisam de um driver que os suporte: s3, GCS e Azure suportam, o driver local lança TemporaryUrlUnsupportedError (STORAGE_TEMPORARY_URL_UNSUPPORTED). Em desenvolvimento local, faz proxy por files.download().

files.delete(id) remove o objeto e o registo e emite file:deleted. É idempotente: apagar um id desconhecido é um no-op silencioso, nunca um 404.

Hooks de pós-processamento ​

file:uploaded dispara depois de o registo ser gravado, por isso a resposta do upload nunca espera pelo teu scanner. O padrão típico é despachar um job de fila e registar o resultado com markScanned, que grava o instante em scannedAt, junta o resultado a metadata.scan e emite file:scanned:

ts
import { defineJob } from '@basaltkit/queue'
import { FILES } from '@basaltkit/files'
import { app } from './app.js'

const files = app.container.get(FILES)

const ScanFile = defineJob<{ tenantId: string; id: string }>({
  name: 'files.scan',
  queue: 'files',
  async handle({ tenantId, id }) {
    // tenant explícito: os jobs não têm ctx; bypassQuarantine porque com requireScan o ficheiro ainda não é servido
    const { content } = await files.download(id, tenantId, { bypassQuarantine: true })
    const clean = await antivirus.check(content)            // o teu scanner
    await files.markScanned(id, { clean }, tenantId)        // emite file:scanned
  },
})

// no upload, despacha o job de análise — sem acoplamento ao caminho do upload
app.hooks.on('file:uploaded', ({ file }) =>
  ScanFile.dispatch({ tenantId: file.tenantId, id: file.id }))

Passa o tenantId explicitamente nos jobs

Dentro de um pedido o tenant é lido de ctx(). Um worker de fila corre fora de qualquer pedido, por isso passa o tenantId que puseste no payload do job — todos os métodos de Files o aceitam como argumento opcional exatamente por isto. Sem ele obténs 400 FILE_TENANT_REQUIRED, não o ficheiro de outro tenant.

As derivações (miniaturas, transcodificações) seguem a mesma forma, usando o pipeline de imagem do armazenamento. Precisa de um imageProcessor — SharpImageProcessor de @basaltkit/image-sharp — no storagePlugin, e as operações de disco têm de correr no contexto do tenant do ficheiro:

ts
import { runWithContext } from '@basaltkit/core'
import { STORAGE } from '@basaltkit/storage'

app.hooks.on('file:uploaded', async ({ file }) => {
  if (!file.contentType.startsWith('image/')) return
  const disk = app.container.get(STORAGE).disk('uploads')
  await runWithContext({ tenant: { id: file.tenantId } } as never, async () => {
    await disk.image(file.path).resize(256, 256).webp().save(`${file.path}-thumb.webp`)
  })
})

Sem um processador configurado, o terminal do pipeline lança ImageProcessingUnavailableError (STORAGE_IMAGE_UNAVAILABLE). Vê a secção do pipeline de imagem no guia de armazenamento.

Rotas ​

fileRoutes() monta endpoints de leitura/gestão para os ficheiros do tenant atual. São construídas sobre o route() neutro de @basaltkit/http, por isso servem de forma idêntica em Fastify, Express e Hono. O upload é opcional (upload: { maxBytes, … }); sem ele, escreves tu essa rota com o body neutro upload() mostrado em Fazer upload.

RotaCorpoDevolve
GET /files—os ficheiros que o utilizador pode ler, cada um via present
GET /files/:id—um ficheiro via present, ou 404 FILE_NOT_FOUND
GET /files/:id/content—os bytes, em stream, Content-Disposition: attachment; 404, ou 423/403 enquanto em quarentena
POST /files (opcional)multipart/form-data201 com os ficheiros criados, via present
POST /files/:id/url{ expiresIn? } (predefinição '15m', no máximo maxUrlTtl){ url } — assinado, attachment
DELETE /files/:id—204, ou 404 FILE_NOT_FOUND

O GET /files/:id/content faz stream diretamente do disco com stream(): nada vai para buffer, um cliente que se desliga a meio do download destrói a fonte, e o HEAD responde apenas com os cabeçalhos. É autorizado com a ação 'download', e tanto a barreira de quarentena (423/403) como o 404 fecham antes do primeiro byte. Um driver que não consegue fazer stream (sem getStream) continua a servir, com buffer. Desliga a rota com fileRoutes({ download: false }) se a tua instalação só distribui URLs assinados.

Projeção pública. Nenhuma rota responde com o FileRecord cru. Cada registo passa por present, por omissão toPublicFile(record): { id, name, contentType, size, createdAt, scannedAt?, scan?: { clean }, metadata? }. Ficam no servidor: path (a organização do teu bucket), checksum, tenantId, uploadedBy (o id de um utilizador — de outra pessoa, num drive partilhado) e o detail do scanner (saída do motor: nomes de assinaturas, versões, caminhos temporários); metadata perde apenas a sua entrada interna scan. Escolhe a tua própria forma de propósito:

ts
fileRoutes({
  shared: true,
  // corre depois da autorização, como quem chama
  present: (file, user) => ({ ...toPublicFile(file), mine: file.uploadedBy === user.id }),
})

Atualizar a partir do @basaltkit/files 5.x

Estas rotas devolviam o registo inteiro. Um cliente que lesse delas path, checksum, uploadedBy, tenantId ou metadata.scan.detail precisa agora de um present que o volte a acrescentar. files.get() / files.list() e os hooks continuam a entregar o registo completo ao teu código de servidor.

O POST /files só é montado quando passas upload. Não tem decisão de authorize por registo — ainda não há registo — por isso aceita qualquer utilizador autenticado e apoia-se nas regras do próprio Files (scope de tenant, validate, quota). Limita-o na borda com maxBytes / maxFiles / allowedTypes, e aplica-lhe rate limit como a qualquer outra rota de escrita.

Só o dono, por predefinição. Um utilizador só alcança os ficheiros cujo uploadedBy é o seu próprio ctx().user.id — por isso passa uploadedBy no teu handler de upload (acima). Um ficheiro que o utilizador não pode alcançar responde 404, tal como um inexistente, e um ficheiro enviado sem uploadedBy não é alcançável por ninguém através destas rotas. Escolhe outra política explicitamente:

ts
fileRoutes({ shared: true })   // um drive do tenant: todos os membros alcançam todos os ficheiros

fileRoutes({
  // action: 'read' | 'url' | 'delete'; GET /files mantém os registos permitidos para 'read'
  authorize: (action, record, user) =>
    record.uploadedBy === user.id || (action !== 'delete' && record.metadata?.['public'] === true),
})

Autenticação não é autorização de tenant

Todas as rotas declaram meta: { auth: true }, o que prova quem está a chamar. Não prova que quem chama pertence ao tenant que o pedido resolveu — o tenant vem de um header ou de um Host, ambos controlados pelo cliente. Regista o tenantMembershipPlugin() para que um utilizador válido do tenant A a enviar o identificador do tenant B seja travado com 403 TEAM_NOT_A_MEMBER antes de correr qualquer código de ficheiros. Sem ele, GET /files lista o tenant que o pedido alegar.

Guardar metadata de forma durável ​

O FileStore por predefinição é o MemoryFileStore — por processo, perdido no restart, e os bytes passam então a sobreviver aos registos que apontam para eles. Não existe pacote files-sqlite / files-prisma: a metadata de ficheiros pertence ao teu próprio schema, ao lado das linhas de domínio que a referenciam. O contrato tem seis métodos:

ts
import type { FileStore, FileRecord, FilePatch } from '@basaltkit/files'

class PrismaFileStore implements FileStore {
  constructor(private readonly prisma: PrismaClient) {}
  async create(record: FileRecord) { await this.prisma.file.create({ data: record }) }
  async find(tenantId: string, id: string) { return this.prisma.file.findFirst({ where: { tenantId, id } }) }
  async list(tenantId: string) { return this.prisma.file.findMany({ where: { tenantId } }) }
  async update(tenantId: string, id: string, patch: FilePatch) {
    return this.prisma.file.update({ where: { id }, data: patch })
  }
  async delete(tenantId: string, id: string) { await this.prisma.file.deleteMany({ where: { tenantId, id } }) }
  async totalSize(tenantId: string) {
    const { _sum } = await this.prisma.file.aggregate({ _sum: { size: true }, where: { tenantId } })
    return _sum.size ?? 0
  }
}

filesPlugin({ disk: 'uploads', store: new PrismaFileStore(prisma) })

Todos os métodos recebem o tenantId — mantém-no na cláusula where de todos eles. totalSize é o caminho quente da quota, por isso indexa (tenantId). Vê Persistência.

Referência de opções ​

filesPlugin(options) ​

OpçãoTipoPredefiniçãoPorquê
diskDisk | string— (obrigatório)O disco de armazenamento, por instância ou pelo nome declarado em storagePlugin({ disks }). Um nome desconhecido lança UnknownDiskError quando FILES é resolvido pela primeira vez
storeFileStoreMemoryFileStoreOnde vive a metadata — implementa-o sobre a tua base de dados em produção, ou os registos desaparecem no restart
validateFileValidation{ maxSize: 25 MiB }Política de upload (abaixo). Passar validate funde com o limite predefinido; não o remove
maxTotalBytesnumberilimitadoQuota embutida por tenant, verificada contra store.totalSize() antes de cada upload. Os uploads com quota de um tenant correm um de cada vez no processo, e o total é reverificado depois da inserção (um excesso causado por outra instância é revertido)
checkQuota(tenantId, size) => void | Promise<void>—Quota personalizada — lança para rejeitar. Liga-a a uma feature de plano em @basaltkit/subscriptions. Corre depois de maxTotalBytes
requireScanbooleanfalseQuarentena: download/temporaryUrl lançam 423 FILE_NOT_SCANNED até markScanned reportar o ficheiro limpo, 403 FILE_INFECTED depois de uma análise falhada. Vê Quarentena até à análise

O serviço Files aceita as mesmas opções mais hooks (o HookBus, injetado pelo plugin) e now (um relógio injetável para testes); constrói-o diretamente com new Files({ disk, ... }) quando quiseres o pipeline sem o contentor de DI.

FileValidation (a opção validate) ​

OpçãoTipoPredefiniçãoPorquê
maxSizenumber (bytes)DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024Rejeita payloads maiores com 413. Define Number.POSITIVE_INFINITY para abdicares do limite deliberadamente
allowedTypesstring[]qualquer tipoAllowlist com wildcards type/* ('image/*'). Comparada com o contentType que passas ao upload — ou, com sniff, com o tipo detetado
sniffboolean | (bytes: Uint8Array) => string | nullfalseDeteta o tipo real pelos magic bytes; recusa discrepâncias com 415 FILE_TYPE_MISMATCH, guarda o tipo detetado, mantém a alegação em metadata.declaredType. Vê Sniffing de conteúdo

fileRoutes(options?) ​

OpçãoTipoPredefiniçãoFunção
authorize(action, record, user) => boolean | Promise<boolean>só o donoA tua política por registo. action é 'read' (o registo), 'download' (os bytes), 'url' ou 'delete'; user é ctx().user. Substitui a predefinição
sharedbooleanfalseTodos os utilizadores autenticados do tenant alcançam todos os ficheiros (ignorado quando authorize está definido)
maxUrlTtlDurationInput'1h'O expiresIn mais longo que um cliente pode pedir a POST /files/:id/url
downloadbooleantrueMonta GET /files/:id/content, o download em stream
upload{ maxBytes, maxFiles?, allowedTypes? }— (desligado)Monta POST /files, o upload em stream. maxFiles é 1 por predefinição; allowedTypes compara com o tipo declarado (image/png, image/*)
present(record, user) => unknowntoPublicFileDá forma a cada registo com que as rotas respondem. A predefinição guarda path, checksum, tenantId, uploadedBy e o detail da análise no servidor

Todas as rotas declaram meta: { auth: true } — não há escape auth: false, ao contrário de billingRoutes. Se a autenticação acontecer mesmo numa borda exterior, dispensa a verificação de arranque com o allowUnguardedMeta do adaptador em vez de remover o meta.

POST /files/:id/url aceita { expiresIn } como string de duração ('30s', '15m', '1h'); a predefinição é '15m', tem de ser positivo e no máximo maxUrlTtl (um valor maior ou mal formado responde 400), e assina sempre com a disposição attachment.

Métodos do serviço Files ​

MétodoPorquê
upload(content, input)O pipeline. content é um Buffer/Uint8Array, um Readable do Node, um AsyncIterable<Uint8Array> ou um ReadableStream web; input é { name, contentType, tenantId?, uploadedBy?, metadata?, contentLength? }. Um stream vai diretamente para o backend quando o driver suporta putStream; contentLength é o tamanho declarado pelo cliente (uma pista — o real é sempre medido)
get(id, tenantId?)FileRecord | null — não lança se não encontrar
list(tenantId?)Todos os registos do tenant
download(id, tenantId?, { bypassQuarantine? }){ record, content }; lança FileNotFoundError, e com requireScan FileNotScannedError / FileInfectedError salvo bypassQuarantine (só para o scanner)
downloadStream(id, tenantId?, { bypassQuarantine? }){ record, stream } — o mesmo contrato do download, quarentena incluída, sem buffer. Quem chama tem de consumir ou fazer destroy() da stream; precisa de um driver com getStream
canStreamDownloads()true quando o driver do disco implementa getStream (local, S3, Azure, GCS) — segue o caminho em stream onde existe, e usa buffer onde não existe
temporaryUrl(id, expiresIn, tenantId?, { disposition? })URL assinado; attachment por predefinição. Sujeito a requireScan como o download
delete(id, tenantId?)Remove objeto + registo, emite file:deleted; idempotente
markScanned(id, { clean, detail? }, tenantId?)Regista o resultado de uma análise fora de banda, emite file:scanned

Modos de falha e resolução de problemas ​

ErroCódigoHTTPQuando
FileTooLargeErrorFILE_TOO_LARGE413Payload acima de validate.maxSize — 25 MiB por predefinição, mesmo sem validate
FileTypeNotAllowedErrorFILE_TYPE_NOT_ALLOWED415contentType (com sniff, o tipo detetado) não correspondido por validate.allowedTypes
FileTypeMismatchErrorFILE_TYPE_MISMATCH415validate.sniff está ligado e os bytes contradizem o tipo declarado (error.declared, error.detected)
FileNotScannedErrorFILE_NOT_SCANNED423requireScan está ligado e nenhuma análise reportou ainda o ficheiro limpo
FileInfectedErrorFILE_INFECTED403requireScan está ligado e a última análise reportou o ficheiro não limpo
StorageQuotaExceededErrorFILE_QUOTA_EXCEEDED402maxTotalBytes seria excedido por este upload
FileNotFoundErrorFILE_NOT_FOUND404download / markScanned / GET /files/:id para um id que não é deste tenant
FileTenantRequiredErrorFILE_TENANT_REQUIRED400Sem argumento tenantId e sem ctx().tenant — tipicamente um worker de fila ou a CLI
StorageContentLengthInvalidErrorSTORAGE_CONTENT_LENGTH_INVALID400contentLength negativo, fracionário, não finito ou acima de MAX_SAFE_INTEGER — nada é lido
StorageContentLengthMismatchErrorSTORAGE_CONTENT_LENGTH_MISMATCH400O corpo trouxe mais ou menos bytes do que contentLength — sem objeto nem registo
FileTenantMismatchErrorFILE_TENANT_MISMATCH403Um argumento tenantId diferente de ctx().tenant — dentro de um contexto de tenant o argumento só pode nomear esse tenant, nunca alargar a outro
UnknownDiskErrorSTORAGE_UNKNOWN_DISK—disk: 'name' não corresponde a nenhum disco em storagePlugin({ disks })
TemporaryUrlUnsupportedErrorSTORAGE_TEMPORARY_URL_UNSUPPORTED—temporaryUrl no driver local
StorageFileNotFoundErrorSTORAGE_FILE_NOT_FOUND—O registo existe mas o objeto não — bytes apagados fora de banda, ou o disco/scope mudou por baixo dos registos
ImageProcessingUnavailableErrorSTORAGE_IMAGE_UNAVAILABLE—disk.image(…) sem imageProcessor no storagePlugin
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_METAarranquefileRoutes() registado sem authPlugin — todas as rotas declaram meta.auth
  • 400 FILE_TENANT_REQUIRED a partir de um worker de fila ou de um cron — não há tenant ambiente fora de um pedido. Põe o tenantId no payload do job e passa-o a todas as chamadas de Files.
  • GET /files devolve os ficheiros de outro tenant — o identificador do tenant é fornecido pelo cliente e meta.auth não verifica pertença. Regista o tenantMembershipPlugin().
  • GET /files devolve [] para ficheiros que existem — a política predefinida é só o dono: os ficheiros foram enviados sem uploadedBy, ou por outra pessoa. Passa uploadedBy: ctx().user.id no upload, ou escolhe shared: true / authorize em fileRoutes().
  • Os ficheiros desaparecem depois de um redeploy, mas os bytes continuam no bucket — continuas no MemoryFileStore. Implementa FileStore sobre a tua base de dados.
  • STORAGE_TEMPORARY_URL_UNSUPPORTED só em desenvolvimento — o driver local não consegue assinar URLs. Serve por files.download() em dev, ou corre o MinIO atrás de um disco s3 para os dois ambientes se comportarem igual.
  • Um URL assinado descarrega em vez de pré-visualizar — essa é a predefinição fail-closed. Passa { disposition: 'inline' } por URL quando a renderização de topo for deliberada.
  • A quota do plano nunca recupera depois dos deletes — um checkQuota construído sobre features().consume() só incrementa. Liberta as unidades em file:deleted.

Eventos ​

HookPayload
file:uploaded{ file } — despacha aqui o job de análise/miniatura
file:deleted{ tenantId, id }
file:scanned{ file } — emitido por markScanned

Ver também ​

Publicado sob a licença MIT.