Compare commits

..
Author SHA1 Message Date
raucao 96f86f3f53 Improve Nostr profile/relay settings forms
* Mark input fields as "added" when values were auto-added
* Add suggested values for existing fields (NIP-05/LUD-16)
2026-09-11 16:12:31 -06:00
raucao 0ceedf18e5 Update default discovery relay list 2026-09-11 15:02:59 -06:00
raucao 4f569316ea Wording 2026-09-11 15:02:47 -06:00
raucao 407d9f633e WIP Relay editing UI 2026-08-11 19:45:07 -06:00
raucao a9045fa413 WIP: Improve Nostr user metadata fetching
Use a multi-step process, caching results in between, while informing
the user about the current steps/progress
2026-08-11 18:16:58 -06:00
raucao 52742ba305 Enable Rails cache (memory store) 2026-08-11 18:15:19 -06:00
raucao 6bfe63c234 Fix nostr event fetching issues 2026-08-11 18:14:11 -06:00
raucao 12a1570c7d Add Blossom server URL to global settings 2026-08-11 14:03:38 -06:00
raucao 4c20e8d7e0 Remove dead relay from default discovery relays 2026-08-11 13:29:39 -06:00
raucao d2152d0ca4 WIP Edit nostr profile 2026-08-11 13:26:38 -06:00
raucao cbde625a5d Reorder things in UI 2026-08-11 13:26:38 -06:00
raucao 2fd71d64aa Add timeout for fetching latest event 2026-08-11 13:26:38 -06:00
raucao 22d29b642c Conditional 2026-08-11 13:26:38 -06:00
raucao c11534ab81 Formatting 2026-08-11 13:26:38 -06:00
raucao 6577fd0251 Remove client-side nostr discovery 2026-08-11 13:26:37 -06:00
raucao 949d158b15 Use rails logger 2026-08-11 13:26:37 -06:00
raucao ea4767acc9 WIP Refactor stuff 2026-08-11 13:26:37 -06:00
raucao 0dcd07c6f7 WIP Render nostr profile and relay status with Ruby 2026-08-11 13:26:37 -06:00
raucao 4f22471aa0 WIP fetch relays and profile with ruby 2026-08-11 13:26:36 -06:00
raucao 3e6971b2d1 Fetch user relays, synchronously 2026-08-11 13:26:36 -06:00
raucao 7ebc4d3833 Add global setting and default for discovery relays 2026-08-11 13:26:36 -06:00
raucao 82385913b2 WIP Nostr onboarding 2026-08-11 13:26:36 -06:00
raucao 79e78623e0 Add nostrify lib 2026-08-11 13:26:32 -06:00
49 changed files with 1267 additions and 248 deletions

No files matched your search

