Add Webhooks and XMPP notifications for incoming sats #79

Merged
raucao merged 10 commits from feature/webhooks into master 2023-01-13 04:33:02 +00:00
17 changed files with 195 additions and 5 deletions

View File

@ -9,3 +9,5 @@ BTCPAY_API_URL='http://localhost:23001/api/v1'
LNDHUB_API_URL='http://localhost:3023' LNDHUB_API_URL='http://localhost:3023'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
WEBHOOKS_ALLOWED_IPS='10.1.1.163'

View File

@ -5,3 +5,5 @@ BTCPAY_API_URL='http://10.1.1.163:23001/api/v1'
LNDHUB_LEGACY_API_URL='http://10.1.1.163:3026' LNDHUB_LEGACY_API_URL='http://10.1.1.163:3026'
LNDHUB_API_URL='http://10.1.1.163:3026' LNDHUB_API_URL='http://10.1.1.163:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
WEBHOOKS_ALLOWED_IPS='10.1.1.163'

View File

@ -1,5 +1,9 @@
EJABBERD_API_URL='http://xmpp.example.com/api' EJABBERD_API_URL='http://xmpp.example.com/api'
BTCPAY_API_URL='http://btcpay.example.com/api/v1' BTCPAY_API_URL='http://btcpay.example.com/api/v1'
LNDHUB_LEGACY_API_URL='http://localhost:3023' LNDHUB_LEGACY_API_URL='http://localhost:3023'
LNDHUB_API_URL='http://localhost:3026' LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
WEBHOOKS_ALLOWED_IPS='10.1.1.23'

View File

@ -0,0 +1,40 @@
class WebhooksController < ApplicationController
skip_forgery_protection
before_action :authorize_request
def lndhub
begin
payload = JSON.parse(request.body.read, symbolize_names: true)
raucao marked this conversation as resolved
Review

doesn't rails automatically parse the JSON because the proper content type is set?
so params[:type] and params[:user_login] should be enough?

doesn't rails automatically parse the JSON because the proper content type is set? so `params[:type]` and `params[:user_login]` should be enough?
Review

I didn't know, so I tried (also with explicitly setting the content type in the spec). Doesn't do it.

I didn't know, so I tried (also with explicitly setting the content type in the spec). Doesn't do it.
head :no_content and return unless payload[:type] == "incoming"
rescue
head :unprocessable_entity and return
end
user = User.find_by!(ln_account: payload[:user_login])
raucao marked this conversation as resolved Outdated
Outdated
Review

should not be the case, but this might be nil. maybe we should do a User.find_by!

should not be the case, but this might be nil. maybe we should do a `User.find_by!`

Good idea! Fails much cleaner and more expressively than when trying to access properties on nil later.

Good idea! Fails much cleaner and more expressively than when trying to access properties on `nil` later.
# TODO make configurable
notify_xmpp(user.address, payload[:amount], payload[:memo])
head :ok
end
private
def notify_xmpp(address, amt_sats, memo)
payload = {
type: "normal",
from: "kosmos.org", # TODO domain config
to: address,
subject: "Sats received!",
body: "#{amt_sats} sats received in your wallet. Memo: \"#{memo}\""
}
XmppSendMessageJob.perform_later(payload)
end
def authorize_request
if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip)
head :forbidden and return
end
end
end

View File

@ -7,7 +7,8 @@ class CreateLndhubAccountJob < ApplicationJob
lndhub = LndhubV2.new lndhub = LndhubV2.new
credentials = lndhub.create_account credentials = lndhub.create_account
user.update! ln_login: credentials["login"], user.update! ln_account: credentials["login"],
ln_login: credentials["login"], # TODO remove when production is migrated
ln_password: credentials["password"] ln_password: credentials["password"]
end end
end end

View File

@ -1,4 +1,4 @@
class ExchangeXmppContactsJob < ApplicationJob class XmppExchangeContactsJob < ApplicationJob
queue_as :default queue_as :default
def perform(inviter, username, domain) def perform(inviter, username, domain)

View File

@ -0,0 +1,8 @@
class XmppSendMessageJob < ApplicationJob
queue_as :default
def perform(payload)
ejabberd = EjabberdApiClient.new
ejabberd.send_message payload
end
end

View File

@ -46,7 +46,7 @@ class CreateAccount < ApplicationService
def exchange_xmpp_contacts def exchange_xmpp_contacts
#TODO enable in development when we have easy setup of ejabberd etc. #TODO enable in development when we have easy setup of ejabberd etc.
return if Rails.env.development? return if Rails.env.development?
ExchangeXmppContactsJob.perform_later(@invitation.user, @username, @domain) XmppExchangeContactsJob.perform_later(@invitation.user, @username, @domain)
end end
def create_lndhub_account(user) def create_lndhub_account(user)

View File

@ -17,4 +17,8 @@ class EjabberdApiClient
def add_rosteritem(payload) def add_rosteritem(payload)
post "add_rosteritem", payload post "add_rosteritem", payload
end end
def send_message(payload)
post "send_message", payload
end
end end

View File

@ -25,6 +25,8 @@ Rails.application.routes.draw do
get 'lnurlpay/:address', to: 'lnurlpay#index', constraints: { address: /[^\/]+/} get 'lnurlpay/:address', to: 'lnurlpay#index', constraints: { address: /[^\/]+/}
get 'lnurlpay/:address/invoice', to: 'lnurlpay#invoice', constraints: { address: /[^\/]+/} get 'lnurlpay/:address/invoice', to: 'lnurlpay#invoice', constraints: { address: /[^\/]+/}
post 'webhooks/lndhub', to: 'webhooks#lndhub'
namespace :api do namespace :api do
get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance' get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance'
end end

