14 Commits

Author SHA1 Message Date
9866cd0404 WIP Edit nostr profile 2024-12-24 15:25:37 +01:00
10d29b6fab Reorder things in UI 2024-10-30 13:46:28 +01:00
6f8f60a9e2 Add timeout for fetching latest event 2024-10-30 13:45:56 +01:00
c1b4665706 Conditional
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 18:58:09 +02:00
5447150d4d Formatting
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 18:51:29 +02:00
bf26703b2d Remove client-side nostr discovery
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 18:30:04 +02:00
21c6264ea9 Use rails logger
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 18:21:24 +02:00
79ef9fa6d5 WIP Refactor stuff
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 18:16:09 +02:00
04a9061663 WIP Render nostr profile and relay status with Ruby
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 15:29:43 +02:00
5283f6fce7 WIP fetch relays and profile with ruby 2024-10-16 13:32:15 +02:00
a08a4746f7 Fetch user relays, synchronously 2024-10-12 12:45:50 +02:00
9e3652479b Add global setting and default for discovery relays 2024-10-12 12:45:12 +02:00
011386fb8d WIP Nostr onboarding
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-10 23:37:59 +02:00
4d77f5d38c Add nostrify lib 2024-10-10 23:37:41 +02:00
72 changed files with 809 additions and 1288 deletions

View File

@@ -44,8 +44,6 @@ gem 'pagy', '~> 6.0', '>= 6.0.2'
gem 'flipper' gem 'flipper'
gem 'flipper-active_record' gem 'flipper-active_record'
gem 'flipper-ui' gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1'
# HTTP requests # HTTP requests
gem 'faraday' gem 'faraday'

View File

@@ -197,8 +197,6 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gpgme (2.0.24)
mini_portile2 (~> 2.7)
hashdiff (1.1.0) hashdiff (1.1.0)
i18n (1.14.1) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -485,7 +483,6 @@ GEM
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yard (0.9.34) yard (0.9.34)
zbase32 (0.1.1)
zeitwerk (2.6.12) zeitwerk (2.6.12)
PLATFORMS PLATFORMS
@@ -510,7 +507,6 @@ DEPENDENCIES
flipper flipper
flipper-active_record flipper-active_record
flipper-ui flipper-ui
gpgme (~> 2.0.24)
image_processing (~> 1.12.2) image_processing (~> 1.12.2)
importmap-rails importmap-rails
jbuilder (~> 2.7) jbuilder (~> 2.7)
@@ -544,7 +540,6 @@ DEPENDENCIES
warden warden
web-console (~> 4.2) web-console (~> 4.2)
webmock webmock
zbase32 (~> 0.1.1)
BUNDLED WITH BUNDLED WITH
2.5.5 2.5.5

View File

@@ -0,0 +1,44 @@
<div class="w-[72vw] md:w-[500px]">
<header class="absolute z-10 h-36 sm:h-44 inset-x-1 top-1 rounded-t
bg-cover bg-center bg-gray-50"
style="background-image: url('<%= @profile["banner"]%>');">
<div class="inline-block z-20 size-28 sm:size-32 ml-4 mt-16 sm:mt-20">
<% if @profile["picture"].present? %>
<img src="<%= @profile["picture"] %>"
class="inline-block size:28 sm:size-32 rounded-full border-2 border-white" />
<% else %>
<span class="inline-block size:28 sm:size-32 overflow-hidden rounded-full border-2 border-white bg-gray-100">
<svg class="size-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
<% end %>
</div>
</header>
<main class="mt-44 sm:mt-52">
<%= form_for(@user, url: setting_path(:nostr), html: { :method => :put }) do |f| %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %>
<%= f.text_field :display_name, value: @display_name, class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:display_name].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:display_name].first %></p>
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Nostr address (NIP-05)") do %>
<%= f.text_field :nip05_address, value: @profile["nip05"], class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:nip05_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:nip05_address].first %></p>
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Ligtning address for Zaps") do %>
<%= f.text_field :lud16_address, value: @profile["lud16"], class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:lud16_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:lud16_address].first %></p>
<% end %>
<% end %>
<% end %>
</main>
<footer>
<%# <%= @profile.inspect %>
<%# <%= @profile_event.inspect %>
</footer>
</div>

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
module Settings
class NostrEditProfileComponent < ViewComponent::Base
def initialize(user:, profile_event:)
if profile_event.present?
@user = user
@profile_event = profile_event
@profile = JSON.parse(profile_event["content"])
@display_name = @profile["display_name"] || @profile["displayName"]
if @profile["nip05"].present? && @profile["nip05"] == @user.address
# "Your profile's Nostr address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet"
end
if @profile["lud16"].present? && @profile["lud16"] == @user.address
# "Your profile's Lightning address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet"
end
else
# "We could not find a profile for your public key"
end
end
end
end

View File

