6 Commits

Author SHA1 Message Date
Râu Cao
5a5f62e98a Refactor BTCPay service and API, add lightning balance
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-04 16:02:54 +02:00
Râu Cao
074f9afcbb Fix descriptions not being shown for resettable form fields 2023-09-04 15:37:02 +02:00
Râu Cao
725fd2e5ea Move lndhub admin token to env var/setting 2023-09-04 15:36:22 +02:00
Râu Cao
8349ca5e12 Add admin settings page for BTCPay 2023-09-04 15:25:20 +02:00
Râu Cao
46d59e3371 Improve icons in admin service settings sidenav 2023-09-04 15:24:35 +02:00
Râu Cao
e8e6ee0bc4 Add configurable settings for BTCPay 2023-09-04 15:23:27 +02:00
22 changed files with 254 additions and 129 deletions

View File

@@ -34,6 +34,8 @@ EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin'
EJABBERD_API_URL='https://xmpp.kosmos.org/api'
BTCPAY_API_URL='http://localhost:23001/api/v1'
BTCPAY_STORE_ID=''
BTCPAY_AUTH_TOKEN=''
LNDHUB_API_URL='http://localhost:3023'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'

View File

@@ -2,13 +2,14 @@ PRIMARY_DOMAIN=kosmos.org
REDIS_URL='redis://localhost:6379/0'
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
BTCPAY_STORE_ID='123456'
DISCOURSE_PUBLIC_URL='http://discourse.example.com'
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api'
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'

View File

@@ -6,7 +6,7 @@ module FormElements
@tag = tag
@positioning = :vertical
@title = title
@descripton = description
@description = description
@key = key.to_sym
@type = type
@resettable = is_resettable?(@key)

View File

@@ -1,4 +1,8 @@
<%= link_to @path, class: @link_class, title: (@disabled ? "Coming soon" : nil) do %>
<% if @icon.present? %>
<%= render partial: "icons/#{@icon}", locals: { custom_class: @icon_class } %>
<% elsif @text_icon.present? %>
<span class="mr-3"><%= @text_icon %></span>
<% end %>
<span class="truncate"><%= @name %></span>
<% end %>

View File

@@ -1,11 +1,13 @@
# frozen_string_literal: true
class SidenavLinkComponent < ViewComponent::Base
def initialize(name:, level: 1, path:, icon:, active: false, disabled: false)
def initialize(name:, level: 1, path:, icon: nil, text_icon: nil,
active: false, disabled: false)
@name = name
@level = level
@path = path
@icon = icon
@text_icon = text_icon
@active = active
@disabled = disabled
@link_class = class_names_link(path)

View File

@@ -3,7 +3,7 @@ class Admin::Settings::ServicesController < Admin::SettingsController
@service = params[:s]
if @service.blank?
redirect_to admin_settings_services_path(params: { s: "discourse" })
redirect_to admin_settings_services_path(params: { s: "btcpay" })
end
end

View File

@@ -0,0 +1,21 @@
class Api::BtcpayController < Api::BaseController
def onchain_btc_balance
balance = BtcpayManager::FetchOnchainWalletBalance.call
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch BTC wallet balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
def lightning_btc_balance
balance = BtcpayManager::FetchLightningWalletBalance.call
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch BTC lightning balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
end

View File

@@ -1,13 +0,0 @@
class Api::KreditsController < Api::BaseController
def onchain_btc_balance
btcpay = BtcPay.new
balance = btcpay.onchain_wallet_balance
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch kredits BTC wallet balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
end

View File

@@ -38,6 +38,22 @@ class Setting < RailsSettings::Base
field :sentry_enabled, type: :boolean, readonly: true,
default: (ENV["SENTRY_DSN"].present?.to_s || false)
#
# BTCPay Server
#
field :btcpay_api_url, type: :string,
default: ENV["BTCPAY_API_URL"].presence
field :btcpay_enabled, type: :boolean,
default: (ENV["BTCPAY_API_URL"].present?.to_s || false)
field :btcpay_store_id, type: :string,
default: ENV["BTCPAY_STORE_ID"].presence
field :btcpay_auth_token, type: :string,
default: ENV["BTCPAY_AUTH_TOKEN"].presence
#
# Discourse
#
@@ -97,6 +113,9 @@ class Setting < RailsSettings::Base
field :lndhub_enabled, type: :boolean,
default: (ENV["LNDHUB_API_URL"].present?.to_s || false)
field :lndhub_admin_token, type: :string,
default: ENV["LNDHUB_ADMIN_TOKEN"].presence
field :lndhub_admin_enabled, type: :boolean,
default: (ENV["LNDHUB_ADMIN_UI"] || false)

