Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b545653a93 | ||
|
|
0f02455224 | ||
|
|
1c2c8f26fc | ||
|
|
82408a8e03 | ||
|
|
c17c9b10d9 | ||
|
|
e5d41f4b03 | ||
|
|
2be2c9aa04 | ||
|
|
d9b86182f2 | ||
|
|
79e46d1758 | ||
|
|
5895d7b2bd | ||
|
|
bf5220e881 | ||
|
|
6bc29bfef2 | ||
|
|
9dddd1b020 | ||
|
|
bc294d8829 | ||
|
|
4b243a44ee | ||
|
|
f2961c4f8e | ||
|
|
c4ea779759 | ||
|
|
cfb74b12f9 | ||
|
|
63bea5219b | ||
|
|
74c611325f | ||
|
|
71ebacc46e | ||
|
|
770e395f17 | ||
|
|
4a925619a1 | ||
|
|
db537b5c86 | ||
|
|
cd1dbb67eb | ||
|
|
114596db19 | ||
|
|
96a3999c8b |
@@ -0,0 +1,35 @@
|
||||
module FacebookConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class InvalidDigestError < StandardError; end
|
||||
|
||||
private
|
||||
|
||||
def deletion_processed?(code)
|
||||
request = DeleteRequest.find_by!(confirmation_code: code)
|
||||
request.complete?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def parse_fb_signed_request(signed_request)
|
||||
encoded_signature, payload = signed_request.split('.', 2)
|
||||
|
||||
decoded_signature = Base64.urlsafe_decode64(encoded_signature)
|
||||
decoded_payload = JSON.parse(Base64.urlsafe_decode64(payload))
|
||||
|
||||
expected_signature = OpenSSL::HMAC.digest('sha256', app_secret, payload)
|
||||
|
||||
raise InvalidDigestError if decoded_signature != expected_signature
|
||||
|
||||
decoded_payload
|
||||
end
|
||||
|
||||
def app_url_base
|
||||
ENV.fetch('FRONTEND_URL', nil)
|
||||
end
|
||||
|
||||
def app_secret
|
||||
GlobalConfigService.load('FB_APP_SECRET', '')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class Facebook::ConfirmController < ApplicationController
|
||||
include FacebookConcern
|
||||
|
||||
def show
|
||||
if deletion_processed?(params[:id])
|
||||
render plain: 'Data Deleted Successfully', status: :ok
|
||||
else
|
||||
render plain: 'Processing. If there is an issue, please contact support', status: :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Facebook::DeleteController < ApplicationController
|
||||
include FacebookConcern
|
||||
|
||||
def create
|
||||
signed_request = params['signed_request']
|
||||
payload = parse_fb_signed_request(signed_request)
|
||||
id_to_process = payload['user_id']
|
||||
|
||||
delete_request = DeleteRequest.create(fb_id: id_to_process)
|
||||
status_url = "#{app_url_base}/facebook/confirm/#{delete_request.confirmation_code}"
|
||||
|
||||
# IMPORTANT: Do not change the response format below.
|
||||
# Facebook's Data Deletion Request system specifically expects responses in this format
|
||||
# with a 'url' for status confirmation and a 'confirmation_code' field.
|
||||
# See: https://developers.facebook.com/docs/development/create-an-app/app-dashboard/data-deletion-callback/#implementing
|
||||
render json: { url: status_url, confirmation_code: delete_request.confirmation_code }, status: :ok
|
||||
rescue InvalidDigestError
|
||||
render json: { error: 'Invalid signature' }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
render json: { error: e.message }, status: :malformed_request
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: delete_requests
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# completed_at :datetime
|
||||
# confirmation_code :string not null
|
||||
# deleted_type :string
|
||||
# status :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# deleted_id :integer
|
||||
# fb_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_delete_requests_on_account_id (account_id)
|
||||
# index_delete_requests_on_confirmation_code (confirmation_code) UNIQUE
|
||||
#
|
||||
class DeleteRequest < ApplicationRecord
|
||||
belongs_to :account, optional: true
|
||||
enum :status, %w[pending complete].index_by(&:itself), default: :pending
|
||||
|
||||
before_create :ensure_unqiue_confirmation_code
|
||||
|
||||
private
|
||||
|
||||
def ensure_unqiue_confirmation_code
|
||||
max_retries = 3
|
||||
retry_count = 0
|
||||
|
||||
begin
|
||||
self.confirmation_code = generate_confirmation_code
|
||||
raise ActiveRecord::RecordNotUnique if self.class.exists?(confirmation_code: confirmation_code)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry_count += 1
|
||||
if retry_count > max_retries
|
||||
# it's really really unlikely that we'll ever ever hit this case, but just in case
|
||||
self.confirmation_code = generate_confirmation_code + SecureRandom.alphanumeric(3)
|
||||
else
|
||||
retry
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_confirmation_code
|
||||
SecureRandom.uuid.delete('-')
|
||||
end
|
||||
end
|
||||
@@ -453,6 +453,11 @@ Rails.application.routes.draw do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
namespace :facebook do
|
||||
resources :delete, only: [:create]
|
||||
resources :confirm, only: [:show]
|
||||
end
|
||||
|
||||
namespace :linear do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class CreateDeleteRequests < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :delete_requests do |t|
|
||||
t.string :fb_id, null: false
|
||||
t.string :status, null: false
|
||||
t.string :confirmation_code, null: false
|
||||
t.datetime :completed_at
|
||||
t.string :deleted_type
|
||||
t.integer :deleted_id
|
||||
t.references :account, index: true, null: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :delete_requests, :confirmation_code, unique: true
|
||||
end
|
||||
end
|
||||
+15
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_03_06_081627) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -639,6 +639,20 @@ ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
|
||||
t.index ["account_id"], name: "index_data_imports_on_account_id"
|
||||
end
|
||||
|
||||
create_table "delete_requests", force: :cascade do |t|
|
||||
t.string "fb_id", null: false
|
||||
t.string "status", null: false
|
||||
t.string "confirmation_code", null: false
|
||||
t.datetime "completed_at"
|
||||
t.string "deleted_type"
|
||||
t.integer "deleted_id"
|
||||
t.bigint "account_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_delete_requests_on_account_id"
|
||||
t.index ["confirmation_code"], name: "index_delete_requests_on_confirmation_code", unique: true
|
||||
end
|
||||
|
||||
create_table "email_templates", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "body", null: false
|
||||
|
||||
@@ -41,4 +41,7 @@ module Redis::RedisKeys
|
||||
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
|
||||
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
|
||||
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
|
||||
|
||||
## Meta deletion flags
|
||||
META_DELETE_PROCESSING = 'META_DELETE_PROCESSING::%<id>s'.freeze
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Facebook::ConfirmController do
|
||||
describe 'GET #show' do
|
||||
let(:user_id) { '12345' }
|
||||
|
||||
context 'when deletion is in progress' do
|
||||
before do
|
||||
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
|
||||
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return('true')
|
||||
end
|
||||
|
||||
it 'returns processing status' do
|
||||
get :show, params: { id: user_id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to eq('Processing')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deletion is completed' do
|
||||
before do
|
||||
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
|
||||
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns success message' do
|
||||
get :show, params: { id: user_id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to eq('Data Deleted Successfully')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Facebook::DeleteController do
|
||||
describe 'POST #create' do
|
||||
let(:user_id) { '12345' }
|
||||
let(:app_secret) { 'test_app_secret' }
|
||||
let(:frontend_url) { ENV.fetch('FRONTEND_URL', nil) }
|
||||
let(:redis_key) { format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('FB_APP_SECRET', '').and_return(app_secret)
|
||||
end
|
||||
|
||||
context 'with valid signed request' do
|
||||
let(:payload) { { 'user_id' => user_id }.to_json }
|
||||
let(:encoded_payload) { Base64.urlsafe_encode64(payload) }
|
||||
let(:signature) { OpenSSL::HMAC.digest('sha256', app_secret, encoded_payload) }
|
||||
let(:encoded_signature) { Base64.urlsafe_encode64(signature) }
|
||||
let(:signed_request) { "#{encoded_signature}.#{encoded_payload}" }
|
||||
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:set)
|
||||
allow(Channels::Facebook::RedactContactDataJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'processes the delete request and returns status URL' do
|
||||
post :create, params: { signed_request: signed_request }
|
||||
|
||||
expect(Redis::Alfred).to have_received(:set).with(redis_key, true)
|
||||
expect(Channels::Facebook::RedactContactDataJob).to have_received(:perform_later).with(user_id)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
response_json = response.parsed_body
|
||||
# NOTE: this is an important condition since this is exactly the format facebook expects
|
||||
expect(response_json['url']).to eq("#{frontend_url}/facebook/confirm/#{user_id}")
|
||||
expect(response_json['confirmation_code']).to eq(user_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid signed request' do
|
||||
let(:invalid_signed_request) { 'invalid_signature.invalid_payload' }
|
||||
|
||||
before do
|
||||
# Mock the Base64.urlsafe_decode64 to return valid values for testing
|
||||
allow(Base64).to receive(:urlsafe_decode64).with('invalid_signature').and_return('decoded_signature')
|
||||
allow(Base64).to receive(:urlsafe_decode64).with('invalid_payload').and_return('{"user_id":"12345"}')
|
||||
allow(JSON).to receive(:parse).with('{"user_id":"12345"}').and_return({ 'user_id' => user_id })
|
||||
allow(OpenSSL::HMAC).to receive(:digest).and_return('different_signature')
|
||||
end
|
||||
|
||||
it 'returns an error for invalid signature' do
|
||||
post :create, params: { signed_request: invalid_signed_request }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user