@@ -0,0 +1,21 @@
<% @statuses.each do |status| %>
<%= render StatusTextComponent.new(
text: status[:text],
icon_name: status[:icon_name],
icon_color: status[:icon_color]
) %>
<% end %>
<% if @status == 1 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Edit my profile
</button>
</p>
<% elsif @status == 2 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Create my profile
</button>
</p>
<% end %>

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
module Settings
class NostrProfileStatusComponent < ViewComponent::Base
def initialize(profile_event:, user_address:)
@statuses = []
if profile_event.present?
profile = JSON.parse(profile_event["content"])
@statuses.push({
text: "You have a public Nostr profile",
icon_name: "check-circle",
icon_color: "emerald-500"
})
if profile["nip05"].present? && profile["nip05"] == user_address
@statuses.push({
text: "Your profile's Nostr address is set to <strong>#{ user_address }</strong>",
icon_name: "check-circle",
icon_color: "emerald-500"
})
else
@statuses.push({
text: "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
if profile["lud16"].present? && profile["lud16"] == user_address
@statuses.push({
text: "Your profile's Lightning address is set to <strong>#{ user_address }</strong>",
icon_name: "check-circle",
icon_color: "emerald-500"
})
else
@statuses.push({
text: "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
else
@statuses.push({
text: "We could not find a profile for your public key",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
end
end
end

View File

@@ -0,0 +1,18 @@
<%= render StatusTextComponent.new(
text: @text,
icon_name: @icon_name,
icon_color: @icon_color) %>
<% if @status == 1 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Add the relay to my list
</button>
</p>
<% elsif @status == 2 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Set up default relays
</button>
</p>
<% end %>

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Settings
class NostrRelayStatusComponent < ViewComponent::Base
def initialize(nip65_event:)
if nip65_event.present?
if relay_urls(nip65_event).any? { |r| r.include?("wss://nostr.kosmos.org") }
@text = "You have a relay list, and the Kosmos relay is part of it"
@icon_name = "check-circle"
@icon_color = "emerald-500"
@status = 0
else
@text = "The Kosmos relay is missing from your relay list"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
@status = 1
end
else
@text = "We could not find a relay list for your public key"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
@status = 2
end
end
private
def relay_urls(nip65_event)
nip65_event["tags"].select{ |t| t[0] == "r" }.map{ |t| t[1] }
# @inbox_relay_urls = relay_tags&.select{ |t| t[2] == "read" }&.map{ |t| t[1] }
# @outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
end
end
end

View File

@@ -0,0 +1,8 @@
<p class="flex gap-x-4 items-center">
<span class="inline-block h-6 w-6 grow-0 text-<%= @icon_color %>">
<%= render "icons/#{@icon_name}" %>
</span>
<span>
<%= raw @text %>
</span>
</p>

View File

@@ -0,0 +1,7 @@
class StatusTextComponent < ViewComponent::Base
def initialize(text:, icon_name:, icon_color:)
@text = text
@icon_name = icon_name
@icon_color = icon_color
end
end

View File

@@ -30,7 +30,7 @@ class Admin::UsersController < Admin::BaseController
amount = params[:amount].to_i amount = params[:amount].to_i
notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user]) notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user])
UserManager::CreateInvitations.call(user: @user, amount: amount, notify: notify_user) CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
redirect_to admin_user_path(@user.cn), flash: { redirect_to admin_user_path(@user.cn), flash: {
success: "Added #{amount} invitations to #{@user.cn}'s account" success: "Added #{amount} invitations to #{@user.cn}'s account"

View File

@@ -4,8 +4,13 @@ require "bcrypt"
class SettingsController < ApplicationController class SettingsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_main_nav_section before_action :set_main_nav_section
before_action :set_settings_section, only: [:show, :update, :update_email, :reset_email_password] before_action :set_settings_section, only: [
before_action :set_user, only: [:show, :update, :update_email, :reset_email_password] :show, :update, :update_email, :reset_email_password
]
before_action :set_user, only: [
:show, :update, :update_email, :reset_email_password,
:fetch_nostr_user_metadata
]
def index def index
redirect_to setting_path(:profile) redirect_to setting_path(:profile)
@@ -21,12 +26,10 @@ class SettingsController < ApplicationController
end end
end end
# PUT /settings/:section
def update def update
@user.preferences.merge!(user_params[:preferences] || {}) @user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name] @user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar] @user.avatar_new = user_params[:avatar]
@user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save if @user.save
if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name]) if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
@@ -37,10 +40,6 @@ class SettingsController < ApplicationController
LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new) LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new)
end end
if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
UserManager::UpdatePgpKey.call(user: @user)
end
redirect_to setting_path(@settings_section), flash: { redirect_to setting_path(@settings_section), flash: {
success: 'Settings saved.' success: 'Settings saved.'
} }
@@ -50,7 +49,6 @@ class SettingsController < ApplicationController
end end
end end
# POST /settings/update_email
def update_email def update_email
if @user.valid_ldap_authentication?(security_params[:current_password]) if @user.valid_ldap_authentication?(security_params[:current_password])
if @user.update email: email_params[:email] if @user.update email: email_params[:email]
@@ -68,7 +66,6 @@ class SettingsController < ApplicationController
end end
end end
# POST /settings/reset_email_password
def reset_email_password def reset_email_password
@user.current_password = security_params[:current_password] @user.current_password = security_params[:current_password]
@@ -91,7 +88,6 @@ class SettingsController < ApplicationController
end end
end end
# POST /settings/reset_password
def reset_password def reset_password
current_user.send_reset_password_instructions current_user.send_reset_password_instructions
sign_out current_user sign_out current_user
@@ -99,7 +95,6 @@ class SettingsController < ApplicationController
redirect_to check_your_email_path, notice: msg redirect_to check_your_email_path, notice: msg
end end
# POST /settings/set_nostr_pubkey
def set_nostr_pubkey def set_nostr_pubkey
signed_event = Nostr::Event.new(**nostr_event_from_params) signed_event = Nostr::Event.new(**nostr_event_from_params)
@@ -138,6 +133,28 @@ class SettingsController < ApplicationController
} }
end end
def fetch_nostr_user_metadata
if @user.nostr_pubkey.present?
outbox_relay_urls = nil
# if @nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
# relay_tags = @nip65_event["tags"].select{ |t| t[0] == "r" }
# outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
# end
# @profile = NostrManager::DiscoverUserProfile.call(
# pubkey: @user.nostr_pubkey,
# relays: outbox_relay_urls
# )
@profile = {"content"=>"{\"name\":\"jimmy\",\"picture\":\"https://storage.kosmos.org/jimmy/public/shares/241028-1117-tony.jpg\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
# @profile = {"content"=>"{\"name\":\"jimmy\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
else
@relays, @profile = [nil, nil]
end
render partial: 'nostr_user_metadata'
end
private private
def set_main_nav_section def set_main_nav_section
@@ -162,8 +179,7 @@ class SettingsController < ApplicationController
def user_params def user_params
params.require(:user).permit( params.require(:user).permit(
:display_name, :avatar, :pgp_pubkey, :display_name, :avatar, preferences: UserPreferences.pref_keys
preferences: UserPreferences.pref_keys
) )
end end

View File

@@ -96,7 +96,7 @@ class SignupController < ApplicationController
session[:new_user] = nil session[:new_user] = nil
session[:validation_error] = nil session[:validation_error] = nil
UserManager::CreateAccount.call(account: { CreateAccount.call(account: {
username: @user.cn, username: @user.cn,
domain: Setting.primary_domain, domain: Setting.primary_domain,
email: @user.email, email: @user.email,

View File

@@ -1,35 +0,0 @@
class WebKeyDirectoryController < WellKnownController
before_action :allow_cross_origin_requests
# /.well-known/openpgpkey/hu/:hashed_username(.txt)
def show
@user = User.find_by(cn: params[:l].downcase)
if @user.nil? ||
@user.pgp_pubkey.blank? ||
!@user.pgp_pubkey_contains_user_address?
http_status :not_found and return
end
if params[:hashed_username] != @user.wkd_hash
http_status :unprocessable_entity and return
end
respond_to do |format|
format.text do
response.headers['Content-Type'] = 'text/plain'
render plain: @user.pgp_pubkey
end
format.any do
key = @user.gnupg_key.export
send_data key, filename: "#{@user.wkd_hash}.pem",
type: "application/octet-stream"
end
end
end
def policy
head :ok
end
end

View File

@@ -1,90 +1,3 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default Rails.application.config.action_mailer.default_options
layout 'mailer' layout 'mailer'
private
def send_mail
@template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}"
headers['Message-ID'] = message_id
if @user.pgp_pubkey.present?
mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format|
format.text { render plain: pgp_content }
end
else
mail(to: @user.email, subject: @subject) do |format|
format.text { render @template }
end
end
end
def from_address
self.class.default[:from]
end
def from_domain
Mail::Address.new(from_address).domain
end
def message_id
@message_id ||= "#{SecureRandom.uuid}@#{from_domain}"
end
def boundary
@boundary ||= SecureRandom.hex(8)
end
def pgp_content_type
"multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\""
end
def pgp_nested_content
message_content = render_to_string(template: @template)
message_content_base64 = Base64.encode64(message_content)
nested_boundary = SecureRandom.hex(8)
<<~NESTED_CONTENT
Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1"
Subject: #{@subject}
From: <#{from_address}>
To: #{@user.display_name || @user.cn} <#{@user.email}>
Message-ID: <#{message_id}>
--------------#{nested_boundary}
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: base64
#{message_content_base64}
--------------#{nested_boundary}--
NESTED_CONTENT
end
def pgp_content
encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content)
encrypted_base64 = Base64.encode64(encrypted_content.to_s)
<<~EMAIL_CONTENT
This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
--------------#{boundary}
Content-Type: application/pgp-encrypted
Content-Description: PGP/MIME version identification
Version: 1
--------------#{boundary}
Content-Type: application/octet-stream; name="encrypted.asc"
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc"
-----BEGIN PGP MESSAGE-----
#{encrypted_base64}
-----END PGP MESSAGE-----
--------------#{boundary}--
EMAIL_CONTENT
end
end end

View File

@@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer
@user = params[:user] @user = params[:user]
@subject = params[:subject] @subject = params[:subject]
@body = params[:body] @body = params[:body]
send_mail mail(to: @user.email, subject: @subject)
end end
end end

View File

@@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer
@user = params[:user] @user = params[:user]
@amount_sats = params[:amount_sats] @amount_sats = params[:amount_sats]
@subject = "Sats received" @subject = "Sats received"
send_mail mail to: @user.email, subject: @subject
end end
def remotestorage_auth_created def remotestorage_auth_created
@@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer
"#{access} #{directory}" "#{access} #{directory}"
end end
@subject = "New app connected to your storage" @subject = "New app connected to your storage"
send_mail mail to: @user.email, subject: @subject
end end
def new_invitations_available def new_invitations_available
@user = params[:user] @user = params[:user]
@subject = "New invitations added to your account" @subject = "New invitations added to your account"
send_mail mail to: @user.email, subject: @subject
end end
def bitcoin_donation_confirmed def bitcoin_donation_confirmed
@user = params[:user] @user = params[:user]
@donation = params[:donation] @donation = params[:donation]
@subject = "Donation confirmed" @subject = "Donation confirmed"
send_mail mail to: @user.email, subject: @subject
end end
end end

View File

@@ -20,6 +20,19 @@ module Settings
field :nostr_zaps_relay_limit, type: :integer, field :nostr_zaps_relay_limit, type: :integer,
default: 12 default: 12
field :nostr_discovery_relays, type: :array, default: %w[
wss://nostr.kosmos.org
wss://purplepag.es
wss://relay.nostr.band
wss://njump.me
wss://relay.damus.io
]
def self.nostr_relay_url_http
self.nostr_relay_url.gsub(/^ws:/, "http:")
.gsub(/^wss:/, "https:")
end
end end
end end
end end

View File

@@ -3,10 +3,9 @@ require 'nostr'
class User < ApplicationRecord class User < ApplicationRecord
include EmailValidatable include EmailValidatable
attr_accessor :current_password
attr_accessor :avatar_new
attr_accessor :display_name attr_accessor :display_name
attr_accessor :pgp_pubkey attr_accessor :avatar_new
attr_accessor :current_password
serialize :preferences, coder: UserPreferences serialize :preferences, coder: UserPreferences
@@ -52,8 +51,6 @@ class User < ApplicationRecord
validate :acceptable_avatar validate :acceptable_avatar
validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
# #
# Scopes # Scopes
# #
@@ -168,24 +165,6 @@ class User < ApplicationRecord
Nostr::PublicKey.new(nostr_pubkey).to_bech32 Nostr::PublicKey.new(nostr_pubkey).to_bech32
end end
def pgp_pubkey
@pgp_pubkey ||= ldap_entry[:pgp_key]
end
def gnupg_key
return nil unless pgp_pubkey.present?
GPGME::Key.import(pgp_pubkey)
GPGME::Key.get(pgp_fpr)
end
def pgp_pubkey_contains_user_address?
gnupg_key.uids.map(&:email).include?(address)
end
def wkd_hash
ZBase32.encode(Digest::SHA1.digest(cn))
end
def avatar def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn) @avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
end end
@@ -235,10 +214,4 @@ class User < ApplicationRecord
errors.add(:avatar, "must be a JPEG or PNG file") errors.add(:avatar, "must be a JPEG or PNG file")
end end
end end
def acceptable_pgp_key_format
unless GPGME::Key.valid?(pgp_pubkey)
errors.add(:pgp_pubkey, 'is not a valid armored PGP public key block')
end
end
end end

View File

@@ -0,0 +1,54 @@
class CreateAccount < ApplicationService
def initialize(account:)
@username = account[:username]
@domain = account[:ou] || Setting.primary_domain
@email = account[:email]
@password = account[:password]
@invitation = account[:invitation]
@confirmed = account[:confirmed]
end
def call
user = create_user_in_database
add_ldap_document
create_lndhub_account(user) if Setting.lndhub_enabled
if @invitation.present?
update_invitation(user.id)
end
end
private
def create_user_in_database
User.create!(
cn: @username,
ou: @domain,
email: @email,
password: @password,
password_confirmation: @password,
confirmed_at: @confirmed ? DateTime.now : nil
)
end
def update_invitation(user_id)
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
def add_ldap_document
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
CreateLdapUserJob.perform_later(
username: @username,
domain: @domain,
email: @email,
hashed_pw: hashed_pw,
confirmed: @confirmed
)
end
def create_lndhub_account(user)
#TODO enable in development when we have a local lndhub (mock?) API
return if Rails.env.development?
CreateLndhubAccountJob.perform_later(user)
end
end

View File

@@ -0,0 +1,17 @@
class CreateInvitations < ApplicationService
def initialize(user:, amount:, notify: true)
@user = user
@amount = amount
@notify = notify
end
def call
@amount.times do
Invitation.create(user: @user)
end
if @notify
NotificationMailer.with(user: @user).new_invitations_available.deliver_later
end
end
end

View File

@@ -1,16 +0,0 @@
module LdapManager
class UpdatePgpKey < LdapManagerService
def initialize(dn:, pubkey:)
@dn = dn
@pubkey = pubkey
end
def call
if @pubkey.present?
replace_attribute @dn, :pgpKey, @pubkey
else
delete_attribute @dn, :pgpKey
end
end
end
end

View File

@@ -58,7 +58,7 @@ class LdapService < ApplicationService
attributes = %w[ attributes = %w[
dn cn uid mail displayName admin serviceEnabled dn cn uid mail displayName admin serviceEnabled
mailRoutingAddress mailpassword nostrKey pgpKey mailRoutingAddress mailpassword nostrKey
] ]
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
@@ -73,8 +73,7 @@ class LdapService < ApplicationService
services_enabled: e.try(:serviceEnabled), services_enabled: e.try(:serviceEnabled),
email_maildrop: e.try(:mailRoutingAddress), email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword), email_password: e.try(:mailpassword),
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil, nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil
pgp_key: e.try(:pgpKey) ? e.pgpKey.first : nil
} }
end end
end end
@@ -102,7 +101,7 @@ class LdapService < ApplicationService
dn = "ou=#{ou},cn=users,#{ldap_suffix}" dn = "ou=#{ou},cn=users,#{ldap_suffix}"
aci = <<-EOS aci = <<-EOS
(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || pgpKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";) (target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
EOS EOS
attrs = { attrs = {

View File

@@ -0,0 +1,21 @@
module NostrManager
class DiscoverUserProfile < NostrManagerService
def initialize(pubkey:, relays: nil)
@pubkey = pubkey
@relays = relays.present? ? relays : Setting.nostr_discovery_relays
end
def call
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [0],
limit: 1,
)
NostrManager::FetchLatestEvent.call(
relays: @relays,
filter: filter
)
end
end
end

View File

@@ -0,0 +1,21 @@
module NostrManager
class DiscoverUserRelays < NostrManagerService
def initialize(pubkey:)
@pubkey = pubkey
@relays = Setting.nostr_discovery_relays
end
def call
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [10002],
limit: 1,
)
NostrManager::FetchLatestEvent.call(
relays: @relays,
filter: filter
)
end
end
end

View File

@@ -0,0 +1,59 @@
module NostrManager
class FetchEvent < NostrManagerService
TIMEOUT = 10
def initialize(filter:, relay_url:)
@filter = filter
@relay = new_relay(relay_url)
@client = Nostr::Client.new
end
def call
filter, client, relay = @filter, @client, @relay
event = nil
mutex = Mutex.new
received_event = ConditionVariable.new
log_prefix = "[nostr][#{@relay.name}]"
thread = Thread.new do
client.on :connect do
client.subscribe(filter: filter)
end
client.on :error do |e|
Rails.logger.info "#{log_prefix} Error: #{e}"
Thread.current.exit
end
client.on :message do |m|
msg = JSON.parse(m) rescue nil
if msg && msg[0] == "EVENT" && msg[2]
Rails.logger.debug "#{log_prefix} Event received: #{msg[2]["id"]}"
mutex.synchronize do
event = msg[2]
received_event.signal
end
elsif msg && msg[0] == "EOSE"
Thread.current.exit
end
end
client.connect relay
end
begin
Timeout.timeout(TIMEOUT) do
mutex.synchronize do
received_event.wait(mutex) if event.nil?
end
end
rescue Timeout::Error
Rails.logger.debug "#{log_prefix} Timeout: No event received within #{TIMEOUT} seconds"
ensure
thread.exit
end
event
end
end
end

View File

@@ -0,0 +1,44 @@
module NostrManager
class FetchLatestEvent < NostrManagerService
TIMEOUT = 20
def initialize(relays:, filter:, max_events: 2)
@relays = relays
@filter = filter
@max_events = max_events
end
def call
received_events = 0
events = []
begin
Timeout.timeout(TIMEOUT) do
@relays.each do |url|
event = NostrManager::FetchEvent.call(filter: @filter, relay_url: url)
if event.present?
events << event if events.none? { |e| e["id"] == event["id"] }
received_events += 1
end
if received_events >= @max_events
Rails.logger.debug "Found #{@max_events} events, ending the search"
break
end
end
events.min_by { |e| e["created_at"] }
end
rescue Timeout::Error
if events.size == 1
Rails.logger.debug "[nostr] Timeout: only found 1 event within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
events.first
else
Rails.logger.debug "[nostr] Timeout: no events found within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
nil
end
end
end
end
end

View File

@@ -19,28 +19,28 @@ module NostrManager
thread = Thread.new do thread = Thread.new do
client.on :connect do client.on :connect do
puts "#{log_prefix} Publishing #{event.id}..." Rails.logger.debug "#{log_prefix} Publishing #{event.id}..."
client.publish event client.publish event
end end
client.on :error do |e| client.on :error do |e|
puts "#{log_prefix} Error: #{e}" Rails.logger.debug "#{log_prefix} Error: #{e}"
puts "#{log_prefix} Closing thread..." Rails.logger.debug "#{log_prefix} Closing thread..."
thread.exit thread.exit
end end
client.on :message do |m| client.on :message do |m|
puts "#{log_prefix} Message: #{m}" Rails.logger.debug "#{log_prefix} Message: #{m}"
msg = JSON.parse(m) rescue [] msg = JSON.parse(m) rescue []
if msg[0] == "OK" && msg[1] == event.id && msg[2] if msg[0] == "OK" && msg[1] == event.id && msg[2]
puts "#{log_prefix} Event published. Closing thread..." Rails.logger.debug "#{log_prefix} Event published. Closing thread..."
else else
puts "#{log_prefix} Unexpected message from relay. Closing thread..." Rails.logger.debug "#{log_prefix} Unexpected message from relay. Closing thread..."
end end
thread.exit thread.exit
end end
puts "#{log_prefix} Connecting to #{relay.url}..." Rails.logger.debug "#{log_prefix} Connecting to #{relay.url}..."
client.connect relay client.connect relay
end end

View File

@@ -3,6 +3,7 @@ require "nostr"
class NostrManagerService < ApplicationService class NostrManagerService < ApplicationService
def parse_tags(tags) def parse_tags(tags)
out = {} out = {}
# TODO support more than 1 item for each tag type
tags.each do |tag| tags.each do |tag|
out[tag[0].to_sym] = tag[1, tag.length] out[tag[0].to_sym] = tag[1, tag.length]
end end
@@ -19,4 +20,8 @@ class NostrManagerService < ApplicationService
def site_user def site_user
Nostr::User.new(keypair: site_keypair) Nostr::User.new(keypair: site_keypair)
end end
def new_relay(url)
Nostr::Relay.new(url: url, name: URI.parse(url).host)
end
end end

View File

@@ -1,56 +0,0 @@
module UserManager
class CreateAccount < UserManagerService
def initialize(account:)
@username = account[:username]
@domain = account[:ou] || Setting.primary_domain
@email = account[:email]
@password = account[:password]
@invitation = account[:invitation]
@confirmed = account[:confirmed]
end
def call
user = create_user_in_database
add_ldap_document
create_lndhub_account(user) if Setting.lndhub_enabled
if @invitation.present?
update_invitation(user.id)
end
end
private
def create_user_in_database
User.create!(
cn: @username,
ou: @domain,
email: @email,
password: @password,
password_confirmation: @password,
confirmed_at: @confirmed ? DateTime.now : nil
)
end
def update_invitation(user_id)
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
def add_ldap_document
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
CreateLdapUserJob.perform_later(
username: @username,
domain: @domain,
email: @email,
hashed_pw: hashed_pw,
confirmed: @confirmed
)
end
def create_lndhub_account(user)
#TODO enable in development when we have a local lndhub (mock?) API
return if Rails.env.development?
CreateLndhubAccountJob.perform_later(user)
end
end
end

View File

@@ -1,19 +0,0 @@
module UserManager
class CreateInvitations < UserManagerService
def initialize(user:, amount:, notify: true)
@user = user
@amount = amount
@notify = notify
end
def call
@amount.times do
Invitation.create(user: @user)
end
if @notify
NotificationMailer.with(user: @user).new_invitations_available.deliver_later
end
end
end
end

View File

@@ -1,19 +0,0 @@
require 'gpgme'
module UserManager
class PgpEncrypt < UserManagerService
def initialize(user:, text:)
@user = user
@text = text
end
def call
crypto = GPGME::Crypto.new
crypto.encrypt(
@text,
recipients: @user.gnupg_key,
always_trust: true
)
end
end
end

View File

@@ -1,24 +0,0 @@
module UserManager
class UpdatePgpKey < UserManagerService
def initialize(user:)
@user = user
end
def call
if @user.pgp_pubkey.blank?
@user.update! pgp_fpr: nil
else
result = GPGME::Key.import(@user.pgp_pubkey)
if result.imports.present?
@user.update! pgp_fpr: result.imports.first.fpr
else
# TODO notify Sentry, user
raise "Failed to import OpenPGP pubkey"
end
end
LdapManager::UpdatePgpKey.call(dn: @user.dn, pubkey: @user.pgp_pubkey)
end
end
end

View File

@@ -1,2 +0,0 @@
class UserManagerService < ApplicationService
end

View File

@@ -31,13 +31,28 @@
) %> ) %>
</ul> </ul>
</section> </section>
<section> <section>
<h3>Zaps</h3> <h3>Zaps</h3>
<ul role="list"> <ul role="list">
<%= render FormElements::FieldsetResettableSettingComponent.new( <%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_zaps_relay_limit, key: :nostr_zaps_relay_limit,
title: "Relay limit", title: "Relay limit",
description: "The maximum number of relays to publish zap receipts to" description: "The maximum number of sender-defined relays to try to publish zap receipts to"
) %> ) %>
</ul> </ul>
</section>
<section>
<h3>Onboarding</h3>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
title: "Discovery relays",
description: "Used to discover a user's published relay list and/or profile"
) do %>
<%= f.text_area :nostr_discovery_relays,
value: Setting.nostr_discovery_relays.join("\n"),
class: "h-44 w-80" %>
<% end %>
</ul>
<% end %> <% end %>