+1
View File
@@ -87,6 +87,7 @@
# NOSTR_PRIVATE_KEY='123456abcdef...' # NOSTR_PRIVATE_KEY='123456abcdef...'
# NOSTR_PUBLIC_KEY='123456abcdef...' # NOSTR_PUBLIC_KEY='123456abcdef...'
# NOSTR_RELAY_URL='wss://nostr.kosmos.org' # NOSTR_RELAY_URL='wss://nostr.kosmos.org'
# NOSTR_BLOSSOM_SERVER_URL='https://blossom.kosmos.org'
# RS_STORAGE_URL='https://storage.kosmos.org' # RS_STORAGE_URL='https://storage.kosmos.org'
# RS_REDIS_URL='redis://localhost:6379/2' # RS_REDIS_URL='redis://localhost:6379/2'
+4 -7
View File
@@ -7,16 +7,13 @@ ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay.
## Development environment ## Development environment
Development runs in Docker Compose — run all commands against the `web` container. Development runs in Docker Compose — run all commands against the `web` container.
Start services: `docker compose up` (web, ldap, redis, garage, liquor-cabinet, strfry). Start services: `docker compose up` (web, ldap, redis, minio, liquor-cabinet, strfry).
The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`). Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
First-time LDAP and database setup is automated: the `ldap-init` service First-time LDAP setup (after creating the 389ds backend once):
creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the `docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
databases on first start and runs `db:prepare` on every boot. Manual then `docker compose run web bin/rails ldap:setup`.
equivalents: `docker compose exec ldap dsconf localhost backend create
--suffix="dc=kosmos,dc=org" --be-name="dev"` and `docker compose run --rm web
bin/rails ldap:setup`.
## Common commands (prefix with `docker compose exec web`) ## Common commands (prefix with `docker compose exec web`)
+24 -44
View File
@@ -14,14 +14,13 @@ so:
1. Make sure [Docker Compose is installed][1] and Docker is running (included in 1. Make sure [Docker Compose is installed][1] and Docker is running (included in
Docker Desktop) Docker Desktop)
2. Run `docker compose up --build` and wait until all services have started 3. Run `docker compose up --build` and wait until all services have started
(389ds might take an extra minute to be ready). This will take a while when (389ds might take an extra minute to be ready). This will take a while when
running for the first time, so you might want to do something else in the running for the first time, so you might want to do something else in the
meantime. meantime.
4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"`
On the first start, the `ldap-init` service creates the 389ds back-end, and the 5. `docker compose run web rails ldap:setup`
`web` container then seeds the LDAP directory and the databases automatically. 6. `docker compose run web rails db:setup`
On every start, `web` also applies any pending database migrations.
After these steps, you should have a working Rails app with a handful of test After these steps, you should have a working Rails app with a handful of test
users running on [http://localhost:3000](http://localhost:3000). users running on [http://localhost:3000](http://localhost:3000).
@@ -72,12 +71,15 @@ containers you want to run to the `up` command, like so:
#### LDAP server #### LDAP server
On first start, the `ldap-init` service creates the dirsrv back-end After creating the Docker container for the first time (or after deleting it),
automatically, and the `web` container then seeds it with development entries. you need to run the following command once, in order to create the dirsrv
To do either step manually (for example, after changing the setup), run: back-end:
docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev" docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
docker compose run --rm web bin/rails ldap:setup
Now you can seed the back-end with data using this Rails task:
bundle exec rails ldap:setup
The setup task will first delete any existing entries in the directory tree The setup task will first delete any existing entries in the directory tree
("dc=kosmos,dc=org"), and then create our development entries. ("dc=kosmos,dc=org"), and then create our development entries.
@@ -86,42 +88,20 @@ Note that all 389ds data is stored in the `389ds-data` volume. So if you want
to start over with a fresh installation, delete both that volume as well as the to start over with a fresh installation, delete both that volume as well as the
container. container.
To reset the development environment completely, remove all volumes plus the #### Minio / remoteStorage
generated database files and the first-run marker, then start over:
docker compose down -v If you want to run remoteStorage accounts locally, you will have to create the
rm -f db/*.sqlite3 tmp/.setup-complete respective bucket first. With the `minio` container running (run by default
docker compose up --build when using Docker Compose), follow these steps:
#### Garage / remoteStorage * `docker compose up web redis minio liquor-cabinet`
* Head to http://localhost:9001 and log in with user `minioadmin`, password
remoteStorage accounts use the `garage` S3-compatible object store. On first `minioadmin`
start, Garage automatically configures a single-node cluster and creates the * Create a new bucket called `remotestorage` (or whatever you
`remotestorage` bucket together with a `dev-key1` access key (secret change the `S3_BUCKET` config to)
`1234567890123456`), so no manual setup is required. * Create a new key with ID "dev-key" and secret "123456789" (or whatever you
change `S3_ACCESS_KEY` and `S3_SECRET_KEY` to). Leave the policy field empty,
If you want to run remoteStorage accounts locally, the `garage` container is as it will automatically allow access to the bucket you created.
started by default when using Docker Compose. To run just the remoteStorage
stack:
* `docker compose up web redis garage liquor-cabinet`
The S3 API is available at http://localhost:3900 (region `garage`). If you want
to start over with a fresh storage, delete the `garage-data` volume as well as
the container.
#### Accessing remoteStorage from another machine
remoteStorage clients force HTTPS for any host except `localhost`, and browsers
block plain-HTTP requests to a LAN IP as mixed content. To connect to the dev
remoteStorage from a browser on another machine (including production apps such
as Inspektor), forward the ports over SSH and connect as `localhost`:
ssh -N -L 3000:localhost:3000 -L 4567:localhost:4567 <user>@<dev-host>
Then use `<user>@localhost:3000` as the remoteStorage address in the client.
WeFinger discovery and storage requests are served through the forwarded ports,
so no TLS setup is needed.
### Adding npm modules to use with Stimulus controllers ### Adding npm modules to use with Stimulus controllers
@@ -12,7 +12,7 @@
</p> </p>
<% end %> <% end %>
<%= tag.p class: "flex gap-x-1", data: { <%= tag.div class: "flex gap-x-1", data: {
controller: @resettable ? "settings--resettable-field" : nil, controller: @resettable ? "settings--resettable-field" : nil,
} do %> } do %>
<%= content %> <%= content %>
@@ -0,0 +1,156 @@
<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 %>
<% if @nip05_state[:status] == :added %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @user.address %>">
<div class="relative">
<%= f.text_field :nip05_address, value: @nip05_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200 hidden"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @user.address %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @user.address %>">
Use this
</button>
</div>
</div>
<% elsif @nip05_state[:status] == :different %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @nip05_state[:suggested] %>">
<div class="relative">
<%= f.text_field :nip05_address, value: @nip05_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-20 border-amber-400 ring-1 ring-amber-400 focus:border-amber-400 focus:ring-amber-400" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">
different
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @nip05_state[:suggested] %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @nip05_state[:suggested] %>">
Use this
</button>
</div>
</div>
<% else %>
<%= f.text_field :nip05_address, value: @nip05_state[:value], class: "w-full sm:w-3/5" %>
<% end %>
<% 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: "Lightning address for Zaps") do %>
<% if @lud16_state[:status] == :added %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @user.address %>">
<div class="relative">
<%= f.text_field :lud16_address, value: @lud16_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200 hidden"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @user.address %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @user.address %>">
Use this
</button>
</div>
</div>
<% elsif @lud16_state[:status] == :different %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @lud16_state[:suggested] %>">
<div class="relative">
<%= f.text_field :lud16_address, value: @lud16_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-20 border-amber-400 ring-1 ring-amber-400 focus:border-amber-400 focus:ring-amber-400" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">
different
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @lud16_state[:suggested] %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @lud16_state[:suggested] %>">
Use this
</button>
</div>
</div>
<% else %>
<%= f.text_field :lud16_address, value: @lud16_state[:value], class: "w-full sm:w-3/5" %>
<% end %>
<% 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>
@@ -0,0 +1,39 @@
# frozen_string_literal: true
module Settings
class NostrEditProfileComponent < ViewComponent::Base
attr_reader :user, :profile, :profile_event, :display_name, :nip05_state, :lud16_state
def initialize(user:, profile_event: nil, validation_errors: nil)
@user = user
@profile_event = profile_event
@validation_errors = validation_errors
@profile = {}
@display_name = nil
if profile_event.present? && profile_event["content"].present?
@profile = JSON.parse(profile_event["content"]) rescue {}
@display_name = @profile["display_name"] || @profile["displayName"]
end
@display_name ||= @user.display_name
@nip05_state = compute_field_state(@profile["nip05"])
@lud16_state = compute_field_state(@profile["lud16"])
end
private
def compute_field_state(current_value)
target_value = @user.address
if current_value.blank?
{ status: :added, value: target_value, suggested: nil }
elsif current_value == target_value
{ status: :matched, value: current_value, suggested: nil }
else
{ status: :different, value: current_value, suggested: target_value }
end
end
end
end
@@ -0,0 +1,62 @@
<div class="w-[72vw] md:w-[500px]">
<h3 class="mb-6">Edit relays</h3>
<%= form_with url: "#", local: true,
html: { data: { controller: "relay-list-editor" } } do |f| %>
<ul data-relay-list-editor-target="list" class="mb-4 space-y-2">
<% @relays.each do |relay| %>
<li class="flex gap-x-2 items-center">
<div class="relative grow">
<% if relay[:added] %>
<input type="text" name="relay_urls[]" value="<%= relay[:url] %>"
data-added-value="<%= relay[:url] %>"
data-action="input->relay-list-editor#checkAdded"
class="w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" />
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-relay-list-editor-target="addedBadge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
<% else %>
<input type="text" name="relay_urls[]" value="<%= relay[:url] %>" class="w-full" />
<% end %>
</div>
<select name="relay_markers[]" class="shrink-0 w-36 sm:w-40">
<option value=""<%= %( selected="selected") if relay[:marker].nil? %>>Read & write</option>
<option value="read"<%= %( selected="selected") if relay[:marker] == "read" %>>Read only</option>
<option value="write"<%= %( selected="selected") if relay[:marker] == "write" %>>Write only</option>
</select>
<button type="button" class="btn-md btn-icon btn-outline text-red-700 shrink-0"
data-action="click->relay-list-editor#remove" title="Remove relay">
<%= render partial: "icons/trash-2", locals: { custom_class: "h-4 w-4" } %>
</button>
</li>
<% end %>
</ul>
<template data-relay-list-editor-target="template">
<li class="flex gap-x-2 items-center">
<div class="relative grow">
<input type="text" name="relay_urls[]" value="" class="w-full" />
</div>
<select name="relay_markers[]" class="shrink-0 w-36 sm:w-40">
<option value="">Read & write</option>
<option value="read">Read only</option>
<option value="write">Write only</option>
</select>
<button type="button" class="btn-md btn-icon btn-outline text-red-700 shrink-0"
data-action="click->relay-list-editor#remove" title="Remove relay">
<%= render partial: "icons/trash-2", locals: { custom_class: "h-4 w-4" } %>
</button>
</li>
</template>
<button type="button" class="btn-md btn-gray w-full mb-6"
data-action="click->relay-list-editor#add">
<%= render partial: "icons/plus", locals: { custom_class: "h-4 w-4 inline mr-1" } %>
Add relay
</button>
<%= f.submit "Save relay list", class: "btn-md btn-blue w-full" %>
<% end %>
</div>
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Settings
class NostrEditRelaysComponent < ViewComponent::Base
def initialize(nip65_event: nil)
@nip65_event = nip65_event
@relays = build_relays
end
private
def build_relays
site_relay = Setting.nostr_relay_url
if @nip65_event.blank?
relays = Setting.nostr_discovery_relays.map { |url| { url: url, marker: nil, added: true } }
if site_relay.present?
relays.reject! { |r| r[:url] == site_relay }
relays.unshift({ url: site_relay, marker: nil, added: true })
end
relays
else
existing = @nip65_event["tags"]
.select { |t| t[0] == "r" }
.map { |t| { url: t[1], marker: t[2], added: false } }
if site_relay.present? && existing.none? { |r| r[:url] == site_relay }
existing.unshift({ url: site_relay, marker: nil, added: true })
end
existing
end
end
end
end
@@ -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 %>
@@ -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
@@ -0,0 +1,18 @@
<% @statuses.each do |status| %>
<%= render StatusTextComponent.new(
text: status[:text],
icon_name: status[:icon_name],
icon_color: status[:icon_color]
) %>
<% end %>
<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">
<%= @button_text %>
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= render Settings::NostrEditRelaysComponent.new(
nip65_event: @nip65_event
) %>
<% end %>
</div>
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module Settings
class NostrRelayStatusComponent < ViewComponent::Base
def initialize(nip65_event:, relay_url: Setting.nostr_relay_url)
@nip65_event = nip65_event
@relay_url = relay_url
@relay_host = relay_url.present? ? URI.parse(relay_url).host : nil
@statuses = []
if nip65_event.present?
@statuses.push({
text: "You have a relay list configured",
icon_name: "check-circle",
icon_color: "emerald-500"
})
if relay_urls(nip65_event).include?(@relay_url)
@statuses.push({
text: "The <strong>#{@relay_host}</strong> relay is part of your relay list",
icon_name: "check-circle",
icon_color: "emerald-500"
})
else
@statuses.push({
text: "<strong>#{@relay_host}</strong> is missing from your relay list",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
else
@statuses.push({
text: "We could not find a relay list for your public key",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
@button_text = (nip65_event.present? ? "Edit relays" : "Set up relays")
end
private
def relay_urls(nip65_event)
nip65_event["tags"].select { |t| t[0] == "r" }.map { |t| t[1] }
end
end
end
@@ -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>
+7
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
@@ -1,7 +1,7 @@
class Services::RemotestorageController < Services::BaseController class Services::RemotestorageController < Services::BaseController
before_action :authenticate_user! before_action :authenticate_user!
before_action :require_service_available before_action :require_service_available
before_action :require_service_enabled before_action :require_feature_enabled
# Dashboard # Dashboard
def show def show
@@ -17,8 +17,8 @@ class Services::RemotestorageController < Services::BaseController
http_status :not_found unless Setting.remotestorage_enabled? http_status :not_found unless Setting.remotestorage_enabled?
end end
def require_service_enabled def require_feature_enabled
unless current_user.service_enabled?(:remotestorage) unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden http_status :forbidden
end end
end end
@@ -1,7 +1,8 @@
class Services::RsAuthsController < Services::BaseController class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user! before_action :authenticate_user!
before_action :require_service_enabled before_action :require_feature_enabled
before_action :require_service_available before_action :require_service_available
# before_action :require_service_enabled
before_action :find_rs_auth, only: [:destroy, :launch_app] before_action :find_rs_auth, only: [:destroy, :launch_app]
def index def index
@@ -22,12 +23,9 @@ class Services::RsAuthsController < Services::BaseController
end end
def launch_app def launch_app
user_address = user_address = Rails.env.development? ?
if Rails.env.development? "#{current_user.cn}@localhost:3000" :
"#{current_user.cn}@#{request.host_with_port}" current_user.address
else
current_user.address
end
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}" launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
@@ -36,8 +34,8 @@ class Services::RsAuthsController < Services::BaseController
private private
def require_service_enabled def require_feature_enabled
unless current_user.service_enabled?(:remotestorage) unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden http_status :forbidden
end end
end end
+58 -2
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_metadata
]
def index def index
redirect_to setting_path(:profile) redirect_to setting_path(:profile)
@@ -143,12 +148,63 @@ class SettingsController < ApplicationController
} }
end end
def fetch_nostr_metadata
return render plain: "no pubkey", status: :bad_request if @user.nostr_pubkey.blank?
cache_key = "nostr_metadata:#{@user.id}"
case params[:step]
when "relays"
nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
write_relays = nip65_event ? extract_write_relays(nip65_event) : []
if nip65_event && write_relays.any? && stale?(nip65_event)
auth_event = NostrManager::DiscoverUserRelays.call(
pubkey: @user.nostr_pubkey, relays: write_relays
)
nip65_event = auth_event if auth_event
write_relays = extract_write_relays(nip65_event) if auth_event
end
Rails.cache.write("#{cache_key}:nip65", nip65_event, expires_in: 5.minutes)
Rails.cache.write("#{cache_key}:write_relays", write_relays, expires_in: 5.minutes)
render json: { next: "profile" }
when "profile"
write_relays = Rails.cache.read("#{cache_key}:write_relays") || []
relays = write_relays.any? ? write_relays : nil
profile = NostrManager::DiscoverUserProfile.call(
pubkey: @user.nostr_pubkey, relays: relays
)
Rails.cache.write("#{cache_key}:profile", profile, expires_in: 5.minutes)
render json: { next: "render" }
when "render", nil
@nip65_event = Rails.cache.read("#{cache_key}:nip65")
@profile = Rails.cache.read("#{cache_key}:profile")
render partial: "nostr_metadata", formats: :html
else
render plain: "invalid step", status: :bad_request
end
end
private private
def set_main_nav_section def set_main_nav_section
@current_section = :settings @current_section = :settings
end end
def extract_write_relays(nip65_event)
nip65_event["tags"]
.select { |t| t[0] == "r" && t[2] != "read" }
.map { |t| t[1] }
end
def stale?(event, threshold = 30.minutes)
Time.at(event["created_at"]) < threshold.ago
end
def set_settings_section def set_settings_section
@settings_section = params[:section] @settings_section = params[:section]
allowed_sections = [ allowed_sections = [
+2 -16
View File
@@ -88,14 +88,8 @@ class WebfingerController < WellKnownController
end end
def remotestorage_link def remotestorage_link
auth_url = auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
if Rails.env.development? storage_url = "#{Setting.rs_storage_url}/#{@username}"
new_rs_oauth_url(@username)
else
new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
end
storage_url = "#{remotestorage_storage_base_url}/#{@username}"
{ {
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage", rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
@@ -109,12 +103,4 @@ class WebfingerController < WellKnownController
} }
} }
end end
def remotestorage_storage_base_url
return Setting.rs_storage_url unless Rails.env.development?
uri = URI.parse(Setting.rs_storage_url)
uri.host = request.host
uri.to_s
end
end end
@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "input", "badge", "suggestion" ]
static values = { addedValue: String }
apply (event) {
event.preventDefault()
const value = event.params.value ?? this.addedValueValue
if (this.hasInputTarget && value !== undefined) {
this.inputTarget.value = value
this.checkAdded()
}
}
checkAdded () {
if (!this.hasInputTarget) return
const addedValue = this.hasAddedValueValue ? this.addedValueValue : null
if (!addedValue) return
const isMatch = (this.inputTarget.value.trim() === addedValue.trim())
const emeraldClasses = [
"border-emerald-500", "ring-1", "ring-emerald-500",
"focus:border-emerald-500", "focus:ring-emerald-500",
"bg-emerald-50/20"
]
const amberClasses = [
"border-amber-400", "ring-1", "ring-amber-400",
"focus:border-amber-400", "focus:ring-amber-400"
]
if (isMatch) {
this.inputTarget.classList.remove(...amberClasses)
this.inputTarget.classList.add(...emeraldClasses)
if (this.hasBadgeTarget) {
this.badgeTarget.innerHTML = `
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
`.trim()
this.badgeTarget.classList.remove("hidden")
}
if (this.hasSuggestionTarget) {
this.suggestionTarget.classList.add("hidden")
}
} else {
this.inputTarget.classList.remove(...emeraldClasses)
if (this.hasBadgeTarget) {
this.badgeTarget.classList.add("hidden")
}
if (this.hasSuggestionTarget) {
this.suggestionTarget.classList.remove("hidden")
}
}
}
}
@@ -0,0 +1,50 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "status", "loading", "content" ]
static values = { url: String }
connect () {
this.runStep("relays")
}
async runStep (step) {
const messages = {
relays: "Looking up your relay list…",
profile: "Fetching your profile…",
blossom: "Fetching your media server list…",
render: null
}
if (messages[step]) {
this.statusTarget.textContent = messages[step]
}
try {
const headers = (step === "render")
? { "Accept": "text/html" }
: { "Accept": "application/json" }
const res = await fetch(`${this.urlValue}?step=${step}`, { headers })
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
if (step === "render") {
const html = await res.text()
this.loadingTarget.classList.add("hidden")
this.contentTarget.classList.remove("hidden")
this.contentTarget.innerHTML = html
} else {
const data = await res.json()
if (data.next) {
this.runStep(data.next)
}
}
} catch (error) {
console.warn("Nostr metadata fetch failed:", error.message)
this.statusTarget.textContent = "Something went wrong. Please reload."
}
}
}
@@ -0,0 +1,40 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "list", "template", "addedBadge" ]
add (event) {
event.preventDefault()
const row = this.templateTarget.content.cloneNode(true)
this.listTarget.appendChild(row)
}
remove (event) {
event.preventDefault()
const row = event.target.closest("li")
row.remove()
}
checkAdded (event) {
const input = event.target
const addedValue = input.dataset.addedValue
if (!addedValue) return
const container = input.closest(".relative")
const badge = container?.querySelector("[data-relay-list-editor-target='addedBadge']")
const addedClasses = [
"border-emerald-500", "ring-1", "ring-emerald-500",
"focus:border-emerald-500", "focus:ring-emerald-500",
"bg-emerald-50/20"
]
if (input.value.trim() === addedValue.trim()) {
input.classList.add(...addedClasses)
if (badge) badge.classList.remove("hidden")
} else {
input.classList.remove(...addedClasses)
if (badge) badge.classList.add("hidden")
}
}
}
@@ -18,8 +18,24 @@ module Settings
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_blossom_server_url, type: :string,
default: ENV["NOSTR_BLOSSOM_SERVER_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://nos.lol
wss://relay.snort.social
wss://relay.primal.net
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
@@ -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
@@ -0,0 +1,21 @@
module NostrManager
class DiscoverUserRelays < 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: [10002],
limit: 1,
)
NostrManager::FetchLatestEvent.call(
relays: @relays,
filter: filter
)
end
end
end
+61
View File
@@ -0,0 +1,61 @@
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"
mutex.synchronize do
received_event.signal
end
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
@@ -0,0 +1,32 @@
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
events = []
begin
Timeout.timeout(TIMEOUT) do
@relays.each do |url|
event = NostrManager::FetchEvent.call(filter: @filter, relay_url: url)
if event.present? && events.none? { |e| e["id"] == event["id"] }
events << event
break if events.size >= @max_events
end
end
end
rescue Timeout::Error
Rails.logger.debug "[nostr] Timeout after #{TIMEOUT}s, found #{events.size} events"
end
events.max_by { |e| e["created_at"] }
end
end
end
+7 -7
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
+5
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
@@ -29,15 +29,35 @@
title: "Relay URL", title: "Relay URL",
description: "Websockets URL of a relay associated with #{Setting.primary_domain}" description: "Websockets URL of a relay associated with #{Setting.primary_domain}"
) %> ) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_blossom_server_url,
title: "Blossom server URL",
description: "HTTP URL of the Blossom media server to add to users' server lists during onboarding"
) %>
</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 %>
+1 -1
View File
@@ -305,7 +305,7 @@
<td>remoteStorage</td> <td>remoteStorage</td>
<td> <td>
<%= render FormElements::ToggleComponent.new( <%= render FormElements::ToggleComponent.new(
enabled: @services_enabled.include?("remotestorage"), enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
input_enabled: false input_enabled: false
) %> ) %>
</td> </td>
+2 -1
View File
@@ -43,7 +43,8 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% if Setting.remotestorage_enabled? %> <% if Setting.remotestorage_enabled? &&
Flipper.enabled?(:remotestorage, current_user) %>
<div class="border border-gray-300 rounded-md hover:border-gray-400 <div class="border border-gray-300 rounded-md hover:border-gray-400
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
bg-[url(/img/logos/icon_remotestorage.svg)]"> bg-[url(/img/logos/icon_remotestorage.svg)]">
+48 -36
View File
@@ -1,47 +1,46 @@
<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 and other stuff on our Nostr relay</li>
<% end %>
<% if Setting.nostr_blossom_server_url.present? %>
<li>Publish media files on our Nostr media server</li>
<% end %>
</ul>
<% end %> <% end %>
<div data-settings--nostr-pubkey-target="noExtension" <div data-settings--nostr-pubkey-target="noExtension"
@@ -58,8 +57,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 +85,18 @@
</button> </button>
</p> </p>
<% end %> <% end %>
</div> </section>
</section>
<% if current_user.nostr_pubkey.present? %>
<div data-controller="nostr-metadata"
data-nostr-metadata-url-value="<%= nostr_metadata_settings_path %>">
<div data-nostr-metadata-target="loading"
class="flex flex-col items-center justify-center py-16">
<div class="mb-4 h-12 w-12"></div>
<p data-nostr-metadata-target="status"
class="text-gray-500 animate-pulse">Loading…</p>
</div>
<div data-nostr-metadata-target="content" class="hidden"></div>
</div>
<% end %>
</div>
@@ -0,0 +1,25 @@
<section class="">
<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>
<section class="mb-8 sm:mb-12">
<h3>Relays</h3>
<%= render Settings::NostrRelayStatusComponent.new(
nip65_event: @nip65_event
) %>
</section>
+7 -6
View File
@@ -19,18 +19,19 @@
active: @settings_section.to_s == "email" active: @settings_section.to_s == "email"
) %> ) %>
<% end %> <% end %>
<% if Setting.remotestorage_enabled? &&
Flipper.enabled?(:remotestorage, current_user) %>
<%= render SidenavLinkComponent.new(
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
active: @settings_section.to_s == "remotestorage"
) %>
<% end %>
<% if Setting.lndhub_enabled %> <% if Setting.lndhub_enabled %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
name: "Lightning", path: setting_path(:lightning), icon: "zap", name: "Lightning", path: setting_path(:lightning), icon: "zap",
active: @settings_section.to_s == "lightning" active: @settings_section.to_s == "lightning"
) %> ) %>
<% end %> <% end %>
<% if Setting.remotestorage_enabled? %>
<%= render SidenavLinkComponent.new(
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
active: @settings_section.to_s == "remotestorage"
) %>
<% 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",
@@ -0,0 +1,3 @@
<div>
<%= profile.inspect %>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@ Rails.application.configure do
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
config.cache_store = :null_store config.cache_store = :memory_store
end end
# Don't care if the mailer can't send. # Don't care if the mailer can't send.
+1
View File
@@ -68,6 +68,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_metadata', as: 'nostr_metadata'
end end
end end
+3 -6
View File
@@ -1,9 +1,8 @@
require 'sidekiq/testing'
ldap = LdapService.new ldap = LdapService.new
original_queue_adapter = ActiveJob::Base.queue_adapter Sidekiq::Testing.inline! do
ActiveJob::Base.queue_adapter = :inline
begin
ldap.delete_all_users! ldap.delete_all_users!
puts "Create user: admin" puts "Create user: admin"
@@ -26,6 +25,4 @@ begin
password: "user is user", confirmed: true password: "user is user", confirmed: true
}) })
end end
ensure
ActiveJob::Base.queue_adapter = original_queue_adapter
end end
+14 -40
View File
@@ -12,20 +12,6 @@ services:
DS_DM_PASSWORD: passthebutter DS_DM_PASSWORD: passthebutter
SUFFIX_NAME: "dc=kosmos,dc=org" SUFFIX_NAME: "dc=kosmos,dc=org"
ldap-init:
image: 4teamwork/389ds:latest
networks:
- internal_network
volumes:
- ./docker/ldap-init.sh:/ldap-init.sh:ro
environment:
LDAP_ADMIN_PASSWORD: passthebutter
LDAP_SUFFIX: "dc=kosmos,dc=org"
depends_on:
ldap:
condition: service_healthy
command: ["/bin/sh", "/ldap-init.sh"]
redis: redis:
restart: always restart: always
image: redis:7-alpine image: redis:7-alpine
@@ -40,7 +26,7 @@ services:
web: web:
build: . build: .
tty: true tty: true
command: ["bash", "docker/web-entrypoint.sh"] command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev"
volumes: volumes:
- .:/akkounts - .:/akkounts
- /akkounts/node_modules - /akkounts/node_modules
@@ -71,28 +57,20 @@ services:
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
NOSTR_RELAY_URL: ws://strfry:7777 NOSTR_RELAY_URL: ws://strfry:7777
depends_on: depends_on:
ldap: - ldap
condition: service_started - redis
ldap-init:
condition: service_completed_successfully
redis:
condition: service_started
garage: minio:
image: dxflrs/garage:v2.4.1 image: quay.io/minio/minio:latest
command: ["/garage", "server", "--single-node", "--default-bucket"] command: "server /data --console-address ':9001'"
networks: networks:
- external_network - external_network
- internal_network - internal_network
ports: ports:
- "3900:3900" - "9000:9000"
- "9001:9001"
volumes: volumes:
- ./docker/garage/garage.toml:/etc/garage.toml:ro - minio-data:/data
- garage-data:/var/lib/garage
environment:
GARAGE_DEFAULT_ACCESS_KEY: dev-key1
GARAGE_DEFAULT_SECRET_KEY: "1234567890123456"
GARAGE_DEFAULT_BUCKET: remotestorage
liquor-cabinet: liquor-cabinet:
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1 image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
@@ -101,21 +79,17 @@ services:
- internal_network - internal_network
ports: ports:
- "4567:4567" - "4567:4567"
volumes:
- ./docker/liquor-cabinet/rainbows.conf.rb:/etc/liquor-cabinet/rainbows.conf.rb:ro
command: ["bundle", "exec", "rainbows", "-c", "/etc/liquor-cabinet/rainbows.conf.rb", "--listen", "0.0.0.0:4567"]
environment: environment:
RACK_ENV: staging RACK_ENV: staging
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_DB: 1 REDIS_DB: 1
S3_ENDPOINT: http://garage:3900 S3_ENDPOINT: http://minio:9000
S3_REGION: garage S3_ACCESS_KEY: dev-key
S3_ACCESS_KEY: dev-key1 S3_SECRET_KEY: 123456789
S3_SECRET_KEY: "1234567890123456"
S3_BUCKET: remotestorage S3_BUCKET: remotestorage
depends_on: depends_on:
- garage - minio
- redis - redis
strfry: strfry:
@@ -153,7 +127,7 @@ networks:
volumes: volumes:
389ds-data: 389ds-data:
driver: local driver: local
garage-data: minio-data:
driver: local driver: local
redis-data: redis-data:
driver: local driver: local
-14
View File
@@ -1,14 +0,0 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -e
SUFFIX="${LDAP_SUFFIX:-dc=kosmos,dc=org}"
URI="ldap://ldap:3389"
if dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
backend suffix list --suffix 2>/dev/null | grep -Fqx "$SUFFIX"; then
echo "LDAP backend for $SUFFIX already exists, skipping."
else
echo "Creating LDAP backend for $SUFFIX..."
dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
backend create --suffix "$SUFFIX" --be-name dev
fi
-5
View File
@@ -1,5 +0,0 @@
Rainbows! do
use :ThreadPool
worker_connections 16
client_max_body_size 100 * 1024 * 1024
end
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -e
if [ ! -f tmp/.setup-complete ]; then
echo "First start: setting up LDAP entries and databases..."
bin/rails ldap:setup
bin/rails db:setup
touch tmp/.setup-complete
else
bin/rails db:prepare
fi
rm -f tmp/pids/server.pid
exec bin/dev
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe FormElements::FieldsetComponent, type: :component do
it "renders a div with flex gap-x-1 for vertical positioning" do
rendered = render_inline(described_class.new(title: "Username")) do
"<input type='text' name='username' />".html_safe
end
expect(rendered.css("label.block > div.flex.gap-x-1")).to be_present
expect(rendered.css("label.block > p.flex")).not_to be_present
end
it "renders a div for horizontal positioning" do
rendered = render_inline(described_class.new(positioning: :horizontal, title: "Enable notifications")) do
"<input type='checkbox' />".html_safe
end
expect(rendered.css("label.block > div.relative")).to be_present
end
end
@@ -0,0 +1,86 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Settings::NostrEditProfileComponent, type: :component do
let(:user) { build(:user, cn: "alice") }
before do
allow(user).to receive(:address).and_return("alice@kosmos.org")
allow(user).to receive(:display_name).and_return("Alice Wonderland")
end
describe "when profile event is missing or fields are empty" do
it "prefills NIP-05 and Lightning addresses with user address and marks them as added" do
rendered = render_inline(described_class.new(user: user, profile_event: nil))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@kosmos.org")
expect(nip05_input["class"]).to include("border-emerald-500")
expect(nip05_input["data-action"]).to include("input->field-suggestion#checkAdded")
lud16_input = rendered.css("input[name='user[lud16_address]']").first
expect(lud16_input["value"]).to eq("alice@kosmos.org")
expect(lud16_input["class"]).to include("border-emerald-500")
expect(lud16_input["data-action"]).to include("input->field-suggestion#checkAdded")
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(2)
end
end
describe "when profile has different NIP-05 and Lightning addresses" do
let(:profile_event) do
{
"content" => {
"nip05" => "alice@other.org",
"lud16" => "alice@getalby.com",
"display_name" => "Alice External"
}.to_json
}
end
it "shows current values with different badges and suggestion strips" do
rendered = render_inline(described_class.new(user: user, profile_event: profile_event))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@other.org")
expect(nip05_input["class"]).to include("border-amber-400")
lud16_input = rendered.css("input[name='user[lud16_address]']").first
expect(lud16_input["value"]).to eq("alice@getalby.com")
expect(lud16_input["class"]).to include("border-amber-400")
different_badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "different" }
expect(different_badges.size).to eq(2)
suggestion_buttons = rendered.css("button[data-action='click->field-suggestion#apply']")
expect(suggestion_buttons.size).to eq(2)
expect(suggestion_buttons.first["data-field-suggestion-value-param"]).to eq("alice@kosmos.org")
end
end
describe "when profile addresses match user address" do
let(:profile_event) do
{
"content" => {
"nip05" => "alice@kosmos.org",
"lud16" => "alice@kosmos.org",
"display_name" => "Alice Wonderland"
}.to_json
}
end
it "renders standard inputs without added or different badges" do
rendered = render_inline(described_class.new(user: user, profile_event: profile_event))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@kosmos.org")
expect(nip05_input["class"]).not_to include("border-emerald-500")
expect(nip05_input["class"]).not_to include("border-amber-400")
badges = rendered.css("span.rounded-full").select { |el| %w[added different].include?(el.text.strip) }
expect(badges).to be_empty
end
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Settings::NostrEditRelaysComponent, type: :component do
before do
allow(Setting).to receive(:nostr_relay_url).and_return("wss://nostr.kosmos.org")
allow(Setting).to receive(:nostr_discovery_relays).and_return([
"wss://nos.lol",
"wss://relay.damus.io"
])
end
describe "when user has no relay list (nip65_event is nil)" do
it "renders all relays with added status and badges" do
rendered = render_inline(described_class.new(nip65_event: nil))
expect(rendered.css("input[value='wss://nostr.kosmos.org']")).to be_present
expect(rendered.css("input[value='wss://nos.lol']")).to be_present
expect(rendered.css("input[value='wss://relay.damus.io']")).to be_present
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(3)
end
end
describe "when user has an existing relay list without site relay" do
let(:nip65_event) do
{
"kind" => 10002,
"tags" => [
["r", "wss://nos.lol"],
["r", "wss://relay.damus.io", "read"]
]
}
end
it "prepends the site relay marked as added, while existing relays are not added" do
rendered = render_inline(described_class.new(nip65_event: nip65_event))
relay_inputs = rendered.css("input[name='relay_urls[]']").map { |el| el["value"] }
expect(relay_inputs.first).to eq("wss://nostr.kosmos.org")
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(1)
site_input = rendered.css("input[value='wss://nostr.kosmos.org']").first
expect(site_input["class"]).to include("border-emerald-500")
expect(site_input["data-added-value"]).to eq("wss://nostr.kosmos.org")
expect(site_input["data-action"]).to include("input->relay-list-editor#checkAdded")
other_input = rendered.css("input[value='wss://nos.lol']").first
expect(other_input["class"]).not_to include("border-emerald-500")
expect(other_input["data-added-value"]).to be_nil
select = rendered.css("select[name='relay_markers[]']").first
expect(select["class"]).to include("w-36")
end
end
describe "when user already has site relay in their relay list" do
let(:nip65_event) do
{
"kind" => 10002,
"tags" => [
["r", "wss://nostr.kosmos.org"],
["r", "wss://nos.lol"]
]
}
end
it "renders relays without any added badges" do
rendered = render_inline(described_class.new(nip65_event: nip65_event))
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges).to be_empty
end
end
end
@@ -6,9 +6,7 @@ RSpec.describe Services::RsAuthsController, type: :controller do
before do before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true) allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil) allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([ allow_any_instance_of(Flipper).to receive(:enabled?).and_return(true)
{ services_enabled: ["remotestorage"] }
])
end end
describe "GET /services/storage/rs_auths/:id/launch_app" do describe "GET /services/storage/rs_auths/:id/launch_app" do
@@ -37,20 +35,6 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expect(response).to redirect_to(launch_url) expect(response).to redirect_to(launch_url)
end end
end end
context "when remoteStorage is not enabled for the user" do
before do
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([
{ services_enabled: [] }
])
get :launch_app, params: { id: 1 }
end
it "responds with forbidden" do
expect(response).to have_http_status(:forbidden)
end
end
end end
end end
end end
@@ -11,7 +11,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end end
describe "#create" do describe "#create" do
before(:each) { redis_rs_delete_keys("authorizations:*") }
after(:each) { clear_enqueued_jobs } after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") } after(:all) { redis_rs_delete_keys("authorizations:*") }
@@ -28,9 +27,10 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end end
it "stores a token in redis" do it "stores a token in redis" do
auth user_auth_keys = redis_rs.keys("authorizations:#{user.cn}:*")
expect(user_auth_keys.length).to eq(1)
authorizations = redis_rs.smembers("authorizations:#{user.cn}:#{auth.token}") authorizations = redis_rs.smembers(user_auth_keys.first)
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort) expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
end end
@@ -0,0 +1,88 @@
require 'rails_helper'
RSpec.describe NostrManager::FetchLatestEvent, type: :model do
let(:filter) { Nostr::Filter.new(authors: ["pubkey"], kinds: [0], limit: 1) }
let(:event_v1) { { "id" => "aaa", "created_at" => 1000, "content" => "v1" } }
let(:event_v2) { { "id" => "bbb", "created_at" => 2000, "content" => "v2" } }
describe "with no events found" do
it "returns nil" do
allow(NostrManager::FetchEvent).to receive(:call).and_return(nil)
result = described_class.call(relays: ["wss://a", "wss://b"], filter: filter)
expect(result).to be_nil
end
end
describe "with event on 1 relay only" do
it "returns the event" do
allow(NostrManager::FetchEvent).to receive(:call) do |args|
case args[:relay_url]
when "wss://a" then event_v1
else nil
end
end
result = described_class.call(
relays: ["wss://a", "wss://b", "wss://c"], filter: filter
)
expect(result).to eq(event_v1)
end
end
describe "with events on 2 relays, different versions" do
it "returns the latest event (max created_at)" do
allow(NostrManager::FetchEvent).to receive(:call) do |args|
case args[:relay_url]
when "wss://a" then event_v1
when "wss://b" then event_v2
end
end
result = described_class.call(relays: ["wss://a", "wss://b"], filter: filter)
expect(result).to eq(event_v2)
end
end
describe "with duplicate events (same ID) on 2 relays" do
it "returns the single event without double-counting" do
allow(NostrManager::FetchEvent).to receive(:call).and_return(event_v1)
result = described_class.call(relays: ["wss://a", "wss://b"], filter: filter)
expect(result).to eq(event_v1)
end
end
describe "with timeout after finding 1 event" do
it "still returns the found event" do
allow(NostrManager::FetchEvent).to receive(:call) do |args|
case args[:relay_url]
when "wss://a" then event_v1
when "wss://b" then raise Timeout::Error
end
end
result = described_class.call(relays: ["wss://a", "wss://b"], filter: filter)
expect(result).to eq(event_v1)
end
end
describe "with max_events reached" do
it "stops querying further relays" do
call_count = 0
allow(NostrManager::FetchEvent).to receive(:call) do |args|
call_count += 1
raise "should not query wss://c" if args[:relay_url] == "wss://c"
case args[:relay_url]
when "wss://a" then event_v1
when "wss://b" then event_v2
end
end
described_class.call(
relays: ["wss://a", "wss://b", "wss://c"], filter: filter
)
expect(call_count).to eq(2)
end
end
end