27 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
64de4deddd Fix serviceEnabled indicator on admin page
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-24 21:38:01 +02:00
8f7994d82e 0.10.0
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2024-09-18 15:49:07 +02:00
a7d0e71ab6 Fix spec
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-18 14:46:46 +02:00
27d9f73c61 Set host for RS auth url
Some checks failed
continuous-integration/drone/push Build is failing
With X-Forwarded-Host set on the proxied request, Rails uses that host
for URLs. But we need it to be the accounts domain.
2024-09-14 17:17:09 +02:00
ed3de8b16f Allow CORS for all LNURL endpoints
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-14 16:46:14 +02:00
d7b4c67953 Fix config when set to empty string
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-14 16:40:22 +02:00
7489d4a32f Merge pull request 'Add config for separate primary domain Nostr pubkey' (#204) from feature/nostr_pubkey_primary_domain into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #204
Reviewed-by: Greg <greg@noreply.kosmos.org>
2024-09-13 12:33:11 +00:00
ac77e5b7c1 Allow ENV var for new setting
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2024-09-11 16:31:04 +02:00
e544c28105 Config for separate primary domain Nostr pubkey
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Allow to configure a separate key for the NIP-05 address of the primary
domain vs the accounts domain.
2024-09-11 16:28:12 +02:00
4909dac5c2 Fix typo
All checks were successful
continuous-integration/drone/push Build is passing
The return value of `strip!` is `nil`
2024-09-11 16:26:48 +02:00
3cf4348695 Merge pull request 'Make default user services configurable by admins' (#203) from feature/default_service_settings into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #203
Reviewed-by: galfert <garret.alfert@gmail.com>
2024-09-11 11:21:38 +00:00
af3da0a26c Set CORS headers for all .well-known responses
All checks were successful
continuous-integration/drone/push Build is passing
So we don't have to consider it for reverse proxies etc.
2024-09-10 16:06:11 +02:00
2d32320c7d Style check boxes
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2024-09-05 11:24:38 +02:00
31 changed files with 578 additions and 75 deletions

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

@@ -25,7 +25,6 @@ class Admin::SettingsController < Admin::BaseController
end end
changed_keys.each do |key| changed_keys.each do |key|
Setting.send("#{key}=", clean_param(key)) Setting.send("#{key}=", clean_param(key))
end end
end end
@@ -42,7 +41,7 @@ class Admin::SettingsController < Admin::BaseController
def clean_param(key) def clean_param(key)
if Setting.get_field(key)[:type] == :string if Setting.get_field(key)[:type] == :string
setting_params[key].strip! setting_params[key].strip
else else
setting_params[key] setting_params[key]
end end

View File

@@ -1,7 +1,7 @@
class LnurlpayController < ApplicationController class LnurlpayController < ApplicationController
before_action :check_service_available before_action :check_service_available
before_action :find_user before_action :find_user
before_action :set_cors_access_control_headers, only: [:invoice] before_action :set_cors_access_control_headers
MIN_SATS = 10 MIN_SATS = 10
MAX_SATS = 1_000_000 MAX_SATS = 1_000_000

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)
@@ -128,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

View File

@@ -1,8 +1,6 @@
class WebfingerController < ApplicationController class WebfingerController < WellKnownController
before_action :allow_cross_origin_requests, only: [:show] before_action :allow_cross_origin_requests, only: [:show]
layout false
def show def show
resource = params[:resource] resource = params[:resource]
@@ -76,7 +74,7 @@ class WebfingerController < ApplicationController
end end
def remotestorage_link def remotestorage_link
auth_url = new_rs_oauth_url(@username) auth_url = new_rs_oauth_url(@username, host: Setting.accounts_domain)
storage_url = "#{Setting.rs_storage_url}/#{@username}" storage_url = "#{Setting.rs_storage_url}/#{@username}"
{ {
@@ -91,10 +89,4 @@ class WebfingerController < ApplicationController
} }
} }
end end
def allow_cross_origin_requests
return unless Rails.env.development?
headers['Access-Control-Allow-Origin'] = "*"
headers['Access-Control-Allow-Methods'] = "GET"
end
end end