View File

@@ -89,47 +89,13 @@
</section> </section>
<section class="sm:flex-1 sm:pt-0"> <section class="sm:flex-1 sm:pt-0">
<h3>LDAP</h3>
<table class="divided">
<tbody>
<tr>
<th>Avatar</th>
<td>
<% if @avatar.present? %> <% if @avatar.present? %>
<h3>LDAP<h3>
<p>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" /> <img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
<% else %> </p>
&mdash;
<% end %> <% end %>
</td> <!-- <h3>Actions</h3> -->
</tr>
<tr>
<th>Display name</th>
<td><%= @user.display_name || "—" %></td>
</tr>
<tr>
<th class="align-top">PGP key</th>
<td class="align-top leading-5">
<% if @user.pgp_pubkey.present? %>
<span class="font-mono" title="<%= @user.pgp_fpr %>">
<% if @user.pgp_pubkey_contains_user_address? %>
<%= link_to wkd_key_url(hashed_username: @user.wkd_hash, l: @user.cn, format: :txt),
class: "ks-text-link", target: "_blank" do %>
<%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
<% end %>
<% else %>
<%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
<% end %>
</span><br />
<% @user.gnupg_key.uids.each do |uid| %>
<%= uid.uid %><br />
<% end %>
<% else %>
&mdash;
<% end %>
</td>
</tr>
</tbody>
</table>
</section> </section>
</div> </div>
@@ -239,9 +205,7 @@
) %> ) %>
</td> </td>
<td class="text-right"> <td class="text-right">
<% if @user.nostr_pubkey.present? %>
<%= link_to "Open profile", "https://njump.me/#{@user.nostr_pubkey_bech32}", class: "btn-sm btn-gray" %> <%= link_to "Open profile", "https://njump.me/#{@user.nostr_pubkey_bech32}", class: "btn-sm btn-gray" %>
<% end %>
</td> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -1,6 +1,6 @@
<%= tag.section data: { <%= tag.section data: {
controller: "settings--account--email", controller: "settings--account--email",
"settings--account--email-validation-failed-value": @validation_errors&.[](:email)&.present? "settings--account--email-validation-failed-value": @validation_errors.present?
} do %> } do %>
<h3>E-Mail</h3> <h3>E-Mail</h3>
<%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %> <%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %>
@@ -23,7 +23,7 @@
</span> </span>
</button> </button>
</p> </p>
<% if @validation_errors&.[](:email)&.present? %> <% if @validation_errors.present? && @validation_errors[:email].present? %>
<p class="error-msg"><%= @validation_errors[:email].first %></p> <p class="error-msg"><%= @validation_errors[:email].first %></p>
<% end %> <% end %>
<div class="initial-hidden"> <div class="initial-hidden">
@@ -41,33 +41,10 @@
<% end %> <% end %>
<section> <section>
<h3>Password</h3> <h3>Password</h3>
<p class="mb-6">Use the following button to request an email with a password reset link:</p> <p class="mb-8">Use the following button to request an email with a password reset link:</p>
<%= form_with(url: reset_password_settings_path, method: :post) do %> <%= form_with(url: reset_password_settings_path, method: :post) do %>
<p> <p>
<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %> <%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
</p> </p>
<% end %> <% end %>
</section> </section>
<%= form_for(@user, url: setting_path(:account), html: { :method => :put }) do |f| %>
<section class="!pt-8 sm:!pt-12">
<h3>OpenPGP</h3>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
title: "Public key",
description: "Your OpenPGP public key in ASCII Armor format"
) do %>
<%= f.text_area :pgp_pubkey,
value: @user.pgp_pubkey,
class: "h-24 w-full" %>
<% if @validation_errors&.[](:pgp_pubkey)&.present? %>
<p class="error-msg">This <%= @validation_errors[:pgp_pubkey].first %></p>
<% end %>
<% end %>
</ul>
</section>
<section>
<p class="pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
</section>
<% end %>

