Armazenamento
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:
NELCOTA_STORAGE_BACKEND=disk
NELCOTA_STORAGE_DIR=/storage # projects/<name>/storage on the hostEm produção, um bucket compatível com S3 evita depender de um único disco (Cloudflare R2, Backblaze B2, AWS S3, Wasabi...):
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 themDepois, 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:
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):
-- 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:
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 ...
# 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 bucketimage/*). - Os arquivos são inertes. Todas as respostas incluem
X-Content-Type-Options: nosniffeContent-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, aponteNELCOTA_STORAGE_PUBLIC_URLpara um segundo domínio que encaminhe/storage/v1/object/public/*ao app. - Disco:
Range(busca em vídeos e retomada de downloads),ETageIf-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
302para 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:
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:
SELECT bucket_id || '/' || version AS key, bucket_id || '/' || name AS name
FROM storage.objects;Guias relacionados
Veja API REST, backup, JWT/papéis e solução de problemas.