View File

@@ -1,5 +1,8 @@
class WellKnownController < ApplicationController class WellKnownController < ApplicationController
before_action :require_nostr_enabled, only: [ :nostr ] before_action :require_nostr_enabled, only: [ :nostr ]
before_action :allow_cross_origin_requests, only: [ :nostr ]
layout false
def nostr def nostr
http_status :unprocessable_entity and return if params[:name].blank? http_status :unprocessable_entity and return if params[:name].blank?
@@ -7,8 +10,14 @@ class WellKnownController < ApplicationController
relay_url = Setting.nostr_relay_url.presence relay_url = Setting.nostr_relay_url.presence
if params[:name] == "_" if params[:name] == "_"
# pubkey for the primary domain without a username (e.g. kosmos.org) if domain == Setting.primary_domain
res = { names: { "_": Setting.nostr_public_key } } # pubkey for the primary domain without a username (e.g. kosmos.org)
res = { names: { "_": Setting.nostr_public_key_primary_domain.presence || Setting.nostr_public_key } }
else
# pubkey for the akkounts domain without a username (e.g. accounts.kosmos.org)
res = { names: { "_": Setting.nostr_public_key } }
end
res[:relays] = { "_" => [ relay_url ] } if relay_url res[:relays] = { "_" => [ relay_url ] } if relay_url
else else
@user = User.where(cn: params[:name], ou: domain).first @user = User.where(cn: params[:name], ou: domain).first
@@ -30,4 +39,9 @@ class WellKnownController < ApplicationController
def require_nostr_enabled def require_nostr_enabled
http_status :not_found unless Setting.nostr_enabled? http_status :not_found unless Setting.nostr_enabled?
end end
def allow_cross_origin_requests
headers['Access-Control-Allow-Origin'] = "*"
headers['Access-Control-Allow-Methods'] = "GET"
end
end end

View File

@@ -12,11 +12,27 @@ module Settings
field :nostr_public_key, type: :string, field :nostr_public_key, type: :string,
default: ENV["NOSTR_PUBLIC_KEY"].presence default: ENV["NOSTR_PUBLIC_KEY"].presence
field :nostr_public_key_primary_domain, type: :string,
default: ENV["NOSTR_PUBLIC_KEY_PRIMARY_DOMAIN"].presence
field :nostr_relay_url, type: :string, field :nostr_relay_url, type: :string,
default: ENV["NOSTR_RELAY_URL"].presence default: ENV["NOSTR_RELAY_URL"].presence
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

@@ -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