View File

@@ -5,7 +5,7 @@
<h3>E-Mail Password</h3> <h3>E-Mail Password</h3>
<%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %> <%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %>
<%= hidden_field_tag :section, "email" %> <%= hidden_field_tag :section, "email" %>
<p class="mb-6"> <p class="mb-8">
Use the following button to generate a new email password: Use the following button to generate a new email password:
</p> </p>
<p class="hidden initial-visible"> <p class="hidden initial-visible">

View File

@@ -1,47 +1,43 @@
<section>
<h3>Nostr</h3>
<h4 class="mb-0">Public Key</h4>
<div data-controller="settings--nostr-pubkey" <div data-controller="settings--nostr-pubkey"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>" data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>" data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>" data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>"> data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">
<section class="mb-8 sm:mb-12">
<p class="<%= current_user.nostr_pubkey.present? ? '' : 'hidden' %> mt-2 flex gap-1"> <h3>Nostr</h3>
<h4 class="mb-0">
Public Key
</h4>
<p class="<%= current_user.nostr_pubkey.present? ? '' : 'hidden' %> mt-2 flex gap-x-1">
<input type="text" value="<%= current_user.nostr_pubkey_bech32 %>" disabled <input type="text" value="<%= current_user.nostr_pubkey_bech32 %>" disabled
data-settings--nostr-pubkey-target="pubkeyBech32Input" data-settings--nostr-pubkey-target="pubkeyBech32Input"
name="nostr_public_key" class="relative grow" /> name="nostr_public_key" class="w-full" />
<%= link_to nostr_pubkey_settings_path, <%= link_to nostr_pubkey_settings_path,
class: 'btn-md btn-outline text-red-700 relative shrink-0', class: 'btn-md btn-outline relative grow-0 shrink-0 text-red-700',
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } do %> data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } do %>
Remove Remove
<% end %> <% end %>
</p> </p>
<% if current_user.nostr_pubkey.present? %> <% if current_user.nostr_pubkey.present? %>
<div class="rounded-md bg-blue-50 p-4"> <!-- <div> -->
<div class="flex"> <!-- Pubkey present -->
<div class="flex-shrink-0"> <!-- </div> -->
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-blue-800">
Your user address <strong><%= current_user.address %></strong> is
also a Nostr address now. Use your favorite Nostr app, or for
example <a href="http://metadata.nostr.com" target="_blank"
class="underline">metadata.nostr.com</a>, to add this
<strong>NIP-05</strong> address to your public profile.
</p>
</div>
</div>
</div>
<% else %> <% else %>
<p class="my-4"> <p class="my-4">
If you use any apps on the Nostr network, you can verify your public key Verify your Nostr public key with us in order to enable Nostr-specific
with us in order to enable Nostr-specific features for your account. features for your account:
</p> </p>
<ul class="list-disc list-inside">
<li>Log in with Nostr (no password needed)</li>
<li>Verified Nostr address</li>
<% if Setting.lndhub_enabled? %>
<li>Receive zaps in your Lightning account</li>
<% end %>
<% if Setting.nostr_relay_url.present? %>
<li>Publish notes on <%= link_to "our relay", Setting.nostr_relay_url_http, class: "ks-text-link", target: "_blank" %></li>
<% end %>
</ul>
<% end %> <% end %>
<div data-settings--nostr-pubkey-target="noExtension" <div data-settings--nostr-pubkey-target="noExtension"
@@ -58,8 +54,8 @@
</h3> </h3>
<div class="mt-2 mb-0 text-sm text-blue-800"> <div class="mt-2 mb-0 text-sm text-blue-800">
<p> <p>
We recommend Alby, which you can also use for your Lightning We recommend Alby, which you can also use a wallet for your
Wallet. Lightning account.
</p> </p>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@@ -86,5 +82,11 @@
</button> </button>
</p> </p>
<% end %> <% end %>
</div>
</section> </section>
<% if current_user.nostr_pubkey.present? %>
<%= turbo_frame_tag "nostr_user_metadata", src: nostr_user_metadata_settings_path do %>
<p>Loading...</p>
<% end %>
<% end %>
</div>

View File

@@ -0,0 +1,27 @@
<%= turbo_frame_tag "nostr_user_metadata" do %>
<section>
<h3>Relays</h3>
<%= render Settings::NostrRelayStatusComponent.new(
nip65_event: @nip65_event
) %>
</section>
<section>
<h3>Profile</h3>
<%= render Settings::NostrProfileStatusComponent.new(
profile_event: @profile,
user_address: current_user.address
) %>
<div class="mt-8" data-controller="modal" data-action="keydown.esc->modal#close">
<button data-action="click->modal#open" class="btn-md btn-blue w-full sm:w-auto">
Edit profile
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= render Settings::NostrEditProfileComponent.new(
user: current_user,
profile_event: @profile
) %>
<% end %>
</div>
</section>
<% end %>

View File

@@ -19,12 +19,6 @@
active: @settings_section.to_s == "email" active: @settings_section.to_s == "email"
) %> ) %>
<% end %> <% end %>
<% if Setting.lndhub_enabled %>
<%= render SidenavLinkComponent.new(
name: "Lightning", path: setting_path(:lightning), icon: "zap",
active: @settings_section.to_s == "lightning"
) %>
<% end %>
<% if Setting.remotestorage_enabled? && <% if Setting.remotestorage_enabled? &&
Flipper.enabled?(:remotestorage, current_user) %> Flipper.enabled?(:remotestorage, current_user) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
@@ -32,6 +26,12 @@
active: @settings_section.to_s == "remotestorage" active: @settings_section.to_s == "remotestorage"
) %> ) %>
<% end %> <% end %>
<% if Setting.lndhub_enabled %>
<%= render SidenavLinkComponent.new(
name: "Lightning", path: setting_path(:lightning), icon: "zap",
active: @settings_section.to_s == "lightning"
) %>
<% end %>
<% if Setting.nostr_enabled %> <% if Setting.nostr_enabled %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
name: "Nostr", path: setting_path(:nostr), icon: "nostrich-head", name: "Nostr", path: setting_path(:nostr), icon: "nostrich-head",

View File

@@ -0,0 +1,3 @@
<div>
<%= profile.inspect %>
</div>

View File

@@ -57,22 +57,16 @@ Rails.application.configure do
# routes, locales, etc. This feature depends on the listen gem. # routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker config.file_watcher = ActiveSupport::EventedFileUpdateChecker
config.action_mailer.default_options = {
from: "accounts@localhost"
}
# Don't actually send emails, cache them for viewing via letter opener # Don't actually send emails, cache them for viewing via letter opener
config.action_mailer.delivery_method = :letter_opener config.action_mailer.delivery_method = :letter_opener
# Don't care if the mailer can't send # Don't care if the mailer can't send
config.action_mailer.raise_delivery_errors = true config.action_mailer.raise_delivery_errors = true
# Base URL to be used by email template link helpers # Base URL to be used by email template link helpers
config.action_mailer.default_url_options = { config.action_mailer.default_url_options = { host: "localhost:3000", protocol: "http" }
host: "localhost:3000",
protocol: "http"
}
config.action_mailer.default_options = {
from: "accounts@localhost",
message_id: -> { "<#{Mail.random_tag}@localhost>" },
}
# Allow requests from any IP # Allow requests from any IP
config.web_console.permissions = '0.0.0.0/0' config.web_console.permissions = '0.0.0.0/0'

View File

@@ -63,7 +63,7 @@ Rails.application.configure do
outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain
config.action_mailer.default_url_options = { config.action_mailer.default_url_options = {
host: ENV.fetch('AKKOUNTS_DOMAIN'), host: ENV['AKKOUNTS_DOMAIN'],
protocol: "https", protocol: "https",
} }

View File

@@ -46,12 +46,8 @@ Rails.application.configure do
config.action_mailer.default_url_options = { config.action_mailer.default_url_options = {
host: "accounts.kosmos.org", host: "accounts.kosmos.org",
protocol: "https" protocol: "https",
} from: "accounts@kosmos.org"
config.action_mailer.default_options = {
from: "accounts@kosmos.org",
message_id: -> { "<#{Mail.random_tag}@kosmos.org>" },
} }
config.active_job.queue_adapter = :test config.active_job.queue_adapter = :test

