955 lines
28 KiB
TypeScript
955 lines
28 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 { getCurrentUserFunction, authComponent, toGenericCtx } from './auth';
|
|
import { encryptJWTSecret, decryptJWTSecret } from './auth/utils';
|
|
import type { Id } from './_generated/dataModel';
|
|
|
|
/**
|
|
* Codificar base64url (sem padding, com substituição de caracteres)
|
|
*/
|
|
function base64UrlEncode(data: Uint8Array): string {
|
|
const base64 = btoa(String.fromCharCode(...data));
|
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
}
|
|
|
|
/**
|
|
* Gerar token JWT para Jitsi (função helper interna)
|
|
* Especificação: https://github.com/jitsi/lib-jitsi-meet/blob/master/doc/tokens.md
|
|
*/
|
|
async function criarTokenJWTJitsi(
|
|
secret: string,
|
|
payload: {
|
|
iss: string; // Issuer (appId)
|
|
aud: string; // Audience (domínio do servidor)
|
|
sub: string; // Subject (ID do usuário ou email)
|
|
room: string; // Nome da sala
|
|
moderator: boolean; // Se é moderador
|
|
exp?: number; // Expiração (timestamp, padrão: 1 hora)
|
|
nbf?: number; // Not before (timestamp, padrão: agora)
|
|
}
|
|
): Promise<string> {
|
|
// Header JWT
|
|
const header = {
|
|
alg: 'HS256',
|
|
typ: 'JWT'
|
|
};
|
|
|
|
// Payload com valores padrão
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const jwtPayload = {
|
|
...payload,
|
|
exp: payload.exp || now + 3600, // 1 hora por padrão
|
|
nbf: payload.nbf || now
|
|
};
|
|
|
|
// Codificar header e payload
|
|
const headerEncoded = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
|
|
const payloadEncoded = base64UrlEncode(new TextEncoder().encode(JSON.stringify(jwtPayload)));
|
|
|
|
// Criar assinatura
|
|
const message = `${headerEncoded}.${payloadEncoded}`;
|
|
const keyData = new TextEncoder().encode(secret);
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
keyData,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
|
const signatureEncoded = base64UrlEncode(new Uint8Array(signature));
|
|
|
|
// Retornar token completo
|
|
return `${headerEncoded}.${payloadEncoded}.${signatureEncoded}`;
|
|
}
|
|
|
|
/**
|
|
* Helper para verificar se usuário tem permissão de TI (TI_MASTER, TI_USUARIO ou ADMIN)
|
|
*/
|
|
async function verificarPermissaoTI(
|
|
ctx: Parameters<typeof getCurrentUserFunction>[0],
|
|
usuarioId: Id<'usuarios'>
|
|
): Promise<boolean> {
|
|
const usuario = await ctx.db.get(usuarioId);
|
|
if (!usuario || !usuario.roleId) {
|
|
return false;
|
|
}
|
|
|
|
const role = await ctx.db.get(usuario.roleId);
|
|
if (!role) {
|
|
return false;
|
|
}
|
|
|
|
const rolesPermitidas = ['ti_master', 'ti_usuario', 'admin'];
|
|
return rolesPermitidas.includes(role.nome);
|
|
}
|
|
|
|
/**
|
|
* Validar formato de domínio (FQDN ou localhost:porta)
|
|
*/
|
|
function validarFormatoDominio(domain: string): { valido: boolean; erro?: string } {
|
|
const trimmed = domain.trim();
|
|
if (trimmed.length === 0) {
|
|
return { valido: false, erro: 'Domínio não pode estar vazio' };
|
|
}
|
|
|
|
// Padrão para localhost:porta (ex: localhost:8443, 127.0.0.1:8080)
|
|
const localhostPattern = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/i;
|
|
if (localhostPattern.test(trimmed)) {
|
|
return { valido: true };
|
|
}
|
|
|
|
// Padrão para FQDN (ex: meet.example.com, subdomain.example.com:8443)
|
|
const fqdnPattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(:\d+)?$/;
|
|
if (fqdnPattern.test(trimmed)) {
|
|
return { valido: true };
|
|
}
|
|
|
|
return {
|
|
valido: false,
|
|
erro: 'Domínio deve ser um FQDN válido (ex: meet.example.com) ou localhost:porta (ex: localhost:8443)'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validar formato de App ID (alfanumérico, sem espaços)
|
|
*/
|
|
function validarAppId(appId: string): { valido: boolean; erro?: string } {
|
|
const trimmed = appId.trim();
|
|
if (trimmed.length === 0) {
|
|
return { valido: false, erro: 'App ID não pode estar vazio' };
|
|
}
|
|
|
|
const appIdPattern = /^[a-zA-Z0-9_-]+$/;
|
|
if (!appIdPattern.test(trimmed)) {
|
|
return {
|
|
valido: false,
|
|
erro: 'App ID deve conter apenas letras, números, hífens e underscores'
|
|
};
|
|
}
|
|
|
|
return { valido: true };
|
|
}
|
|
|
|
/**
|
|
* Validar JWT Secret (mínimo 32 caracteres recomendado)
|
|
*/
|
|
function validarJWTSecret(secret: string): { valido: boolean; erro?: string; aviso?: string } {
|
|
if (secret.length === 0) {
|
|
return { valido: true }; // JWT secret é opcional
|
|
}
|
|
|
|
if (secret.length < 16) {
|
|
return { valido: false, erro: 'JWT Secret deve ter no mínimo 16 caracteres' };
|
|
}
|
|
|
|
if (secret.length < 32) {
|
|
return {
|
|
valido: true,
|
|
aviso: 'Recomendado usar JWT Secret com pelo menos 32 caracteres para maior segurança'
|
|
};
|
|
}
|
|
|
|
return { valido: true };
|
|
}
|
|
|
|
/**
|
|
* Obter configuração de Jitsi ativa (compatibilidade com código existente)
|
|
*/
|
|
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,
|
|
ambiente: config.ambiente ?? null,
|
|
ativo: config.ativo,
|
|
testadoEm: config.testadoEm,
|
|
atualizadoEm: config.atualizadoEm,
|
|
jwtSecret: config.jwtSecret,
|
|
jwtIssuer: config.jwtIssuer,
|
|
jwtAudience: config.jwtAudience
|
|
};
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Obter configuração de Jitsi por ambiente
|
|
*/
|
|
export const obterConfigJitsiPorAmbiente = query({
|
|
args: {
|
|
ambiente: v.optional(v.string())
|
|
},
|
|
handler: async (
|
|
ctx,
|
|
args
|
|
): Promise<{
|
|
_id: Id<'configuracaoJitsi'>;
|
|
domain: string;
|
|
appId: string;
|
|
roomPrefix: string;
|
|
useHttps: boolean;
|
|
acceptSelfSignedCert: boolean;
|
|
ambiente: string | null;
|
|
ativo: boolean;
|
|
testadoEm: number | undefined;
|
|
atualizadoEm: number;
|
|
jwtSecret: string | undefined;
|
|
jwtIssuer: string | undefined;
|
|
jwtAudience: string | undefined;
|
|
} | null> => {
|
|
// Se ambiente não especificado, buscar configuração ativa
|
|
if (!args.ambiente) {
|
|
return await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
|
}
|
|
|
|
const config = await ctx.db
|
|
.query('configuracaoJitsi')
|
|
.withIndex('by_ativo_ambiente', (q) => q.eq('ativo', true).eq('ambiente', args.ambiente))
|
|
.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,
|
|
ambiente: config.ambiente ?? null,
|
|
ativo: config.ativo,
|
|
testadoEm: config.testadoEm,
|
|
atualizadoEm: config.atualizadoEm,
|
|
jwtSecret: config.jwtSecret,
|
|
jwtIssuer: config.jwtIssuer,
|
|
jwtAudience: config.jwtAudience
|
|
};
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Listar todas as configurações de Jitsi
|
|
*/
|
|
export const listarConfiguracoesJitsi = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const configs = await ctx.db.query('configuracaoJitsi').collect();
|
|
|
|
return configs.map((config) => ({
|
|
_id: config._id,
|
|
domain: config.domain,
|
|
appId: config.appId,
|
|
roomPrefix: config.roomPrefix,
|
|
useHttps: config.useHttps,
|
|
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
|
ambiente: config.ambiente ?? null,
|
|
ativo: config.ativo,
|
|
testadoEm: config.testadoEm,
|
|
atualizadoEm: config.atualizadoEm,
|
|
configuradoPor: config.configuradoPor
|
|
}));
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Salvar configuração de Jitsi (apenas TI_MASTER/TI_USUARIO/ADMIN)
|
|
* Suporta múltiplos ambientes
|
|
*/
|
|
export const salvarConfigJitsi = mutation({
|
|
args: {
|
|
domain: v.string(),
|
|
appId: v.string(),
|
|
roomPrefix: v.string(),
|
|
useHttps: v.boolean(),
|
|
acceptSelfSignedCert: v.boolean(),
|
|
ambiente: v.optional(v.string()),
|
|
jwtSecret: v.optional(v.string()), // JWT Secret em texto plano (será criptografado)
|
|
jwtAudience: v.optional(v.string()),
|
|
jwtIssuer: v.optional(v.string()),
|
|
configuradoPorId: v.id('usuarios'),
|
|
ativar: v.optional(v.boolean()) // Se true, desativa outras configs do mesmo ambiente
|
|
},
|
|
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) => {
|
|
// Verificar permissão
|
|
const temPermissao = await verificarPermissaoTI(ctx, args.configuradoPorId);
|
|
if (!temPermissao) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Apenas usuários de TI ou administradores podem configurar Jitsi'
|
|
};
|
|
}
|
|
|
|
// Validar domínio
|
|
const validacaoDominio = validarFormatoDominio(args.domain);
|
|
if (!validacaoDominio.valido) {
|
|
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
|
}
|
|
|
|
// Validar appId
|
|
const validacaoAppId = validarAppId(args.appId);
|
|
if (!validacaoAppId.valido) {
|
|
return { sucesso: false as const, erro: validacaoAppId.erro || 'App ID inválido' };
|
|
}
|
|
|
|
// Validar roomPrefix
|
|
if (!args.roomPrefix || args.roomPrefix.trim().length === 0) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Prefixo de sala não pode estar vazio'
|
|
};
|
|
}
|
|
|
|
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'
|
|
};
|
|
}
|
|
|
|
// Validar JWT Secret (se fornecido)
|
|
let jwtSecretCriptografado: string | undefined = undefined;
|
|
if (args.jwtSecret && args.jwtSecret.trim().length > 0) {
|
|
const validacaoJWT = validarJWTSecret(args.jwtSecret);
|
|
if (!validacaoJWT.valido) {
|
|
return { sucesso: false as const, erro: validacaoJWT.erro || 'JWT Secret inválido' };
|
|
}
|
|
|
|
// Criptografar JWT secret
|
|
try {
|
|
jwtSecretCriptografado = await encryptJWTSecret(args.jwtSecret.trim());
|
|
} catch (error) {
|
|
console.error('Erro ao criptografar JWT secret:', error);
|
|
return { sucesso: false as const, erro: 'Erro ao criptografar JWT secret' };
|
|
}
|
|
}
|
|
|
|
// Se ativar=true, desativar outras configs do mesmo ambiente (ou todas se ambiente não especificado)
|
|
if (args.ativar !== false) {
|
|
if (args.ambiente) {
|
|
// Desativar outras configs do mesmo ambiente
|
|
const configsMesmoAmbiente = await ctx.db
|
|
.query('configuracaoJitsi')
|
|
.withIndex('by_ambiente', (q) => q.eq('ambiente', args.ambiente))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.collect();
|
|
|
|
for (const config of configsMesmoAmbiente) {
|
|
await ctx.db.patch(config._id, { ativo: false });
|
|
}
|
|
} else {
|
|
// Desativar todas as configs ativas (comportamento antigo)
|
|
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,
|
|
ambiente: args.ambiente?.trim() || undefined,
|
|
jwtSecret: jwtSecretCriptografado,
|
|
jwtAudience: args.jwtAudience?.trim() || undefined,
|
|
jwtIssuer: args.jwtIssuer?.trim() || undefined,
|
|
ativo: args.ativar !== false,
|
|
configuradoPor: args.configuradoPorId,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Log de atividade
|
|
await registrarAtividade(
|
|
ctx,
|
|
args.configuradoPorId,
|
|
'configurar',
|
|
'jitsi',
|
|
JSON.stringify({ domain: args.domain, appId: args.appId, ambiente: args.ambiente }),
|
|
configId
|
|
);
|
|
|
|
return { sucesso: true as const, configId };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Atualizar configuração existente
|
|
*/
|
|
export const atualizarConfigJitsi = mutation({
|
|
args: {
|
|
configId: v.id('configuracaoJitsi'),
|
|
domain: v.optional(v.string()),
|
|
appId: v.optional(v.string()),
|
|
roomPrefix: v.optional(v.string()),
|
|
useHttps: v.optional(v.boolean()),
|
|
acceptSelfSignedCert: v.optional(v.boolean()),
|
|
ambiente: v.optional(v.string()),
|
|
jwtSecret: v.optional(v.string()), // Se fornecido, será criptografado e atualizado
|
|
jwtAudience: v.optional(v.string()),
|
|
jwtIssuer: v.optional(v.string()),
|
|
atualizadoPorId: v.id('usuarios')
|
|
},
|
|
returns: v.union(
|
|
v.object({ sucesso: v.literal(true) }),
|
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
|
),
|
|
handler: async (ctx, args) => {
|
|
// Verificar permissão
|
|
const temPermissao = await verificarPermissaoTI(ctx, args.atualizadoPorId);
|
|
if (!temPermissao) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Apenas usuários de TI ou administradores podem atualizar configuração Jitsi'
|
|
};
|
|
}
|
|
|
|
const config = await ctx.db.get(args.configId);
|
|
if (!config) {
|
|
return { sucesso: false as const, erro: 'Configuração não encontrada' };
|
|
}
|
|
|
|
// Validar campos se fornecidos
|
|
if (args.domain !== undefined) {
|
|
const validacaoDominio = validarFormatoDominio(args.domain);
|
|
if (!validacaoDominio.valido) {
|
|
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
|
}
|
|
}
|
|
|
|
if (args.appId !== undefined) {
|
|
const validacaoAppId = validarAppId(args.appId);
|
|
if (!validacaoAppId.valido) {
|
|
return { sucesso: false as const, erro: validacaoAppId.erro || 'App ID inválido' };
|
|
}
|
|
}
|
|
|
|
if (args.roomPrefix !== undefined) {
|
|
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'
|
|
};
|
|
}
|
|
}
|
|
|
|
// Criptografar JWT secret se fornecido
|
|
let jwtSecretCriptografado: string | undefined = config.jwtSecret;
|
|
if (args.jwtSecret !== undefined) {
|
|
if (args.jwtSecret.trim().length > 0) {
|
|
const validacaoJWT = validarJWTSecret(args.jwtSecret);
|
|
if (!validacaoJWT.valido) {
|
|
return { sucesso: false as const, erro: validacaoJWT.erro || 'JWT Secret inválido' };
|
|
}
|
|
|
|
try {
|
|
jwtSecretCriptografado = await encryptJWTSecret(args.jwtSecret.trim());
|
|
} catch (error) {
|
|
console.error('Erro ao criptografar JWT secret:', error);
|
|
return { sucesso: false as const, erro: 'Erro ao criptografar JWT secret' };
|
|
}
|
|
} else {
|
|
// Se string vazia, remover JWT secret
|
|
jwtSecretCriptografado = undefined;
|
|
}
|
|
}
|
|
|
|
// Atualizar campos
|
|
const updates: Partial<typeof config> = {
|
|
atualizadoEm: Date.now()
|
|
};
|
|
|
|
if (args.domain !== undefined) updates.domain = args.domain.trim();
|
|
if (args.appId !== undefined) updates.appId = args.appId.trim();
|
|
if (args.roomPrefix !== undefined) updates.roomPrefix = args.roomPrefix.trim();
|
|
if (args.useHttps !== undefined) updates.useHttps = args.useHttps;
|
|
if (args.acceptSelfSignedCert !== undefined)
|
|
updates.acceptSelfSignedCert = args.acceptSelfSignedCert;
|
|
if (args.ambiente !== undefined) updates.ambiente = args.ambiente?.trim() || undefined;
|
|
if (args.jwtSecret !== undefined) updates.jwtSecret = jwtSecretCriptografado;
|
|
if (args.jwtAudience !== undefined) updates.jwtAudience = args.jwtAudience?.trim() || undefined;
|
|
if (args.jwtIssuer !== undefined) updates.jwtIssuer = args.jwtIssuer?.trim() || undefined;
|
|
|
|
await ctx.db.patch(args.configId, updates);
|
|
|
|
// Log de atividade
|
|
await registrarAtividade(
|
|
ctx,
|
|
args.atualizadoPorId,
|
|
'atualizar',
|
|
'jitsi',
|
|
JSON.stringify({ configId: args.configId }),
|
|
args.configId
|
|
);
|
|
|
|
return { sucesso: true as const };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Ativar/desativar configuração
|
|
*/
|
|
export const ativarDesativarConfigJitsi = mutation({
|
|
args: {
|
|
configId: v.id('configuracaoJitsi'),
|
|
ativo: v.boolean(),
|
|
atualizadoPorId: v.id('usuarios')
|
|
},
|
|
returns: v.union(
|
|
v.object({ sucesso: v.literal(true) }),
|
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
|
),
|
|
handler: async (ctx, args) => {
|
|
// Verificar permissão
|
|
const temPermissao = await verificarPermissaoTI(ctx, args.atualizadoPorId);
|
|
if (!temPermissao) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'Apenas usuários de TI ou administradores podem ativar/desativar configuração Jitsi'
|
|
};
|
|
}
|
|
|
|
const config = await ctx.db.get(args.configId);
|
|
if (!config) {
|
|
return { sucesso: false as const, erro: 'Configuração não encontrada' };
|
|
}
|
|
|
|
// Se ativando, desativar outras configs do mesmo ambiente (ou todas se ambiente não especificado)
|
|
if (args.ativo) {
|
|
if (config.ambiente) {
|
|
const configsMesmoAmbiente = await ctx.db
|
|
.query('configuracaoJitsi')
|
|
.withIndex('by_ambiente', (q) => q.eq('ambiente', config.ambiente))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.filter((q) => q.neq(q.field('_id'), args.configId))
|
|
.collect();
|
|
|
|
for (const outraConfig of configsMesmoAmbiente) {
|
|
await ctx.db.patch(outraConfig._id, { ativo: false });
|
|
}
|
|
} else {
|
|
// Desativar todas as outras configs ativas
|
|
const configsAntigas = await ctx.db
|
|
.query('configuracaoJitsi')
|
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
.filter((q) => q.neq(q.field('_id'), args.configId))
|
|
.collect();
|
|
|
|
for (const outraConfig of configsAntigas) {
|
|
await ctx.db.patch(outraConfig._id, { ativo: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
await ctx.db.patch(args.configId, {
|
|
ativo: args.ativo,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Log de atividade
|
|
await registrarAtividade(
|
|
ctx,
|
|
args.atualizadoPorId,
|
|
args.ativo ? 'ativar' : 'desativar',
|
|
'jitsi',
|
|
JSON.stringify({ configId: args.configId }),
|
|
args.configId
|
|
);
|
|
|
|
return { sucesso: true as const };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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 (melhorado)
|
|
*/
|
|
export const testarConexaoJitsi = action({
|
|
args: {
|
|
domain: v.string(),
|
|
useHttps: v.boolean(),
|
|
acceptSelfSignedCert: v.optional(v.boolean()),
|
|
configId: v.optional(v.id('configuracaoJitsi'))
|
|
},
|
|
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 }> => {
|
|
// Validar formato de domínio
|
|
const validacaoDominio = validarFormatoDominio(args.domain);
|
|
if (!validacaoDominio.valido) {
|
|
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
|
}
|
|
|
|
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
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 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
|
|
if (response.status >= 200 && response.status < 600) {
|
|
// Se o teste foi bem-sucedido e há uma config, atualizar testadoEm
|
|
if (args.configId) {
|
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
|
configId: args.configId
|
|
});
|
|
} else {
|
|
// Tentar atualizar config ativa
|
|
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 10 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) {
|
|
if (args.configId) {
|
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
|
configId: args.configId
|
|
});
|
|
} else {
|
|
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)
|
|
if (errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')) {
|
|
if (args.configId) {
|
|
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
|
configId: args.configId
|
|
});
|
|
} else {
|
|
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;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Gerar token JWT para Jitsi
|
|
* Valida que o usuário é participante da conversa e retorna token assinado
|
|
*/
|
|
export const gerarTokenJitsi = action({
|
|
args: {
|
|
roomName: v.string(),
|
|
conversaId: v.id('conversas'),
|
|
chamadaId: v.id('chamadas'),
|
|
ambiente: v.optional(v.string())
|
|
},
|
|
returns: v.union(
|
|
v.object({
|
|
sucesso: v.literal(true),
|
|
token: v.string(),
|
|
payload: v.object({
|
|
iss: v.string(),
|
|
aud: v.string(),
|
|
sub: v.string(),
|
|
room: v.string(),
|
|
moderator: v.boolean(),
|
|
exp: v.number(),
|
|
nbf: v.number()
|
|
})
|
|
}),
|
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
|
),
|
|
handler: async (
|
|
ctx,
|
|
args
|
|
): Promise<
|
|
| {
|
|
sucesso: true;
|
|
token: string;
|
|
payload: {
|
|
iss: string;
|
|
aud: string;
|
|
sub: string;
|
|
room: string;
|
|
moderator: boolean;
|
|
exp: number;
|
|
nbf: number;
|
|
};
|
|
}
|
|
| { sucesso: false; erro: string }
|
|
> => {
|
|
// Obter usuário autenticado (em actions, precisamos usar authComponent)
|
|
const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx));
|
|
if (!authUser) {
|
|
return { sucesso: false as const, erro: 'Não autenticado' };
|
|
}
|
|
|
|
// Buscar usuário no banco usando query
|
|
const usuarioAtual = await ctx.runQuery(api.auth.getCurrentUser, {});
|
|
if (!usuarioAtual) {
|
|
return { sucesso: false as const, erro: 'Usuário não encontrado' };
|
|
}
|
|
|
|
// Verificar se usuário participa da conversa
|
|
const conversa = await ctx.runQuery(api.chamadas.obterChamada, {
|
|
chamadaId: args.chamadaId
|
|
});
|
|
if (!conversa) {
|
|
return { sucesso: false as const, erro: 'Chamada não encontrada ou você não tem acesso' };
|
|
}
|
|
|
|
// Verificar se conversaId corresponde
|
|
if (conversa.conversaId !== args.conversaId) {
|
|
return { sucesso: false as const, erro: 'Conversa não corresponde à chamada' };
|
|
}
|
|
|
|
// Verificar se chamada existe (já obtida acima como conversa)
|
|
const chamada = conversa;
|
|
if (!chamada) {
|
|
return { sucesso: false as const, erro: 'Chamada não encontrada' };
|
|
}
|
|
|
|
// Verificar se roomName corresponde
|
|
if (chamada.roomName !== args.roomName) {
|
|
return { sucesso: false as const, erro: 'Room name não corresponde à chamada' };
|
|
}
|
|
|
|
// Buscar configuração Jitsi
|
|
let config;
|
|
if (args.ambiente) {
|
|
config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiPorAmbiente, {
|
|
ambiente: args.ambiente
|
|
});
|
|
} else {
|
|
config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
|
}
|
|
|
|
if (!config) {
|
|
return { sucesso: false as const, erro: 'Configuração Jitsi não encontrada' };
|
|
}
|
|
|
|
// Se não há JWT secret configurado, retornar erro
|
|
if (!config.jwtSecret) {
|
|
return {
|
|
sucesso: false as const,
|
|
erro: 'JWT Secret não configurado. Configure o JWT Secret nas configurações do Jitsi.'
|
|
};
|
|
}
|
|
|
|
// Descriptografar JWT secret
|
|
let jwtSecret: string;
|
|
try {
|
|
jwtSecret = await decryptJWTSecret(config.jwtSecret);
|
|
} catch (error) {
|
|
console.error('Erro ao descriptografar JWT secret:', error);
|
|
return { sucesso: false as const, erro: 'Erro ao descriptografar JWT secret' };
|
|
}
|
|
|
|
// Determinar se usuário é moderador (anfitrião da chamada)
|
|
const ehModerador = chamada.criadoPor === usuarioAtual._id;
|
|
|
|
// Preparar claims do JWT
|
|
const iss: string = config.jwtIssuer || config.appId;
|
|
const aud: string = config.jwtAudience || config.domain;
|
|
const sub: string = usuarioAtual.email || usuarioAtual._id;
|
|
|
|
// Gerar token
|
|
try {
|
|
const token = await criarTokenJWTJitsi(jwtSecret, {
|
|
iss,
|
|
aud,
|
|
sub,
|
|
room: args.roomName,
|
|
moderator: ehModerador
|
|
});
|
|
|
|
// Retornar token e payload decodificado para debug
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload: {
|
|
iss: string;
|
|
aud: string;
|
|
sub: string;
|
|
room: string;
|
|
moderator: boolean;
|
|
exp: number;
|
|
nbf: number;
|
|
} = {
|
|
iss,
|
|
aud,
|
|
sub,
|
|
room: args.roomName,
|
|
moderator: ehModerador,
|
|
exp: now + 3600, // 1 hora
|
|
nbf: now
|
|
};
|
|
|
|
return { sucesso: true as const, token, payload };
|
|
} catch (error) {
|
|
console.error('Erro ao gerar token JWT:', error);
|
|
return { sucesso: false as const, erro: 'Erro ao gerar token JWT' };
|
|
}
|
|
}
|
|
});
|