7 Commits

Author SHA1 Message Date
6e95fa99df WIP Render nostr profile and relay status with Ruby
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-18 15:01:08 +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
22 changed files with 12207 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
<% @statuses.each do |status| %>
<%= render StatusTextComponent.new(
text: status[:text],
icon_name: status[:icon_name],
icon_color: status[:icon_color]
) %>
<% end %>

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
module Settings
class NostrProfileStatusComponent < ViewComponent::Base
def initialize(profile_event:, user_address:)
if profile_event.present?
profile = JSON.parse(profile_event["content"])
@statuses = []
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,4 @@
<%= render StatusTextComponent.new(
text: @text,
icon_name: @icon_name,
icon_color: @icon_color) %>

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
module Settings
class NostrRelayStatusComponent < ViewComponent::Base
def initialize(relay_urls:)
if relay_urls.present?
if relay_urls.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"
else
@text = "The Kosmos relay is missing from your relay list"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
end
else
@text = "We could not find a relay list for your public key"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
end
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

@@ -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,20 @@ class SettingsController < ApplicationController
} }
end end
def fetch_nostr_user_metadata
if @user.nostr_pubkey.present?
if @nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
@relay_urls = @nip65_event["tags"].select{ |t| t[0] == "r" }&.map{ |t| t[1] }
end
@profile = NostrManager::DiscoverUserProfile.call(pubkey: @user.nostr_pubkey, relays: @relay_urls)
else
@relays, @profile = [nil, nil]
end
render partial: 'nostr_user_relays'
end
private private
def set_main_nav_section def set_main_nav_section

View File

@@ -1,8 +1,14 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import { Nostrify } from "nostrify"
// Connects to data-controller="settings--nostr-pubkey" // Connects to data-controller="settings--nostr-pubkey"
export default class extends Controller { export default class extends Controller {
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ] static targets = [
"noExtension",
"setPubkey", "pubkeyBech32Input",
"relayList", "relayListStatus",
"profileStatusNip05", "profileStatusLud16"
]
static values = { static values = {
userAddress: String, userAddress: String,
pubkeyHex: String, pubkeyHex: String,
@@ -15,6 +21,14 @@ export default class extends Controller {
if (this.hasSetPubkeyTarget) { if (this.hasSetPubkeyTarget) {
this.setPubkeyTarget.disabled = false this.setPubkeyTarget.disabled = false
} }
if (this.pubkeyHexValue) {
// this.discoverUserOnNostr().then(() => {
// this.renderRelayStatus()
// this.renderProfileNip05Status()
// this.renderProfileLud16Status()
// })
}
} else { } else {
this.noExtensionTarget.classList.remove("hidden") this.noExtensionTarget.classList.remove("hidden")
} }
@@ -49,8 +63,172 @@ export default class extends Controller {
} }
} }
async discoverUserOnNostr () {
this.nip65Relays = await this.findUserRelays()
this.profile = await this.findUserProfile()
}
async findUserRelays () {
const controller = new AbortController();
const signal = controller.signal;
const filters = [{ kinds: [10002], authors: [this.pubkeyHexValue], limit: 1 }]
const messages = []
for await (const msg of this.discoveryPool.req(filters, { signal })) {
if (msg[0] === 'EVENT') {
if (!messages.find(m => m.id === msg[2].id)) {
messages.push(msg[2])
}
}
if (msg[0] === 'EOSE') { break }
}
// Close the relay subscription
controller.abort()
if (messages.length === 0) { return messages }
const sortedMessages = messages.sort((a, b) => a.createdAt - b.createdAt)
const newestMessage = messages[messages.length - 1]
return newestMessage.tags.filter(t => t[0] === 'r')
.map(t => { return { url: t[1], marker: t[2] } })
}
async findUserProfile () {
const controller = new AbortController();
const signal = controller.signal;
const filters = [{ kinds: [0], authors: [this.pubkeyHexValue], limit: 1 }]
const messages = []
for await (const msg of this.discoveryPool.req(filters, { signal })) {
if (msg[0] === 'EVENT') {
if (!messages.find(m => m.id === msg[2].id)) {
messages.push(msg[2])
}
}
if (msg[0] === 'EOSE') { break }
}
// Close the relay subscription
controller.abort()
if (messages.length === 0) { return null }
const sortedMessages = messages.sort((a, b) => a.createdAt - b.createdAt)
const newestMessage = messages[messages.length - 1]
return JSON.parse(newestMessage.content)
}
renderRelayStatus () {
let showStatus
if (this.nip65Relays.length > 0) {
if (this.relaysContainAccountsRelay) {
showStatus = 'green'
} else {
showStatus = 'orange'
}
} else {
showStatus = 'red'
}
// showStatus = 'red'
this.relayListStatusTarget
.querySelector(`.status-${showStatus}`)
.classList.remove("hidden")
}
renderProfileNip05Status () {
let showStatus
if (this.profile?.nip05) {
if (this.profile.nip05 === this.userAddressValue) {
showStatus = 'green'
} else {
showStatus = 'red'
}
} else {
showStatus = 'orange'
}
this.profileStatusNip05Target
.querySelector(`.status-${showStatus}`)
.classList.remove("hidden")
}
renderProfileLud16Status () {
let showStatus
if (this.profile?.lud16) {
if (this.profile.lud16 === this.userAddressValue) {
showStatus = 'green'
} else {
showStatus = 'red'
}
} else {
showStatus = 'orange'
}
this.profileStatusLud16Target
.querySelector(`.status-${showStatus}`)
.classList.remove("hidden")
}
// renderRelayList (relays) {
// const html = relays.map(relay => `
// <li class="flex items-center justify-between p-2 border-b">
// <span>${relay.url}</span>
// <button
// data-action="click->list#handleItemClick"
// data-item="${relay.url}"
// class="bg-blue-500 text-white px-3 py-1 rounded">
// Action
// </button>
// </li>
// `).join("")
//
// this.relayListTarget.innerHTML = html
// }
get csrfToken () { get csrfToken () {
const element = document.head.querySelector('meta[name="csrf-token"]') const element = document.head.querySelector('meta[name="csrf-token"]')
return element.getAttribute("content") return element.getAttribute("content")
} }
// Used to find a user's profile and relays
get discoveryRelays () {
return [
'ws://localhost:4777',
'wss://nostr.kosmos.org',
'wss://purplepag.es',
// 'wss://relay.nostr.band',
// 'wss://njump.me',
// 'wss://relay.damus.io',
// 'wss://nos.lol',
// 'wss://eden.nostr.land',
// 'wss://relay.snort.social',
// 'wss://nostr.wine',
// 'wss://relay.primal.net',
// 'wss://nostr.bitcoiner.social',
]
}
get discoveryPool () {
if (!this._discoveryPool) {
this._discoveryPool = new Nostrify.NPool({
open: (url) => new Nostrify.NRelay1(url),
reqRouter: async (filters) => new Map(
this.discoveryRelays.map(relayUrl => [ relayUrl, filters ])
),
eventRouter: async (event) => [],
})
}
return this._discoveryPool
}
get relaysContainAccountsRelay () {
// TODO use URL from view/settings
return !!this.nip65Relays.find(r => r.url.match('wss://nostr.kosmos.org'))
}
} }

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