View File

@@ -1,32 +0,0 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcPay
def initialize
@base_url = ENV["BTCPAY_API_URL"]
@store_id = Rails.application.credentials.btcpay[:store_id]
@auth_token = Rails.application.credentials.btcpay[:auth_token]
end
def onchain_wallet_balance
res = get "stores/#{@store_id}/payment-methods/onchain/BTC/wallet"
{
balance: res["balance"].to_f,
unconfirmed_balance: res["unconfirmedBalance"].to_f,
confirmed_balance: res["confirmedBalance"].to_f
}
end
private
def get(endpoint)
res = Faraday.get("#{@base_url}/#{endpoint}", {}, {
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{@auth_token}"
})
JSON.parse(res.body)
end
end

View File

@@ -0,0 +1,11 @@
module BtcpayManager
class FetchLightningWalletBalance < BtcpayManagerService
def call
res = get "stores/#{@store_id}/lightning/BTC/balance"
{
balance: res["offchain"]["local"].to_i / 1000 # msats to sats
}
end
end
end

View File

@@ -0,0 +1,13 @@
module BtcpayManager
class FetchOnchainWalletBalance < BtcpayManagerService
def call
res = get "stores/#{@store_id}/payment-methods/onchain/BTC/wallet"
{
balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats
unconfirmed_balance: (res["unconfirmedBalance"].to_f * 100000000).to_i,
confirmed_balance: (res["confirmedBalance"].to_f * 100000000).to_i
}
end
end
end

View File

@@ -0,0 +1,22 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcpayManagerService < LdapService
def initialize
@base_url = Setting.btcpay_api_url
@store_id = Setting.btcpay_store_id
@auth_token = Setting.btcpay_auth_token
end
private
def get(endpoint)
res = Faraday.get("#{@base_url}/#{endpoint}", {}, {
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{@auth_token}"
})
JSON.parse(res.body)
end
end

View File

@@ -14,7 +14,7 @@ class LndhubV2 < Lndhub
end
def create_account(payload={})
post "v2/users", payload, admin_token: Rails.application.credentials.lndhub[:admin_token]
post "v2/users", payload, admin_token: Setting.lndhub_admin_token
end
def create_invoice(payload)

View File

@@ -0,0 +1,25 @@
<h3>BTCPay Server</h3>
<ul role="list">
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :btcpay_enabled,
enabled: Setting.btcpay_enabled?,
title: "Enable BTCPay integration",
description: "BTCPay configuration present and features enabled"
) %>
<% if Setting.btcpay_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_api_url,
title: "API URL"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_store_id,
title: "Store ID"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_auth_token,
type: :password,
title: "Auth Token"
) %>
<% end %>
</ul>

View File

@@ -9,29 +9,35 @@
) %>
<% if Setting.lndhub_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_api_url,
title: "API URL"
) %>
<% end %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_admin_enabled,
enabled: Setting.lndhub_admin_enabled?,
title: "Enable LNDHub admin panel",
description: "LNDHub database configuration present and admin panel enabled"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_keysend_enabled,
enabled: Setting.lndhub_keysend_enabled?,
title: "Enable keysend payments",
description: "Allow users to receive invoice-less payments to their Lightning Address"
) %>
<% if Setting.lndhub_keysend_enabled? %>
key: :lndhub_api_url,
title: "API URL"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_public_key,
title: "Public key",
description: "The public key of the Lightning node used by LNDHub"
) %>
key: :lndhub_admin_token,
type: :password,
title: "Admin token",
description: "Auth token for creating new lndhub accounts"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_admin_enabled,
enabled: Setting.lndhub_admin_enabled?,
title: "Enable LNDHub admin panel",
description: "LNDHub database configuration present and admin panel enabled"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_keysend_enabled,
enabled: Setting.lndhub_keysend_enabled?,
title: "Enable keysend payments",
description: "Allow users to receive invoice-less payments to their Lightning Address"
) %>
<% if Setting.lndhub_keysend_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_public_key,
title: "Public key",
description: "The public key of the Lightning node used by LNDHub"
) %>
<% end %>
<% end %>
</ul>

