Signal fixes

This commit is contained in:
Darren Clarke 2025-11-13 11:18:08 +01:00
parent 0e8c9be247
commit 457a86ebcd
7 changed files with 91 additions and 70 deletions

View file

@ -1,19 +1,12 @@
import { PostgresDialect, CamelCasePlugin } from "kysely";
import type {
GeneratedAlways,
Generated,
ColumnType,
Selectable,
} from "kysely";
import type { GeneratedAlways, Generated, ColumnType, Selectable } from "kysely";
import pg from "pg";
import { KyselyAuth } from "@auth/kysely-adapter";
const { Pool, types } = pg;
type Timestamp = ColumnType<Date, Date | string>;
types.setTypeParser(types.builtins.TIMESTAMPTZ, (val) =>
new Date(val).toISOString(),
);
types.setTypeParser(types.builtins.TIMESTAMPTZ, (val) => new Date(val).toISOString());
type GraphileJob = {
taskIdentifier: string;
@ -153,13 +146,23 @@ function getDb(): KyselyAuth<Database> {
const DATABASE_USER = process.env.DATABASE_USER;
const DATABASE_PASSWORD = process.env.DATABASE_PASSWORD;
if (!DATABASE_HOST || !DATABASE_NAME || !DATABASE_PORT || !DATABASE_USER || !DATABASE_PASSWORD) {
throw new Error('Missing required database environment variables: DATABASE_HOST, DATABASE_NAME, DATABASE_PORT, DATABASE_USER, DATABASE_PASSWORD');
if (
!DATABASE_HOST ||
!DATABASE_NAME ||
!DATABASE_PORT ||
!DATABASE_USER ||
!DATABASE_PASSWORD
) {
throw new Error(
"Missing required database environment variables: DATABASE_HOST, DATABASE_NAME, DATABASE_PORT, DATABASE_USER, DATABASE_PASSWORD",
);
}
const port = parseInt(DATABASE_PORT, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error(`Invalid DATABASE_PORT: ${DATABASE_PORT}. Must be a number between 1 and 65535.`);
throw new Error(
`Invalid DATABASE_PORT: ${DATABASE_PORT}. Must be a number between 1 and 65535.`,
);
}
_db = new KyselyAuth<Database>({
@ -185,7 +188,7 @@ export const db = new Proxy({} as KyselyAuth<Database>, {
const value = (instance as any)[prop];
// If it's a function, bind it to the actual instance to preserve 'this' context
if (typeof value === 'function') {
if (typeof value === "function") {
return value.bind(instance);
}

View file

@ -8,8 +8,8 @@ class CdrSignalChannelsController < ApplicationController
{
id: channel.id,
phone_number: channel.options['phone_number'],
bot_token: channel.options['bot_token'],
bot_endpoint: channel.options['bot_endpoint']
# bot_token intentionally excluded - bridge-worker should look it up from cdr database
}
end

View file

@ -115,7 +115,11 @@ class ChannelsCdrSignalController < ApplicationController
channel = channel_for_token(token)
return render json: {}, status: 401 if !channel || !channel.active
return render json: {}, status: 401 if channel.options[:token] != token
# Use constant-time comparison to prevent timing attacks
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
channel.options[:token].to_s,
token.to_s
)
# Handle group creation events
if params[:event] == 'group_created'
@ -218,38 +222,13 @@ class ChannelsCdrSignalController < ApplicationController
Rails.logger.info "Channel ID: #{channel.id}"
begin
# For group messages, search all tickets regardless of customer
# since users may have duplicate phone numbers
all_tickets = Ticket.where.not(state_id: state_ids)
.order(updated_at: :desc)
Rails.logger.info "Found #{all_tickets.count} active tickets (searching all customers for group match)"
ticket = all_tickets.find do |t|
begin
has_preferences = t.preferences.is_a?(Hash)
has_cdr_signal = has_preferences && t.preferences['cdr_signal'].is_a?(Hash)
has_channel_id = has_preferences && t.preferences['channel_id'] == channel.id
if has_cdr_signal && has_channel_id
stored_chat_id = t.preferences['cdr_signal']['chat_id']
Rails.logger.info " - stored_chat_id: #{stored_chat_id}"
Rails.logger.info " - incoming_group_id: #{receiver_phone_number}"
matches = receiver_phone_number == stored_chat_id
Rails.logger.info " - MATCH: #{matches}"
matches
else
Rails.logger.info "Ticket ##{t.number} has no cdr_signal preferences or wrong channel"
false
end
rescue => e
Rails.logger.error "Error checking ticket #{t.id}: #{e.message}"
false
end
end
# Use PostgreSQL JSONB queries to efficiently search preferences without loading all tickets into memory
# This prevents DoS attacks from memory exhaustion
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences->>'channel_id' = ?", channel.id.to_s)
.where("preferences->'cdr_signal'->>'chat_id' = ?", receiver_phone_number)
.order(updated_at: :desc)
.first
if ticket
Rails.logger.info "=== FOUND MATCHING TICKET BY GROUP ID: ##{ticket.number} ==="
@ -441,14 +420,13 @@ class ChannelsCdrSignalController < ApplicationController
end
# Find ticket(s) with this group_id in preferences
# Search all active tickets for matching group
# Use PostgreSQL JSONB queries for efficient lookup (prevents DoS from loading all tickets)
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where.not(state_id: state_ids).find do |t|
t.preferences.is_a?(Hash) &&
t.preferences['cdr_signal'].is_a?(Hash) &&
t.preferences['cdr_signal']['chat_id'] == params[:group_id]
end
ticket = Ticket.where.not(state_id: state_ids)
.where("preferences->'cdr_signal'->>'chat_id' = ?", params[:group_id])
.order(updated_at: :desc)
.first
unless ticket
Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}"
@ -456,11 +434,22 @@ class ChannelsCdrSignalController < ApplicationController
return
end
# Check if the member who joined matches the original recipient
original_recipient = ticket.preferences.dig('cdr_signal', 'original_recipient')
member_phone = params[:member_phone]
# Idempotency check: if already marked as joined, skip update and return success
# This prevents unnecessary database writes when the cron job sends duplicate notifications
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
Rails.logger.debug "Signal group member #{params[:member_phone]} already marked as joined for group #{params[:group_id]} ticket #{ticket.id}, skipping update"
render json: {
success: true,
ticket_id: ticket.id,
ticket_number: ticket.number,
group_joined: true,
already_joined: true
}, status: :ok
return
end
# Update group_joined flag
member_phone = params[:member_phone]
ticket.preferences[:cdr_signal][:group_joined] = true
ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present?
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone