diff --git a/.env.example b/.env.example index 6cff8b9..155ec8a 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,7 @@ DISCOURSE_PUBLIC_URL='https://community.kosmos.org' GITEA_PUBLIC_URL='https://gitea.kosmos.org' MASTODON_PUBLIC_URL='https://kosmos.social' MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org' +RS_STORAGE_URL='https://storage.kosmos.org' EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin' EJABBERD_API_URL='https://xmpp.kosmos.org/api' diff --git a/.env.test b/.env.test index 0c94493..016655b 100644 --- a/.env.test +++ b/.env.test @@ -6,4 +6,6 @@ LNDHUB_API_URL='http://localhost:3026' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946' +RS_STORAGE_URL='https://storage.kosmos.org' + WEBHOOKS_ALLOWED_IPS='10.1.1.23' diff --git a/app/components/form_elements/fieldset_component.html.erb b/app/components/form_elements/fieldset_component.html.erb index 4d82a56..2bde8ce 100644 --- a/app/components/form_elements/fieldset_component.html.erb +++ b/app/components/form_elements/fieldset_component.html.erb @@ -1,4 +1,5 @@ <%= tag.public_send(@tag, class: "mb-6 last:mb-0") do %> + <% if @positioning == :vertical %> + <% else %> +

Invalid positioning argument for FieldsetComponent.

