Pular para o conteúdo
Nelcota
EN : Read in English

Armazenamento

7 min de leitura Editar no GitHub

Os arquivos seguem a mesma regra de todo o sistema: o Postgres decide o acesso. Cada arquivo é uma linha em storage.objects; as políticas RLS dessa tabela determinam quem pode enviar, ler, substituir ou apagar o arquivo. Os bytes ficam no disco do servidor ou em qualquer bucket compatível com S3. O projeto e seus motivos estão nas decisões D77–D80 em Registro de decisões.

Pré-requisitos

Você precisa de metadados de armazenamento inicializados, backend disco/S3 ativo e políticas para seu solicitante. Os exemplos pressupõem usuário autenticado e criação explícita do bucket. Esta página descreve a fonte; confira a versão correspondente para limites e correções publicados. Mantenha segredos do provedor na configuração privada do deploy.

Como ativar

Projetos novos (nelcota init) guardam os arquivos no disco do projeto:

shell
NELCOTA_STORAGE_BACKEND=disk
NELCOTA_STORAGE_DIR=/storage        # projects/<name>/storage on the host

Em produção, um bucket compatível com S3 evita depender de um único disco (Cloudflare R2, Backblaze B2, AWS S3, Wasabi...):

shell
NELCOTA_STORAGE_BACKEND=s3
NELCOTA_STORAGE_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com   # empty = AWS
NELCOTA_STORAGE_S3_BUCKET=files
NELCOTA_STORAGE_S3_REGION=auto                                          # R2: auto; AWS: us-east-1...
NELCOTA_STORAGE_S3_ACCESS_KEY_ID=...
NELCOTA_STORAGE_S3_SECRET_ACCESS_KEY=...
# NELCOTA_STORAGE_S3_VIRTUAL_HOSTED=true   # bucket.endpoint URLs, if the provider requires them

Depois, execute nelcota -p <name> up. Sem NELCOTA_STORAGE_BACKEND, o armazenamento fica desligado e /storage/v1 não existe. Para ativá-lo em um projeto criado antes desse recurso, adicione as duas linhas de disk ao .env e - ./storage:/storage aos volumes do app em docker-compose.yml (antes, execute mkdir storage && chown 65532:65532 storage).

No Unix, a CLI cria a pasta raiz do armazenamento em disco com permissões 0700, pertencente ao UID 65532 do app nos hosts Docker. O servidor também aplica 0700 às pastas existentes na inicialização, antes de abrir o armazenamento. Isso impede outros usuários do host de ler ou listar arquivos privados, incluindo envios em preparação e arquivos antigos, independentemente das permissões individuais. O servidor deve ser dono da pasta e poder alterar suas permissões. No Windows, configure a ACL equivalente na pasta; permissões Unix não configuram ACLs do Windows.

Variável Padrão
NELCOTA_STORAGE_MAX_FILE_SIZE 50 MiB maior tamanho por arquivo; um bucket só pode diminuir esse limite
NELCOTA_STORAGE_MAX_TOTAL_SIZE nenhum limite total de todos os buckets, em bytes
NELCOTA_STORAGE_MIN_FREE_BYTES 1 GiB armazenamento em disco: recusa envios que deixariam menos espaço livre
NELCOTA_STORAGE_UPLOAD_TIMEOUT_SECS 3600 prazo compartilhado para admissão, esperas pelo banco/políticas e recebimento dos bytes
NELCOTA_STORAGE_PUBLIC_URL o da API origem das URLs de arquivos públicos, por exemplo https://files.shop.com

Os envios reservam capacidade enquanto seus corpos são recebidos. Uma requisição sem Content-Length reserva até o tamanho permitido para o arquivo ou a capacidade restante; outro envio pode receber 507 até a liberação da reserva. O total confirmado é verificado em uma transação serializada no banco, inclusive quando várias instâncias do servidor compartilham o mesmo banco. Nas substituições, os bytes antigos são descontados da cota total, mas ainda é necessário espaço em disco para gravar a nova versão.

Um processo pode ter no máximo 32 envios ativos. Tentativas excedentes recebem 503 upload_busy imediatamente. A admissão e o recebimento dos bytes compartilham o prazo configurado, incluindo esperas pelo pool e pela cota (408 upload_timeout); a publicação dos metadados é concluída com os limites de tempo do banco, para que um cancelamento não apague bytes após um commit de resultado incerto.

