Settings page for adding verified nostr pubkeys
This commit is contained in:
@@ -24,6 +24,10 @@
|
||||
@apply text-xl mb-6;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply font-bold mb-4 leading-6;
|
||||
}
|
||||
|
||||
main section {
|
||||
@apply pt-8 sm:pt-12;
|
||||
}
|
||||
|
||||
@@ -32,4 +32,9 @@
|
||||
@apply bg-red-600 hover:bg-red-700 text-white
|
||||
focus:ring-red-500 focus:ring-opacity-75;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
||||
focus:ring-gray-300 focus:ring-opacity-75;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
@apply border-b-red-600;
|
||||
}
|
||||
|
||||
.field_with_errors {
|
||||
@apply inline-block;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
@apply text-red-700;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require 'securerandom'
|
||||
|
||||
class SettingsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_main_nav_section
|
||||
@@ -9,6 +11,9 @@ class SettingsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
if @settings_section == "experiments"
|
||||
session[:shared_secret] ||= SecureRandom.base64(12)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -53,6 +58,36 @@ class SettingsController < ApplicationController
|
||||
redirect_to check_your_email_path, notice: msg
|
||||
end
|
||||
|
||||
def set_nostr_pubkey
|
||||
signed_event = nostr_event_params[:signed_event].to_h.symbolize_keys
|
||||
is_valid_id = NostrManager::ValidateId.call(signed_event)
|
||||
is_valid_sig = NostrManager::VerifySignature.call(signed_event)
|
||||
is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})"
|
||||
|
||||
unless is_valid_id && is_valid_sig && is_correct_content
|
||||
flash[:alert] = "Public key could not be verified"
|
||||
http_status :unprocessable_entity and return
|
||||
end
|
||||
|
||||
pubkey_taken = User.all_except(current_user).where(
|
||||
ou: current_user.ou, nostr_pubkey: signed_event[:pubkey]
|
||||
).any?
|
||||
|
||||
if pubkey_taken
|
||||
flash[:alert] = "Public key already in use for a different account"
|
||||
http_status :unprocessable_entity and return
|
||||
end
|
||||
|
||||
current_user.update! nostr_pubkey: signed_event[:pubkey]
|
||||
session[:shared_secret] = nil
|
||||
|
||||
flash[:success] = "Public key verification successful"
|
||||
http_status :ok
|
||||
rescue
|
||||
flash[:alert] = "Public key could not be verified"
|
||||
http_status :unprocessable_entity and return
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_main_nav_section
|
||||
@@ -61,7 +96,7 @@ class SettingsController < ApplicationController
|
||||
|
||||
def set_settings_section
|
||||
@settings_section = params[:section]
|
||||
allowed_sections = [:profile, :account, :lightning, :xmpp]
|
||||
allowed_sections = [:profile, :account, :lightning, :xmpp, :experiments]
|
||||
|
||||
unless allowed_sections.include?(@settings_section.to_sym)
|
||||
redirect_to setting_path(:profile)
|
||||
@@ -82,4 +117,10 @@ class SettingsController < ApplicationController
|
||||
def email_params
|
||||
params.require(:user).permit(:email, :current_password)
|
||||
end
|
||||
|
||||
def nostr_event_params
|
||||
params.permit(signed_event: [
|
||||
:id, :pubkey, :created_at, :kind, :tags, :content, :sig
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { bech32 } from "bech32"
|
||||
|
||||
function hexToBytes (hex) {
|
||||
let bytes = []
|
||||
for (let c = 0; c < hex.length; c += 2) {
|
||||
bytes.push(parseInt(hex.substr(c, 2), 16))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Connects to data-controller="settings--nostr-pubkey"
|
||||
export default class extends Controller {
|
||||
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
|
||||
static values = { userAddress: String, pubkeyHex: String, sharedSecret: String }
|
||||
|
||||
connect () {
|
||||
if (this.hasPubkeyHexValue && this.pubkeyHexValue.length > 0) {
|
||||
this.pubkeyBech32InputTarget.value = this.pubkeyBech32
|
||||
}
|
||||
|
||||
if (window.nostr) {
|
||||
this.setPubkeyTarget.disabled = false
|
||||
} else {
|
||||
this.noExtensionTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
async setPubkey () {
|
||||
this.setPubkeyTarget.disabled = true
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})`
|
||||
})
|
||||
|
||||
const res = await fetch("/settings/set_nostr_pubkey", {
|
||||
method: "POST", credentials: "include", headers: {
|
||||
"Accept": "application/json", 'Content-Type': 'application/json',
|
||||
"X-CSRF-Token": this.csrfToken
|
||||
}, body: JSON.stringify({ signed_event: signedEvent })
|
||||
});
|
||||
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.warn('Unable to verify pubkey:', error.message)
|
||||
this.setPubkeyTarget.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
get pubkeyBech32 () {
|
||||
const words = bech32.toWords(hexToBytes(this.pubkeyHexValue))
|
||||
return bech32.encode('npub', words)
|
||||
}
|
||||
|
||||
get csrfToken () {
|
||||
const element = document.head.querySelector('meta[name="csrf-token"]')
|
||||
return element.getAttribute("content")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :accounts, through: :lndhub_user
|
||||
|
||||
validates_uniqueness_of :cn
|
||||
validates_uniqueness_of :cn, scope: :ou
|
||||
validates_length_of :cn, minimum: 3
|
||||
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,
|
||||
if: Proc.new{ |u| u.cn.present? },
|
||||
@@ -36,8 +36,11 @@ class User < ApplicationRecord
|
||||
validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
|
||||
if: -> { defined?(@display_name) }
|
||||
|
||||
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||
scope :pending, -> { where(confirmed_at: nil) }
|
||||
validates_uniqueness_of :nostr_pubkey, scope: :ou, allow_blank: true
|
||||
|
||||
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||
scope :pending, -> { where(confirmed_at: nil) }
|
||||
scope :all_except, -> (user) { where.not(id: user) }
|
||||
|
||||
has_encrypted :ln_login, :ln_password
|
||||
|
||||
|
||||
11
app/services/nostr_manager/validate_id.rb
Normal file
11
app/services/nostr_manager/validate_id.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module NostrManager
|
||||
class ValidateId < NostrManagerService
|
||||
def initialize(event)
|
||||
@event = Nostr::Event.new(**event)
|
||||
end
|
||||
|
||||
def call
|
||||
@event.id == Digest::SHA256.hexdigest(JSON.generate(@event.serialize))
|
||||
end
|
||||
end
|
||||
end
|
||||
17
app/services/nostr_manager/verify_signature.rb
Normal file
17
app/services/nostr_manager/verify_signature.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module NostrManager
|
||||
class VerifySignature < NostrManagerService
|
||||
def initialize(event)
|
||||
@event = Nostr::Event.new(**event)
|
||||
end
|
||||
|
||||
def call
|
||||
Schnorr.check_sig!(
|
||||
[@event.id].pack('H*'),
|
||||
[@event.pubkey].pack('H*'),
|
||||
[@event.sig].pack('H*')
|
||||
)
|
||||
rescue Schnorr::InvalidSignatureError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/services/nostr_manager_service.rb
Normal file
4
app/services/nostr_manager_service.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
require "nostr"
|
||||
|
||||
class NostrManagerService < ApplicationService
|
||||
end
|
||||
1
app/views/icons/_science.html.erb
Normal file
1
app/views/icons/_science.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48" class="material-science <%= custom_class %>" fill="currentColor"><path d="M172 936q-41.777 0-59.388-39Q95 858 124 826l248-280V276h-52q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T320 216h320q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T640 276h-52v270l248 280q29 32 11.388 71-17.611 39-59.388 39H172Zm-12-60h640L528 568V276h-96v292L160 876Zm318-300Z"/></svg>
|
||||
|
After Width: | Height: | Size: 488 B |
62
app/views/settings/_experiments.html.erb
Normal file
62
app/views/settings/_experiments.html.erb
Normal file
@@ -0,0 +1,62 @@
|
||||
<section>
|
||||
<h3>Nostr</h3>
|
||||
<h4 class="mb-0">Public Key</h4>
|
||||
<div data-controller="settings--nostr-pubkey"
|
||||
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
|
||||
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
|
||||
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">
|
||||
|
||||
<p class="mt-2 <%= current_user.nostr_pubkey.present? ? "" : "hidden" %>">
|
||||
<input type="text" class="w-full" value="<%= current_user.nostr_pubkey %>"
|
||||
data-settings--nostr-pubkey-target="pubkeyBech32Input" disabled />
|
||||
</p>
|
||||
|
||||
<% unless current_user.nostr_pubkey.present? %>
|
||||
<p class="my-4">
|
||||
If you use any apps on the Nostr network, you can verify your public key
|
||||
with us in order to enable Nostr-specific features for your account.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div data-settings--nostr-pubkey-target="noExtension"
|
||||
class="hidden rounded-md bg-blue-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<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">
|
||||
<h3 class="mb-0 text-sm font-bold text-blue-800">
|
||||
No browser extension found
|
||||
</h3>
|
||||
<div class="mt-2 mb-0 text-sm text-blue-800">
|
||||
<p>
|
||||
We recommend Alby, which you can also use for your Lightning
|
||||
Wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="-mx-2 -my-1.5 flex">
|
||||
<a href="https://getalby.com" target="_blank"
|
||||
class="rounded-md bg-blue-50 px-2 py-1.5 text-sm
|
||||
font-bold text-blue-800 hover:bg-blue-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-600
|
||||
focus:ring-offset-2 focus:ring-offset-blue-50">
|
||||
Get Alby
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-8">
|
||||
<button class="btn-md btn-gray" disabled
|
||||
data-settings--nostr-pubkey-target="setPubkey"
|
||||
data-action="settings--nostr-pubkey#setPubkey">
|
||||
Get public key from browser extension
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -18,3 +18,9 @@
|
||||
active: @settings_section.to_s == "lightning"
|
||||
) %>
|
||||
<% end %>
|
||||
<% if Setting.nostr_enabled %>
|
||||
<%= render SidenavLinkComponent.new(
|
||||
name: "Experiments", path: setting_path(:experiments), icon: "science",
|
||||
active: @settings_section.to_s == "experiments"
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user