- Implemented a comprehensive time synchronization mechanism that applies GMT offsets based on user configuration, ensuring accurate timestamps across the application. - Updated the Jitsi configuration to include SSH settings, allowing for better integration with Docker setups. - Refactored the backend queries and mutations to handle the new SSH configuration fields, ensuring secure and flexible server management. - Enhanced error handling and logging for time synchronization processes, providing clearer feedback for users and developers.
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { v } from "convex/values";
|
|
import { mutation, query, action, internalMutation } from "./_generated/server";
|
|
import { registrarAtividade } from "./logsAtividades";
|
|
import { api, internal } from "./_generated/api";
|
|
import { encryptSMTPPassword } from "./auth/utils";
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obter configuração completa de Jitsi (incluindo SSH, mas sem senha)
|
|
*/
|
|
export const obterConfigJitsiCompleta = query({
|
|
args: {
|
|
configId: v.id("configuracaoJitsi"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const config = await ctx.db.get(args.configId);
|
|
|
|
if (!config) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
_id: config._id,
|
|
domain: config.domain,
|
|
appId: config.appId,
|
|
roomPrefix: config.roomPrefix,
|
|
useHttps: config.useHttps,
|
|
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
|
ativo: config.ativo,
|
|
testadoEm: config.testadoEm,
|
|
atualizadoEm: config.atualizadoEm,
|
|
configuradoEm: config.configuradoEm,
|
|
// Configurações SSH (sem senha)
|
|
sshHost: config.sshHost,
|
|
sshPort: config.sshPort,
|
|
sshUsername: config.sshUsername,
|
|
sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar
|
|
sshKeyPath: config.sshKeyPath,
|
|
dockerComposePath: config.dockerComposePath,
|
|
jitsiConfigPath: config.jitsiConfigPath,
|
|
configuradoNoServidor: config.configuradoNoServidor ?? false,
|
|
configuradoNoServidorEm: config.configuradoNoServidorEm,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 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"),
|
|
// Opcionais: configurações SSH/Docker
|
|
sshHost: v.optional(v.string()),
|
|
sshPort: v.optional(v.number()),
|
|
sshUsername: v.optional(v.string()),
|
|
sshPassword: v.optional(v.string()), // Senha nova (será criptografada)
|
|
sshKeyPath: v.optional(v.string()),
|
|
dockerComposePath: v.optional(v.string()),
|
|
jitsiConfigPath: v.optional(v.string()),
|
|
},
|
|
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",
|
|
};
|
|
}
|
|
|
|
// Buscar config ativa anterior para manter senha SSH se não fornecida
|
|
const configAtiva = await ctx.db
|
|
.query("configuracaoJitsi")
|
|
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
|
.first();
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// Determinar senha SSH: usar nova senha se fornecida, senão manter a atual
|
|
let sshPasswordHash: string | undefined = undefined;
|
|
if (args.sshPassword && args.sshPassword.trim().length > 0) {
|
|
// Nova senha fornecida, criptografar
|
|
sshPasswordHash = await encryptSMTPPassword(args.sshPassword);
|
|
} else if (configAtiva && configAtiva.sshPasswordHash) {
|
|
// Senha não fornecida, manter a atual (já criptografada)
|
|
sshPasswordHash = configAtiva.sshPasswordHash;
|
|
}
|
|
|
|
// 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(),
|
|
// Configurações SSH/Docker
|
|
sshHost: args.sshHost?.trim() || undefined,
|
|
sshPort: args.sshPort || undefined,
|
|
sshUsername: args.sshUsername?.trim() || undefined,
|
|
sshPasswordHash: sshPasswordHash,
|
|
sshKeyPath: args.sshKeyPath?.trim() || undefined,
|
|
dockerComposePath: args.dockerComposePath?.trim() || undefined,
|
|
jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined,
|
|
});
|
|
|
|
// 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;
|
|
},
|
|
});
|
|
|