feat(avatar): foto de perfil com editor de recorte circular

Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece
na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais
quando nao ha nenhuma.

A geometria vai em funcoes puras com testes, porque e onde os erros moram: a
escala minima cobre o quadrado em vez de caber nele (caber deixa um canto
vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e
preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda
para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao
descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer
comparacao, e propagava-se ate apagar o editor.

O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao,
por isso a foto guardada e tao nitida quanto o original permite em vez de
ficar limitada ao tamanho do editor no ecra.

Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem
pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e
recusado apesar de ser imagem: e um documento que pode levar script e seria
servido de volta a partir da nossa propria origem.

A leitura exige sessao, verificada na propria rota porque o guarda de prefixo
so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde
"esta caixa existe" a quem quer que pergunte, para todos os enderecos que
quiser experimentar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 08:04:12 -03:00
1 parent 82b4c8ce17
commit fcfee6a3f9
17 files changed
+978 -16

No files matched your search

+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS mailbox_avatars;
+17
View File
@@ -0,0 +1,17 @@
-- The profile photo a mailbox shows inside LambdaMail.
--
-- Stored in the database rather than the blob spool: an avatar is small,
-- there is at most one per mailbox, and it is read on nearly every screen -
-- so it belongs where the mailbox row already is, and is deleted with it.
--
-- Not to be confused with BIMI, which is one logo for a whole domain and is
-- the only thing an external mail client ever renders.
CREATE TABLE IF NOT EXISTS mailbox_avatars (
mailbox_id UUID PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE,
content_type VARCHAR(32) NOT NULL CHECK (content_type IN ('image/png','image/jpeg','image/webp')),
bytes BYTEA NOT NULL,
-- Lets a conditional request answer 304 instead of resending the image on
-- every screen that shows the person.
etag CHAR(64) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);