View File

@@ -1,63 +1,70 @@
<%= render SidenavLinkComponent.new(
level: 2,
name: "BTCPay",
path: admin_settings_services_path(params: { s: "btcpay" }),
text_icon: Setting.btcpay_enabled? ? "◉" : "○",
active: current_page?(admin_settings_services_path(params: { s: "btcpay" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "Discourse",
path: admin_settings_services_path(params: { s: "discourse" }),
icon: Setting.discourse_enabled? ? "check" : "x",
text_icon: Setting.discourse_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "discourse" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "Drone CI",
path: admin_settings_services_path(params: { s: "droneci" }),
icon: Setting.droneci_enabled? ? "check" : "x",
text_icon: Setting.droneci_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "droneci" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "ejabberd",
path: admin_settings_services_path(params: { s: "ejabberd" }),
icon: Setting.ejabberd_enabled? ? "check" : "x",
text_icon: Setting.ejabberd_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "ejabberd" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "Gitea",
path: admin_settings_services_path(params: { s: "gitea" }),
icon: Setting.gitea_enabled? ? "check" : "x",
text_icon: Setting.gitea_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "gitea" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "LNDHub",
path: admin_settings_services_path(params: { s: "lndhub" }),
icon: Setting.lndhub_enabled? ? "check" : "x",
text_icon: Setting.lndhub_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "lndhub" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "Mastodon",
path: admin_settings_services_path(params: { s: "mastodon" }),
icon: Setting.mastodon_enabled? ? "check" : "x",
text_icon: Setting.mastodon_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "mastodon" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "MediaWiki",
path: admin_settings_services_path(params: { s: "mediawiki" }),
icon: Setting.mediawiki_enabled? ? "check" : "x",
text_icon: Setting.mediawiki_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "mediawiki" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "Nostr",
path: admin_settings_services_path(params: { s: "nostr" }),
icon: Setting.nostr_enabled? ? "check" : "x",
text_icon: Setting.nostr_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "nostr" })),
) %>
<%= render SidenavLinkComponent.new(
level: 2,
name: "RemoteStorage",
path: admin_settings_services_path(params: { s: "remotestorage" }),
icon: Setting.remotestorage_enabled? ? "check" : "x",
text_icon: Setting.remotestorage_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "remotestorage" })),
) %>

View File