+ <% end %> <% end %> diff --git a/app/components/form_elements/fieldset_component.rb b/app/components/form_elements/fieldset_component.rb index 8896137..23fad5b 100644 --- a/app/components/form_elements/fieldset_component.rb +++ b/app/components/form_elements/fieldset_component.rb @@ -2,10 +2,11 @@ module FormElements class FieldsetComponent < ViewComponent::Base - def initialize(tag: "li", title:, description: nil) - @tag = tag - @title = title - @descripton = description + def initialize(tag: "li", positioning: :vertical, title:, description: nil) + @tag = tag + @positioning = positioning + @title = title + @descripton = description end end end diff --git a/app/components/form_elements/fieldset_toggle_component.html.erb b/app/components/form_elements/fieldset_toggle_component.html.erb index 504a5b5..f4acd3d 100644 --- a/app/components/form_elements/fieldset_toggle_component.html.erb +++ b/app/components/form_elements/fieldset_toggle_component.html.erb @@ -1,5 +1,5 @@ <%= tag.public_send @tag, class: "flex items-center justify-between mb-6 last:mb-0", - data: @form.present? ? { + data: @form_enabled ? { controller: "settings--toggle", :'settings--toggle-switch-enabled-value' => @enabled.to_s } : nil do %> @@ -11,16 +11,23 @@ <%= render FormElements::ToggleComponent.new( enabled: @enabled, input_enabled: @input_enabled, - class_names: @form.present? ? "hidden" : nil, + class_names: @form_enabled ? "hidden" : nil, data: { :'settings--toggle-target' => "button", action: "settings--toggle#toggleSwitch" }) %> - <% if @form.present? %> - <%= @form.check_box @attribute, { - checked: @enabled, - data: { :'settings--toggle-target' => "checkbox" } - }, "true", "false" %> + <% if @form_enabled %> + <% if @attribute.present? %> + <%= @form.check_box @attribute, { + checked: @enabled, + data: { :'settings--toggle-target' => "checkbox" } + }, "true", "false" %> + <% else %> + + <%= check_box_tag @field_name, "true", @enabled, { + data: { :'settings--toggle-target' => "checkbox" } + } %> + <% end %> <% end %> <% end %> diff --git a/app/components/form_elements/fieldset_toggle_component.rb b/app/components/form_elements/fieldset_toggle_component.rb index b38bee7..686f5f1 100644 --- a/app/components/form_elements/fieldset_toggle_component.rb +++ b/app/components/form_elements/fieldset_toggle_component.rb @@ -2,11 +2,13 @@ module FormElements class FieldsetToggleComponent < ViewComponent::Base - def initialize(form: nil, attribute: nil, tag: "li", enabled: false, - input_enabled: true, title:, description:) + def initialize(tag: "li", form: nil, attribute: nil, field_name: nil, + enabled: false, input_enabled: true, title:, description:) + @tag = tag @form = form @attribute = attribute - @tag = tag + @field_name = field_name + @form_enabled = @form.present? || @field_name.present? @enabled = enabled @input_enabled = input_enabled @title = title diff --git a/app/controllers/settings/account_controller.rb b/app/controllers/settings/account_controller.rb deleted file mode 100644 index 385a9ff..0000000 --- a/app/controllers/settings/account_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -class Settings::AccountController < SettingsController - - def index - end - - def reset_password - current_user.send_reset_password_instructions - sign_out current_user - msg = "We have sent you an email with a link to reset your password." - redirect_to check_your_email_path, notice: msg - end - -end diff --git a/app/controllers/settings/profile_controller.rb b/app/controllers/settings/profile_controller.rb deleted file mode 100644 index 645bd20..0000000 --- a/app/controllers/settings/profile_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Settings::ProfileController < SettingsController - - def index - @user = current_user - end - - def update - - end - -end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index d2fc3e5..c5bd8f8 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,13 +1,52 @@ class SettingsController < ApplicationController - before_action :require_user_signed_in - before_action :set_current_section + before_action :authenticate_user! + before_action :set_main_nav_section + before_action :set_settings_section, only: ['show', 'update'] def index + redirect_to setting_path(:profile) + end + + def show + @user = current_user + end + + def update + @user = current_user + @user.preferences.merge! user_params[:preferences] + @user.save! + + redirect_to setting_path(@settings_section), flash: { + success: 'Settings saved.' + } + end + + def reset_password + current_user.send_reset_password_instructions + sign_out current_user + msg = "We have sent you an email with a link to reset your password." + redirect_to check_your_email_path, notice: msg end private - def set_current_section + def set_main_nav_section @current_section = :settings end + + def set_settings_section + @settings_section = params[:section] + allowed_sections = [:profile, :account, :lightning, :xmpp] + + unless allowed_sections.include?(@settings_section.to_sym) + redirect_to setting_path(:profile) + end + end + + def user_params + params.require(:user).permit(preferences: [ + :lightning_notify_sats_received, + :xmpp_exchange_contacts_with_invitees + ]) + end end diff --git a/app/controllers/webfinger_controller.rb b/app/controllers/webfinger_controller.rb new file mode 100644 index 0000000..5cf4012 --- /dev/null +++ b/app/controllers/webfinger_controller.rb @@ -0,0 +1,57 @@ +class WebfingerController < ApplicationController + before_action :allow_cross_origin_requests, only: [:show] + + layout false + + def show + resource = params[:resource] + + if resource && resource.match(/acct:\w+/) + useraddress = resource.split(":").last + username, org = useraddress.split("@") + username.downcase! + unless User.where(cn: username, ou: org).any? + head 404 and return + end + + render json: webfinger(useraddress).to_json, + content_type: "application/jrd+json" + else + head 422 and return + end + end + + private + + def webfinger(useraddress) + links = []; + + links << remotestorage_link(useraddress) if Setting.remotestorage_enabled + + { "links" => links } + end + + def remotestorage_link(useraddress) + # TODO use when OAuth routes are available + # auth_url = new_rs_oauth_url(useraddress) + auth_url = "https://example.com/rs/oauth" + storage_url = "#{Setting.rs_storage_url}/#{useraddress}" + + { + "rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage", + "href" => storage_url, + "properties" => { + "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/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 allow_cross_origin_requests + headers['Access-Control-Allow-Origin'] = '*' + headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, OPTIONS' + end +end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 74f880a..7025580 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -12,22 +12,28 @@ class WebhooksController < ApplicationController end user = User.find_by!(ln_account: payload[:user_login]) - - # TODO make configurable - notify_xmpp(user.address, payload[:amount], payload[:memo]) + notify = user.preferences[:lightning_notify_sats_received] + case notify + when "xmpp" + notify_xmpp(user.address, payload[:amount], payload[:memo]) + when "email" + NotificationMailer.with(user: user, amount_sats: payload[:amount]) + .lightning_sats_received.deliver_later + end head :ok end private + # TODO refactor into mailer-like generic class/service def notify_xmpp(address, amt_sats, memo) payload = { type: "normal", from: "kosmos.org", # TODO domain config to: address, subject: "Sats received!", - body: "#{amt_sats} sats received in your Lightning wallet:\n> #{memo}" + body: "#{helpers.number_with_delimiter amt_sats} sats received in your Lightning wallet:\n> #{memo}" } XmppSendMessageJob.perform_later(payload) end diff --git a/app/jobs/xmpp_exchange_contacts_job.rb b/app/jobs/xmpp_exchange_contacts_job.rb index c082e32..f1b20e0 100644 --- a/app/jobs/xmpp_exchange_contacts_job.rb +++ b/app/jobs/xmpp_exchange_contacts_job.rb @@ -2,8 +2,9 @@ class XmppExchangeContactsJob < ApplicationJob queue_as :default def perform(inviter, invitee) - return unless inviter.services_enabled.include?("ejabberd") && - invitee.services_enabled.include?("ejabberd") + return unless inviter.services_enabled.include?("xmpp") && + invitee.services_enabled.include?("xmpp") && + inviter.preferences[:xmpp_exchange_contacts_with_invitees] ejabberd = EjabberdApiClient.new diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb new file mode 100644 index 0000000..84f7dd5 --- /dev/null +++ b/app/mailers/notification_mailer.rb @@ -0,0 +1,8 @@ +class NotificationMailer < ApplicationMailer + def lightning_sats_received + @user = params[:user] + @amount_sats = params[:amount_sats] + @subject = "Sats received" + mail to: @user.email, subject: @subject + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index 4b02225..2cbc615 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -111,4 +111,14 @@ class Setting < RailsSettings::Base # field :nostr_enabled, type: :boolean, default: true + + # + # RemoteStorage + # + + field :remotestorage_enabled, type: :boolean, + default: (ENV["RS_STORAGE_URL"].present?.to_s || false) + + field :rs_storage_url, type: :string, + default: ENV["RS_STORAGE_URL"].presence end diff --git a/app/models/user.rb b/app/models/user.rb index 937bc9c..4945d6e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,8 @@ class User < ApplicationRecord include EmailValidatable + serialize :preferences, UserPreferences + # Relations has_many :invitations, dependent: :destroy has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id' @@ -56,7 +58,7 @@ class User < ApplicationRecord end def devise_after_confirmation - enable_service %w[ discourse ejabberd gitea mediawiki ] + enable_service %w[ discourse gitea mediawiki xmpp ] #TODO enable in development when we have easy setup of ejabberd etc. return if Rails.env.development? || !Setting.ejabberd_enabled? diff --git a/app/models/user_preferences.rb b/app/models/user_preferences.rb new file mode 100644 index 0000000..ffdf7f6 --- /dev/null +++ b/app/models/user_preferences.rb @@ -0,0 +1,29 @@ +DEFAULT_PREFS = YAML.load_file("#{Rails.root}/config/default_preferences.yml") + +class UserPreferences + def self.dump(value) + process(value).to_yaml + end + + def self.load(string) + stored_prefs = YAML.load(string || "{}") + DEFAULT_PREFS.merge(stored_prefs).with_indifferent_access + end + + def self.is_integer?(value) + value.to_i.to_s == value + end + + def self.process(hash) + hash.each do |key, value| + if value == "true" + hash[key] = true + elsif value == "false" + hash[key] = false + elsif value.is_a?(String) && is_integer?(value) + hash[key] = value.to_i + end + end + hash.stringify_keys!.to_h + end +end diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index b90b499..a0a8b87 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -1,6 +1,6 @@ class EjabberdApiClient def initialize - @base_url = ENV["EJABBERD_API_URL"] + @base_url = Setting.ejabberd_api_url end def post(endpoint, payload) diff --git a/app/views/admin/settings/services/_remotestorage.html.erb b/app/views/admin/settings/services/_remotestorage.html.erb new file mode 100644 index 0000000..fdfa3f4 --- /dev/null +++ b/app/views/admin/settings/services/_remotestorage.html.erb @@ -0,0 +1,17 @@ +

RemoteStorage

+ diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 5d89827..42c7963 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -135,7 +135,7 @@ XMPP (ejabberd) <%= render FormElements::ToggleComponent.new( - enabled: @services_enabled.include?("ejabberd"), + enabled: @services_enabled.include?("xmpp"), input_enabled: false ) %> diff --git a/app/views/icons/_bell.html.erb b/app/views/icons/_bell.html.erb index bba561c..3bb750f 100644 --- a/app/views/icons/_bell.html.erb +++ b/app/views/icons/_bell.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/icons/_message-circle.html.erb b/app/views/icons/_message-circle.html.erb index 4b21b32..5ff6406 100644 --- a/app/views/icons/_message-circle.html.erb +++ b/app/views/icons/_message-circle.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/notification_mailer/lightning_sats_received.text.erb b/app/views/notification_mailer/lightning_sats_received.text.erb new file mode 100644 index 0000000..1e122d4 --- /dev/null +++ b/app/views/notification_mailer/lightning_sats_received.text.erb @@ -0,0 +1,3 @@ +You just received <%= number_with_delimiter @amount_sats %> sats in your Lightning account (<%= @user.address %>). Check your wallet app, or open the account page for details: + +<%= wallet_transactions_url %> diff --git a/app/views/settings/_account.html.erb b/app/views/settings/_account.html.erb new file mode 100644 index 0000000..df0a2ad --- /dev/null +++ b/app/views/settings/_account.html.erb @@ -0,0 +1,19 @@ +
+

E-Mail

+

+ <%= label :email, 'Address', class: 'font-bold' %> +

+

+ disabled="disabled" /> +

+
+
+

Password

+

Use the following button to request an email with a password reset link:

+ <%= form_with(url: reset_password_settings_path, method: :post) do %> +

+ <%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %> +

+ <% end %> +
diff --git a/app/views/settings/_lightning.html.erb b/app/views/settings/_lightning.html.erb new file mode 100644 index 0000000..6e75343 --- /dev/null +++ b/app/views/settings/_lightning.html.erb @@ -0,0 +1,25 @@ +<%= form_for @user, url: setting_path(:lightning), html: { :method => :put } do |f| %> +
+

Notifications

+
    + <%= render FormElements::FieldsetComponent.new( + positioning: :horizontal, + title: "Sats received", + description: "Notify me when sats are sent to my Lightning Address" + ) do %> + <% f.fields_for :preferences do |p| %> + <%= p.select :lightning_notify_sats_received, options_for_select([ + ["off", "disabled"], + ["Chat (Jabber)", "xmpp"], + ["E-Mail", "email"] + ], selected: @user.preferences[:lightning_notify_sats_received]) %> + <% end %> + <% end %> +
+
+
+

+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +

+
+<% end %> diff --git a/app/views/settings/_notifications.html.erb b/app/views/settings/_notifications.html.erb new file mode 100644 index 0000000..ff879d3 --- /dev/null +++ b/app/views/settings/_notifications.html.erb @@ -0,0 +1,16 @@ +
+

Lightning Wallet

+ +
    + <%= render FormElements::FieldsetComponent.new( + positioning: :horizontal, + title: "Sats received", + description: "Notify when sats are sent to my Lightning Address" + ) do %> + <%= select_tag :sats_received, options_for_select([ + ["off", "off"], + ["Chat (Jabber)", "xmpp"] + ]) %> + <% end %> +
+
diff --git a/app/views/settings/_profile.html.erb b/app/views/settings/_profile.html.erb new file mode 100644 index 0000000..f1d14ae --- /dev/null +++ b/app/views/settings/_profile.html.erb @@ -0,0 +1,30 @@ +
+

Profile

+

+ <%= label :user_address, 'User address', class: 'font-bold' %> +

+

+ disabled="disabled" + data-clipboard-target="source" /> + +

+

+ Your user address for Chat and Lightning Network. +

+ + <%# <%= form_for(@user, as: "profile", url: settings_profile_path) do |f| %> + <%#

+ <%# <%= f.submit "Save changes", class: 'btn-md btn-blue w-full sm:w-auto' %> + <%#

+ <%# <% end %> +
diff --git a/app/views/settings/_xmpp.html.erb b/app/views/settings/_xmpp.html.erb new file mode 100644 index 0000000..a13ce70 --- /dev/null +++ b/app/views/settings/_xmpp.html.erb @@ -0,0 +1,18 @@ +<%= form_for @user, url: setting_path(:xmpp), html: { :method => :put } do |f| %> +
+

Contacts

+
    + <%= render FormElements::FieldsetToggleComponent.new( + field_name: "user[preferences][xmpp_exchange_contacts_with_invitees]", + enabled: @user.preferences[:xmpp_exchange_contacts_with_invitees], + title: "Exchange contacts when invited user signs up", + description: "Add each others contacts, so you can chat with them immediately" + ) %> +
+
+
+

+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +

+
+<% end %> diff --git a/app/views/settings/account/index.html.erb b/app/views/settings/account/index.html.erb deleted file mode 100644 index effe9c2..0000000 --- a/app/views/settings/account/index.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= render HeaderComponent.new(title: "Settings") %> - -<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/sidenav_settings') do %> -
-

E-Mail

-

- <%= label :email, 'Address', class: 'font-bold' %> -

-

- disabled="disabled" /> -

-
-
-

Password

-

Use the following button to request an email with a password reset link:

- <%= form_with(url: settings_reset_password_path, method: :post) do %> -

- <%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %> -

- <% end %> -
-<% end %> diff --git a/app/views/settings/profile/index.html.erb b/app/views/settings/profile/index.html.erb deleted file mode 100644 index 3e91709..0000000 --- a/app/views/settings/profile/index.html.erb +++ /dev/null @@ -1,34 +0,0 @@ -<%= render HeaderComponent.new(title: "Settings") %> - -<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/sidenav_settings') do %> -
-

Profile

-

- <%= label :user_address, 'User address', class: 'font-bold' %> -

-

- disabled="disabled" - data-clipboard-target="source" /> - -

-

- Your user address for Chat and Lightning Network. -

- - <%# <%= form_for(@user, as: "profile", url: settings_profile_path) do |f| %> - <%#

- <%# <%= f.submit "Save changes", class: 'btn-md btn-blue w-full sm:w-auto' %> - <%#

- <%# <% end %> -
-<% end %> diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb new file mode 100644 index 0000000..38ddcb5 --- /dev/null +++ b/app/views/settings/show.html.erb @@ -0,0 +1,5 @@ +<%= render HeaderComponent.new(title: "Settings") %> + +<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/sidenav_settings') do %> + <%= render partial: @settings_section %> +<% end %> diff --git a/app/views/shared/_admin_sidenav_settings_services.html.erb b/app/views/shared/_admin_sidenav_settings_services.html.erb index c897b6d..142f6fc 100644 --- a/app/views/shared/_admin_sidenav_settings_services.html.erb +++ b/app/views/shared/_admin_sidenav_settings_services.html.erb @@ -47,3 +47,10 @@ icon: Setting.nostr_enabled? ? "check" : "x", active: current_page?(admin_settings_services_path(params: { s: "nostr" })), ) %> +<%= render SidenavLinkComponent.new( + level: 2, + name: "RemoteStorage", + path: admin_settings_services_path(params: { s: "remotestorage" }), + icon: Setting.remotestorage_enabled? ? "check" : "x", + active: current_page?(admin_settings_services_path(params: { s: "remotestorage" })), +) %> diff --git a/app/views/shared/_main_nav.html.erb b/app/views/shared/_main_nav.html.erb index de5b8eb..7f5ee02 100644 --- a/app/views/shared/_main_nav.html.erb +++ b/app/views/shared/_main_nav.html.erb @@ -6,5 +6,5 @@ class: main_nav_class(@current_section, :invitations) %> <%= link_to "Wallet", wallet_path, class: main_nav_class(@current_section, :wallet) %> -<%= link_to "Settings", settings_profile_path, +<%= link_to "Settings", settings_path, class: main_nav_class(@current_section, :settings) %> diff --git a/app/views/shared/_sidenav_settings.html.erb b/app/views/shared/_sidenav_settings.html.erb index 25d345a..3d6c17e 100644 --- a/app/views/shared/_sidenav_settings.html.erb +++ b/app/views/shared/_sidenav_settings.html.erb @@ -1,11 +1,20 @@ <%= render SidenavLinkComponent.new( - name: "Profile", path: settings_profile_path, icon: "user", - active: current_page?(settings_profile_path) + name: "Profile", path: setting_path(:profile), icon: "user", + active: current_page?(setting_path(:profile)) ) %> <%= render SidenavLinkComponent.new( - name: "Account", path: settings_account_path, icon: "key", - active: current_page?(settings_account_path) + name: "Account", path: setting_path(:account), icon: "key", + active: current_page?(setting_path(:account)) ) %> +<% if Setting.ejabberd_enabled %> <%= render SidenavLinkComponent.new( - name: "Security", path: "#", icon: "shield", disabled: true + name: "Chat", path: setting_path(:xmpp), icon: "message-circle", + active: current_page?(setting_path(:xmpp)) ) %> +<% end %> +<% if Setting.lndhub_enabled %> +<%= render SidenavLinkComponent.new( + name: "Wallet", path: setting_path(:lightning), icon: "zap", + active: current_page?(setting_path(:lightning)) +) %> +<% end %> diff --git a/config/default_preferences.yml b/config/default_preferences.yml new file mode 100644 index 0000000..ff7f051 --- /dev/null +++ b/config/default_preferences.yml @@ -0,0 +1,2 @@ +lightning_notify_sats_received: disabled # or xmpp, email +xmpp_exchange_contacts_with_invitees: true diff --git a/config/routes.rb b/config/routes.rb index e729019..fdc8454 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,13 +10,6 @@ Rails.application.routes.draw do match 'signup/:step', to: 'signup#steps', as: :signup_steps, via: [:get, :post] post 'signup_validate', to: 'signup#validate' - namespace :settings do - get 'profile', to: 'profile#index' - post 'profile', to: 'profile#update' - get 'account', to: 'account#index' - post 'reset_password', to: 'account#reset_password' - end - namespace :contributions do root to: 'donations#index' get 'projects', to: 'projects#index' @@ -28,6 +21,12 @@ Rails.application.routes.draw do get 'wallet', to: 'wallet#index' get 'wallet/transactions', to: 'wallet#transactions' + resources :settings, param: 'section', only: ['index', 'show', 'update'] do + collection do + post 'reset_password' + end + end + get 'lnurlpay/:address', to: 'lnurlpay#index', as: 'lightning_address', constraints: { address: /[^\/]+/} get 'lnurlpay/:address/invoice', to: 'lnurlpay#invoice', @@ -54,6 +53,8 @@ Rails.application.routes.draw do end end + get ".well-known/webfinger" => "webfinger#show" + authenticate :user, ->(user) { user.is_admin? } do mount Sidekiq::Web => '/sidekiq' end diff --git a/db/migrate/20230403135149_add_preferences_to_users.rb b/db/migrate/20230403135149_add_preferences_to_users.rb new file mode 100644 index 0000000..2defcb3 --- /dev/null +++ b/db/migrate/20230403135149_add_preferences_to_users.rb @@ -0,0 +1,5 @@ +class AddPreferencesToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :preferences, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index a292eed..1a85f04 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_03_19_101128) do +ActiveRecord::Schema[7.0].define(version: 2023_04_03_135149) do create_table "donations", force: :cascade do |t| t.integer "user_id" t.integer "amount_sats" @@ -57,8 +57,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_19_101128) do t.text "ln_login_ciphertext" t.text "ln_password_ciphertext" t.string "ln_account" + t.string "nostr_pubkey" t.datetime "remember_created_at" t.string "remember_token" + t.text "preferences" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/spec/features/admin/settings_spec.rb b/spec/features/admin/settings_spec.rb index 7a394ba..a522d31 100644 --- a/spec/features/admin/settings_spec.rb +++ b/spec/features/admin/settings_spec.rb @@ -46,5 +46,26 @@ RSpec.describe 'Admin/global settings', type: :feature do expect(page).to_not have_checked_field("setting[ejabberd_enabled]") expect(page).to_not have_field("API URL", disabled: true) end + + scenario "View remoteStorage settings" do + visit admin_settings_services_path(params: { s: "remotestorage" }) + + expect(page).to have_content("Enable RemoteStorage integration") + expect(page).to have_field("Storage URL", + with: "https://storage.kosmos.org", + disabled: true) + end + + scenario "Disable remoteStorage integration" do + visit admin_settings_services_path(params: { s: "remotestorage" }) + expect(page).to have_checked_field("setting[remotestorage_enabled]") + + uncheck "setting[remotestorage_enabled]" + click_button "Save" + + expect(current_url).to eq(admin_settings_services_url(params: { s: "remotestorage" })) + expect(page).to_not have_checked_field("setting[remotestorage_enabled]") + expect(page).to_not have_field("Storage URL", disabled: true) + end end end diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb index 9959661..30511a7 100644 --- a/spec/features/signup_spec.rb +++ b/spec/features/signup_spec.rb @@ -53,11 +53,11 @@ RSpec.describe "Signup", type: :feature do expect(page).to have_content("Choose a password") expect(CreateAccount).to receive(:call) - .with( + .with({ username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", invitation: Invitation.last - ).and_return(true) + }).and_return(true) fill_in "user_password", with: "a-valid-password" click_button "Create account" @@ -97,11 +97,11 @@ RSpec.describe "Signup", type: :feature do expect(page).to have_content("Password is too short") expect(CreateAccount).to receive(:call) - .with( + .with({ username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", invitation: Invitation.last - ).and_return(true) + }).and_return(true) fill_in "user_password", with: "a-valid-password" click_button "Create account" diff --git a/spec/jobs/xmpp_exchange_contacts_job_spec.rb b/spec/jobs/xmpp_exchange_contacts_job_spec.rb index 013a80d..c711e87 100644 --- a/spec/jobs/xmpp_exchange_contacts_job_spec.rb +++ b/spec/jobs/xmpp_exchange_contacts_job_spec.rb @@ -13,7 +13,7 @@ RSpec.describe XmppExchangeContactsJob, type: :job do before do stub_request(:post, "http://xmpp.example.com/api/add_rosteritem") .to_return(status: 200, body: "", headers: {}) - allow_any_instance_of(User).to receive(:services_enabled).and_return(["ejabberd"]) + allow_any_instance_of(User).to receive(:services_enabled).and_return(["xmpp"]) end it "posts add_rosteritem commands to the ejabberd API" do diff --git a/spec/models/user_preferences_spec.rb b/spec/models/user_preferences_spec.rb new file mode 100644 index 0000000..4bb7b42 --- /dev/null +++ b/spec/models/user_preferences_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe UserPreferences, type: :model do + let(:default_prefs) { YAML.load_file("#{Rails.root}/config/default_preferences.yml") } + + describe ".load" do + it "provides default values when no preferences are stored yet" do + expect(UserPreferences.load(nil)).to eq(default_prefs) + end + + it "provides default values for unset preferences" do + prefs = UserPreferences.load("lightning_notify_sats_received: xmpp") + expect(prefs[:lightning_notify_sats_received]).to eq("xmpp") + expect(prefs[:xmpp_exchange_contacts_with_invitees]).to eq(true) + end + end + + describe ".process" do + it "turns all keys into strings" do + res = UserPreferences.process({ foo: "bar" }) + expect(res[:foo]).to be(nil) + expect(res['foo']).to eq("bar") + end + + it "converts value 'true' to boolean" do + res = UserPreferences.process({ lightning_notify_sats_received: "true" }) + expect(res['lightning_notify_sats_received']).to be(true) + end + + it "converts value 'false' to boolean" do + res = UserPreferences.process({ lightning_notify_sats_received: "false" }) + expect(res['lightning_notify_sats_received']).to be(false) + end + + it "converts value string with integer into integer" do + res = UserPreferences.process({ lightning_notify_sats_received_threshold: 1000 }) + expect(res['lightning_notify_sats_received_threshold']).to be_a(Integer) + expect(res['lightning_notify_sats_received_threshold']).to eq(1000) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b8f8675..68677b7 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -108,7 +108,7 @@ RSpec.describe User, type: :model do let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } it "enables default services" do - expect(user).to receive(:enable_service).with(%w[ discourse ejabberd gitea mediawiki ]) + expect(user).to receive(:enable_service).with(%w[ discourse gitea mediawiki xmpp ]) user.send :devise_after_confirmation end @@ -120,10 +120,12 @@ RSpec.describe User, type: :model do expect(job['arguments'][0]['_aj_globalid']).to eq('gid://akkounts/User/1') end - context "for invited user with ejabberd enabled" do + context "for invited user with xmpp enabled" do let(:guest) { create :user, id: 2, cn: "isaacnewton", ou: "kosmos.org", email: "newt@example.com" } before do + # TODO remove when defaults are implemented + user.update! preferences: { xmpp_exchange_contacts_with_invitees: true } Invitation.create! user: user, invited_user_id: guest.id, used_at: DateTime.now allow_any_instance_of(User).to receive(:enable_service) end diff --git a/spec/requests/webfinger_spec.rb b/spec/requests/webfinger_spec.rb new file mode 100644 index 0000000..f944a7a --- /dev/null +++ b/spec/requests/webfinger_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe "WebFinger", type: :request do + describe "remoteStorage link relation" do + context "user exists" do + before do + create :user, cn: 'tony', ou: 'kosmos.org' + end + + context "remoteStorage enabled globally" do + it "includes the remoteStorage link for the user" do + get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org" + expect(response).to have_http_status(:ok) + + res = JSON.parse(response.body) + rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"} + + expect(rs_link["href"]).to eql("https://storage.kosmos.org/tony@kosmos.org") + + oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"] + expect(oauth_url).to eql("https://example.com/rs/oauth") + end + end + + context "remoteStorage not available" do + before do + Setting.remotestorage_enabled = false + end + + it "does not include the remoteStorage link" do + get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org" + expect(response).to have_http_status(:ok) + + res = JSON.parse(response.body) + rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"} + + expect(rs_link).to be_nil + end + end + end + + context "user does not exist" do + it "does return a 404 status" do + get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org" + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/webhooks_spec.rb b/spec/requests/webhooks_spec.rb index 06c834e..96a3b8f 100644 --- a/spec/requests/webhooks_spec.rb +++ b/spec/requests/webhooks_spec.rb @@ -55,22 +55,51 @@ RSpec.describe "Webhooks", type: :request do before do user.save! #FIXME this should not be necessary - post "/webhooks/lndhub", params: payload.to_json end it "returns a 200 status" do + post "/webhooks/lndhub", params: payload.to_json expect(response).to have_http_status(:ok) end - it "sends an XMPP message to the account owner's JID" do - expect(enqueued_jobs.size).to eq(1) + it "does not send notifications by default" do + expect(enqueued_jobs.size).to eq(0) + end - msg = enqueued_jobs.first['arguments'].first - expect(msg["type"]).to eq('normal') - expect(msg["from"]).to eq('kosmos.org') - expect(msg["to"]).to eq(user.address) - expect(msg["subject"]).to eq('Sats received!') - expect(msg["body"]).to match(/^12300 sats received/) + context "notification preference set to 'xmpp'" do + before do + user.update! preferences: { lightning_notify_sats_received: "xmpp" } + post "/webhooks/lndhub", params: payload.to_json + end + + it "sends an XMPP message to the account owner's JID" do + expect(enqueued_jobs.size).to eq(1) + expect(enqueued_jobs.first["job_class"]).to eq("XmppSendMessageJob") + + msg = enqueued_jobs.first["arguments"].first + expect(msg["type"]).to eq("normal") + expect(msg["from"]).to eq("kosmos.org") + expect(msg["to"]).to eq(user.address) + expect(msg["subject"]).to eq("Sats received!") + expect(msg["body"]).to match(/^12,300 sats received/) + end + end + + context "notification preference set to 'email'" do + before do + user.update! preferences: { lightning_notify_sats_received: "email" } + post "/webhooks/lndhub", params: payload.to_json + end + + it "sends an email notification to the account owner" do + expect(enqueued_jobs.size).to eq(1) + expect(enqueued_jobs.first["job_class"]).to eq("ActionMailer::MailDeliveryJob") + args = enqueued_jobs.first['arguments'] + expect(args[0]).to eq("NotificationMailer") + expect(args[1]).to eq("lightning_sats_received") + expect(args[3]["params"]["user"]["_aj_globalid"]).to eq("gid://akkounts/User/1") + expect(args[3]["params"]["amount_sats"]).to eq(12300) + end end end end