Uploads multipart cancelados ou que excedem o prazo abortam as partes enviadas em uma tarefa de limpeza limitada. A criação e a limpeza compartilham a capacidade de 32 uploads; a criação pode levar até 30 segundos e o abortamento até 10 segundos após o cancelamento. Configure também uma regra de ciclo de vida S3 AbortIncompleteMultipartUpload: quedas do processo, provedores indisponíveis ou a perda da resposta de criação antes de receber o ID do upload impedem uma limpeza confiável pelo servidor. A coleta de objetos órfãos só enxerga objetos concluídos, não partes multipart incompletas.

storage.usage guarda o total de bytes confirmado. Triggers por instrução o atualizam de forma transacional em inserções, atualizações, exclusões, upserts e truncamentos, incluindo SQL administrativo; operações em lote agregam suas mudanças uma única vez. Só o papel interno do armazenamento pode lê-lo. Nenhum papel de quem chama a API pode alterá-lo. A migração inicializa esse total enquanto as gravações de metadados estão bloqueadas. Se um operador desativou triggers durante a manutenção, faça a reconciliação antes de retomar as gravações:

SQL
BEGIN;
LOCK TABLE storage.objects IN SHARE MODE;
UPDATE storage.usage SET bytes = (SELECT coalesce(sum(size), 0)::bigint FROM storage.objects)
WHERE singleton;
COMMIT;

Buckets e políticas

Um bucket é uma linha em storage.buckets. Crie-o em uma migração (ou com POST /storage/v1/bucket como service_role):

SQL
-- migrations/V2__avatars.sql
INSERT INTO storage.buckets (id, public, file_size_limit, allowed_mime_types)
VALUES ('avatars', true, 2097152, '{image/*}');

-- Each user writes only into the folder named after their id.
CREATE POLICY avatar_owner ON storage.objects FOR ALL TO authenticated
    USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = auth.uid()::text)
    WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = auth.uid()::text);

Até que uma política permita, apenas service_role acessa qualquer arquivo. public libera somente leituras por /object/public/; gravações em um bucket público continuam sujeitas às políticas.

Operação Políticas necessárias
envio (POST) INSERT
substituição (PUT) INSERT, SELECT, UPDATE
download, assinatura e listagem SELECT
exclusão SELECT, DELETE

owner recebe auth.uid() no envio, por isso owner = auth.uid() é a política mais simples para arquivos privados. Funções auxiliares para caminhos: storage.foldername('a/b/c.png') → {a,b}, storage.filename(...) → c.png, storage.extension(...) → png. A política vê a linha final, incluindo size, portanto uma cota por usuário também pode ser uma política:

SQL
CREATE POLICY quota ON storage.objects AS RESTRICTIVE FOR INSERT TO authenticated
    WITH CHECK ((SELECT coalesce(sum(size), 0) FROM storage.objects
                  WHERE owner = auth.uid()) + size <= 100 * 1024 * 1024);

Nomes de buckets: letras minúsculas, números, - e _; public, sign e list são reservados. Um bucket com arquivos não pode ser apagado.

API HTTP

Todas as rotas aceitam o cabeçalho habitual Authorization: Bearer <jwt> (sem ele = anon). Os caminhos usam codificação percentual; os segmentos não podem estar vazios nem ser . ou ...

shell
# upload (409 if the name exists; PUT replaces)
curl -X POST "$API/storage/v1/object/avatars/$USER_ID/me.png" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: image/png' --data-binary @me.png
# {"id":"...","bucket":"avatars","name":"<id>/me.png","size":48213,
#  "mime_type":"image/png","etag":"...","public_url":"/storage/v1/object/public/avatars/<id>/me.png"}

