Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
@@ -261,7 +261,11 @@ export const enviarMensagem = mutation({
|
||||
arquivoTipo: v.optional(v.string()),
|
||||
mencoes: v.optional(v.array(v.id('usuarios'))),
|
||||
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
|
||||
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()) // ✅ NOVO: Permite criar notificação para si mesmo
|
||||
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
|
||||
// Campos para criptografia E2E
|
||||
criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada
|
||||
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
|
||||
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
@@ -281,6 +285,59 @@ export const enviarMensagem = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
// Validar tamanho da mensagem
|
||||
const MAX_MENSAGEM_LENGTH = 5000;
|
||||
if (args.conteudo.length > MAX_MENSAGEM_LENGTH) {
|
||||
throw new Error(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
|
||||
}
|
||||
|
||||
// Validação de tipo de arquivo (se houver arquivo)
|
||||
if (args.arquivoTipo) {
|
||||
const TIPOS_PERMITIDOS = [
|
||||
// Imagens
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
// Documentos
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
// Arquivos
|
||||
'application/zip',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip'
|
||||
];
|
||||
|
||||
if (!TIPOS_PERMITIDOS.includes(args.arquivoTipo)) {
|
||||
throw new Error('Tipo de arquivo não permitido');
|
||||
}
|
||||
|
||||
// Validar tamanho de arquivo (10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
if (args.arquivoTamanho && args.arquivoTamanho > MAX_FILE_SIZE) {
|
||||
throw new Error(`Arquivo muito grande. O tamanho máximo é ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
|
||||
}
|
||||
|
||||
// Validar nome do arquivo (sanitizar)
|
||||
if (args.arquivoNome) {
|
||||
const nomeSanitizado = args.arquivoNome.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
if (nomeSanitizado !== args.arquivoNome) {
|
||||
// Se o nome foi alterado, usar o sanitizado
|
||||
args.arquivoNome = nomeSanitizado;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa) throw new Error('Conversa não encontrada');
|
||||
@@ -289,7 +346,8 @@ export const enviarMensagem = mutation({
|
||||
}
|
||||
|
||||
// Normalizar conteúdo para busca (remover acentos, lowercase)
|
||||
const conteudoBusca = normalizarTextoParaBusca(args.conteudo);
|
||||
// Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined)
|
||||
const conteudoBusca = args.criptografado ? undefined : normalizarTextoParaBusca(args.conteudo);
|
||||
|
||||
// Verificar se é resposta a outra mensagem
|
||||
if (args.respostaPara) {
|
||||
@@ -320,7 +378,11 @@ export const enviarMensagem = mutation({
|
||||
mencoes: args.mencoes,
|
||||
respostaPara: args.respostaPara,
|
||||
enviadaEm: Date.now(),
|
||||
lidaPor: [] // Inicializar como array vazio
|
||||
lidaPor: [], // Inicializar como array vazio
|
||||
// Campos de criptografia E2E
|
||||
criptografado: args.criptografado ?? false,
|
||||
iv: args.iv,
|
||||
keyId: args.keyId
|
||||
});
|
||||
|
||||
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono)
|
||||
@@ -343,8 +405,12 @@ export const enviarMensagem = mutation({
|
||||
}
|
||||
|
||||
// Atualizar última mensagem da conversa
|
||||
// Se a mensagem estiver criptografada, usar placeholder
|
||||
const ultimaMensagemTexto = args.criptografado
|
||||
? '🔒 Mensagem criptografada'
|
||||
: args.conteudo.substring(0, 100);
|
||||
await ctx.db.patch(args.conversaId, {
|
||||
ultimaMensagem: args.conteudo.substring(0, 100),
|
||||
ultimaMensagem: ultimaMensagemTexto,
|
||||
ultimaMensagemTimestamp: Date.now(),
|
||||
ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem
|
||||
});
|
||||
@@ -793,7 +859,11 @@ export const marcarNotificacaoLida = mutation({
|
||||
if (!usuarioAtual) throw new Error('Não autenticado');
|
||||
|
||||
const notificacao = await ctx.db.get(args.notificacaoId);
|
||||
if (!notificacao) throw new Error('Notificação não encontrada');
|
||||
// Se a notificação não existe (já foi deletada), retornar sucesso silenciosamente
|
||||
// Isso evita erros quando múltiplas tentativas são feitas ou quando a notificação já foi removida
|
||||
if (!notificacao) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
|
||||
if (notificacao.usuarioId !== usuarioAtual._id) {
|
||||
@@ -808,6 +878,11 @@ export const marcarNotificacaoLida = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Se já está marcada como lida, retornar sucesso sem fazer nada
|
||||
if (notificacao.lida) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.notificacaoId, { lida: true });
|
||||
return true;
|
||||
}
|
||||
@@ -890,7 +965,10 @@ export const limparNotificacoesNaoLidas = mutation({
|
||||
export const editarMensagem = mutation({
|
||||
args: {
|
||||
mensagemId: v.id('mensagens'),
|
||||
novoConteudo: v.string()
|
||||
novoConteudo: v.string(),
|
||||
// Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada)
|
||||
iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia
|
||||
keyId: v.optional(v.string()) // Identificador da chave usada para criptografar
|
||||
},
|
||||
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -947,15 +1025,32 @@ export const editarMensagem = mutation({
|
||||
};
|
||||
}
|
||||
|
||||
// Normalizar conteúdo para busca
|
||||
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
|
||||
// Normalizar conteúdo para busca (se não estiver criptografado)
|
||||
// Se a mensagem original estava criptografada, manter criptografado
|
||||
const estaCriptografada = mensagem.criptografado ?? false;
|
||||
const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo);
|
||||
|
||||
// Atualizar mensagem
|
||||
await ctx.db.patch(args.mensagemId, {
|
||||
// Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar
|
||||
const updateData: {
|
||||
conteudo: string;
|
||||
conteudoBusca?: string;
|
||||
editadaEm: number;
|
||||
iv?: string;
|
||||
keyId?: string;
|
||||
} = {
|
||||
conteudo: args.novoConteudo.trim(),
|
||||
conteudoBusca,
|
||||
editadaEm: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
// Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar
|
||||
if (estaCriptografada && (args.iv || args.keyId)) {
|
||||
if (args.iv) updateData.iv = args.iv;
|
||||
if (args.keyId) updateData.keyId = args.keyId;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.mensagemId, updateData);
|
||||
|
||||
return { sucesso: true };
|
||||
}
|
||||
@@ -1148,18 +1243,39 @@ export const adicionarParticipanteSala = mutation({
|
||||
participantes: novosParticipantes
|
||||
});
|
||||
|
||||
// Verificar se a conversa tem criptografia E2E ativa
|
||||
const chaveE2E = await ctx.db
|
||||
.query('chavesCriptografia')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||
.first();
|
||||
|
||||
// Criar notificação para o novo participante
|
||||
const agora = Date.now();
|
||||
await ctx.db.insert('notificacoes', {
|
||||
usuarioId: args.participanteId,
|
||||
tipo: 'adicionado_grupo',
|
||||
conversaId: args.conversaId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
titulo: 'Adicionado a sala de reunião',
|
||||
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}`,
|
||||
descricao: `Você foi adicionado à sala de reunião "${conversa.nome || 'Sem nome'}" por ${usuarioAtual.nome}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`,
|
||||
lida: false,
|
||||
criadaEm: Date.now()
|
||||
criadaEm: agora
|
||||
});
|
||||
|
||||
// Se a conversa tem E2E ativo, notificar sobre a chave
|
||||
if (chaveE2E) {
|
||||
await ctx.db.insert('notificacoes', {
|
||||
usuarioId: args.participanteId,
|
||||
tipo: 'alerta_seguranca',
|
||||
conversaId: args.conversaId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
titulo: 'Chave de criptografia E2E disponível',
|
||||
descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`,
|
||||
lida: false,
|
||||
criadaEm: agora
|
||||
});
|
||||
}
|
||||
|
||||
return { sucesso: true };
|
||||
}
|
||||
});
|
||||
@@ -1764,28 +1880,42 @@ export const listarConversas = query({
|
||||
export const obterMensagens = query({
|
||||
args: {
|
||||
conversaId: v.id('conversas'),
|
||||
limit: v.optional(v.number())
|
||||
limit: v.optional(v.number()),
|
||||
cursor: v.optional(v.id('mensagens')) // ID da última mensagem carregada (para paginação)
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
if (!usuarioAtual) return { mensagens: [], hasMore: false };
|
||||
|
||||
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
return { mensagens: [], hasMore: false };
|
||||
}
|
||||
|
||||
// Buscar mensagens (excluir agendadas)
|
||||
const mensagens = await ctx.db
|
||||
const limit = args.limit || 50;
|
||||
let query = ctx.db
|
||||
.query('mensagens')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
|
||||
.order('desc')
|
||||
.take(args.limit || 50);
|
||||
.order('desc');
|
||||
|
||||
// Se há cursor, buscar mensagens anteriores a ele
|
||||
if (args.cursor) {
|
||||
const cursorMsg = await ctx.db.get(args.cursor);
|
||||
if (cursorMsg && cursorMsg.conversaId === args.conversaId) {
|
||||
// Buscar mensagens anteriores à mensagem do cursor
|
||||
query = query.filter((q) => q.lt(q.field('_creationTime'), cursorMsg._creationTime));
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar uma mensagem a mais para verificar se há mais mensagens
|
||||
const mensagens = await query.take(limit + 1);
|
||||
const hasMore = mensagens.length > limit;
|
||||
const mensagensParaRetornar = hasMore ? mensagens.slice(0, limit) : mensagens;
|
||||
|
||||
// Filtrar mensagens agendadas e garantir que são da conversa correta
|
||||
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
|
||||
const mensagensFiltradas = mensagens.filter((m) => {
|
||||
const mensagensFiltradas = mensagensParaRetornar.filter((m) => {
|
||||
// Excluir agendadas
|
||||
if (m.agendadaPara) return false;
|
||||
|
||||
@@ -1842,7 +1972,15 @@ export const obterMensagens = query({
|
||||
);
|
||||
|
||||
// Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
|
||||
return mensagensEnriquecidas.filter((m) => m !== null).reverse();
|
||||
const mensagensFinais = mensagensEnriquecidas.filter((m) => m !== null).reverse();
|
||||
|
||||
return {
|
||||
mensagens: mensagensFinais,
|
||||
hasMore,
|
||||
nextCursor: hasMore && mensagensFinais.length > 0
|
||||
? mensagensFinais[mensagensFinais.length - 1]._id
|
||||
: null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2480,3 +2618,144 @@ export const limparIndicadoresDigitacao = internalMutation({
|
||||
return indicadoresAntigos.length;
|
||||
}
|
||||
});
|
||||
|
||||
// ========== CRIPTOGRAFIA E2E ==========
|
||||
|
||||
/**
|
||||
* Compartilha uma chave de criptografia E2E para uma conversa
|
||||
* A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método)
|
||||
*/
|
||||
export const compartilharChaveCriptografia = mutation({
|
||||
args: {
|
||||
conversaId: v.id('conversas'),
|
||||
chaveCompartilhada: v.string(), // Chave criptografada (base64)
|
||||
keyId: v.string() // Identificador único da chave
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) {
|
||||
throw new Error('Não autenticado');
|
||||
}
|
||||
|
||||
// Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa) {
|
||||
throw new Error('Conversa não encontrada');
|
||||
}
|
||||
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error('Você não pertence a esta conversa');
|
||||
}
|
||||
|
||||
// Desativar chaves antigas da conversa
|
||||
const chavesAntigas = await ctx.db
|
||||
.query('chavesCriptografia')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||
.collect();
|
||||
|
||||
for (const chaveAntiga of chavesAntigas) {
|
||||
await ctx.db.patch(chaveAntiga._id, { ativo: false });
|
||||
}
|
||||
|
||||
// Criar nova chave
|
||||
await ctx.db.insert('chavesCriptografia', {
|
||||
conversaId: args.conversaId,
|
||||
chaveCompartilhada: args.chaveCompartilhada,
|
||||
keyId: args.keyId,
|
||||
criadoPor: usuarioAtual._id,
|
||||
criadoEm: Date.now(),
|
||||
ativo: true
|
||||
});
|
||||
|
||||
// Criar notificações para outros participantes sobre a ativação/regeneração da chave
|
||||
const agora = Date.now();
|
||||
for (const participanteId of conversa.participantes) {
|
||||
if (participanteId !== usuarioAtual._id) {
|
||||
await ctx.db.insert('notificacoes', {
|
||||
usuarioId: participanteId,
|
||||
tipo: 'alerta_seguranca',
|
||||
conversaId: args.conversaId,
|
||||
remetenteId: usuarioAtual._id,
|
||||
titulo: 'Criptografia E2E atualizada',
|
||||
descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`,
|
||||
lida: false,
|
||||
criadaEm: agora
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sucesso: true };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém a chave de criptografia ativa para uma conversa
|
||||
*/
|
||||
export const obterChaveCriptografia = query({
|
||||
args: {
|
||||
conversaId: v.id('conversas')
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa) {
|
||||
return null;
|
||||
}
|
||||
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buscar chave ativa
|
||||
const chave = await ctx.db
|
||||
.query('chavesCriptografia')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||
.first();
|
||||
|
||||
if (!chave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chaveCompartilhada: chave.chaveCompartilhada,
|
||||
keyId: chave.keyId,
|
||||
criadoPor: chave.criadoPor,
|
||||
criadoEm: chave.criadoEm
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifica se uma conversa tem criptografia E2E habilitada
|
||||
*/
|
||||
export const verificarCriptografiaE2E = query({
|
||||
args: {
|
||||
conversaId: v.id('conversas')
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa) {
|
||||
return false;
|
||||
}
|
||||
if (!conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar se existe chave ativa
|
||||
const chave = await ctx.db
|
||||
.query('chavesCriptografia')
|
||||
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true))
|
||||
.first();
|
||||
|
||||
return chave !== null;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user