View File

@@ -65,17 +65,16 @@ Rails.application.routes.draw do
post 'reset_email_password' post 'reset_email_password'
post 'set_nostr_pubkey' post 'set_nostr_pubkey'
delete 'nostr_pubkey', to: 'settings#remove_nostr_pubkey' delete 'nostr_pubkey', to: 'settings#remove_nostr_pubkey'
get 'fetch_nostr_user_metadata', as: 'nostr_user_metadata'
end end
end end
get '.well-known/webfinger', to: 'webfinger#show' get '.well-known/webfinger', to: 'webfinger#show'
get '.well-known/nostr', to: 'well_known#nostr' get '.well-known/nostr', to: 'well_known#nostr'
get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: 'lightning_address'
get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: 'lightning_address_keysend'
get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key
get '.well-known/openpgpkey/policy', to: 'web_key_directory#policy'
get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: 'lnurlpay_invoice'
post 'webhooks/lndhub', to: 'webhooks#lndhub' post 'webhooks/lndhub', to: 'webhooks#lndhub'

View File

@@ -1,6 +1,4 @@
:concurrency: 2 :concurrency: 2
production:
:concurrency: 10
:queues: :queues:
- default - default
- mailers - mailers

View File

@@ -1,5 +0,0 @@
class AddPgpFprToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :pgp_fpr, :string
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_09_22_205634) do ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -132,7 +132,6 @@ ActiveRecord::Schema[7.1].define(version: 2024_09_22_205634) do
t.datetime "remember_created_at" t.datetime "remember_created_at"
t.string "remember_token" t.string "remember_token"
t.text "preferences" t.text "preferences"
t.string "pgp_fpr"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end

View File

@@ -7,7 +7,7 @@ Sidekiq::Testing.inline! do
puts "Create user: admin" puts "Create user: admin"
UserManager::CreateAccount.call(account: { CreateAccount.call(account: {
username: "admin", domain: "kosmos.org", email: "admin@example.com", username: "admin", domain: "kosmos.org", email: "admin@example.com",
password: "admin is admin", confirmed: true password: "admin is admin", confirmed: true
}) })
@@ -20,7 +20,7 @@ Sidekiq::Testing.inline! do
email = Faker::Internet.unique.email email = Faker::Internet.unique.email
next if username.length < 3 next if username.length < 3
UserManager::CreateAccount.call(account: { CreateAccount.call(account: {
username: username, domain: "kosmos.org", email: email, username: username, domain: "kosmos.org", email: email,
password: "user is user", confirmed: true password: "user is user", confirmed: true
}) })

View File

@@ -1,13 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZvGiUxYJKwYBBAHaRw8BAQdARPZXLqyB3nylJuzuARlOJxqc9mchMKHI4Cy+
hPWlzja0GEFkbWluIDxhZG1pbkBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEE0pie1+fG
ImdZwzGnwgEYSg8AulYFAmbxolMCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC
AwECHgcCF4AACgkQwgEYSg8AulaldAEA7yzh7XRCdIJDHgLUvKHsy2NnyLaDD1Tl
hyZWbl5og0IBAJAQ2Dm82YXMdUK3X1OGlK8KH5O4E5lSFY4+8/xx0UEJuDgEZvGi
UxIKKwYBBAGXVQEFAQEHQJc8pzzeIF7Hm5z1eseRAqGvFa+V1BIDf+1XQzuJhhxi
AwEIB4h+BBgWCgAmFiEE0pie1+fGImdZwzGnwgEYSg8AulYFAmbxolMCGwwFCQWj
moAACgkQwgEYSg8AulbLtgEApZvuDqSP77lrl1jmtCAJEEZk/ofsRFkf1g3U3Zhm
9PcA/1+AbcyqjLTcqIPjHmZyGEPiaAvEsBzbPKEPiL3JYhkG
=45sx
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -111,7 +111,7 @@ services:
- redis - redis
strfry: strfry:
image: gitea.kosmos.org/kosmos/strfry-deno:2.0.0 image: gitea.kosmos.org/kosmos/strfry-deno:1.1.1
volumes: volumes:
- ./docker/strfry/strfry.conf:/etc/strfry.conf - ./docker/strfry/strfry.conf:/etc/strfry.conf
- ./extras/strfry:/opt/strfry - ./extras/strfry:/opt/strfry

View File

