Ir para o conteúdo
Trilha
Capítulos

Receitas

Arquivos

Onde um upload mora, como ele volta, e a varredura dos objetos que ninguém aponta.

ctx --pack · est. 2001 tokens est. · llms.txt da receita

O framework sabia receber um arquivo e sabia devolver. Onde guardar nunca foi dito — e o que se escreve no lugar é os.WriteFile em ./uploads com o nome que o cliente mandou, que são os dois erros clássicos de uma vez: um caminho que sai do diretório, e duas pessoas subindo nota.pdf.

O trilha/blob é um módulo opcional: disco por padrão, S3-compatível quando uma variável diz, e nenhum SDK nos dois casos.

Ou: trilha add blob#

Esta página inteira documenta o trilha/blob; a receita blob é a tela e a ligação construídas em cima dele, então raramente se escreve o encanamento abaixo à mão:

trilha add blob --dry-run
  + internal/arquivos/arquivos.go
  + internal/arquivos/arquivos_test.go
  + app/arquivos/page.go
  + app/arquivos/chave__/route.go
  + arquivos_test.go
  ~ app/setup.go (uma linha acrescentada)

--dry-run: nada foi escrito

O internal/arquivos/arquivos.go é o Arquivos = blob.New(...), ligado uma vez, exatamente como abaixo; a página e a rota são o ReceiveFile e o SendFile já escritos, listando o que chegou e devolvendo pela chave. O que esta página soma é o que o arquivo da receita não explica: por que a chave é o digest e não o nome, no que uma URL pré-assinada pode e não pode confiar, e como funciona a varredura de órfãos. Rode a receita pela tela; mantenha esta página aberta pelas garantias por trás dela — blob.ServeOpts{Proxy: true}, o Orphans, qual loja escolher no blob.FromEnv().

A loja#

// Arquivos is the store, wired once. FromEnv is a directory on a laptop and a
// bucket in production, decided by one variable — and a URL it cannot read is
// a panic at boot, because an application that starts with the wrong storage
// loses files quietly.
var Arquivos = blob.New(blob.FromEnv())

O TRILHA_BLOB_URL decide:

ValorLoja
sem valor./data/blob em disco
file:///var/lib/app/blobesse diretório
s3://balde?region=sa-east-1AWS
s3://balde?region=us-east-1&endpoint=https://minio.localMinIO, Garage, R2 — path style por padrão

As credenciais vêm de AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY e AWS_SESSION_TOKEN, que é onde toda outra ferramenta já procura. Uma URL que ele não entende é pânico no boot, nunca um padrão silencioso: aplicação que sobe com o storage errado perde arquivo calada.

O pacote inteiro, para quando você preferir ligar na mão em vez de por variável:

SímboloPapel
blob.New(loja) *Filesa porta de entrada: Put, PutBytes, Get, Stat, Serve, Delete, Orphans
blob.NewDisk(dir) *Diskum diretório — o que um notebook e um servidor só querem
blob.NewMemory() *Memoryum mapa, para um teste que não pode tocar no disco
blob.S3{Bucket, Region, Endpoint, Key, Secret, Session, PathStyle, HTTP}o balde, assinado com SigV4 e mais nada — sem SDK
Files.PutBytes(ctx, nome, b, contentType)um arquivo que a aplicação produziu (um PDF gerado, um CSV), e não um que alguém enviou
Disk.Keys, Memory.Keys, S3.Keyspercorre toda chave da loja, uma chamada por chave, que é a metade com que o Orphans compara o banco
blob.Presigner, S3.Presign(ctx, chave, ttl)a loja que sabe entregar uma URL temporária; o Serve usa quando existe e transmite quando não, e o blob.ErrNotFound é o que uma chave ausente responde nos dois casos

Recebendo#

