43 Commits

Author SHA1 Message Date
d6d09b57b8 Merge pull request 'Add support for Lightning Zaps' (#190) from feature/170-nostr_zaps into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #190
2024-06-03 16:44:48 +00:00
231dfc8404 Log correct publish status
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2024-05-21 18:28:46 +02:00
eeb9b0a331 Improve NostrManager::PublishEvent
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
* Use URI hostname as relay name
* Log relay name/URL for every websocket event
* Fix variable assignment for nostr event
* Fix Sidekiq job finishing too early, by creating a new thread waiting
  for it to be closed from a callback
2024-05-21 18:08:14 +02:00
08e783d185 Remove default nil values
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-19 17:07:27 +02:00
fa5dc8ca46 Fix argument name
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-19 16:54:51 +02:00
bc34e9c5e0 Allow CORS requests for lnurlp invoice
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-19 16:48:09 +02:00
f388bd0237 Merge branch 'master' into feature/170-nostr_zaps
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-10 12:01:27 +00:00
48041630ca Limit number of relays to publish zap receipts to
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-10 13:57:25 +02:00
2d1ff29eca Improve nostr settings, fix allowsNostr property name
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-10 13:19:09 +02:00
46fa42e387 Merge pull request 'Refactor Nostr auth, add login via Nostr (web extension)' (#188) from feature/nostr_login into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #188
Reviewed-by: bumi <bumi@noreply.kosmos.org>
2024-05-10 11:01:00 +00:00
c6c5d80fb4 WIP Persist zaps, create and send zap receipts
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-09 14:31:37 +02:00
c0f4e7925e Use zap comment for description/memo
Some checks failed
continuous-integration/drone/push Build is failing
But use the hashed zap request event for the description hash.
2024-05-04 17:07:23 +02:00
49d24990b4 Add zap model, user relation 2024-05-04 17:05:34 +02:00
619bd954b7 WIP 2024-04-21 10:51:41 +02:00
e27c64b5f1 WIP Check for zaps, send zap receipt on incoming zap tx 2024-04-21 10:35:30 +02:00
b36baf26eb Refactor WebhooksController 2024-04-21 10:02:17 +02:00
adedaa5f7b Add task for easily creating test invoices 2024-04-21 10:01:54 +02:00
596ed7fccc Use lndhub.go v2 endpoint for invoice creation 2024-04-21 10:01:18 +02:00
5685e1b7bc Move lndhub invoice creation to service 2024-04-16 20:19:15 +02:00
c3b82fc2a9 WIP Verify and respond to zap requests
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-16 19:13:10 +02:00
77e2fe5792 Add helper method for parsing nostr event tags 2024-04-16 19:10:48 +02:00
bc43082839 Add admin settings for nostr keys 2024-04-16 19:07:52 +02:00
b09225543b Add Nostr relay service to Docker Compose config 2024-04-15 14:03:37 +02:00
f2507409a3 Announce nostr pubkey on lnurlp endpoint 2024-04-15 14:03:37 +02:00
46b4723999 Add global settings for account service's Nostr keys 2024-04-15 14:03:37 +02:00
3f90a011c4 Document URLs 2024-04-15 14:03:37 +02:00
3ba333e802 Indentation 2024-04-15 14:03:37 +02:00
d9dff3e872 Merge branch 'master' into feature/nostr_login
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2024-04-15 12:03:12 +00:00
6ddeacb779 Merge pull request 'Add Mastodon aliases and links to Webfinger when enabled' (#189) from feature/mastodon_webfinger into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #189
Reviewed-by: galfert <garret.alfert@gmail.com>
2024-04-14 10:18:15 +00:00
78aff3d796 Fix spec
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
The test env has Mastodon enabled now
2024-04-04 17:22:57 +03:00
8f600f44bd Add Mastodon aliases and links to Webfinger when enabled
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
Also requires "remotestorage" service to be enabled via attribute
2024-04-04 17:17:57 +03:00
819ecf6ad8 Add #service_enabled? method to user model 2024-04-04 13:28:09 +03:00
945eaba5e1 Add login via nostr (web extension)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-04-01 19:04:48 +03:00
22d362e1a0 Refactor Nostr settings/connect
* Use NIP-42 auth event instead of short text note
* Verify event ID and signature using the nostr gem instead of custom code
2024-04-01 18:27:08 +03:00
d4e67a830c Update nostr gem 2024-04-01 18:27:08 +03:00
670b2da1ef Ad-hoc content update
All checks were successful
continuous-integration/drone/push Build is passing
Before #186 is implemented
2024-03-29 10:33:28 +04:00
ed5c5b3081 Add remotestorage queue to Sidekiq config
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-29 09:47:30 +04:00
4ee6bfddfa Merge pull request 'Improvements/adjustments for Mastodon integration' (#185) from chore/mastodon into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #185
2024-03-29 05:24:10 +00:00
8b60890061 Add Phanpy to recommended Mastodon apps
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
It's too good not to.
2024-03-29 09:21:17 +04:00
0367450c4b Replace hyphen with underscore in Mastodon address
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Unfortunately, Mastodon only allows underscores for usernames, and
reversely, akkounts only allows hyphens and no underscores.
2024-03-29 09:08:15 +04:00
e6f5623c7f Enable Mastodon service by default (for now) 2024-03-29 09:06:41 +04:00
367f566ccb Merge pull request 'Add global setting for default services, enable for preconfirmed accounts' (#184) from feature/preconfirmed_accounts into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #184
2024-03-28 13:23:22 +00:00
80e69df75c Add global setting for default services, enable for preconfirmed accounts
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
Co-authored-by: Greg Karékinian <greg@karekinian.com>
2024-03-28 17:21:20 +04:00
64 changed files with 1877 additions and 255 deletions

View File

@@ -1,4 +1,5 @@
PRIMARY_DOMAIN=kosmos.org
AKKOUNTS_DOMAIN=accounts.kosmos.org
REDIS_URL='redis://localhost:6379/0'
@@ -11,10 +12,15 @@ DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api'
MASTODON_PUBLIC_URL='http://example.social'
LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
NOSTR_PRIVATE_KEY='7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea'
NOSTR_PUBLIC_KEY='bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf'
RS_STORAGE_URL='https://storage.kosmos.org'
RS_REDIS_URL='redis://localhost:6379/1'

View File

@@ -62,7 +62,7 @@ gem "sentry-rails"
gem 'discourse_api'
gem "lnurl"
gem 'manifique'
gem 'nostr'
gem 'nostr', '~> 0.6.0'
group :development, :test do
# Use sqlite3 as the database for Active Record

View File

@@ -155,7 +155,7 @@ GEM
ruby2_keywords
e2mmap (0.1.0)
ecdsa (1.2.0)
ecdsa_ext (0.5.0)
ecdsa_ext (0.5.1)
ecdsa (~> 1.2.0)
erubi (1.12.0)
et-orbi (1.2.7)
@@ -278,9 +278,9 @@ GEM
racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4)
nostr (0.5.0)
nostr (0.6.0)
bech32 (~> 1.4)
bip-schnorr (~> 0.6)
bip-schnorr (~> 0.7)
ecdsa (~> 1.2)
event_emitter (~> 0.2)
faye-websocket (~> 0.11)
@@ -517,7 +517,7 @@ DEPENDENCIES
lockbox
manifique
net-ldap
nostr
nostr (~> 0.6.0)
pagy (~> 6.0, >= 6.0.2)
pg (~> 1.5)
puma (~> 4.1)

View File

@@ -42,6 +42,11 @@
focus:ring-red-500 focus:ring-opacity-75;
}
.btn-outline-purple {
@apply border-2 border-purple-500 hover:bg-purple-100
focus:ring-purple-400 focus:ring-opacity-75;
}
.btn:disabled {
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
focus:ring-gray-300 focus:ring-opacity-75;

View File

@@ -63,4 +63,9 @@ class ApplicationController < ActionController::Base
@fetch_balance_retried = true
lndhub_fetch_balance
end
def nostr_event_from_params
params.permit!
params[:signed_event].to_h.symbolize_keys
end
end

View File

@@ -1,13 +1,15 @@
class LnurlpayController < ApplicationController
before_action :check_service_available
before_action :find_user
before_action :set_cors_access_control_headers, only: [:invoice]
MIN_SATS = 10
MAX_SATS = 1_000_000
MAX_COMMENT_CHARS = 100
# GET /.well-known/lnurlp/:username
def index
render json: {
res = {
status: "OK",
callback: "https://#{Setting.accounts_domain}/lnurlpay/#{@user.cn}/invoice",
tag: "payRequest",
@@ -16,8 +18,16 @@ class LnurlpayController < ApplicationController
metadata: metadata(@user.address),
commentAllowed: MAX_COMMENT_CHARS
}
if Setting.nostr_enabled?
res[:allowsNostr] = true
res[:nostrPubkey] = Setting.nostr_public_key
end
render json: res
end
# GET /.well-known/keysend/:username
def keysend
http_status :not_found and return unless Setting.lndhub_keysend_enabled?
@@ -32,8 +42,9 @@ class LnurlpayController < ApplicationController
}
end
# GET /lnurlpay/:username/invoice
def invoice
amount = params[:amount].to_i / 1000 # msats
amount = params[:amount].to_i / 1000 # msats to sats
comment = params[:comment] || ""
address = @user.address
@@ -42,53 +53,109 @@ class LnurlpayController < ApplicationController
return
end
if !valid_comment?(comment)
render json: { status: "ERROR", reason: "Comment too long" }
return
if params[:nostr].present? && Setting.nostr_enabled?
handle_zap_request amount, params[:nostr], params[:lnurl]
else
handle_pay_request address, amount, comment
end
end
private
def set_cors_access_control_headers
headers['Access-Control-Allow-Origin'] = "*"
headers['Access-Control-Allow-Headers'] = "*"
headers['Access-Control-Allow-Methods'] = "GET"
end
memo = "To #{address}"
memo = "#{memo}: \"#{comment}\"" if comment.present?
def check_service_available
http_status :not_found unless Setting.lndhub_enabled?
end
payment_request = @user.ln_create_invoice({
amount: amount, # we create invoices in sats
memo: memo,
description_hash: Digest::SHA2.hexdigest(metadata(address)),
})
def find_user
@user = User.where(cn: params[:username], ou: Setting.primary_domain).first
http_status :not_found if @user.nil?
end
render json: {
status: "OK",
successAction: {
tag: "message",
message: "Sats received. Thank you!"
},
routes: [],
pr: payment_request
}
end
def metadata(address)
"[[\"text/identifier\",\"#{address}\"],[\"text/plain\",\"Sats for #{address}\"]]"
end
private
def valid_amount?(amount_in_sats)
amount_in_sats <= MAX_SATS && amount_in_sats >= MIN_SATS
end
def find_user
@user = User.where(cn: params[:username], ou: Setting.primary_domain).first
http_status :not_found if @user.nil?
end
def valid_comment?(comment)
comment.length <= MAX_COMMENT_CHARS
end
def metadata(address)
"[[\"text/identifier\", \"#{address}\"], [\"text/plain\", \"Send sats, receive thanks.\"]]"
end
def handle_pay_request(address, amount, comment)
if !valid_comment?(comment)
render json: { status: "ERROR", reason: "Comment too long" }
return
end
def valid_amount?(amount_in_sats)
amount_in_sats <= MAX_SATS && amount_in_sats >= MIN_SATS
end
desc = "To #{address}"
desc = "#{desc}: \"#{comment}\"" if comment.present?
def valid_comment?(comment)
comment.length <= MAX_COMMENT_CHARS
end
invoice = LndhubManager::CreateUserInvoice.call(
user: @user, payload: {
amount: amount, # sats
description: desc,
description_hash: Digest::SHA256.hexdigest(metadata(address)),
}
)
private
render json: {
status: "OK",
successAction: {
tag: "message",
message: "Sats received. Thank you!"
},
routes: [],
pr: invoice["payment_request"]
}
end
def check_service_available
http_status :not_found unless Setting.lndhub_enabled?
end
def nostr_event_from_payload(nostr_param)
event_obj = JSON.parse(nostr_param).transform_keys(&:to_sym)
Nostr::Event.new(**event_obj)
rescue => e
return nil
end
def valid_zap_request?(amount, event, lnurl)
NostrManager::VerifyZapRequest.call(
amount: amount, event: event, lnurl: lnurl
)
end
def handle_zap_request(amount, nostr_param, lnurl_param)
event = nostr_event_from_payload(nostr_param)
unless event.present? && valid_zap_request?(amount*1000, event, lnurl_param)
render json: { status: "ERROR", reason: "Invalid zap request" }
return
end
# TODO might want to use the existing invoice and zap record if there are
# multiple calls with the same zap request
desc = "Zap for #{@user.address}"
desc = "#{desc}: \"#{event.content}\"" if event.content.present?
invoice = LndhubManager::CreateUserInvoice.call(
user: @user, payload: {
amount: amount, # sats
description: desc,
description_hash: Digest::SHA256.hexdigest(event.to_json),
}
)
@user.zaps.create! request: event,
payment_request: invoice["payment_request"],
amount: amount
render json: { status: "OK", pr: invoice["payment_request"] }
end
end

View File

@@ -3,7 +3,7 @@ class Services::ChatController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.services_enabled.include?(:xmpp)
@service_enabled = current_user.service_enabled?(:xmpp)
end
private

View File

@@ -3,7 +3,7 @@ class Services::MastodonController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.services_enabled.include?(:mastodon)
@service_enabled = current_user.service_enabled?(:mastodon)
end
private

View File

@@ -5,7 +5,7 @@ class Services::RemotestorageController < Services::BaseController
# Dashboard
def show
# unless current_user.services_enabled.include?(:remotestorage)
# unless current_user.service_enabled?(:remotestorage)
# redirect_to service_remotestorage_info_path
# end
@rs_auths = current_user.remote_storage_authorizations

View File

@@ -87,25 +87,27 @@ class SettingsController < ApplicationController
end
def set_nostr_pubkey
signed_event = nostr_event_params[:signed_event].to_h.symbolize_keys
signed_event = Nostr::Event.new(**nostr_event_from_params)
is_valid_id = NostrManager::ValidateId.call(event: signed_event)
is_valid_sig = NostrManager::VerifySignature.call(event: signed_event)
is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})"
is_valid_sig = signed_event.verify_signature
is_valid_auth = NostrManager::VerifyAuth.call(
event: signed_event,
challenge: session[:shared_secret]
)
unless is_valid_id && is_valid_sig && is_correct_content
unless is_valid_sig && is_valid_auth
flash[:alert] = "Public key could not be verified"
http_status :unprocessable_entity and return
end
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event[:pubkey])
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
if user_with_pubkey.present? && (user_with_pubkey != current_user)
flash[:alert] = "Public key already in use for a different account"
http_status :unprocessable_entity and return
end
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event[:pubkey])
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event.pubkey)
session[:shared_secret] = nil
flash[:success] = "Public key verification successful"
@@ -160,12 +162,6 @@ class SettingsController < ApplicationController
params.require(:user).permit(:current_password)
end
def nostr_event_params
params.permit(signed_event: [
:id, :pubkey, :created_at, :kind, :content, :sig, tags: []
])
end
def generate_email_password
characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
class Users::SessionsController < Devise::SessionsController
# before_action :configure_sign_in_params, only: [:create]
# GET /resource/sign_in
def new
session[:shared_secret] = SecureRandom.base64(12)
super
end
# POST /resource/sign_in
# def create
# super
# end
# DELETE /resource/sign_out
# def destroy
# super
# end
# POST /users/nostr_login
def nostr_login
signed_event = Nostr::Event.new(**nostr_event_from_params)
is_valid_sig = signed_event.verify_signature
is_valid_auth = NostrManager::VerifyAuth.call(
event: signed_event,
challenge: session[:shared_secret]
)
session[:shared_secret] = nil
unless is_valid_sig && is_valid_auth
flash[:alert] = "Login verification failed"
http_status :unauthorized and return
end
user = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
if user.present?
set_flash_message!(:notice, :signed_in)
sign_in("user", user)
render json: { redirect_url: after_sign_in_path_for(user) }, status: :ok
else
flash[:alert] = "Failed to find your account. Nostr login may be disabled."
http_status :unauthorized
end
end
protected
def set_flash_message(key, kind, options = {})
# Hide flash message after redirecting from a signin route while logged in
super unless key == :alert && kind == "already_authenticated"
end
# If you have extra params to permit, append them to the sanitizer.
# def configure_sign_in_params
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
# end
end