@@ -1,57 +0,0 @@
# Nostr
## strfry
The `extras/strfry` directory contains code to integrate [strfry][1] with
akkounts, so that notes published to the relay have to be authored by (or in
some cases just related to) local users who have verified their Nostr public
key.
### Requirements
[Deno](https://deno.com/) needs to be installed on the machine that you run
strfry on.
We provide a Docker image with recent strfry and Deno builds:
https://gitea.kosmos.org/kosmos/-/packages/container/strfry-deno/
### Configuration
You can use either environment variables (see e.g. the `strfry` service in
`docker-compose-yml`) or a local `.env` file in the same working directory
that you place the extra files in (e.g. `/opt/strfry`).
In your `strfry.conf`, configure `strfry-policy.ts` as the write policy, like so:
```
writePolicy {
plugin = "/opt/strfry/strfry-policy.ts"
}
```
All dependencies will be downloaded and cached automatically when the plugin is
called for the first time.
### Manual tasks
You can sync all notes authored by local users (any account that has verified
their Nostr pubkey with akkounts) from a remote [strfry][1] relay via negentropy
sync:
deno run -A /opt/strfry/strfry-sync.ts wss://nostr.kosmos.org
Or, in the running container when using Docker Compose:
docker compose exec strfry deno run -A /opt/strfry/strfry-sync.ts wss://nostr.kosmos.org
The `strfry` service container also exposes the local relay on your local host
on port 4777.
[nak](https://github.com/fiatjaf/nak) is a helpful tool for manual Nostr tasks.
Here's how you can grab a note by its event ID from a remote relay and publish
it to your local strfry for example:
nak req -i 0fb010192685b86b0810b3de3706fbbf3b8c1db30b14533094a2b9700c820cdc nostr.kosmos.org | nak event ws://localhost:4777
[1]: https://github.com/hoytech/strfry

236
extras/strfry/deno.lock generated
View File

@@ -1,231 +1,101 @@
{ {
"version": "4", "version": "3",
"packages": {
"specifiers": { "specifiers": {
"jsr:@nostr/tools@*": "2.3.1", "jsr:@nostr/tools@^2.3.1": "jsr:@nostr/tools@2.3.1",
"jsr:@nostr/tools@^2.3.1": "2.3.1", "npm:@noble/ciphers@^0.5.1": "npm:@noble/ciphers@0.5.3",
"jsr:@nostrify/nostrify@0.36": "0.36.2", "npm:@noble/curves@1.2.0": "npm:@noble/curves@1.2.0",
"jsr:@nostrify/policies@*": "0.36.1", "npm:@noble/hashes@1.3.1": "npm:@noble/hashes@1.3.1",
"jsr:@nostrify/strfry@*": "0.2.1", "npm:@scure/base@1.1.1": "npm:@scure/base@1.1.1",
"jsr:@nostrify/types@0.35": "0.35.0", "npm:ldapts": "npm:ldapts@7.0.12"
"jsr:@nostrify/types@0.36": "0.36.0",
"jsr:@std/bytes@^1.0.5": "1.0.5",
"jsr:@std/encoding@~0.224.1": "0.224.3",
"jsr:@std/json@^1.0.1": "1.0.1",
"jsr:@std/streams@^1.0.7": "1.0.9",
"jsr:@std/streams@^1.0.8": "1.0.9",
"npm:@noble/ciphers@~0.5.1": "0.5.3",
"npm:@noble/curves@1.2.0": "1.2.0",
"npm:@noble/hashes@1.3.1": "1.3.1",
"npm:@scure/base@1.1.1": "1.1.1",
"npm:@scure/bip32@^1.4.0": "1.6.2",
"npm:@scure/bip39@^1.3.0": "1.5.4",
"npm:ldapts@*": "7.0.12",
"npm:lru-cache@^10.2.0": "10.4.3",
"npm:nostr-tools@^2.7.0": "2.12.0",
"npm:websocket-ts@^2.1.5": "2.2.1",
"npm:zod@^3.23.8": "3.24.2"
}, },
"jsr": { "jsr": {
"@nostr/tools@2.3.1": { "@nostr/tools@2.3.1": {
"integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61", "integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61",
"dependencies": [ "dependencies": [
"npm:@noble/ciphers", "npm:@noble/ciphers@^0.5.1",
"npm:@noble/curves", "npm:@noble/curves@1.2.0",
"npm:@noble/hashes", "npm:@noble/hashes@1.3.1",
"npm:@scure/base" "npm:@scure/base@1.1.1"
]
},
"@nostrify/nostrify@0.36.2": {
"integrity": "cc4787ca170b623a2e5dfed1baa4426077daa6143af728ea7dd325d58f4d04d6",
"dependencies": [
"jsr:@nostrify/types@0.35",
"jsr:@std/encoding",
"npm:@scure/bip32",
"npm:@scure/bip39",
"npm:lru-cache",
"npm:nostr-tools",
"npm:websocket-ts",
"npm:zod"
]
},
"@nostrify/policies@0.36.1": {
"integrity": "6d59af115a687fcd18b6caebab0e4f50ee6cdb0aafa2aacd0aec2065021275b4",
"dependencies": [
"jsr:@nostrify/nostrify",
"jsr:@nostrify/types@0.35",
"npm:nostr-tools"
]
},
"@nostrify/strfry@0.2.1": {
"integrity": "be437b13f49e6564e557da23072bf642723a603568f672543a64d9fda6663432",
"dependencies": [
"jsr:@nostrify/types@0.36",
"jsr:@std/json",
"jsr:@std/streams@^1.0.8"
]
},
"@nostrify/types@0.35.0": {
"integrity": "b8d515563d467072694557d5626fa1600f74e83197eef45dd86a9a99c64f7fe6"
},
"@nostrify/types@0.36.0": {
"integrity": "b3413467debcbd298d217483df4e2aae6c335a34765c90ac7811cf7c637600e7"
},
"@std/bytes@1.0.5": {
"integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e"
},
"@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
},
"@std/json@1.0.1": {
"integrity": "1f0f70737e8827f9acca086282e903677bc1bb0c8ffcd1f21bca60039563049f",
"dependencies": [
"jsr:@std/streams@^1.0.7"
]
},
"@std/streams@1.0.9": {
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035",
"dependencies": [
"jsr:@std/bytes"
] ]
} }
}, },
"npm": { "npm": {
"@noble/ciphers@0.5.3": { "@noble/ciphers@0.5.3": {
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==" "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
}, "dependencies": {}
"@noble/curves@1.1.0": {
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"dependencies": [
"@noble/hashes@1.3.1"
]
}, },
"@noble/curves@1.2.0": { "@noble/curves@1.2.0": {
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": [ "dependencies": {
"@noble/hashes@1.3.2" "@noble/hashes": "@noble/hashes@1.3.2"
] }
},
"@noble/curves@1.8.2": {
"integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==",
"dependencies": [
"@noble/hashes@1.7.2"
]
}, },
"@noble/hashes@1.3.1": { "@noble/hashes@1.3.1": {
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"dependencies": {}
}, },
"@noble/hashes@1.3.2": { "@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
}, "dependencies": {}
"@noble/hashes@1.7.2": {
"integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ=="
}, },
"@scure/base@1.1.1": { "@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
}, "dependencies": {}
"@scure/base@1.2.4": {
"integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@scure/bip32@1.6.2": {
"integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==",
"dependencies": [
"@noble/curves@1.8.2",
"@noble/hashes@1.7.2",
"@scure/base@1.2.4"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@scure/bip39@1.5.4": {
"integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==",
"dependencies": [
"@noble/hashes@1.7.2",
"@scure/base@1.2.4"
]
}, },
"@types/asn1@0.2.4": { "@types/asn1@0.2.4": {
"integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==",
"dependencies": [ "dependencies": {
"@types/node" "@types/node": "@types/node@18.16.19"
] }
}, },
"@types/node@18.16.19": { "@types/node@18.16.19": {
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==" "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dependencies": {}
}, },
"@types/uuid@9.0.8": { "@types/uuid@9.0.8": {
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"dependencies": {}
}, },
"asn1@0.2.6": { "asn1@0.2.6": {
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": [ "dependencies": {
"safer-buffer" "safer-buffer": "safer-buffer@2.1.2"
] }
}, },
"debug@4.3.5": { "debug@4.3.5": {
"integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
"dependencies": [ "dependencies": {
"ms" "ms": "ms@2.1.2"
] }
}, },
"ldapts@7.0.12": { "ldapts@7.0.12": {
"integrity": "sha512-orwgIejUi/ZyGah9y8jWZmFUg8Ci5M8WAv0oZjSf3MVuk1sRBdor9Qy1ttGHbYpWj96HXKFunQ8AYZ8WWGp17g==", "integrity": "sha512-orwgIejUi/ZyGah9y8jWZmFUg8Ci5M8WAv0oZjSf3MVuk1sRBdor9Qy1ttGHbYpWj96HXKFunQ8AYZ8WWGp17g==",
"dependencies": [ "dependencies": {
"@types/asn1", "@types/asn1": "@types/asn1@0.2.4",
"@types/uuid", "@types/uuid": "@types/uuid@9.0.8",
"asn1", "asn1": "asn1@0.2.6",
"debug", "debug": "debug@4.3.5",
"strict-event-emitter-types", "strict-event-emitter-types": "strict-event-emitter-types@2.0.0",
"uuid" "uuid": "uuid@9.0.1"
] }
},
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
}, },
"ms@2.1.2": { "ms@2.1.2": {
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
}, "dependencies": {}
"nostr-tools@2.12.0": {
"integrity": "sha512-pUWEb020gTvt1XZvTa8AKNIHWFapjsv2NKyk43Ez2nnvz6WSXsrTFE0XtkNLSRBjPn6EpxumKeNiVzLz74jNSA==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
"@noble/hashes@1.3.1",
"@scure/base@1.1.1",
"@scure/bip32@1.3.1",
"@scure/bip39@1.2.1",
"nostr-wasm"
]
},
"nostr-wasm@0.1.0": {
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
}, },
"safer-buffer@2.1.2": { "safer-buffer@2.1.2": {
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dependencies": {}
}, },
"strict-event-emitter-types@2.0.0": { "strict-event-emitter-types@2.0.0": {
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"dependencies": {}
}, },
"uuid@9.0.1": { "uuid@9.0.1": {
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
}, "dependencies": {}
"websocket-ts@2.2.1": { }
"integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q=="
},
"zod@3.24.2": {
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="
} }
}, },
"remote": { "remote": {

View File

@@ -1,8 +1,8 @@
import { NostrEvent, NostrRelayInfo, NostrRelayOK, NPolicy } from 'jsr:@nostrify/types@^0.35.0'; import type { IterablePubkeys, Policy } from 'https://gitlab.com/soapbox-pub/strfry-policies/-/raw/develop/mod.ts';
import { nip57 } from 'jsr:@nostr/tools';
import { Client } from 'npm:ldapts'; import { Client } from 'npm:ldapts';
import { nip57 } from '@nostr/tools';
export interface LdapConfig { interface LdapConfig {
url: string; url: string;
bindDN: string; bindDN: string;
password: string; password: string;
@@ -10,17 +10,16 @@ export interface LdapConfig {
whitelistPubkeys?: IterablePubkeys; whitelistPubkeys?: IterablePubkeys;
} }
export class LdapPolicy implements NPolicy { const ldapPolicy: Policy<LdapConfig> = async (msg, opts) => {
constructor(private opts: LdapConfig) {} const client = new Client({ url: opts.url });
const { kind, tags } = msg.event;
let { pubkey } = msg.event;
let out = { id: msg.event.id }
// deno-lint-ignore require-await if (opts.whitelistPubkeys.includes(pubkey)) {
async call(event: NostrEvent): Promise<NostrRelayOK> { out['action'] = 'accept';
const client = new Client({ url: this.opts.url }); out['msg'] = '';
const { id, kind, tags } = event; return out;
let { pubkey } = event;
if (this.opts.whitelistPubkeys.includes(pubkey)) {
return ['OK', id, true, ''];
} }
// Zap receipt // Zap receipt
@@ -29,7 +28,9 @@ export class LdapPolicy implements NPolicy {
const invalidZapRequestMsg = 'Zap receipts must contain a valid zap request from a relay member'; const invalidZapRequestMsg = 'Zap receipts must contain a valid zap request from a relay member';
if (typeof descriptionTag === 'undefined') { if (typeof descriptionTag === 'undefined') {
return ['OK', id, false, invalidZapRequestMsg]; out['action'] = 'reject';
out['msg'] = invalidZapRequestMsg;
return out;
} }
const zapRequestJSON = descriptionTag[1]; const zapRequestJSON = descriptionTag[1];
@@ -42,41 +43,35 @@ export class LdapPolicy implements NPolicy {
if (validationResult === null) { if (validationResult === null) {
pubkey = JSON.parse(zapRequestJSON).pubkey; pubkey = JSON.parse(zapRequestJSON).pubkey;
} else { } else {
return ['OK', id, false, invalidZapRequestMsg]; out['action'] = 'reject';
out['msg'] = invalidZapRequestMsg;
return out;
} }
} }
const out = { accept: true, msg: ''};
try { try {
await client.bind(this.opts.bindDN, this.opts.password); await client.bind(opts.bindDN, opts.password);
const { searchEntries } = await client.search(this.opts.searchDN, { const { searchEntries } = await client.search(opts.searchDN, {
filter: `(nostrKey=${pubkey})`, filter: `(nostrKey=${pubkey})`,
attributes: ['nostrKey'] attributes: ['nostrKey']
}); });
const memberKey = searchEntries[0]?.nostrKey; const memberKey = searchEntries[0]?.nostrKey;
if (memberKey === pubkey) { if (memberKey === pubkey) {
out['accept'] = true; out['action'] = 'accept';
out['msg'] = '';
} else { } else {
out['accept'] = false; out['action'] = 'reject';
out['msg'] = 'Only members can publish notes on this relay'; out['msg'] = 'Only members can publish notes on this relay';
} }
} catch (ex) { } catch (ex) {
out['accept'] = false; out['action'] = 'reject';
out['msg'] = 'Auth service temporarily unavailable'; out['msg'] = 'Auth service temporarily unavailable';
} finally { } finally {
await client.unbind(); await client.unbind();
return ['OK', id, out['accept'], out['msg']]; return out;
} }
}
get info(): NostrRelayInfo {
return {
limitation: {
restricted_writes: true,
},
}; };
}
} export default ldapPolicy;

View File

@@ -1,20 +1,20 @@
#!/bin/sh #!/bin/sh
//bin/true; exec deno run --unstable-kv -A "$0" "$@" //bin/true; exec deno run -A "$0" "$@"
import { import {
AntiDuplicationPolicy, antiDuplicationPolicy,
HellthreadPolicy, hellthreadPolicy,
PipePolicy, pipeline,
rateLimitPolicy,
readStdin, readStdin,
writeStdout, writeStdout,
} from 'jsr:@nostrify/policies'; } from 'https://gitlab.com/soapbox-pub/strfry-policies/-/raw/develop/mod.ts';
import { strfry } from 'jsr:@nostrify/strfry'; import ldapPolicy from './ldap-policy.ts';
import { LdapConfig, LdapPolicy } from './ldap-policy.ts';
import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts"; import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts";
const dirname = new URL('.', import.meta.url).pathname; const dirname = new URL('.', import.meta.url).pathname;
await load({ envPath: `${dirname}/.env`, export: true }); await load({ envPath: `${dirname}/.env`, export: true });
const ldapConfig: LdapConfig = { const ldapConfig = {
url: Deno.env.get("LDAP_URL"), url: Deno.env.get("LDAP_URL"),
bindDN: Deno.env.get("LDAP_BIND_DN"), bindDN: Deno.env.get("LDAP_BIND_DN"),
password: Deno.env.get("LDAP_PASSWORD"), password: Deno.env.get("LDAP_PASSWORD"),
@@ -22,10 +22,13 @@ const ldapConfig: LdapConfig = {
whitelistPubkeys: Deno.env.get("WHITELIST_PUBKEYS")?.split(',') whitelistPubkeys: Deno.env.get("WHITELIST_PUBKEYS")?.split(',')
} }
const policy = new PipePolicy([ for await (const msg of readStdin()) {
new HellthreadPolicy({ limit: 10 }), const result = await pipeline(msg, [
new AntiDuplicationPolicy({ kv: await Deno.openKv(), expireIn: 60000, minLength: 50 }), [hellthreadPolicy, { limit: 10 }],
new LdapPolicy(ldapConfig) [antiDuplicationPolicy, { ttl: 60000, minLength: 50 }],
[rateLimitPolicy, { whitelist: ['127.0.0.1'] }],
[ldapPolicy, ldapConfig],
]); ]);
await strfry(policy); writeStdout(result);
}

View File

@@ -21,7 +21,7 @@ namespace :ldap do
desc "Add custom attributes to schema" desc "Add custom attributes to schema"
task add_custom_attributes: :environment do |t, args| task add_custom_attributes: :environment do |t, args|
%w[ admin service_enabled nostr_key pgp_key ].each do |name| %w[ admin service_enabled nostr_key ].each do |name|
Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add") Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add")
Rake::Task['ldap:modify_ldap_schema'].reenable Rake::Task['ldap:modify_ldap_schema'].reenable
end end
@@ -29,7 +29,7 @@ namespace :ldap do
desc "Delete custom attributes from schema" desc "Delete custom attributes from schema"
task delete_custom_attributes: :environment do |t, args| task delete_custom_attributes: :environment do |t, args|
%w[ admin service_enabled nostr_key pgp_key ].each do |name| %w[ admin service_enabled nostr_key ].each do |name|
Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete") Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete")
Rake::Task['ldap:modify_ldap_schema'].reenable Rake::Task['ldap:modify_ldap_schema'].reenable
end end

View File

@@ -5,5 +5,5 @@ attributeTypes: ( 1.3.6.1.4.1.61554.1.1.2.1.21
NAME 'nostrKey' NAME 'nostrKey'
DESC 'Nostr public key' DESC 'Nostr public key'
EQUALITY caseIgnoreMatch EQUALITY caseIgnoreMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE ) SINGLE-VALUE )

