Settings page for adding verified nostr pubkeys

This commit is contained in:
Râu Cao
2023-03-08 13:44:18 +07:00
parent bc4d9ff528
commit 49de4007ab
21 changed files with 621 additions and 233 deletions

View File

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

View File

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

View File

@@ -15,6 +15,10 @@
@apply border-b-red-600;
}
.field_with_errors {
@apply inline-block;
}
.error-msg {
@apply text-red-700;
}

View File

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

View File

@@ -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")
}
}

View File

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

View 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

View 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

View File

@@ -0,0 +1,4 @@
require "nostr"
class NostrManagerService < ApplicationService
end

View 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

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

View File

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