feat: foto de perfil com recorte, salva no R2 com copia local redundante - #20
Merged
Conversation
Backend: - utils/storage.js: helpers de R2/disco centralizados. A logica de R2 estava duplicada dentro de files.routes.js; agora ha um lugar so, e files.routes.js passa a importar dali. - storeFileWithRedundancy(): grava SEMPRE no disco local e, quando o R2 esta configurado, tambem envia pra la. Diferente do upload de arquivos de KB, que e R2 *ou* local. Falha no R2 degrada para somente-local em vez de derrubar a operacao. - POST/DELETE /api/users/me/avatar: valida tipo (JPEG/PNG/WebP) e tamanho (2MB), troca a imagem e limpa a anterior nas duas copias. A antiga so e removida depois da nova ser gravada. - Avatar exposto em /auth/me, login e magic link. Frontend: - AvatarUploader: recorte quadrado com zoom e arraste (react-easy-crop), gerando um JPEG 512x512 via canvas antes do upload - o servidor recebe a imagem ja pronta, sem precisar processar. - Avatar aparece no Perfil e na sidebar. Se a URL do R2 falhar ao carregar, o componente cai para a copia local (onError) e so depois para a inicial do nome - a redundancia so serve se o frontend usa.
Contributor
There was a problem hiding this comment.
Pull request overview
Implementa upload/edição de foto de perfil com recorte no frontend e persistência com redundância no backend (cópia local + Cloudflare R2 quando configurado), além de expor URLs do avatar nas respostas de autenticação e perfil para uso imediato na UI.
Changes:
- Adiciona
AvatarUploadercom recorte (react-easy-crop) e upload do avatar via/users/me/avatar, incluindo remoção. - Centraliza utilitários de armazenamento (R2 + disco) em
backend/src/utils/storage.jse refatorafiles.routes.jspara reutilizar. - Propaga
avatar_urleavatar_fallback_urlno Perfil, Sidebar e rotas de auth (/auth/*).
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/services/api.js | Expõe métodos uploadAvatar/deleteAvatar no userAPI. |
| frontend/src/pages/Profile.jsx | Integra AvatarUploader ao perfil e sincroniza avatar no AuthContext/localStorage. |
| frontend/src/i18n/locales/pt.json | Adiciona strings de UI para avatar e botão “Cancelar”. |
| frontend/src/i18n/locales/en.json | Adiciona strings de UI para avatar e botão “Cancel”. |
| frontend/src/components/Sidebar.jsx | Renderiza avatar na sidebar com fallback para URL local. |
| frontend/src/components/AvatarUploader.jsx | Novo componente de recorte + upload/remoção do avatar. |
| frontend/package.json | Adiciona dependência react-easy-crop. |
| frontend/package-lock.json | Atualiza lockfile com react-easy-crop e dependências. |
| backend/src/utils/storage.js | Novo util centralizado para cópia local e upload/remoção no R2. |
| backend/src/modules/users/users.routes.js | Novos endpoints de upload/remoção de avatar e exposição no publicProfile. |
| backend/src/modules/files/files.routes.js | Remove duplicação e importa helpers de utils/storage.js. |
| backend/src/modules/auth/auth.routes.js | Inclui avatar_url e avatar_fallback_url nas respostas de auth. |
Files not reviewed (1)
- frontend/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+48
to
+53
| export const saveLocalCopy = (tenantId, fileName, buffer) => { | ||
| const dir = path.join(getUploadsDir(), String(tenantId)); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| fs.writeFileSync(path.join(dir, fileName), buffer); | ||
| return buildLocalUrl(tenantId, fileName); | ||
| }; |
Comment on lines
+244
to
+248
| <img | ||
| src={avatarSrc} | ||
| alt="" | ||
| className="w-100 h-100 rounded-circle" | ||
| style={{ objectFit: 'cover' }} |
Comment on lines
+95
to
+99
| // Avatar: URL principal (R2 quando configurado) -> copia local -> inicial do | ||
| // nome. `avatarFailed` cobre o caso da imagem principal nao carregar. | ||
| const avatarSrc = avatarFailed | ||
| ? user?.avatar_fallback_url | ||
| : (user?.avatar_url || user?.avatar_fallback_url); |
Comment on lines
+129
to
+138
| {displayedUrl ? ( | ||
| <img | ||
| src={displayedUrl} | ||
| alt={name || 'avatar'} | ||
| className="rounded-circle" | ||
| style={{ width: '88px', height: '88px', objectFit: 'cover' }} | ||
| onError={() => { | ||
| // URL principal indisponível (R2 fora do ar, bucket movido...) | ||
| if (!useFallback && fallbackUrl) setUseFallback(true); | ||
| }} |
Comment on lines
+176
to
+182
| for await (const chunk of data.file) { | ||
| total += chunk.length; | ||
| if (total > MAX_AVATAR_BYTES) { | ||
| return reply.status(413).send({ error: 'Imagem muito grande (máx. 2MB)' }); | ||
| } | ||
| chunks.push(chunk); | ||
| } |
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
O que entrega
Foto de perfil com recorte, gravada no Cloudflare R2 e no disco local como redundância.
Frontend —
AvatarUploaderRecorte quadrado com zoom e arraste (
react-easy-crop, com máscara circular). O recorte é aplicado viacanvasgerando um JPEG 512×512 antes do upload — o servidor recebe a imagem já pronta, sem precisar de biblioteca de processamento de imagem no backend.O avatar aparece no Perfil e na sidebar. Se a URL principal (R2) falhar ao carregar, o componente cai para a cópia local via
onError, e só depois para a inicial do nome — a redundância só tem valor se o frontend souber usá-la.Backend — redundância de verdade
Criei
utils/storage.jsporque a lógica de R2 estava duplicada dentro defiles.routes.js; agora existe um lugar só, efiles.routes.jspassa a importar de lá (sem mudança de comportamento).A diferença entre os dois modos ficou explícita:
storeFileWithRedundancySe o envio ao R2 falhar, degrada para somente-local em vez de derrubar a operação inteira.
Endpoints novos:
POST /api/users/me/avatar— valida tipo (JPEG/PNG/WebP) e tamanho (2MB), grava nas duas pontas, remove a imagem anterior. A antiga só é apagada depois da nova ser gravada, para nunca ficar sem avatar se a limpeza falhar.DELETE /api/users/me/avatarAvatar exposto em
/auth/me, login e magic link, para aparecer já na autenticação.Test plan
npm run buildsem erros;node --checknos 4 arquivos de backendbackend/uploads/{tenant}/e avatar na sidebarstoragevolta comor2+local