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
70 changed files with 1356 additions and 1268 deletions

No files matched your search

+1
View File
@@ -87,6 +87,7 @@
# NOSTR_PRIVATE_KEY='123456abcdef...'
# NOSTR_PUBLIC_KEY='123456abcdef...'
# NOSTR_RELAY_URL='wss://nostr.kosmos.org'
# NOSTR_BLOSSOM_SERVER_URL='https://blossom.kosmos.org'
# RS_STORAGE_URL='https://storage.kosmos.org'
# 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 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
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
First-time LDAP and database setup is automated: the `ldap-init` service
creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the
databases on first start and runs `db:prepare` on every boot. Manual
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`.
First-time LDAP setup (after creating the 389ds backend once):
`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
then `docker compose run web bin/rails ldap:setup`.
## 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
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
running for the first time, so you might want to do something else in the
meantime.
On the first start, the `ldap-init` service creates the 389ds back-end, and the
`web` container then seeds the LDAP directory and the databases automatically.
On every start, `web` also applies any pending database migrations.
4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"`
5. `docker compose run web rails ldap:setup`
6. `docker compose run web rails db:setup`
After these steps, you should have a working Rails app with a handful of test
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
On first start, the `ldap-init` service creates the dirsrv back-end
automatically, and the `web` container then seeds it with development entries.
To do either step manually (for example, after changing the setup), run:
After creating the Docker container for the first time (or after deleting it),
you need to run the following command once, in order to create the dirsrv
back-end:
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
docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
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
("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
container.
To reset the development environment completely, remove all volumes plus the
generated database files and the first-run marker, then start over:
#### Minio / remoteStorage
docker compose down -v
rm -f db/*.sqlite3 tmp/.setup-complete
docker compose up --build
If you want to run remoteStorage accounts locally, you will have to create the
respective bucket first. With the `minio` container running (run by default
when using Docker Compose), follow these steps:
#### Garage / remoteStorage
remoteStorage accounts use the `garage` S3-compatible object store. On first
start, Garage automatically configures a single-node cluster and creates the
`remotestorage` bucket together with a `dev-key1` access key (secret
`1234567890123456`), so no manual setup is required.
If you want to run remoteStorage accounts locally, the `garage` container is
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.
* `docker compose up web redis minio liquor-cabinet`
* Head to http://localhost:9001 and log in with user `minioadmin`, password
`minioadmin`
* Create a new bucket called `remotestorage` (or whatever you
change the `S3_BUCKET` config to)
* 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,
as it will automatically allow access to the bucket you created.
### Adding npm modules to use with Stimulus controllers
@@ -12,7 +12,7 @@
</p>
<% end %>
<%= tag.p class: "flex gap-x-1", data: {
<%= tag.div class: "flex gap-x-1", data: {
controller: @resettable ? "settings--resettable-field" : nil,
} do %>
<%= 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
+42 -144
View File
@@ -1,10 +1,7 @@
class Rs::OauthController < ApplicationController
skip_before_action :verify_authenticity_token, only: :token
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
before_action :set_cors_headers, only: :token
def new
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
@@ -12,12 +9,9 @@ class Rs::OauthController < ApplicationController
@redirect_uri = params[:redirect_uri]
@client_id = params[:client_id]
@state = params[:state]
@response_type = params[:response_type].presence || "token"
@code_challenge = params[:code_challenge]
@code_challenge_method = params[:code_challenge_method].presence || "S256"
@root_access_requested = (@scopes & [":r",":rw"]).any?
@denial_url = error_url(@redirect_uri, "error=access_denied", @state)
@denial_url = url_with_state("#{@redirect_uri}#error=access_denied", @state)
@expire_at_dates = [["Never", nil],
["In 1 month", 1.month.from_now],
@@ -26,45 +20,35 @@ class Rs::OauthController < ApplicationController
unless current_user == @user
sign_out :user
redirect_params = {
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state]
}
if @response_type == "code"
redirect_params[:response_type] = "code"
redirect_params[:code_challenge] = @code_challenge
redirect_params[:code_challenge_method] = @code_challenge_method
end
redirect_to new_rs_oauth_url(@user.cn, redirect_params)
redirect_to new_rs_oauth_url(@user.cn,
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state])
return
end
unless @client_id.present?
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state),
allow_other_host: true) and return
end
if @scopes.empty?
error_redirect(@redirect_uri, "error=invalid_scope", @state) and return
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state),
allow_other_host: true) and return
end
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
error_redirect(@redirect_uri, "error=invalid_client", @state) and return
end
unless %w[token code].include?(@response_type)
error_redirect(@redirect_uri, "error=unsupported_response_type", @state) and return
end
if @response_type == "code" &&
!valid_code_challenge?(@code_challenge, @code_challenge_method)
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_client", @state),
allow_other_host: true) and return
end
@client_id.gsub!(/http(s)?:\/\//, "")
if auth = current_user.remote_storage_authorizations.valid.where(permissions: @scopes, client_id: @client_id).first
redirect_to(url_with_state("#{@redirect_uri}#access_token=#{auth.token}", @state),
allow_other_host: true) and return
end
end
def create
@@ -73,7 +57,6 @@ class Rs::OauthController < ApplicationController
http_status :forbidden and return
end
response_type = params[:response_type].presence || "token"
permissions = parse_scopes params[:scope]
redirect_uri = params[:redirect_uri].presence
client_id = params[:client_id].presence
@@ -81,81 +64,32 @@ class Rs::OauthController < ApplicationController
expire_at = params[:expire_at].presence
if permissions.empty?
error_redirect(redirect_uri, "error=invalid_scope", state, response_type) and return
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return
end
unless client_id.present?
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state),
allow_other_host: true) and return
end
unless hostname_of(client_id) == hostname_of(redirect_uri)
error_redirect(redirect_uri, "error=invalid_client", state, response_type) and return
end
unless %w[token code].include?(response_type)
error_redirect(redirect_uri, "error=unsupported_response_type", state, response_type) and return
redirect_to(url_with_state("#{redirect_uri}#error=invalid_client", state),
allow_other_host: true) and return
end
client_id.gsub!(/http(s)?:\/\//, "")
if response_type == "code"
challenge = params[:code_challenge].presence
method = params[:code_challenge_method].presence || "S256"
unless valid_code_challenge?(challenge, method)
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
end
code = RsOauthManager::IssueCode.call(
user: current_user,
client_id: client_id,
redirect_uri: redirect_uri,
permissions: permissions,
expire_at: expire_at,
user_agent: request.user_agent,
code_challenge: challenge,
code_challenge_method: method
)
redirect_to url_with_state("#{redirect_uri}#{query_separator(redirect_uri)}code=#{CGI.escape(code.code)}", state),
allow_other_host: true
else
auth = current_user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at,
user_agent: request.user_agent
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
end
end
def token
return head :ok if request.options?
unless params[:grant_type] == "authorization_code"
return render_token_error("unsupported_grant_type")
end
if params[:code].blank? || params[:code_verifier].blank? ||
params[:client_id].blank? || params[:redirect_uri].blank?
return render_token_error("invalid_request")
end
auth = RsOauthManager::ExchangeCode.call(
code: params[:code],
client_id: params[:client_id],
redirect_uri: params[:redirect_uri],
code_verifier: params[:code_verifier]
auth = current_user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at
)
render_token(token_response(auth))
rescue RsOauthManagerService::InvalidGrant
render_token_error("invalid_grant")
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
end
private
@@ -171,60 +105,24 @@ class Rs::OauthController < ApplicationController
end
end
def set_cors_headers
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
headers["Access-Control-Allow-Headers"] = "Content-Type"
end
def token_response(auth)
body = {
access_token: auth.token,
token_type: "bearer",
scope: auth.permissions.join(" ")
}
if auth.expire_at.present?
body[:expires_in] = (auth.expire_at - Time.current).to_i
end
body
end
def hostname_of(uri)
RsOauthManagerService.hostname_of(uri)
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end
def parse_scopes(scope_string)
RsOauthManagerService.parse_scopes(scope_string)
end
return [] if scope_string.blank?
def error_redirect(redirect_uri, error, state, response_type = @response_type)
redirect_to error_url(redirect_uri, error, state, response_type),
allow_other_host: true
end
scopes = scope_string.
gsub(/\[|\]/, "").
gsub(/\,/, " ").
gsub(/\/:/, ":").
split(/\s/).map(&:strip).
reject(&:empty?)
def error_url(redirect_uri, error, state, response_type = @response_type)
separator = response_type == "code" ? query_separator(redirect_uri) : "#"
url_with_state("#{redirect_uri}#{separator}#{error}", state)
end
scopes = [":r"] if scopes.include?("*:r")
scopes = [":rw"] if scopes.include?("*:rw")
def query_separator(uri)
uri.to_s.include?("?") ? "&" : "?"
end
def valid_code_challenge?(challenge, method)
method == "S256" && challenge.to_s.match?(/\A[A-Za-z0-9\-._~]{43,128}\z/)
end
def render_token(body)
response.headers["Cache-Control"] = "no-store"
render json: body
end
def render_token_error(error)
response.headers["Cache-Control"] = "no-store"
render json: { error: error }, status: :bad_request
scopes
end
def url_with_state(url, state)
@@ -1,7 +1,7 @@
class Services::RemotestorageController < Services::BaseController
before_action :authenticate_user!
before_action :require_service_available
before_action :require_service_enabled
before_action :require_feature_enabled
# Dashboard
def show
@@ -17,8 +17,8 @@ class Services::RemotestorageController < Services::BaseController
http_status :not_found unless Setting.remotestorage_enabled?
end
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
def require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden
end
end
@@ -1,7 +1,8 @@
class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_service_enabled
before_action :require_feature_enabled
before_action :require_service_available
# before_action :require_service_enabled
before_action :find_rs_auth, only: [:destroy, :launch_app]
def index
@@ -22,15 +23,10 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
user_address =
if Rails.env.development?
"#{current_user.cn}@#{request.host_with_port}"
else
current_user.address
end
user_address = Rails.env.development? ?
"#{current_user.cn}@localhost:3000" :
current_user.address
# Storage-first bearer token issuance (remoteStorage spec §11): the app
# receives the address and runs the application-first flow itself.
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
redirect_to launch_url, allow_other_host: true
@@ -38,8 +34,8 @@ class Services::RsAuthsController < Services::BaseController
private
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
def require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden
end
end
+58 -2
View File
@@ -4,8 +4,13 @@ require "bcrypt"
class SettingsController < ApplicationController
before_action :authenticate_user!
before_action :set_main_nav_section
before_action :set_settings_section, only: [:show, :update, :update_email, :reset_email_password]
before_action :set_user, only: [:show, :update, :update_email, :reset_email_password]
before_action :set_settings_section, only: [
:show, :update, :update_email, :reset_email_password
]
before_action :set_user, only: [
:show, :update, :update_email, :reset_email_password,
:fetch_nostr_metadata
]
def index
redirect_to setting_path(:profile)
@@ -143,12 +148,63 @@ class SettingsController < ApplicationController
}
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
def set_main_nav_section
@current_section = :settings
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
@settings_section = params[:section]
allowed_sections = [
+3 -26
View File
@@ -88,42 +88,19 @@ class WebfingerController < WellKnownController
end
def remotestorage_link
auth_url =
if Rails.env.development?
new_rs_oauth_url(@username)
else
new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
end
storage_url = "#{remotestorage_storage_base_url}/#{@username}"
auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
storage_url = "#{Setting.rs_storage_url}/#{@username}"
{
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
href: storage_url,
properties: {
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-27",
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
"http://tools.ietf.org/html/rfc6749#section-3.1" => auth_url,
"http://tools.ietf.org/html/rfc6749#section-3.2" => token_endpoint_url,
"http://tools.ietf.org/html/rfc7636" => "S256",
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
"http://tools.ietf.org/html/rfc7233": "GET", # content range requests
"http://remotestorage.io/spec/web-authoring": nil
}
}
end
def token_endpoint_url
return rs_oauth_token_url if Rails.env.development?
rs_oauth_token_url(host: Setting.rs_accounts_domain)
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
@@ -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,
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,
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
+2 -2
View File
@@ -17,8 +17,8 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token
before_create :store_token_in_redis
before_create :find_or_create_web_app
after_commit :schedule_token_expiry, on: :create
after_commit :notify_user, on: :create
after_create :schedule_token_expiry
after_create :notify_user
before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job
-33
View File
@@ -1,33 +0,0 @@
class RsAuthorizationCode < ApplicationRecord
belongs_to :user
CODE_TTL = 60.seconds
serialize :permissions, coder: JSON
attr_accessor :code
before_validation :generate_code
validates :code_challenge, presence: true,
format: { with: /\A[A-Za-z0-9\-._~]{43,128}\z/ }
validates :code_challenge_method, inclusion: { in: %w[S256] }
scope :active, -> { where(expires_at: Time.current..) }
def self.find_by_code(raw_code)
return nil if raw_code.blank?
active.find_by(code_digest: Digest::SHA256.hexdigest(raw_code))
end
private
def generate_code
return if code_digest.present?
self.code = SecureRandom.urlsafe_base64(32)
self.code_digest = Digest::SHA256.hexdigest(code)
self.expires_at ||= CODE_TTL.from_now
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
client.on :connect do
puts "#{log_prefix} Publishing #{event.id}..."
Rails.logger.debug "#{log_prefix} Publishing #{event.id}..."
client.publish event
end
client.on :error do |e|
puts "#{log_prefix} Error: #{e}"
puts "#{log_prefix} Closing thread..."
Rails.logger.debug "#{log_prefix} Error: #{e}"
Rails.logger.debug "#{log_prefix} Closing thread..."
thread.exit
end
client.on :message do |m|
puts "#{log_prefix} Message: #{m}"
Rails.logger.debug "#{log_prefix} Message: #{m}"
msg = JSON.parse(m) rescue []
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
puts "#{log_prefix} Unexpected message from relay. Closing thread..."
Rails.logger.debug "#{log_prefix} Unexpected message from relay. Closing thread..."
end
thread.exit
end
puts "#{log_prefix} Connecting to #{relay.url}..."
Rails.logger.debug "#{log_prefix} Connecting to #{relay.url}..."
client.connect relay
end
+5
View File
@@ -3,6 +3,7 @@ require "nostr"
class NostrManagerService < ApplicationService
def parse_tags(tags)
out = {}
# TODO support more than 1 item for each tag type
tags.each do |tag|
out[tag[0].to_sym] = tag[1, tag.length]
end
@@ -19,4 +20,8 @@ class NostrManagerService < ApplicationService
def site_user
Nostr::User.new(keypair: site_keypair)
end
def new_relay(url)
Nostr::Relay.new(url: url, name: URI.parse(url).host)
end
end
@@ -1,47 +0,0 @@
module RsOauthManager
class ExchangeCode < RsOauthManagerService
def initialize(code:, client_id:, redirect_uri:, code_verifier:)
@code = code
@client_id = client_id
@redirect_uri = redirect_uri
@code_verifier = code_verifier
end
def call
auth_code = RsAuthorizationCode.find_by_code(@code)
raise InvalidGrant if auth_code.nil?
raise InvalidGrant unless hostname_of(@client_id) == hostname_of(@redirect_uri)
raise InvalidGrant unless hostname_of(@client_id) == hostname_of(auth_code.client_id)
raise InvalidGrant unless @redirect_uri == auth_code.redirect_uri
raise InvalidGrant unless VerifyPkce.call(
code_challenge: auth_code.code_challenge,
code_challenge_method: auth_code.code_challenge_method,
code_verifier: @code_verifier
)
user = auth_code.user
auth = nil
# Claim the code atomically so a concurrent redemption can't issue a
# second token. The whole exchange is one transaction: if creating the
# authorization fails, the code is not consumed and can be retried.
RsAuthorizationCode.transaction do
claimed = RsAuthorizationCode.where(id: auth_code.id).delete_all
raise InvalidGrant if claimed != 1
auth = user.remote_storage_authorizations.create!(
permissions: auth_code.permissions,
client_id: auth_code.client_id,
redirect_uri: auth_code.redirect_uri,
app_name: auth_code.client_id,
expire_at: auth_code.permission_expire_at,
user_agent: auth_code.user_agent
)
end
auth
end
end
end
@@ -1,29 +0,0 @@
module RsOauthManager
class IssueCode < RsOauthManagerService
def initialize(user:, client_id:, redirect_uri:, permissions:,
code_challenge:, code_challenge_method: "S256",
expire_at: nil, user_agent: nil)
@user = user
@client_id = client_id
@redirect_uri = redirect_uri
@permissions = permissions
@code_challenge = code_challenge
@code_challenge_method = code_challenge_method
@expire_at = expire_at
@user_agent = user_agent
end
def call
RsAuthorizationCode.create!(
user: @user,
client_id: @client_id,
redirect_uri: @redirect_uri,
permissions: @permissions,
permission_expire_at: @expire_at,
user_agent: @user_agent,
code_challenge: @code_challenge,
code_challenge_method: @code_challenge_method
)
end
end
end
@@ -1,21 +0,0 @@
module RsOauthManager
class VerifyPkce < RsOauthManagerService
def initialize(code_challenge:, code_challenge_method:, code_verifier:)
@code_challenge = code_challenge
@code_challenge_method = code_challenge_method
@code_verifier = code_verifier.to_s
end
def call
return false unless @code_challenge_method == "S256"
expected = Base64.urlsafe_encode64(
Digest::SHA256.digest(@code_verifier), padding: false
)
ActiveSupport::SecurityUtils.secure_compare(
expected, @code_challenge.to_s
)
end
end
end
-27
View File
@@ -1,27 +0,0 @@
class RsOauthManagerService < ApplicationService
InvalidGrant = Class.new(StandardError)
def self.parse_scopes(scope_string)
return [] if scope_string.blank?
scopes = scope_string.
gsub(/\[|\]/, "").
gsub(/\,/, " ").
gsub(/\/:/, ":").
split(/\s/).map(&:strip).
reject(&:empty?)
scopes = [":r"] if scopes.include?("*:r")
scopes = [":rw"] if scopes.include?("*:rw")
scopes
end
def self.hostname_of(uri)
uri.to_s.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end
def hostname_of(uri)
self.class.hostname_of(uri)
end
end
@@ -29,15 +29,35 @@
title: "Relay URL",
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>
</section>
<section>
<h3>Zaps</h3>
<ul role="list">
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :nostr_zaps_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>
</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 %>
+1 -1
View File
@@ -305,7 +305,7 @@
<td>remoteStorage</td>
<td>
<%= render FormElements::ToggleComponent.new(
enabled: @services_enabled.include?("remotestorage"),
enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
input_enabled: false
) %>
</td>
+2 -1
View File
@@ -43,7 +43,8 @@
<% end %>
</div>
<% 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
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
bg-[url(/img/logos/icon_remotestorage.svg)]">
-3
View File
@@ -40,9 +40,6 @@
<%= f.hidden_field :user_id, value: @user.id %>
<%= f.hidden_field :client_id, value: @client_id %>
<%= f.hidden_field :state, value: @state %>
<%= f.hidden_field :response_type, value: @response_type %>
<%= f.hidden_field :code_challenge, value: @code_challenge %>
<%= f.hidden_field :code_challenge_method, value: @code_challenge_method %>
<p class="mt-8 mb-6">
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
+48 -36
View File
@@ -1,47 +1,46 @@
<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-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">
<p class="<%= current_user.nostr_pubkey.present? ? '' : 'hidden' %> mt-2 flex gap-1">
<div data-controller="settings--nostr-pubkey"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">
<section class="mb-8 sm:mb-12">
<h3>Nostr</h3>
<h4 class="mb-0">
Public Key
</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
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,
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 %>
Remove
<% end %>
</p>
<% if current_user.nostr_pubkey.present? %>
<div class="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 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>
<!-- <div> -->
<!-- Pubkey present -->
<!-- </div> -->
<% else %>
<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.
Verify your Nostr public key with us in order to enable Nostr-specific
features for your account:
</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 %>
<div data-settings--nostr-pubkey-target="noExtension"
@@ -58,8 +57,8 @@
</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.
We recommend Alby, which you can also use a wallet for your
Lightning account.
</p>
</div>
<div class="mt-4">
@@ -86,5 +85,18 @@
</button>
</p>
<% 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"
) %>
<% 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 %>
<%= render SidenavLinkComponent.new(
name: "Lightning", path: setting_path(:lightning), icon: "zap",
active: @settings_section.to_s == "lightning"
) %>
<% 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 %>
<%= render SidenavLinkComponent.new(
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
config.action_controller.perform_caching = false
config.cache_store = :null_store
config.cache_store = :memory_store
end
# Don't care if the mailer can't send.
-5
View File
@@ -8,8 +8,3 @@
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
purge_rs_authorization_codes:
command: "RsAuthorizationCode.where(expires_at: ..1.hour.ago).delete_all"
schedule: every hour
+1 -2
View File
@@ -68,6 +68,7 @@ Rails.application.routes.draw do
post 'reset_email_password'
post 'set_nostr_pubkey'
delete 'nostr_pubkey', to: 'settings#remove_nostr_pubkey'
get 'fetch_nostr_metadata', as: 'nostr_metadata'
end
end
@@ -115,8 +116,6 @@ Rails.application.routes.draw do
end
namespace :rs do
post 'oauth/token', to: 'oauth#token', as: :oauth_token
match 'oauth/token', to: 'oauth#token', via: :options
resource :oauth, only: [:new, :create], path_names: {
new: ':username', create: ':username'
}, controller: 'oauth'
@@ -1,19 +0,0 @@
class CreateRsAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
create_table :rs_authorization_codes do |t|
t.references :user, null: false, foreign_key: true
t.string :client_id, null: false
t.string :redirect_uri, null: false
t.text :permissions, default: [].to_json
t.datetime :permission_expire_at
t.string :code_digest, null: false
t.string :code_challenge, null: false
t.string :code_challenge_method, null: false, default: "S256"
t.datetime :expires_at, null: false
t.timestamps
end
add_index :rs_authorization_codes, :code_digest, unique: true
end
end
@@ -1,6 +0,0 @@
class AddUserAgentToRsAuthorizations < ActiveRecord::Migration[8.1]
def change
add_column :remote_storage_authorizations, :user_agent, :text
add_column :rs_authorization_codes, :user_agent, :text
end
end
+1 -20
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
ActiveRecord::Schema[8.1].define(version: 2025_05_28_092931) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -110,7 +110,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
t.string "redirect_uri"
t.string "token"
t.datetime "updated_at", null: false
t.text "user_agent"
t.integer "user_id", null: false
t.integer "web_app_id"
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
@@ -118,23 +117,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
end
create_table "rs_authorization_codes", force: :cascade do |t|
t.string "client_id", null: false
t.string "code_challenge", null: false
t.string "code_challenge_method", default: "S256", null: false
t.string "code_digest", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.datetime "permission_expire_at"
t.text "permissions", default: "[]"
t.string "redirect_uri", null: false
t.datetime "updated_at", null: false
t.text "user_agent"
t.integer "user_id", null: false
t.index ["code_digest"], name: "index_rs_authorization_codes_on_code_digest", unique: true
t.index ["user_id"], name: "index_rs_authorization_codes_on_user_id"
end
create_table "settings", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -182,6 +164,5 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users"
add_foreign_key "rs_authorization_codes", "users"
add_foreign_key "zaps", "users"
end
+3 -6
View File
@@ -1,9 +1,8 @@
require 'sidekiq/testing'
ldap = LdapService.new
original_queue_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :inline
begin
Sidekiq::Testing.inline! do
ldap.delete_all_users!
puts "Create user: admin"
@@ -26,6 +25,4 @@ begin
password: "user is user", confirmed: true
})
end
ensure
ActiveJob::Base.queue_adapter = original_queue_adapter
end
+14 -42
View File
@@ -1,7 +1,6 @@
services:
ldap:
image: 4teamwork/389ds:latest
platform: linux/amd64
volumes:
- 389ds-data:/data
networks:
@@ -13,21 +12,6 @@ services:
DS_DM_PASSWORD: passthebutter
SUFFIX_NAME: "dc=kosmos,dc=org"
ldap-init:
image: 4teamwork/389ds:latest
platform: linux/amd64
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:
restart: always
image: redis:7-alpine
@@ -42,7 +26,7 @@ services:
web:
build: .
tty: true
command: ["bash", "docker/web-entrypoint.sh"]
command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev"
volumes:
- .:/akkounts
- /akkounts/node_modules
@@ -73,28 +57,20 @@ services:
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
NOSTR_RELAY_URL: ws://strfry:7777
depends_on:
ldap:
condition: service_started
ldap-init:
condition: service_completed_successfully
redis:
condition: service_started
- ldap
- redis
garage:
image: dxflrs/garage:v2.4.1
command: ["/garage", "server", "--single-node", "--default-bucket"]
minio:
image: quay.io/minio/minio:latest
command: "server /data --console-address ':9001'"
networks:
- external_network
- internal_network
ports:
- "3900:3900"
- "9000:9000"
- "9001:9001"
volumes:
- ./docker/garage/garage.toml:/etc/garage.toml:ro
- garage-data:/var/lib/garage
environment:
GARAGE_DEFAULT_ACCESS_KEY: dev-key1
GARAGE_DEFAULT_SECRET_KEY: "1234567890123456"
GARAGE_DEFAULT_BUCKET: remotestorage
- minio-data:/data
liquor-cabinet:
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
@@ -103,21 +79,17 @@ services:
- internal_network
ports:
- "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:
RACK_ENV: staging
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_DB: 1
S3_ENDPOINT: http://garage:3900
S3_REGION: garage
S3_ACCESS_KEY: dev-key1
S3_SECRET_KEY: "1234567890123456"
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: dev-key
S3_SECRET_KEY: 123456789
S3_BUCKET: remotestorage
depends_on:
- garage
- minio
- redis
strfry:
@@ -155,7 +127,7 @@ networks:
volumes:
389ds-data:
driver: local
garage-data:
minio-data:
driver: local
redis-data:
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
+28 -289
View File
@@ -128,10 +128,8 @@ RSpec.describe Rs::OauthController, type: :controller do
}
end
it "does not reuse the existing token (renders the consent dialog)" do
expect(response.response_code).to eq(200)
expect(response.location).to be_nil
expect(user.remote_storage_authorizations.count).to eq(1)
it "redirects to the redirect_uri with the existing token" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
end
end
@@ -149,100 +147,36 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
end
context "when response_type is code" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
state: "foobar123"
}
context "with different redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/a_new_route",
client_id: "https://example.com"
}
end
it "redirects to the new redirect_uri" do
expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
end
end
it "renders the consent dialog and exposes the code challenge" do
expect(response.response_code).to eq(200)
expect(assigns["response_type"]).to eq("code")
expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(assigns["code_challenge_method"]).to eq("S256")
end
context "with state param given" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "uses a query-based denial url" do
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
end
end
context "when response_type is code without a code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
state: "foobar123"
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
end
context "when response_type is code with the plain method" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
context "with an unsupported response_type" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "id_token"
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with a malformed code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "too-short",
code_challenge_method: "S256"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
it "redirects to the redirect_uri with token and state" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
end
end
@@ -346,12 +280,10 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when user is signed in" do
before do
sign_in user
request.user_agent = "RSpec Device"
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "when no valid token exists" do
@@ -371,10 +303,6 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
it "stores the device's user agent" do
expect(@auth.user_agent).to eq("RSpec Device")
end
it "redirects to the redirect_uri" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
@@ -484,101 +412,6 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
context "with response_type=code" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
@code = RsAuthorizationCode.last
end
it "creates an authorization code" do
expect(@code.permissions).to eq(%w(documents photos contacts:rw))
expect(@code.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(@code.code_challenge_method).to eq("S256")
expect(@code.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
expect(user.reload.remote_storage_authorizations.count).to eq(0)
end
it "redirects with the code in the query string" do
location = response.location
expect(location).to match(%r{\Ahttps://example\.com\?code=.+&state=foobar123\z})
raw_code = CGI.parse(URI.parse(location).query)["code"].first
expect(RsAuthorizationCode.find_by_code(raw_code)).to eq(@code)
end
end
context "with response_type=code but no code_challenge" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
it "does not create an authorization code" do
expect(RsAuthorizationCode.count).to eq(0)
end
end
context "with an unsupported response_type" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
response_type: "id_token",
expire_at: 1.month.from_now
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with response_type=code and a redirect_uri that already has a query" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com/?foo=bar",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
end
it "appends the code with an ampersand" do
expect(response.location).to match(%r{\Ahttps://example\.com/\?foo=bar&code=.+&state=foobar123\z})
end
end
context "when the user_id is different from the signed in user" do
before do
post :create, params: {
@@ -618,98 +451,4 @@ RSpec.describe Rs::OauthController, type: :controller do
end
end
end
describe "POST /rs/oauth/token" do
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
def issue_code
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
end
def token_params(code, **overrides)
{
grant_type: "authorization_code",
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
}.merge(overrides)
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "with a valid code" do
before do
@code = issue_code
post :token, params: token_params(@code)
end
it "returns an access token" do
expect(response.response_code).to eq(200)
json = JSON.parse(response.body)
expect(json["access_token"]).to be_present
expect(json["token_type"]).to eq("bearer")
expect(json["scope"]).to eq("documents:rw")
end
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
it "sets CORS headers" do
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
end
end
context "with a bad code_verifier" do
before do
@code = issue_code
post :token, params: token_params(@code, code_verifier: "wrong-verifier")
end
it "returns invalid_grant" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_grant")
end
end
context "with an unsupported grant_type" do
before { post :token, params: { grant_type: "password" } }
it "returns unsupported_grant_type" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("unsupported_grant_type")
end
end
context "with missing required parameters" do
before { post :token, params: { grant_type: "authorization_code", code: "x" } }
it "returns invalid_request" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_request")
end
end
context "on error" do
before { post :token, params: { grant_type: "password" } }
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
end
end
end
@@ -6,12 +6,10 @@ RSpec.describe Services::RsAuthsController, type: :controller do
before do
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(LdapService).to receive(:fetch_users).and_return([
{ services_enabled: ["remotestorage"] }
])
allow_any_instance_of(Flipper).to receive(:enabled?).and_return(true)
end
describe "GET /services/storage/rs_auths/:id/launch_app (storage-first)" do
describe "GET /services/storage/rs_auths/:id/launch_app" do
context "when user is signed in" do
before do
sign_in user
@@ -32,28 +30,10 @@ RSpec.describe Services::RsAuthsController, type: :controller do
@auth.destroy
end
it "hands the app the user address in the URL fragment" do
it "redirects to the given URL with the correct RS URL fragment params" do
launch_url = "https://app.example.com#remotestorage=#{user.address}"
expect(response).to redirect_to(launch_url)
end
it "does not hand over an access token" do
expect(response.location).not_to include("access_token")
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
-18
View File
@@ -49,24 +49,6 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
end
end
end
context "with PKCE parameters" do
before do
visit new_rs_oauth_path(username: user.cn,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents:rw",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256")
end
it "includes the PKCE parameters in the consent form" do
expect(page).to have_selector("input[name='response_type'][value='code']", visible: false)
expect(page).to have_selector("input[name='code_challenge'][value='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM']", visible: false)
expect(page).to have_selector("input[name='code_challenge_method'][value='S256']", visible: false)
end
end
end
context "when signed out" do
@@ -12,6 +12,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#create" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
let(:auth) do
user.remote_storage_authorizations.create!(
@@ -26,9 +27,10 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end
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)
end
@@ -47,6 +49,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#destroy" do
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
it "removes the token from redis" do
auth = user.remote_storage_authorizations.create!(
@@ -56,7 +59,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
)
auth.destroy!
expect(redis_rs.exists?("authorizations:#{user.cn}:#{auth.token}")).to be(false)
expect(redis_rs.keys("authorizations:#{user.address}:*")).to be_empty
end
context "with expiry set" do
@@ -76,6 +79,8 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#find_or_create_web_app" do
context "with origin that looks hosted" do
after(:all) { redis_rs_delete_keys("authorizations:*") }
let(:auth) do
user.remote_storage_authorizations.create!(
permissions: %w(documents:rw),
@@ -168,6 +173,8 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end
describe "#launch_url" do
after(:all) { redis_rs_delete_keys("authorizations:*") }
context "without start URL" do
before do
AppCatalog::WebApp.create!(
@@ -243,6 +250,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
include ActiveJob::TestHelper
after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("authorizations:*") }
before { allow(user).to receive(:display_name).and_return("Jimmy") }
-79
View File
@@ -1,79 +0,0 @@
require 'rails_helper'
RSpec.describe RsAuthorizationCode, type: :model do
let(:user) { create :user }
# Example value from RFC 7636, Appendix B
let(:challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
def new_code(**attrs)
RsAuthorizationCode.new(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: challenge,
**attrs
)
end
def build_code(**attrs)
new_code(**attrs).tap(&:save!)
end
describe "validations" do
it "is valid with a well-formed S256 challenge" do
expect(new_code).to be_valid
end
it "rejects a challenge shorter than 43 characters" do
expect(new_code(code_challenge: "short")).not_to be_valid
end
it "rejects a challenge with invalid characters" do
expect(new_code(code_challenge: "#{'a' * 42}!")).not_to be_valid
end
it "rejects an unsupported challenge method" do
code = new_code(code_challenge_method: "plain")
expect(code).not_to be_valid
expect(code.errors[:code_challenge_method]).to be_present
end
end
describe "#create" do
it "generates a raw code and stores only its digest" do
code = build_code
expect(code.code).to be_present
expect(code.code_digest).to eq(Digest::SHA256.hexdigest(code.code))
end
it "sets a short expiry" do
code = build_code
expect(code.expires_at).to be_within(5.seconds).of(60.seconds.from_now)
end
end
describe ".find_by_code" do
it "finds a code by its raw value" do
code = build_code
expect(RsAuthorizationCode.find_by_code(code.code)).to eq(code)
end
it "returns nil for an unknown code" do
expect(RsAuthorizationCode.find_by_code("does-not-exist")).to be_nil
end
it "returns nil for a blank code" do
expect(RsAuthorizationCode.find_by_code(nil)).to be_nil
end
it "returns nil for an expired code" do
code = build_code(expires_at: 1.minute.ago)
expect(RsAuthorizationCode.find_by_code(code.code)).to be_nil
end
end
end
-57
View File
@@ -1,57 +0,0 @@
require 'rails_helper'
RSpec.describe "remoteStorage OAuth token endpoint", type: :request do
let(:user) { create :user }
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
before do
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)
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
def issue_code
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
end
it "exchanges a code for an access token without a session" do
code = issue_code
post "/rs/oauth/token", params: {
grant_type: "authorization_code",
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
}
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/json")
json = JSON.parse(response.body)
expect(json["access_token"]).to be_present
expect(json["token_type"]).to eq("bearer")
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
end
it "answers CORS preflight requests" do
options "/rs/oauth/token"
expect(response).to have_http_status(:ok)
expect(response.headers["Access-Control-Allow-Methods"]).to include("POST")
end
end
-9
View File
@@ -133,15 +133,6 @@ RSpec.describe "WebFinger", type: :request do
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
expect(oauth_url).to eql("http://localhost/rs/oauth/tony")
authorization_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.1"]
expect(authorization_url).to eql("http://localhost/rs/oauth/tony")
token_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.2"]
expect(token_url).to eql("http://localhost/rs/oauth/token")
expect(rs_link["properties"]["http://tools.ietf.org/html/rfc7636"]).to eql("S256")
expect(rs_link["properties"]["http://remotestorage.io/spec/version"]).to eql("draft-dejong-remotestorage-27")
end
it "returns CORS headers" do
@@ -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
@@ -1,118 +0,0 @@
require 'rails_helper'
RSpec.describe RsOauthManager::ExchangeCode do
let(:user) { create :user }
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
before do
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)
end
def issue_code(**overrides)
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256",
user_agent: "RSpec Device",
**overrides
)
end
def exchange(code, **overrides)
described_class.call(
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier,
**overrides
)
end
it "creates an authorization and consumes the code" do
code = issue_code
auth = nil
expect { auth = exchange(code) }.to change(RemoteStorageAuthorization, :count).by(1)
expect(auth.permissions).to eq(%w(documents:rw))
expect(auth.client_id).to eq("example.com")
expect(auth.token).to be_present
expect(auth.user_agent).to eq("RSpec Device")
expect(RsAuthorizationCode.exists?(code.id)).to be(false)
end
it "carries over the permission expiry" do
expire_at = 1.month.from_now
code = issue_code(expire_at: expire_at)
auth = exchange(code)
expect(auth.expire_at).to be_within(1.second).of(expire_at)
end
it "rejects an unknown code" do
expect do
described_class.call(
code: "unknown",
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
)
end.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a reused code" do
code = issue_code
exchange(code)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects an expired code" do
code = issue_code
code.update_columns(expires_at: 1.minute.ago)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a mismatching redirect_uri" do
code = issue_code
expect { exchange(code, redirect_uri: "https://example.com/other") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a mismatching client_id host" do
code = issue_code
expect { exchange(code, client_id: "https://evil.example.org") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a code whose stored client_id host differs from the presented one" do
code = RsAuthorizationCode.create!(
user: user,
client_id: "evil.example.org",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
end
it "rejects a bad code_verifier" do
code = issue_code
expect { exchange(code, code_verifier: "wrong-verifier") }
.to raise_error(RsOauthManagerService::InvalidGrant)
end
end
@@ -1,31 +0,0 @@
require 'rails_helper'
RSpec.describe RsOauthManager::IssueCode do
let(:user) { create :user }
subject do
described_class.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now,
user_agent: "RSpec Device"
)
end
it "creates a short-lived authorization code" do
expect { subject }.to change(RsAuthorizationCode, :count).by(1)
expect(subject.code).to be_present
expect(subject.client_id).to eq("example.com")
expect(subject.permissions).to eq(%w(documents:rw))
expect(subject.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(subject.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
expect { subject }.not_to change(RemoteStorageAuthorization, :count)
end
end
@@ -1,41 +0,0 @@
require 'rails_helper'
RSpec.describe RsOauthManager::VerifyPkce do
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
subject do
described_class.call(
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
code_verifier: code_verifier
)
end
context "with the S256 method" do
let(:code_challenge_method) { "S256" }
it "accepts a matching verifier" do
expect(subject).to be(true)
end
it "rejects a non-matching verifier" do
expect(
described_class.call(
code_challenge: code_challenge,
code_challenge_method: "S256",
code_verifier: "wrong-verifier"
)
).to be(false)
end
end
context "with an unsupported method" do
let(:code_challenge_method) { "plain" }
it "rejects the verifier" do
expect(subject).to be(false)
end
end
end
+5
View File
@@ -1,3 +1,8 @@
def redis_rs
@redis_rs ||= Redis.new(url: Setting.rs_redis_url)
end
def redis_rs_delete_keys(pattern)
keys = redis_rs.keys(pattern)
redis_rs.del(*keys)
end