View File

@@ -7,14 +7,15 @@ class WebfingerController < ApplicationController
resource = params[:resource]
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
@username, @org = @useraddress.split("@")
@username, @domain = @useraddress.split("@")
unless Rails.env.development?
# Allow different domains (e.g. localhost:3000) in development only
head 404 and return unless @org == Setting.primary_domain
head 404 and return unless @domain == Setting.primary_domain
end
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
unless @user = User.where(ou: Setting.primary_domain)
.find_by(cn: @username.downcase)
head 404 and return
end
@@ -28,12 +29,50 @@ class WebfingerController < ApplicationController
private
def webfinger
links = [];
jrd = {
subject: "acct:#{@user.address}",
aliases: [],
links: []
}
# TODO check if storage service is enabled for user, not just globally
links << remotestorage_link if Setting.remotestorage_enabled
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
# https://docs.joinmastodon.org/spec/webfinger/
jrd[:aliases] += mastodon_aliases
jrd[:links] += mastodon_links
end
{ "links" => links }
if Setting.remotestorage_enabled && @user.service_enabled?(:remotestorage)
# https://datatracker.ietf.org/doc/draft-dejong-remotestorage/
jrd[:links] << remotestorage_link
end
jrd
end
def mastodon_aliases
[
"#{Setting.mastodon_public_url}/@#{@user.cn}",
"#{Setting.mastodon_public_url}/users/#{@user.cn}"
]
end
def mastodon_links
[
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: "#{Setting.mastodon_public_url}/@#{@user.cn}"
},
{
rel: "self",
type: "application/activity+json",
href: "#{Setting.mastodon_public_url}/users/#{@user.cn}"
},
{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: "#{Setting.mastodon_public_url}/authorize_interaction?uri={uri}"
}
]
end
def remotestorage_link
@@ -41,9 +80,9 @@ class WebfingerController < ApplicationController
storage_url = "#{Setting.rs_storage_url}/#{@username}"
{
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",
"href" => storage_url,
"properties" => {
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
href: storage_url,
properties: {
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter

View File

@@ -2,45 +2,66 @@ class WebhooksController < ApplicationController
skip_forgery_protection
before_action :authorize_request
before_action :process_payload
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
@user = User.find_by!(ln_account: @payload[:user_login])
if zap = @user.zaps.find_by(payment_request: @payload[:payment_request])
zap_receipt = NostrManager::CreateZapReceipt.call(
zap: zap,
paid_at: Time.parse(@payload[:settled_at]).to_i,
preimage: @payload[:preimage]
)
zap.update! receipt: zap_receipt.to_h
NostrManager::PublishZapReceipt.call(zap: zap)
end
user = User.find_by!(ln_account: payload[:user_login])
notify = user.preferences[:lightning_notify_sats_received]
case notify
when "xmpp"
notify_xmpp(user.address, payload[:amount], payload[:memo])
when "email"
NotificationMailer.with(user: user, amount_sats: payload[:amount])
.lightning_sats_received.deliver_later
end
send_notifications
head :ok
end
private
# TODO refactor into mailer-like generic class/service
def notify_xmpp(address, amt_sats, memo)
payload = {
type: "normal",
from: Setting.xmpp_notifications_from_address,
to: address,
subject: "Sats received!",
body: "#{helpers.number_with_delimiter amt_sats} sats received in your Lightning wallet:\n> #{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
def process_payload
@payload = JSON.parse(request.body.read, symbolize_names: true)
unless @payload[:type] == "incoming" &&
@payload[:state] == "settled"
head :no_content and return
end
rescue
head :unprocessable_entity and return
end
def send_notifications
case @user.preferences[:lightning_notify_sats_received]
when "xmpp"
notify_xmpp
when "email"
notify_email
end
end
# TODO refactor into mailer-like generic class/service
def notify_xmpp
XmppSendMessageJob.perform_later({
type: "normal",
from: Setting.xmpp_notifications_from_address,
to: @user.address,
subject: "Sats received!",
body: "#{helpers.number_with_delimiter @payload[:amount]} sats received in your Lightning wallet:\n> #{@payload[:memo]}"
})
end
def notify_email
NotificationMailer.with(user: @user, amount_sats: @payload[:amount])
.lightning_sats_received.deliver_later
end
end

View File

@@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="nostr-login"
export default class extends Controller {
static targets = [ "loginForm", "loginButton" ]
static values = { site: String, sharedSecret: String }
connect() {
if (window.nostr) {
this.loginButtonTarget.disabled = false
this.loginFormTarget.classList.remove("hidden")
}
}
async login () {
this.loginButtonTarget.disabled = true
try {
// Auth based on NIP-42
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 22242,
tags: [
["site", this.siteValue],
["challenge", this.sharedSecretValue]
],
content: ""
})
const res = await fetch("/users/nostr_login", {
method: "POST", credentials: "include", headers: {
"Accept": "application/json", 'Content-Type': 'application/json',
"X-CSRF-Token": this.csrfToken
}, body: JSON.stringify({ signed_event: signedEvent })
})
if (res.status === 200) {
res.json().then(r => { window.location.href = r.redirect_url })
} else {
window.location.reload()
}
} catch (error) {
console.warn('Unable to authenticate:', error.message)
} finally {
this.loginButtonTarget.disabled = false
}
}
get csrfToken () {
const element = document.head.querySelector('meta[name="csrf-token"]')
return element.getAttribute("content")
}
}

View File

@@ -3,7 +3,12 @@ import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="settings--nostr-pubkey"
export default class extends Controller {
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
static values = { userAddress: String, pubkeyHex: String, sharedSecret: String }
static values = {
userAddress: String,
pubkeyHex: String,
site: String,
sharedSecret: String
}
connect () {
if (window.nostr) {
@@ -19,11 +24,15 @@ export default class extends Controller {
this.setPubkeyTarget.disabled = true
try {
// Auth based on NIP-42
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})`
kind: 22242,
tags: [
["site", this.siteValue],
["challenge", this.sharedSecretValue]
],
content: ""
})
const res = await fetch("/settings/set_nostr_pubkey", {

View File

@@ -1,7 +1,7 @@
class CreateLdapUserJob < ApplicationJob
queue_as :default
def perform(username, domain, email, hashed_pw)
def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
attr = {
objectclass: ["top", "account", "person", "extensibleObject"],
@@ -12,6 +12,10 @@ class CreateLdapUserJob < ApplicationJob
userPassword: hashed_pw
}
if confirmed
attr[:serviceEnabled] = Setting.default_services
end
ldap_client.add(dn: dn, attributes: attr)
end

View File

@@ -0,0 +1,7 @@
class NostrPublishEventJob < ApplicationJob
queue_as :nostr
def perform(event:, relay_url:)
NostrManager::PublishEvent.call(event: event, relay_url: relay_url)
end
end

View File

@@ -2,8 +2,8 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
return unless inviter.services_enabled.include?("xmpp") &&
invitee.services_enabled.include?("xmpp") &&
return unless inviter.service_enabled?(:xmpp) &&
invitee.service_enabled?(:xmpp) &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new

View File

@@ -160,7 +160,17 @@ class Setting < RailsSettings::Base
# Nostr
#
field :nostr_enabled, type: :boolean, default: false
field :nostr_enabled, type: :boolean,
default: ENV["NOSTR_PRIVATE_KEY"].present?
field :nostr_private_key, type: :string,
default: ENV["NOSTR_PRIVATE_KEY"].presence
field :nostr_public_key, type: :string,
default: ENV["NOSTR_PUBLIC_KEY"].presence
field :nostr_zaps_relay_limit, type: :integer,
default: 12
#
# OpenCollective
@@ -206,4 +216,9 @@ class Setting < RailsSettings::Base
#
# field :email_imap_port, type: :string,
# default: ENV["EMAIL_IMAP_PORT"].presence || 993
def self.default_services
# TODO Make configurable from respective service settings page
%w[ discourse gitea mastodon mediawiki xmpp ]
end
end

View File

@@ -17,16 +17,15 @@ class User < ApplicationRecord
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
has_one :inviter, through: :invitation, source: :user
has_many :invitees, through: :invitations
has_many :donations, dependent: :nullify
has_many :remote_storage_authorizations
has_many :zaps
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
primary_key: "ln_account", foreign_key: "login"
has_many :accounts, through: :lndhub_user
has_many :remote_storage_authorizations
#
# Validations
#
@@ -93,9 +92,7 @@ class User < ApplicationRecord
LdapManager::UpdateEmail.call(dn: self.dn, address: self.email)
else
# E-Mail from signup confirmed (i.e. account activation)
# TODO Make configurable, only activate globally enabled services
enable_service %w[ discourse gitea mediawiki xmpp ]
enable_default_services
# TODO enable in development when we have easy setup of ejabberd etc.
return if Rails.env.development? || !Setting.ejabberd_enabled?
@@ -133,7 +130,7 @@ class User < ApplicationRecord
def mastodon_address
return nil unless Setting.mastodon_enabled?
"#{self.cn}@#{Setting.mastodon_address_domain}"
"#{self.cn.gsub("-", "_")}@#{Setting.mastodon_address_domain}"
end
def valid_attribute?(attribute_name)
@@ -141,10 +138,8 @@ class User < ApplicationRecord
self.errors[attribute_name].blank?
end
def ln_create_invoice(payload)
lndhub = Lndhub.new
lndhub.authenticate self
lndhub.addinvoice payload
def enable_default_services
enable_service Setting.default_services
end
def dn
@@ -178,6 +173,10 @@ class User < ApplicationRecord
ldap_entry[:services_enabled] || []
end
def service_enabled?(name)
services_enabled.map(&:to_sym).include?(name.to_sym)
end
def enable_service(service)
current_services = services_enabled
new_services = Array(service).map(&:to_s)

17
app/models/zap.rb Normal file
View File

@@ -0,0 +1,17 @@
class Zap < ApplicationRecord
belongs_to :user
def request_event
nostr_event_from_hash(request)
end
def receipt_event
nostr_event_from_hash(receipt)
end
private
def nostr_event_from_hash(hash)
Nostr::Event.new(**hash.symbolize_keys)
end
end

View File

@@ -35,11 +35,15 @@ class CreateAccount < ApplicationService
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
# TODO move to confirmation
# (and/or add email_confirmed to entry and use in login filter)
def add_ldap_document
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
CreateLdapUserJob.perform_later(@username, @domain, @email, hashed_pw)
CreateLdapUserJob.perform_later(
username: @username,
domain: @domain,
email: @email,
hashed_pw: hashed_pw,
confirmed: @confirmed
)
end
def create_lndhub_account(user)

View File

@@ -0,0 +1,13 @@
module LndhubManager
class CreateUserInvoice < LndhubV2
def initialize(user:, payload:)
@user = user
@payload = payload
end
def call
authenticate @user
create_invoice @payload
end
end
end

View File

@@ -0,0 +1,25 @@
module NostrManager
class CreateZapReceipt < NostrManagerService
def initialize(zap:, paid_at:, preimage:)
@zap, @paid_at, @preimage = zap, paid_at, preimage
end
def call
request_tags = parse_tags(@zap.request_event.tags)
site_user.create_event(
kind: 9735,
created_at: @paid_at,
content: "",
tags: [
["p", request_tags[:p].first],
["e", request_tags[:e]&.first],
["a", request_tags[:a]&.first],
["bolt11", @zap.payment_request],
["preimage", @preimage],
["description", @zap.request_event.to_json]
].reject { |t| t[1].nil? }
)
end
end
end

View File

@@ -0,0 +1,50 @@
module NostrManager
class PublishEvent < NostrManagerService
def initialize(event:, relay_url:)
relay_name = URI.parse(relay_url).host
@relay = Nostr::Relay.new(url: relay_url, name: relay_name)
if event.is_a?(Nostr::Event)
@event = event
else
@event = Nostr::Event.new(**event.symbolize_keys)
end
@client = Nostr::Client.new
end
def call
client, relay, event = @client, @relay, @event
log_prefix = "[nostr][#{relay.name}]"
thread = Thread.new do
client.on :connect do
puts "#{log_prefix} Publishing #{event.id}..."
client.publish event
end
client.on :error do |e|
puts "#{log_prefix} Error: #{e}"
puts "#{log_prefix} Closing thread..."
thread.exit
end
client.on :message do |m|
puts "#{log_prefix} Message: #{m}"
msg = JSON.parse(m) rescue []
if msg[0] == "OK" && msg[1] == event.id && msg[2]
puts "#{log_prefix} Event published. Closing thread..."
else
puts "#{log_prefix} Unexpected message from relay. Closing thread..."
end
thread.exit
end
puts "#{log_prefix} Connecting to #{relay.url}..."
client.connect relay
end
thread.join
end
end
end

View File

@@ -0,0 +1,19 @@
module NostrManager
class PublishZapReceipt < NostrManagerService
def initialize(zap:, delayed: true)
@zap, @delayed = zap, delayed
end
def call
tags = parse_tags(@zap.request_event.tags)
tags[:relays].take(Setting.nostr_zaps_relay_limit).each do |relay_url|
if @delayed
NostrPublishEventJob.perform_later(event: @zap.receipt, relay_url: relay_url)
else
NostrManager::PublishEvent.call(event: @zap.receipt_event, relay_url: relay_url)
end
end
end
end
end

View File

@@ -1,11 +0,0 @@
module NostrManager
class ValidateId < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
@event.id == Digest::SHA256.hexdigest(JSON.generate(@event.serialize))
end
end
end

View File

@@ -0,0 +1,18 @@
module NostrManager
class VerifyAuth < NostrManagerService
def initialize(event:, challenge:)
@event = event
@challenge_expected = challenge
@site_expected = Setting.accounts_domain
end
def call
tags = parse_tags(@event.tags)
site_given = tags[:site].first
challenge_given = tags[:challenge].first
site_given == @site_expected &&
challenge_given == @challenge_expected
end
end
end

View File

@@ -1,17 +0,0 @@
module NostrManager
class VerifySignature < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
Schnorr.check_sig!(
[@event.id].pack('H*'),
[@event.pubkey].pack('H*'),
[@event.sig].pack('H*')
)
rescue Schnorr::InvalidSignatureError
false
end
end
end

View File

@@ -0,0 +1,51 @@
module NostrManager
class VerifyZapRequest < NostrManagerService
def initialize(amount:, event:, lnurl: nil)
@amount, @event, @lnurl = amount, event, lnurl
end
# https://github.com/nostr-protocol/nips/blob/27fef638e2460139cc9078427a0aec0ce4470517/57.md#appendix-d-lnurl-server-zap-request-validation
def call
tags = parse_tags(@event.tags)
@event.verify_signature &&
@event.kind == 9734 &&
tags.present? &&
valid_p_tag?(tags[:p]) &&
valid_e_tag?(tags[:e]) &&
valid_a_tag?(tags[:a]) &&
valid_amount_tag?(tags[:amount]) &&
valid_lnurl_tag?(tags[:lnurl])
end
def valid_p_tag?(tag)
return false unless tag.present? && tag.length == 1
key = Nostr::PublicKey.new(tag.first) rescue nil
key.present?
end
def valid_e_tag?(tag)
return true unless tag.present?
# TODO validate format of event ID properly
tag.length == 1 && tag.first.is_a?(String)
end
def valid_a_tag?(tag)
return true unless tag.present?
# TODO validate format of event coordinate properly
tag.length == 1 && tag.first.is_a?(String)
end
def valid_amount_tag?(tag)
return true unless tag.present?
amount = tag.first
amount.is_a?(String) && amount.to_i == @amount
end
def valid_lnurl_tag?(tag)
return true unless tag.present?
# TODO validate lnurl matching recipient's lnurlp
tag.first.is_a?(String)
end
end
end

View File

@@ -1,4 +1,22 @@
require "nostr"
class NostrManagerService < ApplicationService
def parse_tags(tags)
out = {}
tags.each do |tag|
out[tag[0].to_sym] = tag[1, tag.length]
end
out
end
def site_keypair
Nostr::KeyPair.new(
private_key: Nostr::PrivateKey.new(Setting.nostr_private_key),
public_key: Nostr::PublicKey.new(Setting.nostr_public_key)
)
end
def site_user
Nostr::User.new(keypair: site_keypair)
end
end

View File

@@ -7,4 +7,27 @@
title: "Enable Nostr integration (experimental)",
description: "Allow adding nostr pubkeys and resolve user addresses via NIP-05"
) %>
<% if Setting.nostr_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_private_key,
type: :password,
title: "Private key",
description: "The private key of the accounts service, used when publishing events (e.g. zap receipts)"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_public_key,
title: "Public key",
description: "The corresponding public key of the accounts service"
) %>
</ul>
</section>
<section>
<h3>Zaps</h3>
<ul role="list">
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_zaps_relay_limit,
title: "Relay limit",
description: "The maximum number of relays to publish zap receipts to"
) %>
</ul>
<% end %>

View File

@@ -16,8 +16,8 @@
<p>
There's something to do for everyone, especially non-programmers! For
example, we need more help with graphics, UI/UX design, and
content/copywriting. We also need moderators for social media. And beta
testers for our software. The list doesn't end there.
content/copywriting. Also, testing any of our software and reporting
issues you encounter along the way is very valuable.
</p>
<p>
A good way to get started is to join one of our
@@ -43,7 +43,7 @@
</p>
<p>
We have run two 6-month trials so far, with the next trial period
starting sometime in Q1 2024. Watch your email for notifications about it!
starting sometime in Q2 2024. Watch your email for notifications about it!
</p>
</section>
<% end %>

View File

@@ -55,4 +55,27 @@
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
</p>
<% end %>
<div data-controller="nostr-login"
data-nostr-login-target="loginForm"
data-nostr-login-site-value="<%= Setting.accounts_domain %>"
data-nostr-login-shared-secret-value="<%= session[:shared_secret] %>"
class="hidden">
<div class="relative my-6">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-white px-2 text-sm text-gray-500 italic">or</span>
</div>
</div>
<p>
<button disabled tabindex="5"
class="w-full btn-md btn-gray text-purple-600"
data-nostr-login-target="loginButton"
data-action="nostr-login#login">
Log in with Nostr
</button>
</p>
</div>
<% end %>

View File

@@ -98,7 +98,17 @@
description: "The official Web app",
icon_path: "/img/logos/icon_mastodon-2.svg",
links: [
["Launch", "https://kosmos.social"]
["Launch", "https://kosmos.social"],
["GitHub", "https://github.com/mastodon/mastodon"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
<%= render AppInfoComponent.new(
@@ -150,6 +160,15 @@
["Google Play", "https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(
@@ -180,6 +199,15 @@
["App Store", "https://apps.apple.com/app/mammoth-for-mastodon/id1667573899"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(

View File

@@ -3,6 +3,7 @@
<h4 class="mb-0">Public Key</h4>
<div data-controller="settings--nostr-pubkey"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">

View File

@@ -1,7 +1,14 @@
require 'sidekiq/web'
Rails.application.routes.draw do
devise_for :users, controllers: { confirmations: 'users/confirmations' }
devise_for :users, controllers: {
confirmations: 'users/confirmations',
sessions: 'users/sessions'
}
devise_scope :user do
post 'users/nostr_login', to: 'users/sessions#nostr_login'
end
get 'welcome', to: 'welcome#index'
get 'check_your_email', to: 'welcome#check_your_email'

View File

@@ -2,3 +2,5 @@
:queues:
- default
- mailers
- remotestorage
- nostr

View File

@@ -0,0 +1,13 @@
class CreateZaps < ActiveRecord::Migration[7.1]
def change
create_table :zaps do |t|
t.references :user, null: false, foreign_key: true
t.json :request
t.json :receipt, default: nil
t.text :payment_request
t.bigint :amount
t.timestamps
end
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_03_16_153558) do
ActiveRecord::Schema[7.1].define(version: 2024_04_22_171653) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -136,8 +136,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_16_153558) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
create_table "zaps", force: :cascade do |t|
t.integer "user_id", null: false
t.json "request"
t.json "receipt"
t.text "payment_request"
t.bigint "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_zaps_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users"
add_foreign_key "zaps", "users"
end

View File

@@ -107,6 +107,17 @@ services:
- minio
- redis
nostr-relay:
image: pluja/strfry:latest
volumes:
- ./docker/strfry/strfry.conf:/etc/strfry.conf
- strfry-data:/app/strfry-db
networks:
- external_network
- internal_network
ports:
- "4777:7777"
# phpldapadmin:
# image: osixia/phpldapadmin:0.9.0
# ports:
@@ -128,3 +139,5 @@ volumes:
driver: local
redis-data:
driver: local
strfry-data:
driver: local

138
docker/strfry/strfry.conf Normal file
View File

@@ -0,0 +1,138 @@
##
## Default strfry config
##
# Directory that contains the strfry LMDB database (restart required)
db = "./strfry-db/"
dbParams {
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
maxreaders = 256
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
mapsize = 10995116277760
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
noReadAhead = false
}
events {
# Maximum size of normalised JSON, in bytes
maxEventSize = 65536
# Events newer than this will be rejected
rejectEventsNewerThanSeconds = 900
# Events older than this will be rejected
rejectEventsOlderThanSeconds = 94608000
# Ephemeral events older than this will be rejected
rejectEphemeralEventsOlderThanSeconds = 60
# Ephemeral events will be deleted from the DB when older than this
ephemeralEventsLifetimeSeconds = 300
# Maximum number of tags allowed
maxNumTags = 2000
# Maximum size for tag values, in bytes
maxTagValSize = 1024
}
relay {
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
bind = "0.0.0.0"
# Port to open for the nostr websocket protocol (restart required)
port = 7777
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
nofiles = 200000
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
realIpHeader = ""
info {
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
name = "akkounts-nostr-relay"
# NIP-11: Detailed information about relay, free-form
description = "Local strfry instance for akkounts development"
# NIP-11: Administrative nostr pubkey, for contact purposes
pubkey = ""
# NIP-11: Alternative administrative contact (email, website, etc)
contact = ""
}
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
maxWebsocketPayloadSize = 131072
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
autoPingSeconds = 55
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
enableTcpKeepalive = false
# How much uninterrupted CPU time a REQ query should get during its DB scan
queryTimesliceBudgetMicroseconds = 10000
# Maximum records that can be returned per filter
maxFilterLimit = 500
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
maxSubsPerConnection = 20
writePolicy {
# If non-empty, path to an executable script that implements the writePolicy plugin logic
plugin = ""
}
compression {
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
enabled = true
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
slidingWindow = true
}
logging {
# Dump all incoming messages
dumpInAll = true
# Dump all incoming EVENT messages
dumpInEvents = false
# Dump all incoming REQ/CLOSE messages
dumpInReqs = false
# Log performance metrics for initial REQ database scans
dbScanPerf = true
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
invalidEvents = true
}
numThreads {
# Ingester threads: route incoming requests, validate events/sigs (restart required)
ingester = 3
# reqWorker threads: Handle initial DB scan for events (restart required)
reqWorker = 3
# reqMonitor threads: Handle filtering of new events (restart required)
reqMonitor = 3
# negentropy threads: Handle negentropy protocol messages (restart required)
negentropy = 2
}
negentropy {
# Support negentropy protocol messages
enabled = true
# Maximum records that sync will process before returning an error
maxSyncEvents = 1000000
}
}

55
lib/nostr/event_kind.rb Normal file
View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
module Nostr
# Defines the event kinds that can be emitted by clients.
module EventKind
# The content is set to a stringified JSON object +{name: <username>, about: <string>,
# picture: <url, string>}+ describing the user who created the event. A relay may delete past set_metadata
# events once it gets a new one for the same pubkey.
#
# @return [Integer]
#
SET_METADATA = 0
# The content is set to the text content of a note (anything the user wants to say).
# Non-plaintext notes should instead use kind 1000-10000 as described in NIP-16.
#
# @return [Integer]
#
TEXT_NOTE = 1
# The content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to
# recommend to its followers.
#
# @return [Integer]
#
RECOMMEND_SERVER = 2
# A special event with kind 3, meaning "contact list" is defined as having a list of p tags, one for each of
# the followed/known profiles one is following.
#
# @return [Integer]
#
CONTACT_LIST = 3
# A special event with kind 4, meaning "encrypted direct message". An event of this kind has its +content+
# equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a
# shared cipher generated by combining the recipient's public-key with the sender's private-key.
#
# @return [Integer]
#
ENCRYPTED_DIRECT_MESSAGE = 4
# NIP-57 Zap request
#
# @return [Integer]
#
ZAP_REQUEST = 9734
# NIP-57 Zap receipt
#
# @return [Integer]
#
ZAP_RECEIPT = 9735
end
end

View File

@@ -19,18 +19,6 @@ namespace :ldap do
}, true
end
# TODO
desc "Add application account to directory"
task add_application_account: :environment do |t, args|
# Add uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org with userPassword
end
# TODO
desc "Add application ACI/permissions for OU, i.e. read/search users"
task add_application_account: :environment do |t, args|
# (target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
end
desc "Add custom attributes to schema"
task add_custom_attributes: :environment do |t, args|
%w[ admin service_enabled nostr_key ].each do |name|

View File

@@ -1,3 +1,6 @@
require "digest"
require "pp"
namespace :lndhub do
desc "Generate wallets for all users"
task :generate_wallets => :environment do |t, args|
@@ -22,6 +25,21 @@ namespace :lndhub do
puts "--\nSum of user balances: #{sum} sats"
end
desc "Create test invoice"
task :create_test_invoice, [:username, :amount, :description] => :environment do |t, args|
user = User.find_by cn: args[:username]
desc = args[:description]
hash = Digest::SHA256.hexdigest(desc)
invoice = LndhubManager::CreateUserInvoice.call(user: user, payload: {
"amount": args[:amount].to_i,
"description": desc,
"description_hash": hash
})
pp invoice
end
desc "Migrate existing accounts to lndhub.go"
task :migrate => :environment do |t, args|
# user = User.find_by cn: "jimmy"

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="54"
height="54"
viewBox="0 0 54 54"
version="1.1"
xml:space="preserve"
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"
id="svg4"
sodipodi:docname="icon_phanpy.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs4" /><sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.359375"
inkscape:cx="26.192932"
inkscape:cy="24.542502"
inkscape:window-width="2160"
inkscape:window-height="1281"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<rect
id="Logo-simple"
serif:id="Logo simple"
x="0"
y="0"
width="63.993999"
height="63.993999"
style="fill:none" />
<g
id="Logo-simple1"
serif:id="Logo simple"
transform="translate(-5.123639,-4.9968626)">
<g
id="g4">
<path
d="m 37.774,11.471 c 14.639,3.752 19.034,16.557 15.889,31.304 -0.696,3.261 -2.563,6.661 -6.356,8.693 -3.204,1.717 -8.07,2.537 -15.338,0.55 0,0 -9.634,-2.404 -9.634,-2.404 C 11.651,46.992 8.378,38.733 10.027,31.823 13.654,16.622 25.57,8.343 37.774,11.471 Z"
style="fill:#a4bff7"
id="path1" />
<path
d="m 36.76,15.429 c 12.289,3.15 15.547,14.114 12.907,26.493 -0.947,4.44 -4.937,9.365 -16.664,6.143 L 23.319,45.648 C 15.465,43.725 12.789,37.848 14.001,32.771 17.017,20.132 26.612,12.828 36.76,15.429 Z"
style="fill:#d8e7fe"
id="path2" />
<path
d="m 27.471,24.991 c -1.457,-0.698 -7.229,3.213 -7.663,8.926 -0.182,2.39 4.55,3.237 5.071,-0.169 0.725,-4.743 3.715,-8.218 2.592,-8.757 z"
style="fill:#6081e6"
id="path3" />
<path
d="m 38.217,26.996 c -2.083,0.327 -0.382,5.901 -0.595,10.727 -0.123,2.8 4.388,3.464 4.703,2.011 1.098,-5.073 -2.066,-13.058 -4.108,-12.738 z"
style="fill:#6081e6"
id="path4" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,4 +0,0 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
add: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || serviceEnabled || displayName || jpegPhoto || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

View File

@@ -1,4 +0,0 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
delete: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

20
spec/factories/zaps.rb Normal file
View File

@@ -0,0 +1,20 @@
FactoryBot.define do
factory :zap do
user { nil }
request {
Nostr::Event.new(
id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712487443,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]],
content: "",
sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2"
).to_h
}
payment_request { "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu" }
receipt { nil }
end
end

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

@@ -0,0 +1,19 @@
{
"id": 58,
"type": "incoming",
"user_login": "123456abcdef",
"amount": 21000,
"fee": 0,
"memo": "Zap for satoshi@kosmos.org",
"description_hash": "b1a9910724bc9c1b03b4eba2e2d78ac69a8ac7b244e6ff6d4e7391bf6893f26a",
"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

@@ -0,0 +1,9 @@
{
"id": "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
"pubkey": "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
"created_at": 1711963922,
"kind": 22242,
"tags": [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
"content": "",
"sig": "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
}

View File

@@ -0,0 +1,22 @@
{
"kind": 9734,
"created_at": 1712066899,
"content": "",
"tags": [
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["amount", "21000"],
[
"relays",
"wss://relay.primal.net",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.damus.io",
"wss://eden.nostr.land",
"wss://atlas.nostr.land",
"wss://nostr.bitcoiner.social",
"wss://relay.mostr.pub",
"wss://relay.mostr.pub/",
"wss://nostr-01.bolt.observer"
]
]
}

View File

@@ -3,12 +3,24 @@ require 'rails_helper'
RSpec.describe CreateLdapUserJob, type: :job do
let(:ldap_client_mock) { instance_double(Net::LDAP) }
subject(:job) {
before do
allow_any_instance_of(described_class).to receive(:ldap_client).and_return(ldap_client_mock)
end
subject(:job) {
described_class.perform_later(
'halfinney', 'kosmos.org', 'halfinney@example.com',
'remember-remember-the-5th-of-november'
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november'
)
}
subject(:job_for_preconfirmed_account) {
described_class.perform_later(
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november',
confirmed: true
)
}
@@ -30,6 +42,26 @@ RSpec.describe CreateLdapUserJob, type: :job do
)
end
it "adds default services for pre-confirmed accounts" do
allow(ldap_client_mock).to receive(:add) # spy on mock
allow(Setting).to receive(:default_services).and_return(["xmpp", "discourse"])
perform_enqueued_jobs { job_for_preconfirmed_account }
expect(ldap_client_mock).to have_received(:add).with(
dn: "cn=halfinney,ou=kosmos.org,cn=users,dc=kosmos,dc=org",
attributes: {
objectclass: ["top", "account", "person", "extensibleObject"],
cn: "halfinney",
sn: "halfinney",
uid: "halfinney",
mail: "halfinney@example.com",
serviceEnabled: ["xmpp", "discourse"],
userPassword: "remember-remember-the-5th-of-november"
}
)
end
after do
clear_enqueued_jobs
clear_performed_jobs

View File

@@ -16,6 +16,10 @@ RSpec.describe User, type: :model do
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
context "Mastodon service not configured" do
before do
Setting.mastodon_enabled = false
end
it "returns nil" do
expect(user.mastodon_address).to be_nil
end
@@ -41,6 +45,14 @@ RSpec.describe User, type: :model do
expect(user.mastodon_address).to eq("jimmy@kosmos.social")
end
end
describe "username contains hyphen/dash" do
let(:jammy) { build :user, cn: "jammy-jellyfish", ou: "kosmos.org" }
it "returns the user address" do
expect(jammy.mastodon_address).to eq("jammy_jellyfish@kosmos.org")
end
end
end
end
@@ -72,6 +84,25 @@ RSpec.describe User, type: :model do
end
end
describe "#service_enabled?" do
before do
allow(user).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["gitea", "xmpp"]
})
end
it "returns true or false" do
expect(user.service_enabled?("gitea")).to be(true)
expect(user.service_enabled?("email")).to be(false)
end
it "returns false when service is not enabled" do
expect(user.service_enabled?(:gitea)).to be(true)
expect(user.service_enabled?(:email)).to be(false)
end
end
describe "#enable_service" do
before do
allow(user).to receive(:ldap_entry).and_return({
@@ -147,7 +178,7 @@ RSpec.describe User, type: :model do
after { clear_enqueued_jobs }
it "enables default services" do
expect(user).to receive(:enable_service).with(%w[ discourse gitea mediawiki xmpp ])
expect(user).to receive(:enable_service).with(%w[ discourse gitea mastodon mediawiki xmpp ])
user.send :devise_after_confirmation
end

12
spec/models/zap_spec.rb Normal file
View File

@@ -0,0 +1,12 @@
require 'rails_helper'
RSpec.describe Zap, type: :model do
describe "#request_event" do
let(:user) { create :user, cn: 'satoshi', ou: 'kosmos.org', ln_account: 'abcdefg123456' }
let(:zap) { create :zap, user: user }
it "returns the stored request as a Nostr::Event" do
expect(zap.request_event).to be_a(Nostr::Event)
end
end
end

View File

@@ -1,9 +1,8 @@
require 'rails_helper'
RSpec.describe "/lnurlpay", type: :request do
context "Non-existent user" do
describe "GET /.well-known/lnurlpay/:username" do
describe "GET /.well-known/lnurlp/:username" do
it "returns a 404" do
get lightning_address_path(username: "csw")
expect(response).to have_http_status(:not_found)
@@ -30,9 +29,10 @@ RSpec.describe "/lnurlpay", type: :request do
before do
login_as user, :scope => :user
Setting.nostr_enabled = false
end
describe "GET /.well-known/lnurlpay/:username" do
describe "GET /.well-known/lnurlp/:username" do
it "returns a formatted Lightning Address response" do
get lightning_address_path(username: "satoshi")
@@ -45,17 +45,41 @@ RSpec.describe "/lnurlpay", type: :request do
expect(res["minSendable"]).to be_a(Integer)
expect(res["maxSendable"]).to be_a(Integer)
expect(res["commentAllowed"]).to be_a(Integer)
expect(res["allowsNostr"]).to be_nil
end
context "with support for nostr zaps" do
before do
Setting.nostr_enabled = true
end
it "returns NIP-57 properties" do
get lightning_address_path(username: "satoshi")
res = JSON.parse(response.body)
expect(res["allowsNostr"]).to be(true)
expect(res["nostrPubkey"]).to eq(Setting.nostr_public_key)
end
end
end
describe "GET /lnurlpay/:username/invoice" do
let(:invoice) {
{
"payment_hash" => "35778ccdb8319b5104e41d2043d18446bf91bcd51450b5f1cf1b6082a7cc6203",
"payment_request" => "lnbc210n1pnzzr6rpp5x4mcendcxxd4zp8yr5sy85vyg6ler0x4z3gttuw0rdsg9f7vvgpshp52y6nf64apaqta2kjuwp2apglewqa9fva2mada6x2mmdj20t57jdscqzzsxqyz5vqsp5a3h88efdc436wunupz293gdtvm5843yfcfc8hxm2rpdunaetl39q9qyyssq07ec02dqr4epa73ssy0lzwglw49aa9rfywlp0c7jpnf448uapsgqch79d4222xqlh8674lzddvcyptpnwqqq8vpppf8djrn8yjf53dqpzwx5kh",
"expires_at" => "2024-04-19T12:17:07.725314947Z"
}
}
before do
allow_any_instance_of(User).to receive(:ln_create_invoice).and_return("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an")
allow(LndhubManager::CreateUserInvoice).to receive(:call)
.and_return(invoice)
end
it "returns a formatted lnurlpay response" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 50000, comment: "Coffee time!"
amount: 21000, comment: "Coffee time!"
})
expect(response).to have_http_status(:ok)
@@ -64,32 +88,108 @@ RSpec.describe "/lnurlpay", type: :request do
expect(res["status"]).to eq('OK')
expect(res["successAction"]["tag"]).to eq('message')
expect(res["successAction"]["message"]).to match('Thank you')
expect(res["pr"]).to eq("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an")
expect(res["pr"]).to eq("lnbc210n1pnzzr6rpp5x4mcendcxxd4zp8yr5sy85vyg6ler0x4z3gttuw0rdsg9f7vvgpshp52y6nf64apaqta2kjuwp2apglewqa9fva2mada6x2mmdj20t57jdscqzzsxqyz5vqsp5a3h88efdc436wunupz293gdtvm5843yfcfc8hxm2rpdunaetl39q9qyyssq07ec02dqr4epa73ssy0lzwglw49aa9rfywlp0c7jpnf448uapsgqch79d4222xqlh8674lzddvcyptpnwqqq8vpppf8djrn8yjf53dqpzwx5kh")
end
context "amount too low" do
describe "amount too low" do
it "returns an error" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 5000, comment: "Coffee time!"
})
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["status"]).to eq('ERROR')
expect(res["reason"]).to eq('Invalid amount')
end
end
context "comment too long" do
describe "comment too long" do
it "returns an error" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 5000000, comment: "Coffee time is the best time, so here's some money for you to get some. May I suggest to sample some Pacamara beans from El Salvador?"
})
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["status"]).to eq('ERROR')
expect(res["reason"]).to eq('Comment too long')
end
end
context "zap request" do
before do
Setting.nostr_enabled = true
end
describe "with invalid request event" do
it "returns an error" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 2100000, nostr: { foo: "bar" }.to_json
})
res = JSON.parse(response.body)
expect(res["status"]).to eq('ERROR')
expect(res["reason"]).to eq('Invalid zap request')
end
end
describe "with valid request event" do
let(:event) {
Nostr::Event.new(
id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712487443,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]],
content: "",
sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2"
)
}
let(:invoice) {
{
"payment_hash" => "d6fa1d4b66587ab247d7b1f7608f2aed780a0cca021d09a30279c3dd9ff80f85",
"payment_request" => "lnbc210n1pnzzyvjpp56map6jmxtpaty37hk8mkpre2a4uq5rx2qgwsngcz08pam8lcp7zshp5xs7v3qlx0j0gyu9grrzx9xgews3t9vq64v30579le9z9wqr6fc5scqzzsxqyz5vqsp5kmltj5eayh47c6trwj8wdrz5nxymqp0eqwtk7k5nk6ytyz522nvs9qyyssqvkluufkp34gtzxdg0uyqcsdum2n34xz94tqr4jfwwx53czteutvj7eptz4lm5vcu0m8jqzxck484ycxzcqgqlqmpj2r3jxjlj4x6nygp8fvnag",
"expires_at" => "2024-04-19T12:26:58.432434748Z"
}
}
it "returns an invoice" do
expect(LndhubManager::CreateUserInvoice).to receive(:call)
.with(user: user, payload: {
amount: 21,
description: "Zap for satoshi@kosmos.org",
description_hash: "b1a9910724bc9c1b03b4eba2e2d78ac69a8ac7b244e6ff6d4e7391bf6893f26a"
})
.and_return(invoice)
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 21000, nostr: event.to_json
})
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["status"]).to eq('OK')
expect(res["pr"]).to eq(invoice["payment_request"])
end
it "creates a zap record" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 21000, nostr: event.to_json
})
zap = user.zaps.find_by payment_request: invoice["payment_request"]
expect(zap.request_event.id).to eq(event.id)
expect(zap.receipt).to be_nil
end
end
end
end
describe "GET /.well-known/keysend/:username/" do

View File

@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe "Settings", type: :request do
let(:user) { create :user, cn: 'mark', ou: 'kosmos.org' }
let(:other_user) { create :user, id: 2, cn: 'markymark', ou: 'kosmos.org', email: 'markymark@interscope.com' }
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
before do
login_as user, :scope => :user
@@ -25,7 +26,7 @@ RSpec.describe "Settings", type: :request do
describe "POST /settings/set_nostr_pubkey" do
before do
session_stub = { shared_secret: "rMjWEmvcvtTlQkMd" }
session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
allow_any_instance_of(SettingsController).to receive(:session).and_return(session_stub)
end
@@ -36,19 +37,12 @@ RSpec.describe "Settings", type: :request do
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(0)
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a success status" do
@@ -67,19 +61,12 @@ RSpec.describe "Settings", type: :request do
).and_return(other_user)
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do
@@ -91,23 +78,21 @@ RSpec.describe "Settings", type: :request do
end
end
context "With wrong username" do
context "With wrong site tag" do
before do
Setting.accounts_domain = "accounts.wikipedia.org"
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "2e1e20ee762d6a5b5b30835eda9ca03146e4baf82490e53fd75794c08de08ac0",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678255391,
kind: 1,
content: "Connect my public key to admin@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "2ace19c9db892ac6383848721a3e08b13d90d689fdeac60d9633a623d3f08eb7e0d468f1b3e928d1ea979477c2ec46ee6cdb2d053ef2e4ed3c0630a51d249029"
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
after do
Setting.accounts_domain = "accounts.kosmos.org"
end
it "returns a 422 status" do
@@ -126,19 +111,12 @@ RSpec.describe "Settings", type: :request do
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do

View File

@@ -0,0 +1,94 @@
require 'rails_helper'
RSpec.describe "Devise login sessions", type: :request do
let(:user) { create :user, cn: 'fiatjaf', ou: 'kosmos.org' }
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
before do
login_as user, :scope => :user
allow_any_instance_of(User).to receive(:dn)
.and_return("cn=#{user.cn},ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:nostr_pubkey).and_return(nil)
allow(LdapManager::FetchUserByNostrKey).to receive(:call).with(
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(nil)
end
describe "POST /users/nostr_login" do
before do
session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
end
context "With key configured for an account" do
before do
expect(LdapManager::FetchUserByNostrKey).to receive(:call).with(
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(user)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a success status" do
expect(response).to have_http_status(200)
end
end
context "With wrong site tag" do
before do
Setting.accounts_domain = "accounts.wikipedia.org"
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
after do
Setting.accounts_domain = "accounts.kosmos.org"
end
it "returns a 422 status" do
expect(response).to have_http_status(401)
end
it "informs the user about the failure" do
expect(flash[:alert]).to eq("Login verification failed")
end
end
context "With wrong shared secret" do
before do
session_stub = { shared_secret: "ho-chi-minh" }
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do
expect(response).to have_http_status(401)
end
it "informs the user about the failure" do
expect(flash[:alert]).to eq("Login verification failed")
end
end
end
end

View File

@@ -1,13 +1,87 @@
require 'rails_helper'
RSpec.describe "WebFinger", type: :request do
describe "remoteStorage link relation" do
context "user exists" do
before do
create :user, cn: 'tony', ou: 'kosmos.org'
describe "User does not exist" do
it "returns a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
context "User exists" do
let(:user) { create :user, cn: 'tony', ou: 'kosmos.org' }
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["mastodon", "remotestorage"]
})
end
describe "Mastodon entries" do
context "Mastodon available" do
it "includes the Mastodon aliases and links for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).to include("http://example.social/@tony")
expect(res["aliases"]).to include("http://example.social/users/tony")
profile_link = res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}
self_link = res["links"].find{|l| l["rel"] == "self"}
ostatus_link = res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}
expect(profile_link["type"]).to eql("text/html")
expect(profile_link["href"]).to eql("http://example.social/@tony")
expect(self_link["type"]).to eql("application/activity+json")
expect(self_link["href"]).to eql("http://example.social/users/tony")
expect(ostatus_link["template"]).to eql("http://example.social/authorize_interaction?uri={uri}")
end
end
context "remoteStorage enabled globally" do
context "Mastodon not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
context "Mastodon not available" do
before do
Setting.mastodon_enabled = false
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
end
describe "remoteStorage entries" do
context "remoteStorage available" do
it "includes the remoteStorage link for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
@@ -22,6 +96,25 @@ RSpec.describe "WebFinger", type: :request do
end
end
context "remoteStorage not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include the remoteStorage link" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"}
expect(rs_link).to be_nil
end
end
context "remoteStorage not available" do
before do
Setting.remotestorage_enabled = false
@@ -38,12 +131,5 @@ RSpec.describe "WebFinger", type: :request do
end
end
end
context "user does not exist" do
it "does return a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -53,9 +53,7 @@ RSpec.describe "Webhooks", type: :request 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
end
before { user.save! } #FIXME this should not be necessary
it "returns a 200 status" do
post "/webhooks/lndhub", params: payload.to_json
@@ -63,9 +61,15 @@ RSpec.describe "Webhooks", type: :request do
end
it "does not send notifications by default" do
post "/webhooks/lndhub", params: payload.to_json
expect(enqueued_jobs.size).to eq(0)
end
it "does not send a zap receipt" do
expect(NostrManager::PublishZapReceipt).not_to receive(:call)
post "/webhooks/lndhub", params: payload.to_json
end
context "notification preference set to 'xmpp'" do
before do
Setting.xmpp_notifications_from_address = "botka@kosmos.org"
@@ -103,5 +107,53 @@ RSpec.describe "Webhooks", type: :request do
end
end
end
describe "Valid payload for zap transaction" do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:zap) { create :zap, user: user }
let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/incoming-zap.json", File.dirname(__FILE__)))) }
let(:zap_receipt) {
Nostr::Event.new(
id: "cb66278a9add37a2f1e018826327ff15304e8055ff7b910100225baf83a9d691",
sig: "a808d6792e21824bfddc98742b6831b1070e8b21e12aa424d2bb168a09f3a95a217d4513e803f2acb6e38404f763eb09fa07a341ee9c8c4c7d18bbe3d381eb6f",
pubkey: "bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf",
created_at: 1673428978,
kind: 9735,
tags: [
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"],
["bolt11", "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu"],
["preimage", "3539663535656537343331663432653165396430623966633664656664646563"],
["description", "{\"id\":\"3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff\",\"sig\":\"e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2\",\"pubkey\":\"730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c\",\"created_at\":1712487443,\"kind\":9734,\"tags\":[[\"relays\",\"wss://nostr.kosmos.org\",\"wss://relay.example.com\"],[\"p\",\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\"]],\"content\":\"\"}"]
],
content: ""
)
}
before do
user.save!
zap.save!
allow(NostrManager::CreateZapReceipt).to receive(:call)
.and_return(zap_receipt)
allow(NostrManager::PublishZapReceipt).to receive(:call)
.and_return(true)
end
it "returns a 200 status" do
post "/webhooks/lndhub", params: payload.to_json
expect(response).to have_http_status(:ok)
end
it "creates and adds a zap receipt to the zap record" do
post "/webhooks/lndhub", params: payload.to_json
zap = user.zaps.first
expect(zap.receipt).not_to be_nil
end
it "publishes the zap receipt" do
expect(NostrManager::PublishZapReceipt).to receive(:call).with(zap: zap)
post "/webhooks/lndhub", params: payload.to_json
end
end
end
end

View File

@@ -53,11 +53,32 @@ RSpec.describe CreateAccount, type: :model do
expect(enqueued_jobs.size).to eq(1)
args = enqueued_jobs.first['arguments']
expect(args[0]).to eq('halfinney')
expect(args[1]).to eq('kosmos.org')
expect(args[2]).to eq('halfinney@example.com')
expect(args[3]).to match(/^{SSHA512}.{171}=/)
args = enqueued_jobs.first['arguments'][0]
expect(args["username"]).to eq('halfinney')
expect(args["domain"]).to eq('kosmos.org')
expect(args["email"]).to eq('halfinney@example.com')
expect(args["hashed_pw"]).to match(/^{SSHA512}.{171}=/)
end
after do
clear_enqueued_jobs
end
end
describe "#add_ldap_document for pre-confirmed account" do
include ActiveJob::TestHelper
let(:service) { CreateAccount.new(account: {
username: 'halfinney',
email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november',
confirmed: true
})}
it "enqueues a job to create the LDAP user document" do
service.send(:add_ldap_document)
args = enqueued_jobs.first['arguments'][0]
expect(args["confirmed"]).to be(true)
end
after do

View File

@@ -0,0 +1,45 @@
require 'rails_helper'
RSpec.describe NostrManager::CreateZapReceipt, type: :model do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:zap) { create :zap, user: user }
# before do
# user.save!
# zap.save!
# end
subject {
described_class.call(
zap: zap, paid_at: 1673428978,
preimage: "3539663535656537343331663432653165396430623966633664656664646563"
)
}
describe "Zap receipt" do
it "is a kind:9735 note" do
expect(subject).to be_a(Nostr::Event)
expect(subject.kind).to eq(9735)
end
it "sets created_at to when the invoice was paid" do
expect(subject.created_at).to eq(1673428978)
end
it "contains the zap recipient" do
expect(subject.tags.find{|t| t[0] == "p"}[1]).to eq("07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3")
end
it "contains the bolt11 invoice" do
expect(subject.tags.find{|t| t[0] == "bolt11"}[1]).to eq(zap.payment_request)
end
it "contains the invoice preimage" do
expect(subject.tags.find{|t| t[0] == "preimage"}[1]).to eq("3539663535656537343331663432653165396430623966633664656664646563")
end
it "contains the serialized zap request event as description" do
expect(subject.tags.find{|t| t[0] == "description"}[1]).to eq(zap.request_event.to_json)
end
end
end

View File

@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe NostrManager::PublishZapReceipt, type: :model do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:zap) { create :zap, user: user }
describe "Default/delayed execution" do
it "publishes zap receipts to all requested relays" do
expect(NostrPublishEventJob).to receive(:perform_later)
.exactly(2).times.and_return(true)
described_class.call(zap: zap)
end
context "with a long relay list" do
before do
relays = zap.request["tags"].find { |t| t.first == "relays" }
[
"wss://aegonstargaryen.example.com", "wss://visenya.example.com",
"wss://rhaenys.example.com", "wss://housevelaryon.example.com",
"wss://aemond.example.com", "wss://jaehaerys.example.com",
"wss://daenerys.example.com", "wss://corlys.example.com",
"wss://laenor.example.com", "wss://alysanne.example.com",
"wss://balerion.example.com", "wss://meraxes.example.com",
"wss://vhaegar.example.com", "wss://vermax.example.com",
"wss://caraxes.example.com"
].each do |url|
relays << url
end
end
it "limits publishing attempts to the first 12 relays" do
expect(NostrPublishEventJob).to receive(:perform_later)
.exactly(12).times.and_return(true)
described_class.call(zap: zap)
end
end
end
end

View File

@@ -0,0 +1,206 @@
require 'rails_helper'
RSpec.describe NostrManager::VerifyZapRequest, type: :model do
describe "Signature invalid" do
let(:event) {
Nostr::Event.new(
id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712487443,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]],
content: "",
sig: "1234562bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns false" do
expect(subject).to be(false)
end
end
describe "p tag missing" do
let(:event) {
Nostr::Event.new(
id: "eb53c5e728b625831e318c7ab09373502dd4a41ed11c17f80f32fd1c3fe1252b",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712580669,
kind: 9734,
tags: [["relays", "wss://nostr.kosmos.org","wss://relay.example.com"]],
content: "",
sig: "8e22dcb3e91d080c9549ea0808b018194cc352dde3056a4911796d6f0239de7e1955f287c7e6769909e59ab0ffa09e307178c32128dc451823fb00102ed7e80a"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns false" do
expect(subject).to be(false)
end
end
describe "p tag not a valid pubkey" do
let(:event) {
Nostr::Event.new(
id: "f2c23f038cf4d0307147d40ddceb87e34ee94acf632f5a67217a55a0b41c5d95",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712581702,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org","wss://relay.example.com"],
["p","123456abcdef"]
],
content: "",
sig: "b33e6b231273d38629f5efb86325ce3b4e02a7d84dfa1f37167ca2de8392cea654ae34000fc365ddfc6e21dc8ce2365fcb000ccb9064fbbcd54c7ea4a943955b"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns false" do
expect(subject).to be(false)
end
end
describe "Minimum valid request" do
let(:event) {
Nostr::Event.new(
id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712487443,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]],
content: "",
sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns true" do
expect(subject).to be(true)
end
end
describe "Optional amount property" do
describe "does not match given amount" do
let(:event) {
Nostr::Event.new(
id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712579812,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org","wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"],
["amount", "21000"]
],
content: "",
sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns false" do
expect(subject).to be(false)
end
end
describe "matches given amount" do
let(:event) {
Nostr::Event.new(
id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712579812,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org","wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"],
["amount", "21000"]
],
content: "",
sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 21000, event: event, lnurl: nil)
}
it "returns true" do
expect(subject).to be(true)
end
end
end
describe "Optional amount property" do
describe "does not match given amount" do
let(:event) {
Nostr::Event.new(
id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712579812,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org","wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"],
["amount", "21000"]
],
content: "",
sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil)
}
it "returns false" do
expect(subject).to be(false)
end
end
describe "matches given amount" do
let(:event) {
Nostr::Event.new(
id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712579812,
kind: 9734,
tags: [
["relays", "wss://nostr.kosmos.org","wss://relay.example.com"],
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"],
["amount", "21000"]
],
content: "",
sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869"
)
}
subject {
NostrManager::VerifyZapRequest.call(amount: 21000, event: event, lnurl: nil)
}
it "returns true" do
expect(subject).to be(true)
end
end
end
end