feat: enhance time synchronization and Jitsi configuration handling
- 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.
This commit is contained in:
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type * as actions_email from "../actions/email.js";
|
||||
import type * as actions_jitsiServer from "../actions/jitsiServer.js";
|
||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||
import type * as actions_smtp from "../actions/smtp.js";
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"actions/email": typeof actions_email;
|
||||
"actions/jitsiServer": typeof actions_jitsiServer;
|
||||
"actions/linkPreview": typeof actions_linkPreview;
|
||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||
"actions/smtp": typeof actions_smtp;
|
||||
|
||||
424
packages/backend/convex/actions/jitsiServer.ts
Normal file
424
packages/backend/convex/actions/jitsiServer.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { Client } from "ssh2";
|
||||
import { readFileSync } from "fs";
|
||||
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
|
||||
|
||||
/**
|
||||
* Interface para configuração SSH
|
||||
*/
|
||||
interface SSHConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executar comando via SSH
|
||||
*/
|
||||
async function executarComandoSSH(
|
||||
config: SSHConfig,
|
||||
comando: string
|
||||
): Promise<{ sucesso: boolean; output: string; erro?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
conn.on("ready", () => {
|
||||
conn.exec(comando, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
resolve({ sucesso: false, output: "", erro: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
stream
|
||||
.on("close", (code: number | null, signal: string | null) => {
|
||||
conn.end();
|
||||
if (code === 0) {
|
||||
resolve({ sucesso: true, output: output.trim() });
|
||||
} else {
|
||||
resolve({
|
||||
sucesso: false,
|
||||
output: output.trim(),
|
||||
erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
resolve({ sucesso: false, output: "", erro: err.message });
|
||||
}).connect({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined,
|
||||
readyTimeout: 10000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ler arquivo via SSH
|
||||
*/
|
||||
async function lerArquivoSSH(
|
||||
config: SSHConfig,
|
||||
caminho: string
|
||||
): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> {
|
||||
const comando = `cat "${caminho}" 2>&1`;
|
||||
const resultado = await executarComandoSSH(config, comando);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" };
|
||||
}
|
||||
|
||||
return { sucesso: true, conteudo: resultado.output };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escrever arquivo via SSH
|
||||
*/
|
||||
async function escreverArquivoSSH(
|
||||
config: SSHConfig,
|
||||
caminho: string,
|
||||
conteudo: string
|
||||
): Promise<{ sucesso: boolean; erro?: string }> {
|
||||
// Escapar conteúdo para shell
|
||||
const conteudoEscapado = conteudo
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$")
|
||||
.replace(/`/g, "\\`");
|
||||
|
||||
const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF'
|
||||
${conteudo}
|
||||
JITSI_CONFIG_EOF`;
|
||||
|
||||
const resultado = await executarComandoSSH(config, comando);
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" };
|
||||
}
|
||||
|
||||
return { sucesso: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplicar configurações do Jitsi no servidor Docker via SSH
|
||||
*/
|
||||
export const aplicarConfiguracaoServidor = action({
|
||||
args: {
|
||||
configId: v.id("configuracaoJitsi"),
|
||||
sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave)
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
sucesso: v.literal(true),
|
||||
mensagem: v.string(),
|
||||
detalhes: v.optional(v.string()),
|
||||
}),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args): Promise<
|
||||
| { sucesso: true; mensagem: string; detalhes?: string }
|
||||
| { sucesso: false; erro: string }
|
||||
> => {
|
||||
try {
|
||||
// Buscar configuração
|
||||
const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
if (!config || config._id !== args.configId) {
|
||||
return { sucesso: false as const, erro: "Configuração não encontrada" };
|
||||
}
|
||||
|
||||
// Verificar se tem configurações SSH
|
||||
const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, {
|
||||
configId: args.configId,
|
||||
});
|
||||
|
||||
if (!configFull || !configFull.sshHost) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.",
|
||||
};
|
||||
}
|
||||
|
||||
// Configurar SSH
|
||||
let sshPasswordDecrypted: string | undefined = undefined;
|
||||
|
||||
// Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
|
||||
if (args.sshPassword) {
|
||||
sshPasswordDecrypted = args.sshPassword;
|
||||
} else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") {
|
||||
// Tentar descriptografar senha armazenada
|
||||
try {
|
||||
sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash);
|
||||
} catch (error) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sshConfig: SSHConfig = {
|
||||
host: configFull.sshHost,
|
||||
port: configFull.sshPort || 22,
|
||||
username: configFull.sshUsername || "",
|
||||
password: sshPasswordDecrypted,
|
||||
keyPath: configFull.sshKeyPath || undefined,
|
||||
};
|
||||
|
||||
if (!sshConfig.username) {
|
||||
return { sucesso: false as const, erro: "Usuário SSH não configurado" };
|
||||
}
|
||||
|
||||
if (!sshConfig.password && !sshConfig.keyPath) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Senha SSH ou caminho da chave deve ser fornecido",
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg";
|
||||
const dockerComposePath = configFull.dockerComposePath || ".";
|
||||
|
||||
// Extrair host e porta do domain
|
||||
const [host, portStr] = configFull.domain.split(":");
|
||||
const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80;
|
||||
const protocol = configFull.useHttps ? "https" : "http";
|
||||
|
||||
const detalhes: string[] = [];
|
||||
|
||||
// 1. Atualizar arquivo .env do docker-compose
|
||||
if (dockerComposePath) {
|
||||
const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE
|
||||
CONFIG=${basePath}
|
||||
TZ=America/Recife
|
||||
ENABLE_LETSENCRYPT=0
|
||||
HTTP_PORT=${protocol === "https" ? 8000 : port}
|
||||
HTTPS_PORT=${configFull.useHttps ? port : 8443}
|
||||
PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""}
|
||||
DOMAIN=${host}
|
||||
ENABLE_AUTH=0
|
||||
ENABLE_GUESTS=1
|
||||
ENABLE_TRANSCRIPTION=0
|
||||
ENABLE_RECORDING=0
|
||||
ENABLE_PREJOIN_PAGE=0
|
||||
START_AUDIO_MUTED=0
|
||||
START_VIDEO_MUTED=0
|
||||
ENABLE_XMPP_WEBSOCKET=0
|
||||
ENABLE_P2P=1
|
||||
MAX_NUMBER_OF_PARTICIPANTS=10
|
||||
RESOLUTION_WIDTH=1280
|
||||
RESOLUTION_HEIGHT=720
|
||||
JWT_APP_ID=${configFull.appId}
|
||||
JWT_APP_SECRET=
|
||||
`;
|
||||
|
||||
const envPath = `${dockerComposePath}/.env`;
|
||||
const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent);
|
||||
|
||||
if (!resultadoEnv.sucesso) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`,
|
||||
};
|
||||
}
|
||||
|
||||
detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`);
|
||||
}
|
||||
|
||||
// 2. Atualizar configuração do Prosody
|
||||
const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`;
|
||||
const prosodyContent = `-- Configuração Prosody para ${host}
|
||||
-- Gerada automaticamente pelo SGSE
|
||||
|
||||
VirtualHost "${host}"
|
||||
authentication = "anonymous"
|
||||
modules_enabled = {
|
||||
"bosh";
|
||||
"ping";
|
||||
"speakerstats";
|
||||
"turncredentials";
|
||||
"presence";
|
||||
"conference_duration";
|
||||
}
|
||||
c2s_require_encryption = false
|
||||
allow_anonymous_s2s = false
|
||||
|
||||
Component "conference.${host}" "muc"
|
||||
storage = "memory"
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
Component "jitsi-videobridge.${host}"
|
||||
component_secret = ""
|
||||
|
||||
Component "focus.${host}"
|
||||
component_secret = ""
|
||||
`;
|
||||
|
||||
const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent);
|
||||
|
||||
if (!resultadoProsody.sucesso) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`,
|
||||
};
|
||||
}
|
||||
|
||||
detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`);
|
||||
|
||||
// 3. Atualizar configuração do Jicofo
|
||||
const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`;
|
||||
const jicofoContent = `# Configuração Jicofo
|
||||
# Gerada automaticamente pelo SGSE
|
||||
org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host}
|
||||
org.jitsi.jicofo.jid=XMPP_USER@${host}
|
||||
org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host}
|
||||
org.jitsi.jicofo.app.ID=${configFull.appId}
|
||||
`;
|
||||
|
||||
const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent);
|
||||
|
||||
if (!resultadoJicofo.sucesso) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`,
|
||||
};
|
||||
}
|
||||
|
||||
detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`);
|
||||
|
||||
// 4. Atualizar configuração do JVB
|
||||
const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`;
|
||||
const jvbContent = `# Configuração JVB (Jitsi Video Bridge)
|
||||
# Gerada automaticamente pelo SGSE
|
||||
org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.*
|
||||
org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host}
|
||||
org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host}
|
||||
org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb
|
||||
org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host}
|
||||
`;
|
||||
|
||||
const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent);
|
||||
|
||||
if (!resultadoJvb.sucesso) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`,
|
||||
};
|
||||
}
|
||||
|
||||
detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`);
|
||||
|
||||
// 5. Reiniciar containers Docker
|
||||
if (dockerComposePath) {
|
||||
const resultadoRestart = await executarComandoSSH(
|
||||
sshConfig,
|
||||
`cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1`
|
||||
);
|
||||
|
||||
if (!resultadoRestart.sucesso) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`,
|
||||
};
|
||||
}
|
||||
|
||||
detalhes.push(`✓ Containers Docker reiniciados`);
|
||||
}
|
||||
|
||||
// Atualizar timestamp de configuração no servidor
|
||||
await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, {
|
||||
configId: args.configId,
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: true as const,
|
||||
mensagem: "Configurações aplicadas com sucesso no servidor Jitsi",
|
||||
detalhes: detalhes.join("\n"),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao aplicar configurações: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Testar conexão SSH
|
||||
*/
|
||||
export const testarConexaoSSH = action({
|
||||
args: {
|
||||
sshHost: v.string(),
|
||||
sshPort: v.optional(v.number()),
|
||||
sshUsername: v.string(),
|
||||
sshPassword: v.optional(v.string()),
|
||||
sshKeyPath: v.optional(v.string()),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), mensagem: v.string() }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args): Promise<
|
||||
| { sucesso: true; mensagem: string }
|
||||
| { sucesso: false; erro: string }
|
||||
> => {
|
||||
try {
|
||||
if (!args.sshPassword && !args.sshKeyPath) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: "Senha SSH ou caminho da chave deve ser fornecido",
|
||||
};
|
||||
}
|
||||
|
||||
const sshConfig: SSHConfig = {
|
||||
host: args.sshHost,
|
||||
port: args.sshPort || 22,
|
||||
username: args.sshUsername,
|
||||
password: args.sshPassword || undefined,
|
||||
keyPath: args.sshKeyPath || undefined,
|
||||
};
|
||||
|
||||
// Tentar executar um comando simples
|
||||
const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'");
|
||||
|
||||
if (resultado.sucesso && resultado.output.includes("SSH_OK")) {
|
||||
return {
|
||||
sucesso: true as const,
|
||||
mensagem: "Conexão SSH estabelecida com sucesso",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: resultado.erro || "Falha ao estabelecer conexão SSH",
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: `Erro ao testar SSH: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -32,6 +33,45 @@ export const obterConfigJitsi = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
@@ -43,6 +83,14 @@ export const salvarConfigJitsi = mutation({
|
||||
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") }),
|
||||
@@ -73,6 +121,12 @@ export const salvarConfigJitsi = mutation({
|
||||
};
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -83,6 +137,16 @@ export const salvarConfigJitsi = mutation({
|
||||
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(),
|
||||
@@ -93,6 +157,14 @@ export const salvarConfigJitsi = mutation({
|
||||
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
|
||||
@@ -280,3 +352,21 @@ export const marcarConfigTestada = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,13 +10,18 @@ import { api, internal } from './_generated/api';
|
||||
export const obterConfiguracao = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const config = await ctx.db
|
||||
// Buscar todas as configurações e pegar a mais recente (por atualizadoEm)
|
||||
const configs = await ctx.db
|
||||
.query('configuracaoRelogio')
|
||||
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true))
|
||||
.first();
|
||||
.collect();
|
||||
|
||||
// Pegar a configuração mais recente (ordenar por atualizadoEm desc)
|
||||
const config = configs.length > 0
|
||||
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
|
||||
: null;
|
||||
|
||||
if (!config) {
|
||||
// Retornar configuração padrão
|
||||
// Retornar configuração padrão (GMT-3 para Brasília)
|
||||
return {
|
||||
servidorNTP: 'pool.ntp.org',
|
||||
portaNTP: 123,
|
||||
@@ -24,13 +29,13 @@ export const obterConfiguracao = query({
|
||||
fallbackParaPC: true,
|
||||
ultimaSincronizacao: null,
|
||||
offsetSegundos: null,
|
||||
gmtOffset: 0,
|
||||
gmtOffset: -3, // GMT-3 para Brasília
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
gmtOffset: config.gmtOffset ?? 0,
|
||||
gmtOffset: config.gmtOffset ?? -3, // Padrão GMT-3 para Brasília se não configurado
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -64,11 +69,14 @@ export const salvarConfiguracao = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar configuração existente
|
||||
const configExistente = await ctx.db
|
||||
// Buscar configuração existente (pegar a mais recente)
|
||||
const configs = await ctx.db
|
||||
.query('configuracaoRelogio')
|
||||
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno))
|
||||
.first();
|
||||
.collect();
|
||||
|
||||
const configExistente = configs.length > 0
|
||||
? configs.sort((a, b) => (b.atualizadoEm || 0) - (a.atualizadoEm || 0))[0]
|
||||
: null;
|
||||
|
||||
if (configExistente) {
|
||||
// Atualizar configuração existente
|
||||
@@ -77,7 +85,7 @@ export const salvarConfiguracao = mutation({
|
||||
portaNTP: args.portaNTP,
|
||||
usarServidorExterno: args.usarServidorExterno,
|
||||
fallbackParaPC: args.fallbackParaPC,
|
||||
gmtOffset: args.gmtOffset ?? 0,
|
||||
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
|
||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
@@ -89,7 +97,7 @@ export const salvarConfiguracao = mutation({
|
||||
portaNTP: args.portaNTP,
|
||||
usarServidorExterno: args.usarServidorExterno,
|
||||
fallbackParaPC: args.fallbackParaPC,
|
||||
gmtOffset: args.gmtOffset ?? 0,
|
||||
gmtOffset: args.gmtOffset ?? -3, // Padrão GMT-3 para Brasília
|
||||
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||
atualizadoEm: Date.now(),
|
||||
});
|
||||
@@ -131,16 +139,75 @@ export const sincronizarTempo = action({
|
||||
}
|
||||
|
||||
// Tentar obter tempo de um servidor NTP público via HTTP
|
||||
// Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica
|
||||
// Nota: NTP real requer protocolo UDP na porta 123, aqui usamos APIs HTTP que retornam UTC
|
||||
// O GMT offset será aplicado no frontend
|
||||
try {
|
||||
// Usar API pública de tempo como fallback
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo do servidor');
|
||||
const servidorNTP = config.servidorNTP || 'pool.ntp.org';
|
||||
let serverTime: number;
|
||||
|
||||
// Mapear servidores NTP conhecidos para APIs HTTP que retornam UTC
|
||||
// Todos os servidores NTP retornam UTC, então usamos APIs que retornam UTC
|
||||
if (servidorNTP.includes('pool.ntp.org') || servidorNTP.includes('ntp.org') || servidorNTP.includes('ntp.br')) {
|
||||
// pool.ntp.org e servidores .org/.br - usar API que retorna UTC
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo do servidor');
|
||||
}
|
||||
const data = (await response.json()) as { unixtime: number; datetime: string };
|
||||
// unixtime está em segundos, converter para milissegundos
|
||||
serverTime = data.unixtime * 1000;
|
||||
} else if (servidorNTP.includes('time.google.com') || servidorNTP.includes('google')) {
|
||||
// Google NTP - usar API que retorna UTC
|
||||
try {
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo');
|
||||
}
|
||||
const data = (await response.json()) as { unixtime: number };
|
||||
serverTime = data.unixtime * 1000;
|
||||
} catch {
|
||||
// Fallback para outra API UTC
|
||||
const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo do servidor');
|
||||
}
|
||||
const data = (await response.json()) as { unixTime: number };
|
||||
serverTime = data.unixTime * 1000;
|
||||
}
|
||||
} else if (servidorNTP.includes('time.windows.com') || servidorNTP.includes('windows')) {
|
||||
// Windows NTP - usar API que retorna UTC
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo do servidor');
|
||||
}
|
||||
const data = (await response.json()) as { unixtime: number };
|
||||
serverTime = data.unixtime * 1000;
|
||||
} else {
|
||||
// Para outros servidores NTP, usar API genérica que retorna UTC
|
||||
// Tentar worldtimeapi primeiro
|
||||
try {
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo');
|
||||
}
|
||||
const data = (await response.json()) as { unixtime: number };
|
||||
serverTime = data.unixtime * 1000;
|
||||
} catch {
|
||||
// Fallback para timeapi.io
|
||||
try {
|
||||
const response = await fetch('https://timeapi.io/api/Time/current/zone?timeZone=UTC');
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao obter tempo');
|
||||
}
|
||||
const data = (await response.json()) as { unixTime: number };
|
||||
serverTime = data.unixTime * 1000;
|
||||
} catch {
|
||||
// Último fallback: usar tempo do servidor Convex (já está em UTC)
|
||||
serverTime = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { datetime: string };
|
||||
const serverTime = new Date(data.datetime).getTime();
|
||||
const localTime = Date.now();
|
||||
const offsetSegundos = Math.floor((serverTime - localTime) / 1000);
|
||||
|
||||
@@ -160,23 +227,26 @@ export const sincronizarTempo = action({
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
timestamp: serverTime,
|
||||
timestamp: serverTime, // Retorna UTC (sem GMT offset aplicado)
|
||||
usandoServidorExterno: true,
|
||||
offsetSegundos,
|
||||
};
|
||||
} catch {
|
||||
// Se falhar e fallbackParaPC estiver ativo, usar tempo local
|
||||
if (config.fallbackParaPC) {
|
||||
return {
|
||||
sucesso: true,
|
||||
timestamp: Date.now(),
|
||||
usandoServidorExterno: false,
|
||||
offsetSegundos: 0,
|
||||
aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Falha ao sincronizar tempo e fallback desabilitado');
|
||||
} catch (error) {
|
||||
// Sempre usar fallback como última opção, mesmo se desabilitado
|
||||
// Isso evita que o sistema trave completamente se o servidor externo não estiver disponível
|
||||
const aviso = config.fallbackParaPC
|
||||
? 'Falha ao sincronizar com servidor externo, usando relógio do PC'
|
||||
: 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.';
|
||||
|
||||
console.warn('Erro ao sincronizar tempo com servidor externo:', error);
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
timestamp: Date.now(),
|
||||
usandoServidorExterno: false,
|
||||
offsetSegundos: 0,
|
||||
aviso,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -369,38 +369,23 @@ export const registrarPonto = mutation({
|
||||
throw new Error('Configuração de ponto não encontrada');
|
||||
}
|
||||
|
||||
// Obter configuração de ponto para GMT offset (buscar configuração ativa)
|
||||
const configPonto = await ctx.db
|
||||
.query('configuracaoPonto')
|
||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||
.first();
|
||||
|
||||
// Converter timestamp para data/hora com ajuste de GMT
|
||||
// O timestamp está em UTC, precisamos aplicar o GMT offset
|
||||
const gmtOffset = configPonto?.gmtOffset ?? 0;
|
||||
// Converter timestamp para data/hora
|
||||
// O timestamp pode vir ajustado com GMT offset do frontend (se GMT !== 0)
|
||||
// ou em UTC puro (se GMT === 0). Usamos UTC methods para extrair os valores
|
||||
// diretamente do timestamp recebido, seja ele ajustado ou não
|
||||
const dataObj = new Date(args.timestamp);
|
||||
// Usar UTC methods porque:
|
||||
// - Se GMT === 0: timestamp está em UTC puro, métodos UTC extraem corretamente
|
||||
// - Se GMT !== 0: timestamp já vem ajustado do frontend, métodos UTC extraem o horário ajustado
|
||||
const hora = dataObj.getUTCHours();
|
||||
const minuto = dataObj.getUTCMinutes();
|
||||
const segundo = dataObj.getUTCSeconds();
|
||||
|
||||
// Calcular horário ajustado manualmente a partir de UTC
|
||||
const dataUTC = new Date(args.timestamp);
|
||||
let hora = dataUTC.getUTCHours() + gmtOffset;
|
||||
const minuto = dataUTC.getUTCMinutes();
|
||||
const segundo = dataUTC.getUTCSeconds();
|
||||
|
||||
// Ajustar hora se ultrapassar os limites do dia
|
||||
let diasOffset = 0;
|
||||
if (hora >= 24) {
|
||||
hora = hora - 24;
|
||||
diasOffset = 1;
|
||||
} else if (hora < 0) {
|
||||
hora = hora + 24;
|
||||
diasOffset = -1;
|
||||
}
|
||||
|
||||
// Calcular data ajustada
|
||||
const dataAjustada = new Date(args.timestamp);
|
||||
if (diasOffset !== 0) {
|
||||
dataAjustada.setUTCDate(dataAjustada.getUTCDate() + diasOffset);
|
||||
}
|
||||
const data = dataAjustada.toISOString().split('T')[0]!; // YYYY-MM-DD
|
||||
// Obter data no formato YYYY-MM-DD usando UTC
|
||||
const ano = dataObj.getUTCFullYear();
|
||||
const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dia = String(dataObj.getUTCDate()).padStart(2, '0');
|
||||
const data = `${ano}-${mes}-${dia}`;
|
||||
|
||||
// Verificar se já existe registro no mesmo minuto
|
||||
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
||||
@@ -544,7 +529,7 @@ export const registrarPonto = mutation({
|
||||
} | null = null;
|
||||
|
||||
if (
|
||||
configPonto?.validarLocalizacao !== false &&
|
||||
config.validarLocalizacao !== false &&
|
||||
args.informacoesDispositivo?.latitude &&
|
||||
args.informacoesDispositivo?.longitude
|
||||
) {
|
||||
@@ -553,7 +538,7 @@ export const registrarPonto = mutation({
|
||||
usuario.funcionarioId,
|
||||
args.informacoesDispositivo.latitude,
|
||||
args.informacoesDispositivo.longitude,
|
||||
configPonto?.toleranciaDistanciaMetros ?? 100
|
||||
config.toleranciaDistanciaMetros ?? 100
|
||||
);
|
||||
|
||||
validacaoGeofencing = geofencing;
|
||||
@@ -822,6 +807,7 @@ export const obterEstatisticas = query({
|
||||
args: {
|
||||
dataInicio: v.string(), // YYYY-MM-DD
|
||||
dataFim: v.string(), // YYYY-MM-DD
|
||||
funcionarioId: v.optional(v.id('funcionarios')),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
@@ -831,11 +817,16 @@ export const obterEstatisticas = query({
|
||||
|
||||
// TODO: Verificar permissão (RH ou TI)
|
||||
|
||||
const registros = await ctx.db
|
||||
let registros = await ctx.db
|
||||
.query('registrosPonto')
|
||||
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||
.collect();
|
||||
|
||||
// Filtrar por funcionário se fornecido
|
||||
if (args.funcionarioId) {
|
||||
registros = registros.filter((r) => r.funcionarioId === args.funcionarioId);
|
||||
}
|
||||
|
||||
const totalRegistros = registros.length;
|
||||
const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length;
|
||||
const foraDoPrazo = totalRegistros - dentroDoPrazo;
|
||||
|
||||
@@ -715,8 +715,19 @@ export default defineSchema({
|
||||
roomPrefix: v.string(), // Prefixo para nomes de salas
|
||||
useHttps: v.boolean(), // Usar HTTPS
|
||||
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
|
||||
// Configurações SSH/Docker para configuração automática do servidor
|
||||
sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local")
|
||||
sshPort: v.optional(v.number()), // Porta SSH (padrão: 22)
|
||||
sshUsername: v.optional(v.string()), // Usuário SSH
|
||||
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
|
||||
sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha)
|
||||
dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker")
|
||||
jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg")
|
||||
ativo: v.boolean(), // Configuração ativa
|
||||
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
|
||||
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
|
||||
configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
|
||||
configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
|
||||
configuradoPor: v.id("usuarios"), // Usuário que configurou
|
||||
atualizadoEm: v.number(), // Timestamp de atualização
|
||||
}).index("by_ativo", ["ativo"]),
|
||||
|
||||
Reference in New Issue
Block a user