diff --git a/.env.example b/.env.example index e8dc0f3..155ec8a 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +AKKOUNTS_DOMAIN=accounts.example.com + SMTP_SERVER=smtp.example.com SMTP_PORT=587 SMTP_LOGIN=accounts @@ -7,6 +9,8 @@ SMTP_DOMAIN=example.com SMTP_AUTH_METHOD=plain SMTP_ENABLE_STARTTLS=auto +REDIS_URL='redis://localhost:6379/1' + LDAP_HOST=localhost LDAP_PORT=389 LDAP_ADMIN_PASSWORD=passthebutter 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/.gitea/release-drafter.yml b/.gitea/release-drafter.yml new file mode 100644 index 0000000..9fcce2b --- /dev/null +++ b/.gitea/release-drafter.yml @@ -0,0 +1,13 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +version-resolver: + major: + labels: + - 'release/major' + minor: + labels: + - 'release/minor' + patch: + labels: + - 'release/patch' + default: patch diff --git a/.gitea/workflows/release_drafter.yml b/.gitea/workflows/release_drafter.yml new file mode 100644 index 0000000..ba9dc39 --- /dev/null +++ b/.gitea/workflows/release_drafter.yml @@ -0,0 +1,11 @@ +name: Release Drafter +on: + pull_request: + types: [closed] +jobs: + release_drafter_job: + name: Update release notes draft + runs-on: ubuntu-latest + steps: + - name: Release Drafter + uses: https://github.com/raucao/gitea-release-drafter@dev diff --git a/Dockerfile b/Dockerfile index 0eead4c..f2692e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,13 @@ # syntax=docker/dockerfile:1 FROM ruby:2.7.6 -RUN apt-get update -qq && apt-get install -y curl ldap-utils + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \ + ldap-utils tini RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - RUN apt-get update && apt-get install -y nodejs + WORKDIR /akkounts COPY Gemfile /akkounts/Gemfile COPY Gemfile.lock /akkounts/Gemfile.lock @@ -12,11 +17,5 @@ RUN gem install foreman RUN npm install -g yarn RUN yarn install -# Add a script to be executed every time the container starts. -COPY docker/entrypoint.sh /usr/bin/ -RUN chmod +x /usr/bin/entrypoint.sh -ENTRYPOINT ["entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "--"] EXPOSE 3000 - -# Configure the main process to run when running the image -CMD ["bin", "dev"] diff --git a/Gemfile b/Gemfile index a1918c1..1b6704e 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,10 @@ gem 'faraday' gem 'sidekiq', '< 7' gem 'sidekiq-scheduler' +# Monitoring +gem "sentry-ruby" +gem "sentry-rails" + group :development, :test do # Use sqlite3 as the database for Active Record gem 'sqlite3', '~> 1.4' @@ -62,6 +66,7 @@ group :development do gem 'letter_opener' gem 'letter_opener_web' gem 'faker' + gem 'solargraph' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index e788bef..2d92414 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,7 +68,10 @@ GEM tzinfo (~> 2.0) addressable (2.8.1) public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + backport (1.2.0) bcrypt (3.1.18) + benchmark (0.2.1) bindex (0.8.1) builder (3.2.4) byebug (11.1.3) @@ -109,6 +112,7 @@ GEM dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) + e2mmap (0.1.0) erubi (1.11.0) et-orbi (1.2.7) tzinfo @@ -135,9 +139,15 @@ GEM importmap-rails (1.1.5) actionpack (>= 6.0.0) railties (>= 6.0.0) + jaro_winkler (1.5.4) jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + json (2.6.3) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) launchy (2.5.0) addressable (~> 2.7) letter_opener (1.8.1) @@ -179,6 +189,9 @@ GEM racc (~> 1.4) orm_adapter (0.5.0) pagy (6.0.2) + parallel (1.22.1) + parser (3.2.1.1) + ast (~> 2.4.1) pg (1.2.3) public_suffix (5.0.0) puma (4.3.12) @@ -217,6 +230,7 @@ GEM rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) + rainbow (3.1.1) rake (13.0.6) rb-fsevent (0.11.2) rb-inotify (0.10.1) @@ -229,6 +243,8 @@ GEM responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) + reverse_markdown (2.1.1) + nokogiri rexml (3.2.5) rqrcode (2.1.2) chunky_png (~> 1.0) @@ -251,9 +267,27 @@ GEM rspec-mocks (~> 3.11) rspec-support (~> 3.11) rspec-support (3.12.0) + rubocop (1.48.1) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.26.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.28.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rufus-scheduler (3.8.2) fugit (~> 1.1, >= 1.1.6) + sentry-rails (5.8.0) + railties (>= 5.0) + sentry-ruby (~> 5.8.0) + sentry-ruby (5.8.0) + concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (6.5.5) connection_pool (>= 2.2.2) rack (~> 2.0) @@ -263,6 +297,21 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 4, < 7) tilt (>= 1.4.0) + solargraph (0.48.0) + backport (~> 1.2) + benchmark + bundler (>= 1.17.2) + diff-lcs (~> 1.4) + e2mmap + jaro_winkler (~> 1.5) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.1) + parser (~> 3.0) + reverse_markdown (>= 1.0.5, < 3) + rubocop (>= 0.52) + thor (~> 1.0) + tilt (~> 2.0) + yard (~> 0.9, >= 0.9.24) sprockets (4.1.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -284,6 +333,7 @@ GEM railties (>= 6.0.0) tzinfo (2.0.5) concurrent-ruby (~> 1.0) + unicode-display_width (2.4.2) view_component (2.78.0) activesupport (>= 5.0.0, < 8.0) concurrent-ruby (~> 1.0) @@ -299,11 +349,14 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.7.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yard (0.9.28) + webrick (~> 1.7.0) zeitwerk (2.6.6) PLATFORMS @@ -335,8 +388,11 @@ DEPENDENCIES rails-settings-cached (~> 2.8.3) rqrcode (~> 2.0) rspec-rails + sentry-rails + sentry-ruby sidekiq (< 7) sidekiq-scheduler + solargraph sprockets-rails sqlite3 (~> 1.4) stimulus-rails diff --git a/README.md b/README.md index b2e7205..90fec7f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ so: 1. Make sure [Docker Compose is installed][1] and Docker is running (included in Docker Desktop) -2. Uncomment the `web` section in `docker-compose.yml` +2. Uncomment the `redis`, `web`, and `sidekiq` sections in `docker-compose.yml` 3. Run `docker compose up` and wait until 389ds announces its successful start in the log output 4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"` @@ -23,9 +23,8 @@ so: After these steps, you should have a working Rails app with a handful of test users running on [http://localhost:3000](http://localhost:3000). - Log in with username "admin" and password "admin is admin". All users listed on -[http://localhost:3000/admin/ldap_users](http://localhost:3000/admin/ldap_users) +[http://localhost:3000/admin/users](http://localhost:3000/admin/users) have the password "user is user". ### Rails app @@ -79,6 +78,15 @@ The setup task will first delete any existing entries in the directory tree Note that all 389ds data is stored in `tmp/389ds`. So if you want to start over with a fresh installation, delete both that directory as well as the container. +### Solargraph + +[Solargraph](https://solargraph.org/) is a Ruby language server, which you may +use with your editor to add features like auto-completion and syntax +validation. You can add inline documentation for bundled gems with this +command: + + bundle exec yard gems + ## Documentation ### Rails diff --git a/app/assets/stylesheets/components/links.css b/app/assets/stylesheets/components/links.css index c46f68b..92c9c75 100644 --- a/app/assets/stylesheets/components/links.css +++ b/app/assets/stylesheets/components/links.css @@ -5,10 +5,4 @@ &:visited { @apply text-indigo-600; } &:active { @apply text-red-600; } } - - .devise-links { - a { - @apply ks-text-link; - } - } } 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/components/form_elements/toggle_component.html.erb b/app/components/form_elements/toggle_component.html.erb index 41a6708..8d6faf9 100644 --- a/app/components/form_elements/toggle_component.html.erb +++ b/app/components/form_elements/toggle_component.html.erb @@ -1,6 +1,6 @@ <%= button_tag type: "button", name: "toggle", data: @data, role: "switch", aria: { checked: @enabled.to_s }, - disabled: !@input_enabled, + tabindex: @tabindex, disabled: !@input_enabled, class: "#{ @enabled ? 'bg-blue-600' : 'bg-gray-200' } #{ @class_names.present? ? @class_names : '' } relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer diff --git a/app/components/form_elements/toggle_component.rb b/app/components/form_elements/toggle_component.rb index d2f5eae..8bb7181 100644 --- a/app/components/form_elements/toggle_component.rb +++ b/app/components/form_elements/toggle_component.rb @@ -2,11 +2,12 @@ module FormElements class ToggleComponent < ViewComponent::Base - def initialize(enabled:, input_enabled: true, data: nil, class_names: nil) + def initialize(enabled:, input_enabled: true, data: nil, class_names: nil, tabindex: nil) @enabled = !!enabled @input_enabled = input_enabled @data = data @class_names = class_names + @tabindex = tabindex end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c672886..ee049bc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,18 @@ class ApplicationController < ActionController::Base render :text => exception, :status => 500 end + before_action :sentry_set_user + + def sentry_set_user + return unless Setting.sentry_enabled + + if user_signed_in? + Sentry.set_user(id: current_user.id, username: current_user.cn) + else + Sentry.set_user({}) + end + end + def require_user_signed_in unless user_signed_in? redirect_to welcome_path and return 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/javascript/controllers/notification_controller.js b/app/javascript/controllers/notification_controller.js index e0291ce..9e09dc4 100644 --- a/app/javascript/controllers/notification_controller.js +++ b/app/javascript/controllers/notification_controller.js @@ -4,6 +4,10 @@ export default class extends Controller { static targets = ["buttons", "countdown"] connect() { + // Devise timeoutable ends up adding a second flash message without content + // TODO investigate bug + if (this.element.textContent.trim() == "true") return; + const timeoutSeconds = parseInt(this.data.get("timeout")); setTimeout(() => { 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 3a7e8c5..b1364a5 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -2,6 +2,13 @@ class Setting < RailsSettings::Base cache_prefix { "v1" } + # + # Internal services + # + + field :redis_url, type: :string, readonly: true, + default: ENV["REDIS_URL"] || "redis://localhost:6379/0" + # # Registrations # @@ -10,6 +17,13 @@ class Setting < RailsSettings::Base account accounts donations mail webmaster support ] + # + # Sentry + # + + field :sentry_enabled, type: :boolean, readonly: true, + default: (ENV["SENTRY_DSN"].present?.to_s || false) + # # Discourse # diff --git a/app/models/user.rb b/app/models/user.rb index ecc50f5..380d4e7 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' @@ -24,6 +26,7 @@ class User < ApplicationRecord validates_format_of :cn, without: /\A-/, if: Proc.new{ |u| u.cn.present? }, message: "is invalid. Usernames need to start with a letter." + # FIXME This needs a server restart to apply values validates_format_of :cn, without: /\A(#{Setting.reserved_usernames.join('|')})\z/i, message: "has already been taken" @@ -40,7 +43,9 @@ class User < ApplicationRecord devise :ldap_authenticatable, :confirmable, :recoverable, - :validatable + :validatable, + :timeoutable, + :rememberable def ldap_before_save self.email = Devise::LDAP::Adapter.get_ldap_param(self.cn, "mail").first @@ -55,16 +60,23 @@ 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? if inviter.present? - exchange_xmpp_contact_with_inviter if Setting.ejabberd_enabled? + if Setting.ejabberd_enabled? && + inviter.preferences[:xmpp_exchange_contacts_with_invitees] + exchange_xmpp_contact_with_inviter + end end end + def send_devise_notification(notification, *args) + devise_mailer.send(notification, self, *args).deliver_later + end + def reset_password(new_password, new_password_confirmation) self.password = new_password self.password_confirmation = new_password_confirmation @@ -130,8 +142,8 @@ class User < ApplicationRecord end def exchange_xmpp_contact_with_inviter - return unless inviter.services_enabled.include?("ejabberd") && - services_enabled.include?("ejabberd") + return unless inviter.services_enabled.include?("xmpp") && + services_enabled.include?("xmpp") XmppExchangeContactsJob.perform_later(inviter, self.cn, self.ou) end 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 7930aa1..bac501e 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/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/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 687d90a..f7c0a70 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -7,19 +7,43 @@ <%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>