View File

@ -0,0 +1,9 @@
class AddLnAccountToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :ln_account, :string
User.all.each do |user|
user.update! ln_account: user.ln_login
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2021_11_20_010540) do ActiveRecord::Schema[7.0].define(version: 2023_01_11_113139) do
create_table "donations", force: :cascade do |t| create_table "donations", force: :cascade do |t|
t.integer "user_id" t.integer "user_id"
t.integer "amount_sats" t.integer "amount_sats"
@ -48,6 +48,7 @@ ActiveRecord::Schema[7.0].define(version: 2021_11_20_010540) do
t.string "unconfirmed_email" t.string "unconfirmed_email"
t.text "ln_login_ciphertext" t.text "ln_login_ciphertext"
t.text "ln_password_ciphertext" t.text "ln_password_ciphertext"
t.string "ln_account"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end

View File

@ -2,8 +2,10 @@ FactoryBot.define do
factory :user do factory :user do
id { 1 } id { 1 }
cn { "jimmy" } cn { "jimmy" }
ou { "kosmos.org" }
email { "jimmy@example.com" } email { "jimmy@example.com" }
password { "dis-muh-password" } password { "dis-muh-password" }
confirmed_at { DateTime.now } confirmed_at { DateTime.now }
ln_account { "123456" }
end end
end end

19
spec/fixtures/lndhub/incoming.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"id": 58,
"type": "incoming",
"user_login": "123456abcdef",
"amount": 12300,
"fee": 0,
"memo": "Buy you some beers",
"description_hash": "106af234beebd478206535486051b4f212bd31d2ed0f93e3efce7b5e7603d743",
"payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu",
"destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946",
"r_hash": "03aa6aeb2ed5a569f1a40925041f7d7163458e96b705e5eafe0f10188575dc08",
"preimage": "3539663535656537343331663432653165396430623966633664656664646563",
"keysend": false,
"state": "settled",
"created_at": "2023-01-11T09:22:57.546364Z",
"expires_at": "2023-01-12T09:22:57.547209Z",
"updated_at": "2023-01-11T09:22:58.046236131Z",
"settled_at": "2023-01-11T09:22:58.046232174Z"
}

19
spec/fixtures/lndhub/outgoing.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"id": 59,
"type": "outgoing",
"user_login": "123456abcdef",
"amount": 12400,
"fee": 10,
"memo": "Top up mobile phone",
"description_hash": "106af234beebd478206535486051b4f212bd31d2ed0f93e3efce7b5e7603d743",
"payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu",
"destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946",
"r_hash": "03aa6aeb2ed5a569f1a40925041f7d7163458e96b705e5eafe0f10188575dc08",
"preimage": "3539663535656537343331663432653165396430623966633664656664646563",
"keysend": false,
"state": "settled",
"created_at": "2023-01-11T09:22:57.546364Z",
"expires_at": "2023-01-12T09:22:57.547209Z",
"updated_at": "2023-01-11T09:22:58.046236131Z",
"settled_at": "2023-01-11T09:22:58.046232174Z"
}

View File

@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
require 'webmock/rspec' require 'webmock/rspec'
RSpec.describe ExchangeXmppContactsJob, type: :job do RSpec.describe XmppExchangeContactsJob, type: :job do
let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
subject(:job) { subject(:job) {

View File

@ -0,0 +1,77 @@
require 'rails_helper'
RSpec.describe "Webhooks", type: :request do
describe "Allowed IP addresses" do
context "IP not allowed" do
it "returns a 403 status" do
post "/webhooks/lndhub"
expect(response).to have_http_status(:forbidden)
end
end
context "IP allowed" do
before do
ENV['WEBHOOKS_ALLOWED_IPS'] = '127.0.0.1'
end
it "does not return a 403 status" do
post "/webhooks/lndhub"
expect(response).not_to have_http_status(:forbidden)
end
end
end
# Webhooks from lndhub.go
describe "/webhooks/lndhub" do
before do
ENV['WEBHOOKS_ALLOWED_IPS'] = '127.0.0.1'
end
describe "Payload cannot be processed as JSON" do
before do
post "/webhooks/lndhub", params: "Foo"
end
it "returns a 422 status" do
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe "Valid payload for outgoing payment" do
let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/outgoing.json", File.dirname(__FILE__)))) }
before do
post "/webhooks/lndhub", params: payload.to_json
end
it "returns a 204 status" do
expect(response).to have_http_status(:no_content)
end
end
describe "Valid payload for incoming payment" do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/incoming.json", File.dirname(__FILE__)))) }
before do
user.save! #FIXME this should not be necessary
post "/webhooks/lndhub", params: payload.to_json
end
it "returns a 200 status" do
expect(response).to have_http_status(:ok)
end
it "sends an XMPP message to the account owner's JID" do
expect(enqueued_jobs.size).to eq(1)
msg = enqueued_jobs.first['arguments'].first
expect(msg["type"]).to eq('normal')
expect(msg["from"]).to eq('kosmos.org')
expect(msg["to"]).to eq(user.address)
expect(msg["subject"]).to eq('Sats received!')
expect(msg["body"]).to match(/^12300 sats received/)
end
end
end
end