Add Webhooks and XMPP notifications for incoming sats #79
@ -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'
|
||||||
|
@ -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'
|
||||||
|
@ -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'
|
||||||
|
40
app/controllers/webhooks_controller.rb
Normal file
40
app/controllers/webhooks_controller.rb
Normal 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
|
|||||||
|
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
bumi
commented
should not be the case, but this might be nil. maybe we should do a should not be the case, but this might be nil. maybe we should do a `User.find_by!`
raucao
commented
Good idea! Fails much cleaner and more expressively than when trying to access properties on 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
|
@ -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
|
||||||
|
@ -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)
|
8
app/jobs/xmpp_send_message_job.rb
Normal file
8
app/jobs/xmpp_send_message_job.rb
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
class XmppSendMessageJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(payload)
|
||||||
|
ejabberd = EjabberdApiClient.new
|
||||||
|
ejabberd.send_message payload
|
||||||
|
end
|
||||||
|
end
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
9
db/migrate/20230111113139_add_ln_account_to_users.rb
Normal file
9
db/migrate/20230111113139_add_ln_account_to_users.rb
Normal 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
|
@ -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
|
||||||
|
@ -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
19
spec/fixtures/lndhub/incoming.json
vendored
Normal 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
19
spec/fixtures/lndhub/outgoing.json
vendored
Normal 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"
|
||||||
|
}
|
@ -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) {
|
77
spec/requests/webhooks_spec.rb
Normal file
77
spec/requests/webhooks_spec.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user
doesn't rails automatically parse the JSON because the proper content type is set?
so
params[:type]
andparams[:user_login]
should be enough?I didn't know, so I tried (also with explicitly setting the content type in the spec). Doesn't do it.