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