<%= f.text_field :cn, autofocus: true, autocomplete: "username", - required: true, class: "relative grow"%> + required: true, class: "relative grow", tabindex: "1" %> @ kosmos.org

-

+

<%= f.label :password, class: 'block mb-2 font-bold' %> <%= f.password_field :password, autocomplete: "current-password", - required: true, class: "w-full"%> + required: true, class: "w-full", tabindex: "2" %>

-

- <%= f.submit "Log in", class: 'btn-md btn-blue w-full' %> + + <%= tag.div class: "flex items-center mb-8 gap-x-3", data: { + controller: "settings--toggle", + :'settings--toggle-switch-enabled-value' => "false" + } do %> +

+ <%= render FormElements::ToggleComponent.new( + enabled: false, input_enabled: true, class_names: "hidden", + tabindex: "3", data: { + :'settings--toggle-target' => "button", + action: "settings--toggle#toggleSwitch" + }) %> + <%= f.check_box :remember_me, { + checked: false, + data: { :'settings--toggle-target' => "checkbox" } + }, "true", "false" %> +
+ <%= f.label :remember_me, + class: "text-gray-500 flex flex-col", + data: { action: "click->settings--toggle#toggleSwitch" } %> +

+ <%= link_to "Forgot your password?", new_password_path(resource_name), + class: "text-gray-500 underline" %>
+

