diff --git a/.env.example b/.env.example index 9f308d3..cd29496 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,5 @@ BTCPAY_API_URL='http://localhost:23001/api/v1' LNDHUB_API_URL='http://localhost:3023' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.163' diff --git a/.env.production b/.env.production index 243f0e4..d99e4fe 100644 --- a/.env.production +++ b/.env.production @@ -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_API_URL='http://10.1.1.163:3026' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.163' diff --git a/.env.test b/.env.test index ce5e1f7..03daa76 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,9 @@ EJABBERD_API_URL='http://xmpp.example.com/api' + BTCPAY_API_URL='http://btcpay.example.com/api/v1' + LNDHUB_LEGACY_API_URL='http://localhost:3023' LNDHUB_API_URL='http://localhost:3026' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.23' diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 0000000..487f43c --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -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) + 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]) + + # 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 diff --git a/app/jobs/create_lndhub_account_job.rb b/app/jobs/create_lndhub_account_job.rb index 9cf33b1..52d2a2c 100644 --- a/app/jobs/create_lndhub_account_job.rb +++ b/app/jobs/create_lndhub_account_job.rb @@ -7,7 +7,8 @@ class CreateLndhubAccountJob < ApplicationJob lndhub = LndhubV2.new 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"] end end diff --git a/app/jobs/exchange_xmpp_contacts_job.rb b/app/jobs/xmpp_exchange_contacts_job.rb similarity index 91% rename from app/jobs/exchange_xmpp_contacts_job.rb rename to app/jobs/xmpp_exchange_contacts_job.rb index 5e5caa2..01f64f1 100644 --- a/app/jobs/exchange_xmpp_contacts_job.rb +++ b/app/jobs/xmpp_exchange_contacts_job.rb @@ -1,4 +1,4 @@ -class ExchangeXmppContactsJob < ApplicationJob +class XmppExchangeContactsJob < ApplicationJob queue_as :default def perform(inviter, username, domain) diff --git a/app/jobs/xmpp_send_message_job.rb b/app/jobs/xmpp_send_message_job.rb new file mode 100644 index 0000000..e8eebf5 --- /dev/null +++ b/app/jobs/xmpp_send_message_job.rb @@ -0,0 +1,8 @@ +class XmppSendMessageJob < ApplicationJob + queue_as :default + + def perform(payload) + ejabberd = EjabberdApiClient.new + ejabberd.send_message payload + end +end diff --git a/app/services/create_account.rb b/app/services/create_account.rb index f332a53..c5cb69a 100644 --- a/app/services/create_account.rb +++ b/app/services/create_account.rb @@ -46,7 +46,7 @@ class CreateAccount < ApplicationService def exchange_xmpp_contacts #TODO enable in development when we have easy setup of ejabberd etc. return if Rails.env.development? - ExchangeXmppContactsJob.perform_later(@invitation.user, @username, @domain) + XmppExchangeContactsJob.perform_later(@invitation.user, @username, @domain) end def create_lndhub_account(user) diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index 65b3795..7930aa1 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -17,4 +17,8 @@ class EjabberdApiClient def add_rosteritem(payload) post "add_rosteritem", payload end + + def send_message(payload) + post "send_message", payload + end end diff --git a/config/routes.rb b/config/routes.rb index b0cf70e..12f3236 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,8 @@ Rails.application.routes.draw do get 'lnurlpay/:address', to: 'lnurlpay#index', constraints: { address: /[^\/]+/} get 'lnurlpay/:address/invoice', to: 'lnurlpay#invoice', constraints: { address: /[^\/]+/} + post 'webhooks/lndhub', to: 'webhooks#lndhub' + namespace :api do get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance' end diff --git a/db/migrate/20230111113139_add_ln_account_to_users.rb b/db/migrate/20230111113139_add_ln_account_to_users.rb new file mode 100644 index 0000000..d625344 --- /dev/null +++ b/db/migrate/20230111113139_add_ln_account_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 773ba10..486c465 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.integer "user_id" t.integer "amount_sats" @@ -48,6 +48,7 @@ ActiveRecord::Schema[7.0].define(version: 2021_11_20_010540) do t.string "unconfirmed_email" t.text "ln_login_ciphertext" t.text "ln_password_ciphertext" + t.string "ln_account" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/spec/factories/devise.rb b/spec/factories/devise.rb index cf486ba..a2188e9 100644 --- a/spec/factories/devise.rb +++ b/spec/factories/devise.rb @@ -2,8 +2,10 @@ FactoryBot.define do factory :user do id { 1 } cn { "jimmy" } + ou { "kosmos.org" } email { "jimmy@example.com" } password { "dis-muh-password" } confirmed_at { DateTime.now } + ln_account { "123456" } end end diff --git a/spec/fixtures/lndhub/incoming.json b/spec/fixtures/lndhub/incoming.json new file mode 100644 index 0000000..4de12ab --- /dev/null +++ b/spec/fixtures/lndhub/incoming.json @@ -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" +} diff --git a/spec/fixtures/lndhub/outgoing.json b/spec/fixtures/lndhub/outgoing.json new file mode 100644 index 0000000..fcd9acb --- /dev/null +++ b/spec/fixtures/lndhub/outgoing.json @@ -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" +} diff --git a/spec/jobs/exchange_xmpp_contacts_job_spec.rb b/spec/jobs/xmpp_exchange_contacts_job_spec.rb similarity index 95% rename from spec/jobs/exchange_xmpp_contacts_job_spec.rb rename to spec/jobs/xmpp_exchange_contacts_job_spec.rb index 177e03c..0064b69 100644 --- a/spec/jobs/exchange_xmpp_contacts_job_spec.rb +++ b/spec/jobs/xmpp_exchange_contacts_job_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' require 'webmock/rspec' -RSpec.describe ExchangeXmppContactsJob, type: :job do +RSpec.describe XmppExchangeContactsJob, type: :job do let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } subject(:job) { diff --git a/spec/requests/webhooks_spec.rb b/spec/requests/webhooks_spec.rb new file mode 100644 index 0000000..06c834e --- /dev/null +++ b/spec/requests/webhooks_spec.rb @@ -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