@@ -1 +1 @@
yEs5CyuAbqphlDWgtw/YQvkPn+EN4ecen2dAjs7zvYErkRRWp99FinGlQIMe6NRkMLLLSIj2BwR/wlscn1kLpIfwGpxfSZ89srK3do6Mb5QogpxdUsnQB8qv5PTGRQFBcjM47s1Q5m0t+OKxGvOnLyKnQp+cVS2KFJMbSzQarW8wIZSz2gKArn9Ttk0kqUHMlJWNY7Yh6xIrrxlEalaTOVzPdtnF7u8Tobminu15eeWHMormMRz4dYSaDc6hUtfpdy1NzOHaeXIU9A9RY/iytxuIQNgcMAlcWbPe//rVk/unH2F8xqSOfed4h/nC08F/qq4z8va3kEXBSdW/G91aIDMu1mo0kX3YNibq8s25C/CfGpzw39ozJ9erTBH7hy6nfmxU6qZuWcTGDj3NOfKe/XIfDcpOjsqkT2IOFARrYodb67q23IuOufraK1/FD4LXu8l0S8/Oi0cqMjtPPs7tS0M1C3DrbmlEzGKETrHpmoKHqjA0rgOmK4ZZM9LeI+l8Z+fDpYcCak9fLGGxnjf+nKiYMSUtm9+1dwycG2lpBV6fbmIKHJWngO2jVGcycODkc525oUaAO4hdPMqrz1AdU3AzYmLJTxW3aZ4uL5NyEJ7TbUBC0HT7h2gEi/tUry4cfD2EsM9bCrCUNuMBrnPqd4r8AvORoqqYIw1IEsP0RgWa2+hfeG1QCjBRPFHQOcqo+W25CelivMe79qI08w0iC8S4hfOQO4QrmMgtd1BhcR+wVpVE3X9EJZi3Hl7z14hXcSic+gkswJMtVZcnJL4rmZ0iEW1mpqUuegsX5vB/4qPxiQyeB80pg8Q33shvUbixzSBkl6znmLSiIffsiDsGOsnuzfl/MUT+JBs3UswNt4tSp7nEwhUjKFHrZHrAJiGCdtIS6yDPGe3HfQv1JkQ+9A8zv88hRmzeIx2JyT/shtIqGo+4ZTJd5cma--Lij/n0+cpstyZD28--FOUhwW3y+0jdaYkKvG2xrg==
tmI5vm7qZhaigr52jEBVWkRdj+EE+9OmPh3vWXC7kA/OHuuucpr7SodychuMkQDPLM0BLk88LFsqvRIR+mqnLWpRC+P9aeUFE6ohxSWzcAd7Y4sgxUD8zpCRPndrwTw0hxXXj1WZSYeWn4BoAB34aV+gYen2MajZF3a95hJGtS5yjgWxvLVkQQKqRDfykkfX6fCS0BPo5X7sT7m4xwCATD/D4219wajm5W3TIdkriHtwt28ZLspaRWA5e0UkzKf8+/Gaj2CrW7UWcvew8R93zQ5RA2/Sp3sDTVN+kLz9I9Q095lQC0ywCAEFYHeKmc2tjrzqRaAAWu06xmWLqGIg21G+A/UU9lUJOkIpxQACWoOfS2IoXR1nXhgXMopkz3aCBXDxKw554v4H2QyOceOsuRf2C685ibMqzQkKMmJ4tcbiOJL77DUc08JTjB8Dq4Ohr8sMzXbV/hATevjYoRP0XarLekqhLv90ZLuIVY16DwB0CzACeNBKeKbeLqJF51upRRWgi+gTbYpV04yUwnXdyssF8mydWocgihrTryBi8F6PsuhBGcaYdP+0yibnGxDCC4x2rupbBfMj2OIX7pYzgtIHB3Eo954Y+bCoggqbE/Qrb9VVXNMgtKgLt8EGWU2tg6wl9QicitIq87uLDAade93zTn6rmcKPywjMDo6jbVIs653ZdUhiKdHGdpnJccbgQ/iLSPB1umNnCeaEX5jM+K9zBvl7ZMCdSk1YIQ==--ekKumqLiSlVJNwMe--K/ecXmmMT1x+WnIXMbHBDw==

View File

@@ -54,7 +54,8 @@ Rails.application.routes.draw do
post 'webhooks/lndhub', to: 'webhooks#lndhub'
namespace :api do
get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance'
get 'btcpay/onchain_btc_balance', to: 'btcpay#onchain_btc_balance'
get 'btcpay/lightning_btc_balance', to: 'btcpay#lightning_btc_balance'
end
namespace :admin do

View File

@@ -23,7 +23,7 @@ RSpec.describe 'Admin/global settings', type: :feature do
scenario "Opening service settings shows page for first service" do
visit admin_settings_services_path
expect(current_url).to eq(admin_settings_services_url(params: { s: "discourse" }))
expect(current_url).to eq(admin_settings_services_url(params: { s: "btcpay" }))
end
scenario "View service settings" do

View File

@@ -0,0 +1,79 @@
require 'rails_helper'
require 'webmock/rspec'
RSpec.describe "/api/btcpay", type: :request do
describe "GET /onchain_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 200, headers: {}, body: {
balance: 0.91108606,
unconfirmedBalance: 0,
confirmedBalance: 0.91108606
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(91108606)
expect(res["unconfirmed_balance"]).to eq(0)
expect(res["confirmed_balance"]).to eq(91108606)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
end
describe "GET /lightning_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/lightning/BTC/balance")
.to_return(status: 200, headers: {}, body: {
offchain: {
local: 4200000000
},
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_btcpay_lightning_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(4200000)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
end
end

View File

@@ -1,43 +0,0 @@
require 'rails_helper'
require 'webmock/rspec'
RSpec.describe "/api/kredits", type: :request do
describe "GET /onchain_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 200, headers: {}, body: {
balance: 0.91108606,
unconfirmedBalance: 0,
confirmedBalance: 0.91108606
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_kredits_onchain_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(0.91108606)
expect(res["unconfirmed_balance"]).to eq(0)
expect(res["confirmed_balance"]).to eq(0.91108606)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_kredits_onchain_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
end
end