@@ -30,7 +30,8 @@
<% Setting.available_services.each do |option| %> <% Setting.available_services.each do |option| %>
<div class="md:inline-block"> <div class="md:inline-block">
<%= f.check_box :default_services, <%= f.check_box :default_services,
{ multiple: true, checked: Setting.default_services.include?(option) }, { multiple: true, checked: Setting.default_services.include?(option),
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 mr-0.5" },
option, nil %> option, nil %>
<%= f.label "default_services_#{option.parameterize}", service_human_name(option) %> <%= f.label "default_services_#{option.parameterize}", service_human_name(option) %>
</div> </div>

View File

@@ -19,6 +19,11 @@
title: "Public key", title: "Public key",
description: "The corresponding public key of the accounts service" description: "The corresponding public key of the accounts service"
) %> ) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_public_key_primary_domain,
title: "Public key for primary domain (NIP-05)",
description: "(optional) A different pubkey to announce for the _@#{Setting.primary_domain} Nostr address"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new( <%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_relay_url, key: :nostr_relay_url,
title: "Relay URL", title: "Relay URL",
@@ -26,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

@@ -184,7 +184,7 @@
<td>XMPP (ejabberd)</td> <td>XMPP (ejabberd)</td>
<td> <td>
<%= render FormElements::ToggleComponent.new( <%= render FormElements::ToggleComponent.new(
enabled: @services_enabled.include?("xmpp"), enabled: @services_enabled.include?("ejabberd"),
input_enabled: false input_enabled: false
) %> ) %>
</td> </td>

View File

@@ -1,47 +1,43 @@
<section> <div data-controller="settings--nostr-pubkey"
<h3>Nostr</h3> data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
<h4 class="mb-0">Public Key</h4> data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
<div data-controller="settings--nostr-pubkey" data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>" data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>" <section class="mb-8 sm:mb-12">
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>" <h3>Nostr</h3>
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>"> <h4 class="mb-0">
Public Key
<p class="<%= current_user.nostr_pubkey.present? ? '' : 'hidden' %> mt-2 flex gap-1"> </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

@@ -65,6 +65,7 @@ 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

View File

@@ -11,7 +11,7 @@
"postcss-preset-env": "^7.8.3", "postcss-preset-env": "^7.8.3",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
}, },
"version": "0.9.0", "version": "0.10.0",
"scripts": { "scripts": {
"build:css:tailwind": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css", "build:css:tailwind": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css",
"build:css": "yarn run build:css:tailwind" "build:css": "yarn run build:css:tailwind"

View File

@@ -92,7 +92,13 @@ RSpec.describe "WebFinger", type: :request do
expect(rs_link["href"]).to eql("#{Setting.rs_storage_url}/tony") expect(rs_link["href"]).to eql("#{Setting.rs_storage_url}/tony")
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"] oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
expect(oauth_url).to eql("http://www.example.com/rs/oauth/tony") expect(oauth_url).to eql("http://accounts.kosmos.org/rs/oauth/tony")
end
it "returns CORS headers" do
get "/.well-known/nostr.json?name=bobdylan"
expect(response.headers['Access-Control-Allow-Origin']).to eq("*")
expect(response.headers['Access-Control-Allow-Methods']).to eq('GET')
end end
end end

View File

@@ -46,6 +46,12 @@ RSpec.describe "Well-known URLs", type: :request do
expect(res["names"]["bobdylan"]).to eq(user.nostr_pubkey) expect(res["names"]["bobdylan"]).to eq(user.nostr_pubkey)
end end
it "returns CORS headers" do
get "/.well-known/nostr.json?name=bobdylan"
expect(response.headers['Access-Control-Allow-Origin']).to eq("*")
expect(response.headers['Access-Control-Allow-Methods']).to eq('GET')
end
context "without relay configured" do context "without relay configured" do
before do before do
Setting.nostr_relay_url = "" Setting.nostr_relay_url = ""
@@ -73,10 +79,36 @@ RSpec.describe "Well-known URLs", type: :request do
end end
describe "placeholder username for domain's own pubkey" do describe "placeholder username for domain's own pubkey" do
it "returns the configured nostr pubkey" do describe "for primary domain" do
get "/.well-known/nostr.json?name=_" context "no different pubkey configured for primary domain" do
res = JSON.parse(response.body) it "returns the akkounts nostr pubkey" do
expect(res["names"]["_"]).to eq(Setting.nostr_public_key) get "/.well-known/nostr.json?name=_"
res = JSON.parse(response.body)
expect(res["names"]["_"]).to eq("bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf")
end
end
context "different pubkey configured for primary domain" do
before do
Setting.nostr_public_key_primary_domain = "b3e8f62fbe41217ffc0aa1e178d297339932d8ba4f46d9c7df3b61575e78fecc"
end
it "returns the primary domain's nostr pubkey" do
get "/.well-known/nostr.json?name=_"
res = JSON.parse(response.body)
expect(res["names"]["_"]).to eq("b3e8f62fbe41217ffc0aa1e178d297339932d8ba4f46d9c7df3b61575e78fecc")
end
end
end
describe "for akkounts domain" do
it "returns the configured nostr pubkey" do
headers = { "X-Forwarded-Host" => "accounts.kosmos.org" }
get "/.well-known/nostr.json?name=_"
res = JSON.parse(response.body)
expect(res["names"]["_"]).to eq("bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf")
end
end end
context "with relay configured" do context "with relay configured" do