+ <% end %> + +

+ <%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>

<% end %> - - <%= render "devise/shared/links" %> <% end %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index b1d1ccd..2499950 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,25 +1,29 @@ 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/_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/environments/production.rb b/config/environments/production.rb index 1f22161..c51028f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -62,6 +62,11 @@ Rails.application.configure do outgoing_email_address = ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost') outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain + config.action_mailer.default_url_options = { + host: ENV['AKKOUNTS_DOMAIN'], + protocol: "https", + } + config.action_mailer.default_options = { from: outgoing_email_address, message_id: -> { "<#{Mail.random_tag}@#{outgoing_email_domain}>" }, diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 3b0ffeb..bb264bf 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -186,13 +186,13 @@ Devise.setup do |config| # ==> Configuration for :rememberable # The time the user will be remembered without asking for credentials again. - # config.remember_for = 2.weeks + config.remember_for = 2.weeks # Invalidates all the remember me tokens when the user signs out. config.expire_all_remember_me_on_sign_out = true # If true, extends the user's remember period when remembered via cookie. - # config.extend_remember_period = false + config.extend_remember_period = true # Options to be passed to the created cookie. For instance, you can set # secure: true in order to force SSL only cookies. @@ -210,7 +210,7 @@ Devise.setup do |config| # ==> Configuration for :timeoutable # The time you want to timeout the user session without activity. After this # time the user will be asked for credentials again. Default is 30 minutes. - # config.timeout_in = 30.minutes + config.timeout_in = 30.minutes # ==> Configuration for :lockable # Defines which strategy will be used to lock an account. diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..202fe75 --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,9 @@ +if ENV["SENTRY_DSN"].present? + Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = [:active_support_logger, :http_logger] + config.traces_sampler = lambda do |context| + true + end + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..044cec0 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,5 @@ +require_relative "../../app/models/setting" + +Sidekiq.configure_server do |config| + config.redis = { url: Setting.redis_url } +end diff --git a/config/routes.rb b/config/routes.rb index b83d631..743eda1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,7 @@ require 'sidekiq/web' Rails.application.routes.draw do - devise_for :users, :controllers => { :confirmations => "users/confirmations" } + devise_for :users, controllers: { confirmations: "users/confirmations" } get 'welcome', to: 'welcome#index' get 'check_your_email', to: 'welcome#check_your_email' @@ -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', @@ -60,6 +59,9 @@ Rails.application.routes.draw do get 'oauth/token/:id/launch_app' => 'oauth#launch_app', as: :launch_app end + get ".well-known/webfinger" => "webfinger#show" + + authenticate :user, ->(user) { user.is_admin? } do mount Sidekiq::Web => '/sidekiq' end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 615bb16..adc65b2 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,3 +1,4 @@ :concurrency: 2 :queues: - default + - mailers diff --git a/db/migrate/20230319101128_add_remember_created_at_to_users.rb b/db/migrate/20230319101128_add_remember_created_at_to_users.rb new file mode 100644 index 0000000..6457dab --- /dev/null +++ b/db/migrate/20230319101128_add_remember_created_at_to_users.rb @@ -0,0 +1,6 @@ +class AddRememberCreatedAtToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :remember_created_at, :datetime + add_column :users, :remember_token, :string + end +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 18a0b86..8947149 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_12_212030) do +ActiveRecord::Schema[7.0].define(version: 2023_04_03_150200) do create_table "donations", force: :cascade do |t| t.integer "user_id" t.integer "amount_sats" @@ -71,6 +71,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_12_212030) 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/docker-compose.yml b/docker-compose.yml index a4d5a29..8a0de47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,67 @@ services: image: 4teamwork/389ds:latest volumes: - ./tmp/389ds:/data + networks: + - external_network + - internal_network ports: - "389:3389" environment: DS_DM_PASSWORD: passthebutter SUFFIX_NAME: "dc=kosmos,dc=org" + + # redis: + # restart: always + # image: redis:7-alpine + # networks: + # - internal_network + # healthcheck: + # test: ['CMD', 'redis-cli', 'ping'] + # volumes: + # - ./tmp/redis:/data + + # web: + # build: . + # tty: true + # command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev" + # volumes: + # - .:/akkounts + # networks: + # - external_network + # - internal_network + # ports: + # - "3000:3000" + # environment: + # RAILS_ENV: development + # REDIS_URL: redis://redis:6379/0 + # LDAP_HOST: ldap + # LDAP_PORT: 3389 + # LDAP_ADMIN_PASSWORD: passthebutter + # LDAP_USE_TLS: "false" + # depends_on: + # - ldap + # - redis + + # sidekiq: + # build: . + # command: bash -c "bundle exec sidekiq -C config/sidekiq.yml" + # volumes: + # - .:/akkounts + # networks: + # - internal_network + # environment: + # RAILS_ENV: development + # REDIS_URL: redis://redis:6379/0 + # LDAP_HOST: ldap + # LDAP_PORT: 3389 + # LDAP_ADMIN_PASSWORD: passthebutter + # LDAP_USE_TLS: "false" + # LAUNCHY_DRY_RUN: true + # BROWSER: /dev/null + # depends_on: + # - ldap + # - redis + # phpldapadmin: # image: osixia/phpldapadmin:0.9.0 # ports: @@ -16,19 +72,8 @@ services: # PHPLDAPADMIN_HTTPS: false # PHPLDAPADMIN_LDAP_HOSTS: "#PYTHON2BASH:[{'ldap': [{'server': [{'tls': False}, {'port': 3389}]}, {'login': [{'bind_id': 'cn=Directory Manager'}, {'bind_pass': 'passthebutter'}]}]}]" # PHPLDAPADMIN_LDAP_CLIENT_TLS: false - # web: - # build: . - # tty: true - # command: bash -c "sleep 5 && rm -f tmp/pids/server.pid && bin/dev" - # volumes: - # - .:/akkounts - # ports: - # - "3000:3000" - # environment: - # RAILS_ENV: development - # LDAP_HOST: ldap - # LDAP_PORT: 3389 - # LDAP_ADMIN_PASSWORD: passthebutter - # LDAP_USE_TLS: "false" - # depends_on: - # - ldap + +networks: + external_network: + internal_network: + internal: true diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index 3af18f7..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e - -# Remove a potentially pre-existing server.pid for Rails. -rm -f /myapp/tmp/pids/server.pid - -# Then exec the container's main process (what's set as CMD in the Dockerfile). -exec "$@" diff --git a/package.json b/package.json index 066231b..60a5ede 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "postcss-preset-env": "^7.8.3", "tailwindcss": "^3.2.4" }, - "version": "0.4.0", + "version": "0.5.0", "scripts": { "build:css:tailwind": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css", "build:css": "yarn run build:css:tailwind" 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/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 1dfc1ac..c1105c9 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -109,7 +109,7 @@ RSpec.describe User, type: :model do before do Invitation.create! user: user, invited_user_id: guest.id, used_at: DateTime.now - allow_any_instance_of(User).to receive(:services_enabled).and_return(%w[ ejabberd ]) + allow_any_instance_of(User).to receive(:services_enabled).and_return(%w[ xmpp ]) end it "enqueues a job to exchange XMPP contacts between inviter and invitee" do @@ -131,14 +131,16 @@ 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 - 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).and_return(true) end @@ -147,6 +149,17 @@ RSpec.describe User, type: :model do expect(guest).to receive(:exchange_xmpp_contact_with_inviter) guest.send(:devise_after_confirmation) end + + context "automatic contact exchange disabled" do + before do + user.update! preferences: { xmpp_exchange_contacts_with_invitees: false } + end + + it "does not exchange XMPP contacts with the inviter" do + expect(guest).to_not receive(:exchange_xmpp_contact_with_inviter) + guest.send(:devise_after_confirmation) + end + end end end 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