Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions backend/src/modules/auth/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
29 changes: 13 additions & 16 deletions backend/src/modules/users/users.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Comment on lines 257 to +263

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) ====================
Expand Down
39 changes: 15 additions & 24 deletions backend/src/utils/storage.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
};
};
20 changes: 8 additions & 12 deletions frontend/src/components/AvatarUploader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;

Comment on lines +59 to 63
const onCropComplete = useCallback((_area, areaPixels) => {
setCroppedAreaPixels(areaPixels);
Expand Down Expand Up @@ -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'));
Expand All @@ -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) {
Expand All @@ -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)}
/>
) : (
<div
Expand Down Expand Up @@ -174,7 +170,7 @@ export default function AvatarUploader({ avatarUrl, fallbackUrl, name, onChange
<Button variant="link" size="sm" className="p-0" onClick={() => inputRef.current?.click()}>
{t('profile.avatar.change')}
</Button>
{(avatarUrl || fallbackUrl) && (
{avatarUrl && (
<>
<span className="text-muted mx-1">·</span>
<Button
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/Sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,22 @@
color: white;
flex-shrink: 0;
box-shadow: 0 2px 10px rgba(102, 16, 242, 0.3);
overflow: hidden;
}

/* Com foto, a imagem preenche o container inteiro no mesmo raio. Sem o
`background: none`, o gradiente do fallback aparecia nos cantos ao redor
da foto. `inherit` mantem o raio em sincronia com a regra acima. */
.sidebar-user-avatar.has-image {
background: none;
}

.sidebar-user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}

.sidebar-user-info {
Expand Down
13 changes: 4 additions & 9 deletions frontend/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,8 @@ export default function Sidebar({ isOpen, onClose }) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [avatarFailed, setAvatarFailed] = useState(false);

// 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);
// Imagem quebrada cai para a inicial do nome
const avatarSrc = avatarFailed ? null : user?.avatar_url;

// Uma troca de avatar precisa limpar o estado de falha da imagem anterior
useEffect(() => {
Expand Down Expand Up @@ -239,14 +236,12 @@ export default function Sidebar({ isOpen, onClose }) {
{/* User Profile */}
<div className="sidebar-footer">
<Link to="/profile" className="sidebar-user" onClick={onClose}>
<div className="sidebar-user-avatar">
<div className={`sidebar-user-avatar ${avatarSrc ? 'has-image' : ''}`}>
{avatarSrc ? (
<img
src={avatarSrc}
alt=""
className="w-100 h-100 rounded-circle"
style={{ objectFit: 'cover' }}
// Se a URL do R2 falhar, usa a copia local antes de cair na inicial
// Imagem quebrada volta para a inicial do nome
onError={() => setAvatarFailed(true)}
/>
) : (
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/pages/Profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ export default function Profile() {
...user,
name: updated.name,
preferences: updated.preferences,
avatar_url: updated.avatar_url,
avatar_fallback_url: updated.avatar_fallback_url
avatar_url: updated.avatar_url
};
setUser?.(merged);
try {
Expand Down Expand Up @@ -176,7 +175,6 @@ export default function Profile() {
<div className="mb-3">
<AvatarUploader
avatarUrl={profile.avatar_url}
fallbackUrl={profile.avatar_fallback_url}
name={profile.name}
onChange={(updated) => {
setProfile(updated);
Expand Down
Loading