// ReceiveFile is the upload. Ctx.File applies the rules and sniffs the real
// type; blob.Put makes the key out of the content's digest, so the name
// somebody typed never becomes a path and the same file twice is one file.
//
// What goes in the database is the key. Everything else in the Ref — name,
// type, size, digest — is there so a screen can show a line about the file
// without opening it.
func ReceiveFile(c *trilha.Ctx) error {
	up, err := c.File("arquivo", trilha.FileRules{
		Accept:  []string{"application/pdf", "image/*"},
		MaxSize: 50 << 20,
	})
	if err != nil {
		return err
	}
	defer up.Close()

	ref, err := Arquivos.Put(c.Context(), up)
	if err != nil {
		return err
	}
	return saveDocument(c, ref.Key, ref.Name, ref.Type, ref.Size)
}

A chave é o conteúdo, nunca o nome. É o SHA-256 dos bytes, em dois níveis, com a extensão do tipo farejado:

ab/cd/abcd…ef.pdf

Então travessia de caminho não é evitada, é impossível — nada que veio da requisição chega à chave. O mesmo arquivo subido duas vezes é um objeto só. E um diretório não termina com cem mil entradas dentro.

O que o Put não decide é o que significa uma segunda referência à mesma chave. O Ref.SHA256 está ali para a aplicação contar referências se quiser; o Delete apaga o objeto, e se isso está certo é pergunta sua, não do módulo.

Devolvendo#

// SendFile hands it back. From disk this is http.ServeContent — Range, 304 and
// HEAD, which is what makes a large PDF work; from a bucket that can presign,
// it is a redirect and the bytes never touch the application.
func SendFile(c *trilha.Ctx) error {
	doc, err := findDocument(c, c.Param("id"))
	if err != nil {
		return err
	}
	return Arquivos.Serve(c, doc.Key, blob.ServeOpts{Name: doc.Name, Inline: true})
}

De disco isto é http.ServeContent: Range, If-Range, 304 e HEAD, que é o que faz um PDF grande e um vídeo funcionarem. De uma loja que sabe pré-assinar, o Serve responde um redirecionamento para uma URL de cinco minutos, e os bytes não passam pela aplicação.

A varredura#

// SweepOrphans is the command that runs on a schedule: the objects nothing
// points at. A row deleted while the object stayed, an upload that failed
// after the write — both end up here, and neither shows up anywhere else.
//
// The known keys are streamed out of the database rather than collected into a
// slice, because a bucket does not fit in memory and neither does the table.
func SweepOrphans(ctx context.Context) ([]string, error) {
	return Arquivos.Orphans(ctx, func(yield func(string) bool) {
		for _, key := range documentKeys(ctx) {
			if !yield(key) {
				return
			}
		}
	})
}

Toda aplicação acaba precisando disto: a linha apagada com o objeto ficando, o upload que falhou depois da escrita. As chaves conhecidas saem do banco em fluxo, e não numa fatia, porque nem o balde nem a tabela cabem em memória.

O que está testado#

Dois níveis, e é o segundo que conta.

O teste de unidade assina contra um servidor que recomputa a assinatura com o mesmo algoritmo que este pacote escreve — o que prova coerência interna, e nada além disso.

O TestS3AoVivo roda tudo contra um MinIO de verdade: cria o balde, guarda, lê, stat, lista, busca uma URL pré-assinada com um cliente que não assina nada, vê uma URL vencida ser recusada, apaga. Ele é pulado a menos que o TRILHA_S3_TEST aponte para um servidor, porque teste que depende de contêiner falha por motivo errado na máquina de outra pessoa:

docker run -d --name trilha-minio -p 9000:9000 \
  -e MINIO_ROOT_USER=trilha -e MINIO_ROOT_PASSWORD=trilha-secret-123 \
  quay.io/minio/minio:latest server /data

TRILHA_S3_TEST='s3://trilha-teste?region=us-east-1&endpoint=http://localhost:9000&path_style=1' \
AWS_ACCESS_KEY_ID=trilha AWS_SECRET_ACCESS_KEY=trilha-secret-123 \
go test ./blob/ -run TestS3AoVivo -v

Rodar uma vez com o segredo errado também vale: o MinIO responde SignatureDoesNotMatch, que é o que diz que o servidor está mesmo conferindo — e que a execução que passou quer dizer alguma coisa.