@@ -0,0 +1,36 @@
module NostrManager
class DiscoverUserProfile < NostrManagerService
MAX_EVENTS = 2
def initialize(pubkey:, relays: nil)
@pubkey = pubkey
@relays = relays.present? ? relays : Setting.nostr_discovery_relays
end
def call
received_events = 0
profile_events = []
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [0],
limit: 1,
)
@relays.each do |url|
event = NostrManager::FetchLatestEvent.call(filter: filter, relay_url: url)
if event.present?
profile_events << event if profile_events.none? { |e| e["id"] == event["id"] }
received_events += 1
end
if received_events >= MAX_EVENTS
puts "Found #{MAX_EVENTS} events, ending the search"
break
end
end
profile_events.min_by { |e| e["created_at"] }
end
end
end

View File

@@ -0,0 +1,37 @@
module NostrManager
class DiscoverUserRelays < NostrManagerService
MAX_EVENTS = 2
def initialize(pubkey:)
@pubkey = pubkey
@relays = Setting.nostr_discovery_relays
end
def call
received_events = 0
nip65_events = []
user_relays = []
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [10002],
limit: 1,
)
@relays.each do |url|
event = NostrManager::FetchLatestEvent.call(filter: filter, relay_url: url)
if event.present?
nip65_events << event if nip65_events.none? { |e| e["id"] == event["id"] }
received_events += 1
end
if received_events >= MAX_EVENTS
puts "Found #{MAX_EVENTS} events, ending the search"
break
end
end
nip65_events.min_by { |e| e["created_at"] }
end
end
end

View File

@@ -0,0 +1,59 @@
module NostrManager
class FetchLatestEvent < NostrManagerService
TIMEOUT = 1
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
latest_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]
puts "#{log_prefix} Event received: #{msg[2]["id"]}"
mutex.synchronize do
latest_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 latest_event.nil?
end
end
rescue Timeout::Error
puts "#{log_prefix} Timeout: No event received within #{TIMEOUT} seconds"
ensure
thread.exit
end
latest_event
end
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

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

@@ -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,41 @@
<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>
<li>Receive zaps in your Lightning account</li>
<% 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 +52,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 +80,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,15 @@
<%= turbo_frame_tag "nostr_user_metadata" do %>
<section>
<h3>Profile</h3>
<%= render Settings::NostrProfileStatusComponent.new(
profile_event: @profile,
user_address: current_user.address
) %>
</section>
<section>
<h3>Relays</h3>
<%= render Settings::NostrRelayStatusComponent.new(
relay_urls: @relay_urls
) %>
</section>
<% end %>

View File

@@ -6,3 +6,4 @@ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/controllers", under: "controllers"
pin "tailwindcss-stimulus-components" # @4.0.3 pin "tailwindcss-stimulus-components" # @4.0.3
pin "nostrify"

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

1
vendor/gems/nostr vendored Submodule

Submodule vendor/gems/nostr added at 44e7454990

11690
vendor/javascript/nostrify.js vendored Normal file

File diff suppressed because it is too large Load Diff

1
vendor/javascript/nostrify.js.map vendored Normal file

File diff suppressed because one or more lines are too long