import { v } from 'convex/values'; import { mutation, query, action, internalMutation } from './_generated/server'; import { registrarAtividade } from './logsAtividades'; import { api, internal } from './_generated/api'; /** * Obter configuração de Jitsi ativa */ export const obterConfigJitsi = query({ args: {}, handler: async (ctx) => { const config = await ctx.db .query('configuracaoJitsi') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { return null; } return { _id: config._id, domain: config.domain, appId: config.appId, roomPrefix: config.roomPrefix, useHttps: config.useHttps, acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir ativo: config.ativo, testadoEm: config.testadoEm, atualizadoEm: config.atualizadoEm }; } }); /** * Salvar configuração de Jitsi (apenas TI_MASTER) */ export const salvarConfigJitsi = mutation({ args: { domain: v.string(), appId: v.string(), roomPrefix: v.string(), useHttps: v.boolean(), acceptSelfSignedCert: v.boolean(), configuradoPorId: v.id('usuarios') }, returns: v.union( v.object({ sucesso: v.literal(true), configId: v.id('configuracaoJitsi') }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { // Validar domínio (deve ser não vazio) if (!args.domain || args.domain.trim().length === 0) { return { sucesso: false as const, erro: 'Domínio não pode estar vazio' }; } // Validar appId (deve ser não vazio) if (!args.appId || args.appId.trim().length === 0) { return { sucesso: false as const, erro: 'App ID não pode estar vazio' }; } // Validar roomPrefix (deve ser não vazio e alfanumérico) if (!args.roomPrefix || args.roomPrefix.trim().length === 0) { return { sucesso: false as const, erro: 'Prefixo de sala não pode estar vazio' }; } // Validar formato do roomPrefix (apenas letras, números e hífens) const roomPrefixRegex = /^[a-zA-Z0-9-]+$/; if (!roomPrefixRegex.test(args.roomPrefix.trim())) { return { sucesso: false as const, erro: 'Prefixo de sala deve conter apenas letras, números e hífens' }; } // Desativar config anterior const configsAntigas = await ctx.db .query('configuracaoJitsi') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .collect(); for (const config of configsAntigas) { await ctx.db.patch(config._id, { ativo: false }); } // Criar nova config const configId = await ctx.db.insert('configuracaoJitsi', { domain: args.domain.trim(), appId: args.appId.trim(), roomPrefix: args.roomPrefix.trim(), useHttps: args.useHttps, acceptSelfSignedCert: args.acceptSelfSignedCert, ativo: true, configuradoPor: args.configuradoPorId, atualizadoEm: Date.now() }); // Log de atividade await registrarAtividade( ctx, args.configuradoPorId, 'configurar', 'jitsi', JSON.stringify({ domain: args.domain, appId: args.appId }), configId ); return { sucesso: true as const, configId }; } }); /** * Mutation interna para atualizar testadoEm */ export const atualizarTestadoEm = internalMutation({ args: { configId: v.id('configuracaoJitsi') }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.configId, { testadoEm: Date.now() }); return null; } }); /** * Testar conexão com servidor Jitsi */ export const testarConexaoJitsi = action({ args: { domain: v.string(), useHttps: v.boolean(), acceptSelfSignedCert: v.optional(v.boolean()) }, returns: v.union( v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async ( ctx, args ): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => { // Validações básicas if (!args.domain || args.domain.trim().length === 0) { return { sucesso: false as const, erro: 'Domínio não pode estar vazio' }; } try { const protocol = args.useHttps ? 'https' : 'http'; // Extrair host e porta do domain const [host, portStr] = args.domain.split(':'); const port = portStr ? parseInt(portStr, 10) : args.useHttps ? 443 : 80; const url = `${protocol}://${host}:${port}/http-bind`; // Tentar fazer uma requisição HTTP para verificar se o servidor está acessível // Nota: No ambiente Node.js do Convex, podemos usar fetch const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 segundos de timeout try { const response = await fetch(url, { method: 'GET', signal: controller.signal, headers: { 'Content-Type': 'application/xml' } }); clearTimeout(timeoutId); // Qualquer resposta indica que o servidor está acessível // Não precisamos verificar o status code exato, apenas se há resposta if (response.status >= 200 && response.status < 600) { // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); if (configAtiva) { await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { configId: configAtiva._id }); } return { sucesso: true as const, aviso: undefined }; } else { return { sucesso: false as const, erro: `Servidor retornou status ${response.status}` }; } } catch (fetchError: unknown) { clearTimeout(timeoutId); const errorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError); // Se for erro de timeout if (errorMessage.includes('aborted') || errorMessage.includes('timeout')) { return { sucesso: false as const, erro: 'Timeout: Servidor não respondeu em 5 segundos' }; } // Verificar se é erro de certificado SSL autoassinado const isSSLError = errorMessage.includes('CERTIFICATE_VERIFY_FAILED') || errorMessage.includes('self signed certificate') || errorMessage.includes('self-signed certificate') || errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('certificate verify failed'); // Se for erro de certificado e aceitar autoassinado está configurado if (isSSLError && args.acceptSelfSignedCert) { // Aceitar como sucesso se configurado para aceitar certificados autoassinados // (o servidor está acessível, apenas o certificado não é confiável) // Nota: No cliente (navegador), o usuário ainda precisará aceitar o certificado manualmente const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); if (configAtiva) { await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { configId: configAtiva._id }); } return { sucesso: true as const, aviso: 'Servidor acessível com certificado autoassinado. No navegador, você precisará aceitar o certificado manualmente na primeira conexão.' }; } // Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed) // para GET em /http-bind, pois esse endpoint espera POST (BOSH) // Isso indica que o servidor está acessível, apenas não aceita GET if (errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')) { // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); if (configAtiva) { await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, { configId: configAtiva._id }); } return { sucesso: true as const, aviso: undefined }; } // Se for erro de certificado SSL e não está configurado para aceitar if (isSSLError) { return { sucesso: false as const, erro: `Erro de certificado SSL: O servidor está usando um certificado não confiável (provavelmente autoassinado). Para desenvolvimento local, habilite "Aceitar Certificados Autoassinados" nas configurações de segurança. Em produção, use um certificado válido (ex: Let's Encrypt).` }; } return { sucesso: false as const, erro: `Erro ao conectar: ${errorMessage}` }; } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); return { sucesso: false as const, erro: errorMessage || 'Erro ao conectar com o servidor Jitsi' }; } } }); /** * Marcar que a configuração foi testada com sucesso */ export const marcarConfigTestada = mutation({ args: { configId: v.id('configuracaoJitsi') }, handler: async (ctx, args) => { await ctx.db.patch(args.configId, { testadoEm: Date.now() }); } }); /** * Mutation interna para marcar que a configuração foi aplicada no servidor */ export const marcarConfiguradoNoServidor = internalMutation({ args: { configId: v.id('configuracaoJitsi') }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.configId, { configuradoNoServidor: true, configuradoNoServidorEm: Date.now(), configuradoEm: Date.now() }); return null; } });