diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js index cf49a16..d7059cd 100644 --- a/backend/src/modules/auth/auth.routes.js +++ b/backend/src/modules/auth/auth.routes.js @@ -119,7 +119,6 @@ export default async function authRoutes(fastify, options) { // Permite ao frontend aplicar o idioma/tema do perfil ja no login, // sem esperar um novo GET /me. avatar_url: user.avatar?.url || null, - avatar_fallback_url: user.avatar?.local_url || null, preferences: { language: user.preferences?.language || 'pt', theme: user.preferences?.theme || 'system' @@ -237,7 +236,6 @@ export default async function authRoutes(fastify, options) { // Permite ao frontend aplicar o idioma/tema do perfil ja no login, // sem esperar um novo GET /me. avatar_url: user.avatar?.url || null, - avatar_fallback_url: user.avatar?.local_url || null, preferences: { language: user.preferences?.language || 'pt', theme: user.preferences?.theme || 'system' @@ -299,7 +297,6 @@ export default async function authRoutes(fastify, options) { created_at: user.created_at, last_login: user.last_login, avatar_url: user.avatar?.url || null, - avatar_fallback_url: user.avatar?.local_url || null, // Usado pelo frontend para aplicar idioma/tema salvos no perfil preferences: { language: user.preferences?.language || 'pt', diff --git a/backend/src/modules/users/users.routes.js b/backend/src/modules/users/users.routes.js index 6a8d301..58f4735 100644 --- a/backend/src/modules/users/users.routes.js +++ b/backend/src/modules/users/users.routes.js @@ -8,10 +8,9 @@ import { hashPassword } from '../auth/auth.service.js'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { - storeFileWithRedundancy, + storeFile, deleteLocalCopy, - deleteFromR2, - hasR2Config + deleteFromR2 } from '../../utils/storage.js'; const SUPPORTED_LANGUAGES = ['pt', 'en']; @@ -37,8 +36,6 @@ const publicProfile = (user) => ({ created_at: user.created_at, last_login: user.last_login, avatar_url: user.avatar?.url || null, - // URL da copia local, usada pelo frontend como fallback se a do R2 falhar - avatar_fallback_url: user.avatar?.local_url || null, preferences: { language: user.preferences?.language || 'pt', theme: user.preferences?.theme || 'system' @@ -189,7 +186,7 @@ export default async function userRoutes(fastify, options) { const tenantIdStr = request.tenantId.toString(); const fileName = `avatar-${request.currentUser._id}-${crypto.randomBytes(8).toString('hex')}.${extension}`; - const stored = await storeFileWithRedundancy({ + const stored = await storeFile({ tenantId: tenantIdStr, fileName, buffer, @@ -210,8 +207,6 @@ export default async function userRoutes(fastify, options) { $set: { avatar: { url: stored.url, - local_url: stored.localUrl, - r2_url: stored.r2Url, key: stored.key, file_name: fileName, storage: stored.storage, @@ -256,20 +251,22 @@ export default async function userRoutes(fastify, options) { return { success: true, user: publicProfile(updated) }; }); - // Limpeza best-effort das duas copias: falhar aqui nao pode quebrar a - // troca/remocao do avatar, que ja foi persistida. + // Limpeza best-effort: falhar aqui nao pode quebrar a troca/remocao do + // avatar, que ja foi persistida. Remove de onde o arquivo realmente esta, + // conforme o `storage` gravado no upload. function removePreviousAvatar(avatar, tenantIdStr, logger) { + if (avatar.storage === 'r2' && avatar.key) { + deleteFromR2(avatar.key).catch(error => { + logger?.warn?.({ err: error }, 'Falha ao remover avatar anterior do R2'); + }); + return; + } + try { deleteLocalCopy(tenantIdStr, avatar.file_name); } catch (error) { logger?.warn?.({ err: error }, 'Falha ao remover cópia local do avatar anterior'); } - - if (avatar.r2_url && avatar.key && hasR2Config()) { - deleteFromR2(avatar.key).catch(error => { - logger?.warn?.({ err: error }, 'Falha ao remover avatar anterior do R2'); - }); - } } // ==================== GESTAO DE USUARIOS (admin/owner) ==================== diff --git a/backend/src/utils/storage.js b/backend/src/utils/storage.js index 5c2ae09..3222e02 100644 --- a/backend/src/utils/storage.js +++ b/backend/src/utils/storage.js @@ -1,15 +1,9 @@ /** - * Armazenamento de arquivos: Cloudflare R2 com copia local redundante. + * Armazenamento de arquivos: Cloudflare R2 como destino principal, disco + * local apenas como fallback quando o R2 nao esta configurado ou falha. * * A logica de R2 vivia duplicada dentro de files.routes.js; centralizada aqui * para que outros modulos (ex.: avatar do usuario) reusem em vez de copiar. - * - * Diferenca importante entre os dois modos: - * - `storeFile` : R2 **ou** disco local (fallback) - comportamento - * historico do upload de arquivos de KB. - * - `storeFileWithRedundancy`: grava SEMPRE no disco local e, se o R2 estiver - * configurado, tambem envia pra la. Usado no avatar, onde - * queremos a copia local como redundancia mesmo com o R2 ok. */ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; @@ -87,37 +81,34 @@ export const deleteFromR2 = async (key) => { }; /** - * Grava no disco local E no R2 (quando configurado). + * Grava o arquivo no R2 quando configurado; cai para o disco local apenas + * se o R2 nao estiver configurado ou se o envio falhar. * - * A copia local e escrita primeiro e nunca e descartada: se o R2 cair ou a - * conta for perdida, o arquivo continua servivel pelo proprio backend. Uma - * falha no envio ao R2 nao derruba a operacao - degrada para "somente local". + * Nao mantem copia local quando o R2 responde: o disco local e fallback, + * nao replica. Isso evita ocupar disco do servidor com arquivos que ja + * estao no object storage. * - * @returns {{ url: string, localUrl: string, r2Url: string|null, key: string, storage: string }} + * @returns {{ url: string, key: string, storage: 'r2'|'local' }} */ -export const storeFileWithRedundancy = async ({ +export const storeFile = async ({ tenantId, fileName, buffer, contentType, metadata, logger }) => { const tenantIdStr = String(tenantId); const key = `${tenantIdStr}/${fileName}`; - const localUrl = saveLocalCopy(tenantIdStr, fileName, buffer); - - let r2Url = null; if (hasR2Config()) { try { - r2Url = await uploadToR2(key, buffer, contentType, metadata); + const r2Url = await uploadToR2(key, buffer, contentType, metadata); + return { url: r2Url, key, storage: 'r2' }; } catch (error) { - // Degrada para somente-local em vez de falhar o upload inteiro - logger?.warn?.({ err: error }, 'Falha ao enviar para o R2; mantendo apenas a cópia local'); + // Fallback: nao perde o upload por indisponibilidade do R2 + logger?.warn?.({ err: error }, 'Falha ao enviar para o R2; gravando no disco local'); } } return { - url: r2Url || localUrl, - localUrl, - r2Url, + url: saveLocalCopy(tenantIdStr, fileName, buffer), key, - storage: r2Url ? 'r2+local' : 'local' + storage: 'local' }; }; diff --git a/frontend/src/components/AvatarUploader.jsx b/frontend/src/components/AvatarUploader.jsx index 6b39ba9..066e12b 100644 --- a/frontend/src/components/AvatarUploader.jsx +++ b/frontend/src/components/AvatarUploader.jsx @@ -46,7 +46,7 @@ async function getCroppedBlob(imageSrc, croppedAreaPixels) { }); } -export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange }) { +export default function AvatarUploader({ avatarUrl, name, onChange }) { const { t } = useTranslation(); const inputRef = useRef(null); @@ -56,11 +56,10 @@ export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); const [saving, setSaving] = useState(false); const [removing, setRemoving] = useState(false); - // Se a URL principal (R2) falhar, cai para a cópia local — a redundância - // só serve se o frontend souber usá-la. - const [useFallback, setUseFallback] = useState(false); + // Imagem quebrada (URL fora do ar) cai para a inicial do nome + const [imageFailed, setImageFailed] = useState(false); - const displayedUrl = useFallback ? fallbackUrl : (avatarUrl || fallbackUrl); + const displayedUrl = imageFailed ? null : avatarUrl; const onCropComplete = useCallback((_area, areaPixels) => { setCroppedAreaPixels(areaPixels); @@ -98,7 +97,7 @@ export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange formData.append('file', blob, 'avatar.jpg'); const { data } = await userAPI.uploadAvatar(formData); - setUseFallback(false); + setImageFailed(false); onChange?.(data.user); setImageSrc(null); toast.success(t('profile.avatar.updated')); @@ -113,7 +112,7 @@ export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange setRemoving(true); try { const { data } = await userAPI.deleteAvatar(); - setUseFallback(false); + setImageFailed(false); onChange?.(data.user); toast.success(t('profile.avatar.removed')); } catch (err) { @@ -132,10 +131,7 @@ export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange 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); - }} + onError={() => setImageFailed(true)} /> ) : (