curl "$API/storage/v1/object/avatars/$USER_ID/me.png" -H "authorization: Bearer $TOKEN"   # download
curl "$API/storage/v1/object/public/avatars/$USER_ID/me.png"                               # public bucket
curl -X DELETE "$API/storage/v1/object/avatars/$USER_ID/me.png" -H "authorization: Bearer $TOKEN"
Rota
POST /storage/v1/object/{bucket}/{path} enviar um arquivo novo (201)
PUT /storage/v1/object/{bucket}/{path} enviar ou substituir (200 para substituição, 201 para criação)
GET /storage/v1/object/{bucket}/{path} baixar conforme as políticas de quem faz a chamada
GET /storage/v1/object/public/{bucket}/{path} baixar de um bucket público, sem token
POST /storage/v1/object/sign/{bucket}/{path} {"expires_in": 3600} → {"signed_url": "..."} (até 7 dias)
GET /storage/v1/object/sign/{bucket}/{path}?token=... baixar com uma URL assinada, sem token
POST /storage/v1/object/list/{bucket} {"prefix": "a/", "limit": 100, "offset": 0} → {"folders": [...], "objects": [...]}
DELETE /storage/v1/object/{bucket}/{path} apagar (204)
GET/POST /storage/v1/bucket, GET/PUT/DELETE /storage/v1/bucket/{id} buckets (gravações: service_role)

A listagem aplica limit e offset separadamente aos arrays ordenados folders e objects. Avance o deslocamento pelo limite solicitado enquanto qualquer um dos arrays preencher uma página; uma página só com pastas ainda pode ter uma próxima página.

Um arquivo que a pessoa não pode ver responde 404, como se não existisse. Downloads aceitam ?download para forçar “salvar como”. Erros: 400 invalid_path, 404 bucket_not_found/object_not_found, 409 object_exists, 413 file_too_large, 415 mime_type_not_allowed, 507 storage_full. Os endpoints JSON (gravação de buckets, listagem e assinatura) respondem 400/422 invalid_body para um corpo malformado e 415 unsupported_media_type sem Content-Type: application/json; um intervalo que não pode ser servido recebe 416 range_not_satisfiable (com Content-Range: bytes */<size>). Todos os erros usam o formato JSON da API (veja API REST).

Como os arquivos são servidos

  • O tipo vem dos bytes, não do cliente: um “PNG” que na verdade é HTML é armazenado como text/html (e recusado por um bucket image/*).
  • Os arquivos são inertes. Todas as respostas incluem X-Content-Type-Options: nosniff e Content-Security-Policy: sandbox. Apenas imagens (exceto SVG), áudio, vídeo e texto simples abrem no navegador; HTML, SVG, PDF e os demais formatos são baixados. Para servir arquivos públicos em uma origem própria, aponte NELCOTA_STORAGE_PUBLIC_URL para um segundo domínio que encaminhe /storage/v1/object/public/* ao app.
  • Disco: Range (busca em vídeos e retomada de downloads), ETag e If-None-Match. Arquivos públicos podem ficar em cache por 60 s; os privados são revalidados a cada acesso.
  • S3: os downloads recebem um 302 para uma URL pré-assinada válida por no máximo 5 minutos; os bytes vêm diretamente do provedor.

Consistência

Os bytes de um arquivo são armazenados sob uma nova chave (<bucket>/<uuidv7>) em cada envio, e a linha só é gravada quando todos os bytes chegam. Quem lê nunca vê um arquivo incompleto, e um envio que falha ou é recusado não deixa resíduos. Substituir ou apagar um arquivo remove os bytes antigos imediatamente: restaurar o banco não recupera arquivos apagados. Um coletor executado a cada hora remove bytes sem referência em nenhuma linha quando eles completam um dia (resíduos de uma falha durante o envio).

Backups

No armazenamento em disco, cada dump tem seu próprio snapshot <dump>.files/ e manifesto SHA-256. nelcota backup --upload envia esse snapshot imutável para s3://<backup bucket>/<project>/<dump>.files/. Exclusões posteriores não alteram os snapshots antigos. Após perder o servidor:

shell
nelcota -p shop restore backups/nelcota-shop-<date>.dump --files --yes

--files verifica e restaura o snapshot desse dump, localmente ou a partir do bucket de backup. Mantenha o .dump e a pasta .dump.files/ juntos. No armazenamento S3, os arquivos permanecem no provedor; use versionamento ou replicação se precisar de mais cópias.

Exportação

Os metadados são SQL comum (storage.objects), e os bytes são arquivos comuns ou objetos S3. Para recuperá-los com seus nomes:

SQL
SELECT bucket_id || '/' || version AS key, bucket_id || '/' || name AS name
  FROM storage.objects;

Veja API REST, backup, JWT/papéis e solução de problemas.