View File

@@ -1,8 +0,0 @@
dn: cn=schema
changetype: modify
add: attributeTypes
attributeTypes: ( 1.3.6.1.4.1.3401.8.2.11
NAME 'pgpKey'
DESC 'OpenPGP public key block'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )

View File

@@ -14,7 +14,6 @@ RSpec.describe 'Account settings', type: :feature do
.with("invalid password").and_return(false) .with("invalid password").and_return(false)
allow_any_instance_of(User).to receive(:valid_ldap_authentication?) allow_any_instance_of(User).to receive(:valid_ldap_authentication?)
.with("valid password").and_return(true) .with("valid password").and_return(true)
allow_any_instance_of(User).to receive(:pgp_pubkey).and_return(nil)
end end
scenario 'fails with invalid password' do scenario 'fails with invalid password' do
@@ -56,44 +55,4 @@ RSpec.describe 'Account settings', type: :feature do
end end
end end
end end
feature "Update OpenPGP key" do
let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") }
let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
before do
login_as user, :scope => :user
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil
})
end
scenario 'rejects an invalid key' do
expect(UserManager::UpdatePgpKey).not_to receive(:call)
visit setting_path(:account)
fill_in 'Public key', with: invalid_key
click_button "Save"
expect(current_url).to eq(setting_url(:account))
within ".error-msg" do
expect(page).to have_content("This is not a valid armored PGP public key block")
end
end
scenario 'stores a valid key' do
expect(UserManager::UpdatePgpKey).to receive(:call)
.with(user: user).and_return(true)
visit setting_path(:account)
fill_in 'Public key', with: valid_key_alice
click_button "Save"
expect(current_url).to eq(setting_url(:account))
within ".flash-msg" do
expect(page).to have_content("Settings saved")
end
end
end
end end

View File

@@ -9,7 +9,7 @@ RSpec.describe 'Profile settings', type: :feature do
allow(user).to receive(:display_name).and_return("Mark") allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org") allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({ allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil uid: user.cn, ou: user.ou, display_name: "Mark"
}) })
allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64) allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64)

View File

