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

+10
View File
@@ -428,5 +428,15 @@
"user": "Usuario",
"domainAdmin": "Admin del dominio",
"superAdmin": "Super admin"
},
"avatar": {
"title": "Foto de perfil",
"explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.",
"none": "Sin foto",
"choose": "Elegir foto",
"replace": "Cambiar foto",
"zoom": "Zoom",
"dragToPosition": "Arrastre para posicionar",
"notAnImage": "No se pudo leer ese archivo como imagen."
}
}