@@ -52,7 +52,7 @@ RSpec.describe "Signup", type: :feature do
click_button "Continue" click_button "Continue"
expect(page).to have_content("Choose a password") expect(page).to have_content("Choose a password")
expect(UserManager::CreateAccount).to receive(:call) expect(CreateAccount).to receive(:call)
.with(account: { .with(account: {
username: "tony", domain: "kosmos.org", username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password", email: "tony@example.com", password: "a-valid-password",
@@ -96,7 +96,7 @@ RSpec.describe "Signup", type: :feature do
click_button "Create account" click_button "Create account"
expect(page).to have_content("Password is too short") expect(page).to have_content("Password is too short")
expect(UserManager::CreateAccount).to receive(:call) expect(CreateAccount).to receive(:call)
.with(account: { .with(account: {
username: "tony", domain: "kosmos.org", username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password", email: "tony@example.com", password: "a-valid-password",

View File

@@ -1,11 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE
ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy
MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO
dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4
OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s
E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb
DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn
0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
=iIGO
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -1,16 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Alice's OpenPGP certificate
Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U
b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE
ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy
MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO
dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4
OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s
E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb
DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn
0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
=iIGO
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -1,13 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZvFjRhYJKwYBBAHaRw8BAQdACUxVX9bGlbuNR0MNYUyHHxTcOgm4qjwq8Bjg
7P41OFK0GEppbW15IDxqaW1teUBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEEMWv1FiNt
r3cjaxX2BX2Tly+4YsMFAmbxY0YCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC
AwECHgcCF4AACgkQBX2Tly+4YsMjHgEAoOOLrv9pWbi8hhrSMkqJ7FJvsBTQF//U
aJUQRa8CTgoBAI3kyGKZ8gOC8UOOKsUC0LiNCVXPyX45h8T4QFRdEVYKuDgEZvFj
RhIKKwYBBAGXVQEFAQEHQIomqcQ59UjtQex54pz8qGqyxCj2DPJYUat9pXinDgN8
AwEIB4h+BBgWCgAmFiEEMWv1FiNtr3cjaxX2BX2Tly+4YsMFAmbxY0YCGwwFCQWj
moAACgkQBX2Tly+4YsPoVgEA/9Q5Gs1klP4u/nw343V57e9s4RKmEiRSkErnC9wW
Iu0A/jp6Elz2pDQPB2XLwcb+n7JlgA05HI0zWj1+EoM7TC4J
=KQbn
-----END PGP PUBLIC KEY BLOCK-----

Binary file not shown.

View File

@@ -1,87 +0,0 @@
# spec/mailers/welcome_mailer_spec.rb
require 'rails_helper'
RSpec.describe NotificationMailer, type: :mailer do
describe '#lightning_sats_received' do
context "without PGP key" do
let(:user) { create(:user, cn: "phil", email: 'phil@example.com') }
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil
})
end
describe "unencrypted email" do
let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received }
it 'renders the correct to/from headers' do
expect(mail.to).to eq([user.email])
expect(mail.from).to eq(['accounts@kosmos.org'])
end
it 'renders the correct subject' do
expect(mail.subject).to eq('Sats received')
end
it 'uses the correct content type' do
expect(mail.header['content-type'].to_s).to include('text/plain')
end
it 'renders the body with correct content' do
expect(mail.body.encoded).to match(/You just received 21,000 sats/)
expect(mail.body.encoded).to include(user.address)
end
it 'includes a link to the lightning service page' do
expect(mail.body.encoded).to include("https://accounts.kosmos.org/services/lightning")
end
end
end
context "with PGP key" do
let(:pgp_pubkey) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
let(:pgp_fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
let(:user) { create(:user, id: 2, cn: "alice", email: 'alice@example.com', pgp_fpr: pgp_fingerprint) }
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: nil, pgp_key: pgp_pubkey
})
end
describe "encrypted email" do
let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received }
it 'renders the correct to/from headers' do
expect(mail.to).to eq([user.email])
expect(mail.from).to eq(['accounts@kosmos.org'])
end
it 'encrypts the subject line' do
expect(mail.subject).to eq('...')
end
it 'uses the correct content type' do
expect(mail.header['content-type'].to_s).to include('multipart/encrypted')
expect(mail.header['content-type'].to_s).to include('protocol="application/pgp-encrypted"')
end
it 'renders the PGP version part' do
expect(mail.body.encoded).to include("Content-Type: application/pgp-encrypted")
expect(mail.body.encoded).to include("Content-Description: PGP/MIME version identification")
expect(mail.body.encoded).to include("Version: 1")
end
it 'renders the encrypted PGP part' do
expect(mail.body.encoded).to include('Content-Type: application/octet-stream; name="encrypted.asc"')
expect(mail.body.encoded).to include('Content-Description: OpenPGP encrypted message')
expect(mail.body.encoded).to include('Content-Disposition: inline; filename="encrypted.asc"')
expect(mail.body.encoded).to include('-----BEGIN PGP MESSAGE-----')
expect(mail.body.encoded).to include('hF4DR')
end
end
end
end
end

View File

@@ -1,16 +1,20 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe User, type: :model do RSpec.describe User, type: :model do
let(:user) { create :user, cn: "philipp", ou: "kosmos.org", email: "philipp@example.com" } let(:user) { create :user, cn: "philipp" }
let(:dn) { "cn=philipp,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } let(:dn) { "cn=philipp,ou=kosmos.org,cn=users,dc=kosmos,dc=org" }
describe "#address" do describe "#address" do
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
it "returns the user address" do it "returns the user address" do
expect(user.address).to eq("philipp@kosmos.org") expect(user.address).to eq("jimmy@kosmos.org")
end end
end end
describe "#mastodon_address" do describe "#mastodon_address" do
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
context "Mastodon service not configured" do context "Mastodon service not configured" do
before do before do
Setting.mastodon_enabled = false Setting.mastodon_enabled = false
@@ -28,7 +32,7 @@ RSpec.describe User, type: :model do
describe "domain is the same as primary domain" do describe "domain is the same as primary domain" do
it "returns the user address" do it "returns the user address" do
expect(user.mastodon_address).to eq("philipp@kosmos.org") expect(user.mastodon_address).to eq("jimmy@kosmos.org")
end end
end end
@@ -38,7 +42,7 @@ RSpec.describe User, type: :model do
end end
it "returns the user address" do it "returns the user address" do
expect(user.mastodon_address).to eq("philipp@kosmos.social") expect(user.mastodon_address).to eq("jimmy@kosmos.social")
end end
end end
@@ -235,7 +239,7 @@ RSpec.describe User, type: :model do
describe "#nostr_pubkey" do describe "#nostr_pubkey" do
before do before do
allow(user).to receive(:ldap_entry) allow_any_instance_of(User).to receive(:ldap_entry)
.and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" })
end end
@@ -246,7 +250,7 @@ RSpec.describe User, type: :model do
describe "#nostr_pubkey_bech32" do describe "#nostr_pubkey_bech32" do
before do before do
allow(user).to receive(:ldap_entry) allow_any_instance_of(User).to receive(:ldap_entry)
.and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" })
end end
@@ -254,73 +258,4 @@ RSpec.describe User, type: :model do
expect(user.nostr_pubkey_bech32).to eq("npub1qlsc3g0lsl8pw8230w8d9wm6xxcax3f6pkemz5measrmwfxjxteslf2hac") expect(user.nostr_pubkey_bech32).to eq("npub1qlsc3g0lsl8pw8230w8d9wm6xxcax3f6pkemz5measrmwfxjxteslf2hac")
end end
end end
describe "OpenPGP key" do
let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" }
let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" }
let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") }
let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" }
let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") }
before do
GPGME::Key.import(valid_key_alice)
GPGME::Key.import(valid_key_jimmy)
alice.update pgp_fpr: fingerprint_alice
jimmy.update pgp_fpr: fingerprint_jimmy
allow(alice).to receive(:ldap_entry).and_return({ pgp_key: valid_key_alice })
allow(jimmy).to receive(:ldap_entry).and_return({ pgp_key: valid_key_jimmy })
end
after do
alice.gnupg_key.delete!
jimmy.gnupg_key.delete!
end
describe "#acceptable_pgp_key_format" do
it "validates the record when the key is valid" do
alice.pgp_pubkey = valid_key_alice
expect(alice).to be_valid
end
it "adds a validation error when the key is not valid" do
user.pgp_pubkey = invalid_key
expect(user).to_not be_valid
expect(user.errors[:pgp_pubkey]).to be_present
end
end
describe "#pgp_pubkey" do
it "returns the raw pubkey from LDAP" do
expect(alice.pgp_pubkey).to eq(valid_key_alice)
end
end
describe "#gnupg_key" do
subject { alice.gnupg_key }
it "returns a GPGME::Key object from the system's GPG keyring" do
expect(subject).to be_a(GPGME::Key)
expect(subject.fingerprint).to eq(fingerprint_alice)
expect(subject.email).to eq("alice@openpgp.example")
end
end
describe "#pgp_pubkey_contains_user_address?" do
it "returns false when the user address is one of the UIDs of the key" do
expect(alice.pgp_pubkey_contains_user_address?).to eq(false)
end
it "returns true when the user address is missing from the UIDs of the key" do
expect(jimmy.pgp_pubkey_contains_user_address?).to eq(true)
end
end
describe "wkd_hash" do
it "returns a z-base32 encoded SHA-1 digest of the username" do
expect(alice.wkd_hash).to eq("kei1q4tipxxu1yj79k9kfukdhfy631xe")
end
end
end
end end

View File

@@ -1,101 +0,0 @@
require 'rails_helper'
RSpec.describe "OpenPGP Web Key Directory", type: :request do
describe "policy" do
it "returns an empty 200 response" do
get "/.well-known/openpgpkey/policy"
expect(response).to have_http_status(:ok)
expect(response.body).to be_empty
end
end
describe "non-existent user" do
it "returns a 404 status" do
get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle"
expect(response).to have_http_status(:not_found)
end
end
describe "user without pubkey" do
let(:user) { create :user, cn: 'bernd', ou: 'kosmos.org' }
it "returns a 404 status" do
get "/.well-known/openpgpkey/hu/kp95h369c89sx8ia1hn447i868nqyz4t?l=bernd"
expect(response).to have_http_status(:not_found)
end
end
describe "user with pubkey" do
let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" }
let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" }
let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") }
let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" }
let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") }
before do
GPGME::Key.import(valid_key_alice)
GPGME::Key.import(valid_key_jimmy)
alice.update pgp_fpr: fingerprint_alice
jimmy.update pgp_fpr: fingerprint_jimmy
end
after do
alice.gnupg_key.delete!
jimmy.gnupg_key.delete!
end
describe "pubkey does not contain user address" do
before do
allow_any_instance_of(User).to receive(:ldap_entry)
.and_return({ pgp_key: valid_key_alice })
end
it "returns a 404 status" do
get "/.well-known/openpgpkey/hu/kei1q4tipxxu1yj79k9kfukdhfy631xe?l=alice"
expect(response).to have_http_status(:not_found)
end
end
describe "pubkey contains user address" do
before do
allow_any_instance_of(User).to receive(:ldap_entry)
.and_return({ pgp_key: valid_key_jimmy })
end
it "returns the pubkey in binary format" do
get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=jimmy"
expect(response).to have_http_status(:ok)
expect(response.headers['Content-Type']).to eq("application/octet-stream")
expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem")
expect(response.body).to eq(expected_binary_data)
end
context "with wrong capitalization of username" do
it "returns the pubkey as ASCII Armor plain text" do
get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=JimmY"
expect(response).to have_http_status(:ok)
expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem")
expect(response.body).to eq(expected_binary_data)
end
end
context "with .txt extension" do
it "returns the pubkey as ASCII Armor plain text" do
get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy"
expect(response).to have_http_status(:ok)
expect(response.body).to eq(valid_key_jimmy)
expect(response.headers['Content-Type']).to eq("text/plain")
end
end
context "invalid URL" do
it "returns a 422 status" do
get "/.well-known/openpgpkey/hu/123456abcdef?l=alice"
expect(response).to have_http_status(:not_found)
end
end
end
end
end

View File

@@ -1,8 +1,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe UserManager::CreateAccount, type: :model do RSpec.describe CreateAccount, type: :model do
describe "#create_user_in_database" do describe "#create_user_in_database" do
let(:service) { described_class.new(account: { let(:service) { CreateAccount.new(account: {
username: 'isaacnewton', username: 'isaacnewton',
email: 'isaacnewton@example.com', email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn' password: 'bright-ideas-in-autumn'
@@ -19,7 +19,7 @@ RSpec.describe UserManager::CreateAccount, type: :model do
describe "#update_invitation" do describe "#update_invitation" do
let(:invitation) { create :invitation } let(:invitation) { create :invitation }
let(:service) { described_class.new(account: { let(:service) { CreateAccount.new(account: {
username: 'isaacnewton', username: 'isaacnewton',
email: 'isaacnewton@example.com', email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn', password: 'bright-ideas-in-autumn',
@@ -42,7 +42,7 @@ RSpec.describe UserManager::CreateAccount, type: :model do
describe "#add_ldap_document" do describe "#add_ldap_document" do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let(:service) { described_class.new(account: { let(:service) { CreateAccount.new(account: {
username: 'halfinney', username: 'halfinney',
email: 'halfinney@example.com', email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november' password: 'remember-remember-the-5th-of-november'
@@ -68,7 +68,7 @@ RSpec.describe UserManager::CreateAccount, type: :model do
describe "#add_ldap_document for pre-confirmed account" do describe "#add_ldap_document for pre-confirmed account" do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let(:service) { described_class.new(account: { let(:service) { CreateAccount.new(account: {
username: 'halfinney', username: 'halfinney',
email: 'halfinney@example.com', email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november', password: 'remember-remember-the-5th-of-november',
@@ -89,7 +89,7 @@ RSpec.describe UserManager::CreateAccount, type: :model do
describe "#create_lndhub_account" do describe "#create_lndhub_account" do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let(:service) { described_class.new(account: { let(:service) { CreateAccount.new(account: {
username: 'halfinney', email: 'halfinney@example.com', username: 'halfinney', email: 'halfinney@example.com',
password: 'bright-ideas-in-winter' password: 'bright-ideas-in-winter'
})} })}

View File

@@ -1,13 +1,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe UserManager::CreateInvitations, type: :model do RSpec.describe CreateInvitations, type: :model do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let(:user) { create :user } let(:user) { create :user }
describe "#call" do describe "#call" do
before do before do
described_class.call(user: user, amount: 5) CreateInvitations.call(user: user, amount: 5)
end end
after(:each) { clear_enqueued_jobs } after(:each) { clear_enqueued_jobs }
@@ -28,7 +28,7 @@ RSpec.describe UserManager::CreateInvitations, type: :model do
describe "#call with notification disabled" do describe "#call with notification disabled" do
before do before do
described_class.call(user: user, amount: 3, notify: false) CreateInvitations.call(user: user, amount: 3, notify: false)
end end
after(:each) { clear_enqueued_jobs } after(:each) { clear_enqueued_jobs }

View File

@@ -1,74 +0,0 @@
require 'rails_helper'
RSpec.describe UserManager::UpdatePgpKey, type: :model do
include ActiveJob::TestHelper
let(:alice) { create :user, cn: "alice" }
let(:dn) { "cn=alice,ou=kosmos.org,cn=users,dc=kosmos,dc=org" }
let(:pubkey_asc) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
let(:fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
before do
allow(alice).to receive(:dn).and_return(dn)
allow(alice).to receive(:ldap_entry).and_return({
uid: alice.cn, ou: alice.ou, pgp_key: nil
})
end
describe "#call" do
context "with valid key" do
before do
alice.pgp_pubkey = pubkey_asc
allow(LdapManager::UpdatePgpKey).to receive(:call)
.with(dn: alice.dn, pubkey: pubkey_asc)
end
after do
alice.gnupg_key.delete!
end
it "imports the key into the GnuPG keychain" do
described_class.call(user: alice)
expect(alice.gnupg_key).to be_present
end
it "stores the key's fingerprint on the user record" do
described_class.call(user: alice)
expect(alice.pgp_fpr).to eq(fingerprint)
end
it "updates the user's LDAP entry with the new key" do
expect(LdapManager::UpdatePgpKey).to receive(:call)
.with(dn: alice.dn, pubkey: pubkey_asc)
described_class.call(user: alice)
end
end
context "with empty key" do
before do
alice.update pgp_fpr: fingerprint
alice.pgp_pubkey = ""
allow(LdapManager::UpdatePgpKey).to receive(:call)
.with(dn: alice.dn, pubkey: "")
end
it "does not attempt to import the key" do
expect(GPGME::Key).not_to receive(:import)
described_class.call(user: alice)
end
it "removes the key's fingerprint from the user record" do
described_class.call(user: alice)
expect(alice.pgp_fpr).to be_nil
end
it "removes the key from the user's LDAP entry" do
expect(LdapManager::UpdatePgpKey).to receive(:call)
.with(dn: alice.dn, pubkey: "")
described_class.call(user: alice)
end
end
end
end