66 Commits

Author SHA1 Message Date
Râu Cao
2a70bf2fb9 Small refactoring
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-20 13:40:56 +01:00
Râu Cao
9a9947f9ad Respect "start_url" from manifest when launching web apps
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-20 13:32:40 +01:00
Râu Cao
bdf5a18ad4 Re-add more specs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-20 12:21:57 +01:00
Râu Cao
aa399b862a Allow to launch RS apps from dashboard
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-19 19:10:13 +01:00
Râu Cao
713e91a720 Implement RS auth revocation
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-19 18:49:17 +01:00
Râu Cao
8ec2a6d7e4 Remove obsolete spec file 2023-11-19 18:49:06 +01:00
Râu Cao
4ecf2c4246 Improve app list 2023-11-19 18:48:44 +01:00
Râu Cao
4fdf8accd6 Add note
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-18 17:36:18 +01:00
Râu Cao
f451adcb53 Try smaller icons if 256px not available 2023-11-18 17:35:57 +01:00
Râu Cao
721dccb499 Add dropdown components, menus for RS auth items 2023-11-18 17:13:55 +01:00
Râu Cao
27bb7d1bfe Finish working liquor-cabinet setup for Docker Compose
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-16 12:46:19 +01:00
Râu Cao
1d44181fb5 Wording 2023-11-16 12:46:05 +01:00
Râu Cao
de67f59d5c Fail gracefully and log error when token missing in Redis 2023-11-16 12:45:26 +01:00
Râu Cao
1995e6dda2 Fix RS OAuth URL in Webfinger record
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-16 12:44:59 +01:00
Râu Cao
600cfe0f78 Update lockfile 2023-11-16 12:42:39 +01:00
Râu Cao
00049f3743 Add info for running Minio/RS to README
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-11-01 22:01:14 +01:00
Râu Cao
60c0a43f33 Add minio to Docker Compose setup, configure Liquor Cabinet 2023-11-01 21:51:29 +01:00
Râu Cao
0c1b1b4afe Adjust specs for web app metadata fetching 2023-11-01 21:49:08 +01:00
Râu Cao
92310d434a Remove rs namespace from Redis keys
Superfluous, since the whole db should be RS only
2023-11-01 21:48:16 +01:00
Râu Cao
56c127ca0c Only allow primary domain for RS
Replace user addresses with usernames in the respective URLs
2023-11-01 21:46:38 +01:00
Râu Cao
5075fef616 Only show avatar when available on admin user page
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-25 22:16:16 +02:00
Râu Cao
8e090daa9c Fetch web app metadata when creating RS auth 2023-10-25 22:16:16 +02:00
Râu Cao
def87a1621 Remove variants from attachment 2023-10-25 22:16:16 +02:00
Râu Cao
00ec7fa21c WIP Add RS auths/apps to Storage dashboard 2023-10-25 22:16:13 +02:00
Râu Cao
2b8bfaaca8 Add admin page for web apps
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-24 22:42:16 +02:00
Râu Cao
3e9a08a266 Remove (long) obsolete edge case 2023-10-24 17:29:24 +02:00
Râu Cao
fcea11f0e5 Associate RS authorizations with web apps 2023-10-24 17:29:24 +02:00
Râu Cao
261a782963 Only complete icon URLs when given relative or absolute paths 2023-10-24 17:29:24 +02:00
Râu Cao
e964e7e52c Save web app metadata explicitly 2023-10-24 17:29:24 +02:00
Râu Cao
e508407df4 Remove debug statement 2023-10-24 17:29:23 +02:00
Râu Cao
bec827acb1 Store web app icons with proper folder paths 2023-10-24 17:29:23 +02:00
Râu Cao
0a69603643 Update web app metadata when first creating a record 2023-10-24 17:29:23 +02:00
Râu Cao
d4f71e98ed Download and attach icons for web apps 2023-10-24 17:29:23 +02:00
Râu Cao
e56c9bd0d5 Add web app model, service to fetch metadata 2023-10-24 17:29:23 +02:00
Râu Cao
e1b7e1b2ef Update dependencies, add manifique 2023-10-24 17:29:23 +02:00
Râu Cao
1056ffd08e Add optional S3 config/backend for ActiveStorage 2023-10-24 17:29:23 +02:00
be5fe00f20 Merge pull request 'Fix XMPP from-address config not being used' (#150) from bugfix/xmpp_from_address into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #150
2023-10-19 10:47:45 +00:00
Râu Cao
e9c4929726 Fix XMPP from-address config not being used
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2023-10-17 15:21:57 +02:00
14ff0c0e16 Merge pull request 'BTCPay settings, admin page, and new Lightning balance API' (#147) from feature/btcpay_configs into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #147
Reviewed-by: galfert <garret.alfert@gmail.com>
2023-09-26 10:13:09 +00:00
Râu Cao
d939f5d649 Merge branch 'master' into feature/btcpay_configs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2023-09-20 19:12:24 +02:00
Râu Cao
69fffb29d8 Make publishing of BTCPay wallet balances optional
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2023-09-20 18:36:53 +02:00
Râu Cao
91d3b977e9 Fix spec 2023-09-20 18:26:50 +02:00
7a5fd46835 Merge pull request 'Add user avatars to LDAP, upload on profile settings page' (#148) from feature/123-user_avatars into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #148
Reviewed-by: galfert <garret.alfert@gmail.com>
2023-09-13 13:01:25 +00:00
Râu Cao
9c4c5c2553 Use correct content type for image
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2023-09-13 14:49:16 +02:00
Râu Cao
8f819d12c0 Remove debug output 2023-09-13 14:48:51 +02:00
Râu Cao
b810e27480 Use custom docker image with libvips installed in CI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-07 19:40:43 +02:00
Râu Cao
1949f1876f Use attr_reader instead of shared instance variables
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-07 19:22:15 +02:00
Râu Cao
2ba0116ca6 Fix wrong inheritance
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-07 19:17:46 +02:00
Râu Cao
2c2ddabdff Fix code being silly
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-07 19:15:14 +02:00
Râu Cao
dfcdbec0dd Add specs for avatar upload
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2023-09-07 11:42:42 +02:00
Râu Cao
3b67a8791c Add libvips package to Docker container 2023-09-07 11:42:24 +02:00
Râu Cao
d5ab532947 Store and retrieve avatars in/from LDAP exclusively
Some checks failed
continuous-integration/drone/push Build is failing
No need to keep them in two places at the same time. We can fetch them
from LDAP whenever we want to do something with them.
2023-09-06 20:42:26 +02:00
Râu Cao
50c63d5c38 Update user avatar in LDAP 2023-09-06 19:02:07 +02:00
Râu Cao
64d09cfb7f Use variant declarations instead of custom methods 2023-09-06 12:38:47 +02:00
Râu Cao
def44618ef Comments
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-06 12:16:00 +02:00
Râu Cao
9e5aeaf572 Add user avatars 2023-09-06 12:15:53 +02:00
Râu Cao
86f85a90f4 Add/configure ActiveStorage 2023-09-06 12:14:28 +02:00
d8a35ac3fd Merge pull request 'Fix wrong redirect after sign-in for RS OAuth' (#146) from bugfix/rs_oauth_login into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #146
Reviewed-by: galfert <garret.alfert@gmail.com>
2023-09-05 11:03:02 +00:00
Râu Cao
5a5f62e98a Refactor BTCPay service and API, add lightning balance
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-09-04 16:02:54 +02:00
Râu Cao
074f9afcbb Fix descriptions not being shown for resettable form fields 2023-09-04 15:37:02 +02:00
Râu Cao
725fd2e5ea Move lndhub admin token to env var/setting 2023-09-04 15:36:22 +02:00
Râu Cao
8349ca5e12 Add admin settings page for BTCPay 2023-09-04 15:25:20 +02:00
Râu Cao
46d59e3371 Improve icons in admin service settings sidenav 2023-09-04 15:24:35 +02:00
Râu Cao
e8e6ee0bc4 Add configurable settings for BTCPay 2023-09-04 15:23:27 +02:00
Râu Cao
a91ee2bd0a Fix generated usernames in seeds potentially being too short
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 11:35:51 +02:00
Râu Cao
fcb6923c92 Fix wrong redirect after sign-in for RS OAuth
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
We use a custom auth method to pre-fill the username when reaching the
RS OAuth while signed out. However, it needs to redirect back to the RS
OAuth page after sign-in, and not to the root path.
2023-09-04 11:33:16 +02:00
92 changed files with 1604 additions and 513 deletions

View File

@@ -17,7 +17,7 @@ steps:
branch: branch:
- master - master
- name: rspec - name: rspec
image: guildeducation/rails:2.7.2-14.20.0 image: gitea.kosmos.org/kosmos/akkounts-ci:0.1.0
environment: environment:
RAILS_ENV: test RAILS_ENV: test
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0

View File

@@ -10,6 +10,14 @@ SMTP_DOMAIN=example.com
SMTP_AUTH_METHOD=plain SMTP_AUTH_METHOD=plain
SMTP_ENABLE_STARTTLS=auto SMTP_ENABLE_STARTTLS=auto
# S3_ENABLED=true
# S3_ENDPOINT=https://s3.kosmos.org
# S3_REGION=garage
# S3_BUCKET=akkounts-production
# S3_ALIAS_HOST=accounts.s3.kosmos.org
# S3_ACCESS_KEY=123456abcdefg
# S3_SECRET_KEY=123456789123456789123456789
LDAP_HOST=localhost LDAP_HOST=localhost
LDAP_PORT=389 LDAP_PORT=389
LDAP_ADMIN_PASSWORD=passthebutter LDAP_ADMIN_PASSWORD=passthebutter
@@ -34,6 +42,8 @@ EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin'
EJABBERD_API_URL='https://xmpp.kosmos.org/api' EJABBERD_API_URL='https://xmpp.kosmos.org/api'
BTCPAY_API_URL='http://localhost:23001/api/v1' BTCPAY_API_URL='http://localhost:23001/api/v1'
BTCPAY_STORE_ID=''
BTCPAY_AUTH_TOKEN=''
LNDHUB_API_URL='http://localhost:3023' LNDHUB_API_URL='http://localhost:3023'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'

View File

@@ -2,13 +2,14 @@ PRIMARY_DOMAIN=kosmos.org
REDIS_URL='redis://localhost:6379/0' REDIS_URL='redis://localhost:6379/0'
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
BTCPAY_STORE_ID='123456'
DISCOURSE_PUBLIC_URL='http://discourse.example.com' DISCOURSE_PUBLIC_URL='http://discourse.example.com'
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw' DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api' EJABBERD_API_URL='http://xmpp.example.com/api'
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
LNDHUB_API_URL='http://localhost:3026' LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946' LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@
!/tmp/pids/ !/tmp/pids/
!/tmp/pids/.keep !/tmp/pids/.keep
/storage
/public/assets /public/assets
.byebug_history .byebug_history

View File

@@ -4,7 +4,7 @@ FROM ruby:2.7.6
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \ RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \
ldap-utils tini ldap-utils tini libvips
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get install -y nodejs RUN apt-get update && apt-get install -y nodejs

View File

@@ -37,6 +37,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap' gem 'net-ldap'
# Utilities # Utilities
gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0" gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3' gem 'rails-settings-cached', '~> 2.8.3'
gem 'pagy', '~> 6.0', '>= 6.0.2' gem 'pagy', '~> 6.0', '>= 6.0.2'
@@ -46,6 +47,8 @@ gem 'flipper-ui'
# HTTP requests # HTTP requests
gem 'faraday' gem 'faraday'
gem 'down'
gem 'aws-sdk-s3', require: false
# Background/scheduled jobs # Background/scheduled jobs
gem 'sidekiq', '< 7' gem 'sidekiq', '< 7'
@@ -58,6 +61,7 @@ gem "sentry-rails"
# Services # Services
gem 'discourse_api' gem 'discourse_api'
gem "lnurl" gem "lnurl"
gem 'manifique', git: 'https://gitea.kosmos.org/5apps/manifique.git', branch: 'master'
gem 'nostr', git: 'https://gitea.kosmos.org/kosmos/nostr-gem.git', branch: 'feature/ruby_2.7_compat' gem 'nostr', git: 'https://gitea.kosmos.org/kosmos/nostr-gem.git', branch: 'feature/ruby_2.7_compat'
group :development, :test do group :development, :test do

View File

@@ -1,3 +1,13 @@
GIT
remote: https://gitea.kosmos.org/5apps/manifique.git
revision: 8d79113438ee7c3e4288f840a135622519cffd5c
branch: master
specs:
manifique (0.1.0)
faraday (~> 2.7.11)
faraday-follow_redirects (= 0.3.0)
nokogiri (~> 1.15.4)
GIT GIT
remote: https://gitea.kosmos.org/kosmos/nostr-gem.git remote: https://gitea.kosmos.org/kosmos/nostr-gem.git
revision: 596529d9eb50d13b3f385245636698fccf37b442 revision: 596529d9eb50d13b3f385245636698fccf37b442
@@ -14,82 +24,100 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.5) actioncable (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.5) actionmailbox (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
activejob (= 7.0.5) activejob (= 7.0.8)
activerecord (= 7.0.5) activerecord (= 7.0.8)
activestorage (= 7.0.5) activestorage (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.5) actionmailer (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
actionview (= 7.0.5) actionview (= 7.0.8)
activejob (= 7.0.5) activejob (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.5) actionpack (7.0.8)
actionview (= 7.0.5) actionview (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.5) actiontext (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
activerecord (= 7.0.5) activerecord (= 7.0.8)
activestorage (= 7.0.5) activestorage (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.5) actionview (7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.0.5) activejob (7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.5) activemodel (7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
activerecord (7.0.5) activerecord (7.0.8)
activemodel (= 7.0.5) activemodel (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
activestorage (7.0.5) activestorage (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
activejob (= 7.0.5) activejob (= 7.0.8)
activerecord (= 7.0.5) activerecord (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.5) activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.4) addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.2.0)
aws-partitions (1.839.0)
aws-sdk-core (3.185.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.72.0)
aws-sdk-core (~> 3, >= 3.184.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.136.0)
aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
backport (1.2.0) backport (1.2.0)
bcrypt (3.1.18) base64 (0.1.1)
bech32 (1.3.0) bcrypt (3.1.19)
bech32 (1.4.2)
thor (>= 1.1.0) thor (>= 1.1.0)
benchmark (0.2.1) benchmark (0.2.1)
bindex (0.8.1) bindex (0.8.1)
bip-schnorr (0.6.0) bip-schnorr (0.6.0)
ecdsa_ext (~> 0.5.0) ecdsa_ext (~> 0.5.0)
brow (0.4.1)
builder (3.2.4) builder (3.2.4)
byebug (11.1.3) byebug (11.1.3)
capybara (3.39.2) capybara (3.39.2)
@@ -107,7 +135,7 @@ GEM
crack (0.4.5) crack (0.4.5)
rexml rexml
crass (1.0.6) crass (1.0.6)
cssbundling-rails (1.1.2) cssbundling-rails (1.3.3)
railties (>= 6.0.0) railties (>= 6.0.0)
database_cleaner (2.0.2) database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (>= 2, < 3)
@@ -116,7 +144,7 @@ GEM
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.3) date (3.3.3)
devise (4.9.2) devise (4.9.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@@ -135,6 +163,8 @@ GEM
dotenv-rails (2.8.1) dotenv-rails (2.8.1)
dotenv (= 2.8.1) dotenv (= 2.8.1)
railties (>= 3.2) railties (>= 3.2)
down (5.4.1)
addressable (~> 2.8)
e2mmap (0.1.0) e2mmap (0.1.0)
ecdsa (1.2.0) ecdsa (1.2.0)
ecdsa_ext (0.5.0) ecdsa_ext (0.5.0)
@@ -149,9 +179,10 @@ GEM
factory_bot_rails (6.2.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.7.6) faraday (2.7.11)
base64
faraday-net_http (>= 2.0, < 3.1) faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4) ruby2_keywords (>= 0.0.4)
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.3.0)
@@ -159,41 +190,47 @@ GEM
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (3.0.2) faraday-net_http (3.0.2)
faye-websocket (0.11.2) faye-websocket (0.11.3)
eventmachine (>= 0.12.0) eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1) websocket-driver (>= 0.5.1)
ffi (1.15.5) ffi (1.16.3)
flipper (0.28.0) flipper (1.0.0)
brow (~> 0.4.1)
concurrent-ruby (< 2) concurrent-ruby (< 2)
flipper-active_record (0.28.0) flipper-active_record (1.0.0)
activerecord (>= 4.2, < 8) activerecord (>= 4.2, < 8)
flipper (~> 0.28.0) flipper (~> 1.0.0)
flipper-ui (0.28.0) flipper-ui (1.0.0)
erubi (>= 1.0.0, < 2.0.0) erubi (>= 1.0.0, < 2.0.0)
flipper (~> 0.28.0) flipper (~> 1.0.0)
rack (>= 1.4, < 3) rack (>= 1.4, < 4)
rack-protection (>= 1.5.3, <= 4.0.0) rack-protection (>= 1.5.3, <= 4.0.0)
sanitize (< 7) sanitize (< 7)
fugit (1.8.1) fugit (1.8.1)
et-orbi (~> 1, >= 1.2.7) et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.1.0) globalid (1.2.1)
activesupport (>= 5.0) activesupport (>= 6.1)
hashdiff (1.0.1) hashdiff (1.0.1)
i18n (1.14.1) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (1.1.6) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (1.2.1)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
jaro_winkler (1.5.6) jaro_winkler (1.5.6)
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.6.3) json (2.6.3)
kramdown (2.4.0) kramdown (2.4.0)
rexml rexml
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
@@ -206,10 +243,10 @@ GEM
listen (3.8.0) listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
lnurl (1.0.1) lnurl (1.1.0)
bech32 (~> 1.1) bech32 (~> 1.1)
lockbox (1.2.0) lockbox (1.3.0)
loofah (2.21.3) loofah (2.21.4)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -220,10 +257,11 @@ GEM
marcel (1.0.2) marcel (1.0.2)
matrix (0.4.2) matrix (0.4.2)
method_source (1.0.0) method_source (1.0.0)
mini_mime (1.1.2) mini_magick (4.12.0)
minitest (5.18.0) mini_mime (1.1.5)
minitest (5.20.0)
multipart-post (2.3.0) multipart-post (2.3.0)
net-imap (0.3.6) net-imap (0.3.7)
date date
net-protocol net-protocol
net-ldap (0.18.0) net-ldap (0.18.0)
@@ -231,50 +269,51 @@ GEM
net-protocol net-protocol
net-protocol (0.2.1) net-protocol (0.2.1)
timeout timeout
net-smtp (0.3.3) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.15.2-arm64-darwin) nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.2-x86_64-linux) nokogiri (1.15.4-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
pagy (6.0.4) pagy (6.1.0)
parallel (1.23.0) parallel (1.23.0)
parser (3.2.2.3) parser (3.2.2.4)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.2.3) pg (1.2.3)
public_suffix (5.0.1) public_suffix (5.0.3)
puma (4.3.12) puma (4.3.12)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.7.1) racc (1.7.1)
rack (2.2.7) rack (2.2.8)
rack-protection (3.0.6) rack-protection (3.1.0)
rack rack (~> 2.2, >= 2.2.4)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.5) rails (7.0.8)
actioncable (= 7.0.5) actioncable (= 7.0.8)
actionmailbox (= 7.0.5) actionmailbox (= 7.0.8)
actionmailer (= 7.0.5) actionmailer (= 7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
actiontext (= 7.0.5) actiontext (= 7.0.8)
actionview (= 7.0.5) actionview (= 7.0.8)
activejob (= 7.0.5) activejob (= 7.0.8)
activemodel (= 7.0.5) activemodel (= 7.0.8)
activerecord (= 7.0.5) activerecord (= 7.0.8)
activestorage (= 7.0.5) activestorage (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.5) railties (= 7.0.8)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3) rails-dom-testing (2.2.0)
activesupport (>= 4.2.0) activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
@@ -282,9 +321,9 @@ GEM
rails-settings-cached (2.8.3) rails-settings-cached (2.8.3)
activerecord (>= 5.0.0) activerecord (>= 5.0.0)
railties (>= 5.0.0) railties (>= 5.0.0)
railties (7.0.5) railties (7.0.8)
actionpack (= 7.0.5) actionpack (= 7.0.8)
activesupport (= 7.0.5) activesupport (= 7.0.8)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@@ -296,13 +335,13 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
rbs (2.8.4) rbs (2.8.4)
redis (4.8.1) redis (4.8.1)
regexp_parser (2.8.1) regexp_parser (2.8.2)
responders (3.1.0) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.2.5) rexml (3.2.6)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
@@ -312,7 +351,7 @@ GEM
rspec-expectations (3.12.3) rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-mocks (3.12.5) rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.0.3) rspec-rails (6.0.3)
@@ -323,32 +362,36 @@ GEM
rspec-expectations (~> 3.12) rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.12)
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-support (3.12.0) rspec-support (3.12.1)
rubocop (1.52.1) rubocop (1.57.1)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.3) parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0) rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0) rubocop-ast (1.29.0)
parser (>= 3.2.1.0) parser (>= 3.2.1.0)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.2.0)
ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rufus-scheduler (3.9.1) rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
sanitize (6.0.1) sanitize (6.1.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
sentry-rails (5.9.0) sentry-rails (5.12.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.9.0) sentry-ruby (~> 5.12.0)
sentry-ruby (5.9.0) sentry-ruby (5.12.0)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (6.5.9) sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3) connection_pool (>= 2.2.5, < 3)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.5.0, < 5) redis (>= 4.5.0, < 5)
@@ -372,55 +415,57 @@ GEM
thor (~> 1.0) thor (~> 1.0)
tilt (~> 2.0) tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24) yard (~> 0.9, >= 0.9.24)
sprockets (4.2.0) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2) sprockets-rails (3.4.2)
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (1.6.3-arm64-darwin) sqlite3 (1.6.7-arm64-darwin)
sqlite3 (1.6.3-x86_64-linux) sqlite3 (1.6.7-x86_64-linux)
stimulus-rails (1.2.1) stimulus-rails (1.3.0)
railties (>= 6.0.0) railties (>= 6.0.0)
thor (1.2.2) thor (1.3.0)
tilt (2.2.0) tilt (2.3.0)
timeout (0.3.2) timeout (0.4.0)
turbo-rails (1.4.0) turbo-rails (1.5.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2) unicode-display_width (2.5.0)
view_component (3.2.0) view_component (3.6.0)
activesupport (>= 5.2.0, < 8.0) activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
method_source (~> 1.0) method_source (~> 1.0)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.18.1) webmock (3.19.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.5) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yard (0.9.34) yard (0.9.34)
zeitwerk (2.6.8) zeitwerk (2.6.12)
PLATFORMS PLATFORMS
arm64-darwin-22 arm64-darwin-22
ruby
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
aws-sdk-s3
byebug (~> 11.1) byebug (~> 11.1)
capybara capybara
cssbundling-rails cssbundling-rails
@@ -429,12 +474,14 @@ DEPENDENCIES
devise_ldap_authenticatable devise_ldap_authenticatable
discourse_api discourse_api
dotenv-rails dotenv-rails
down
factory_bot_rails factory_bot_rails
faker faker
faraday faraday
flipper flipper
flipper-active_record flipper-active_record
flipper-ui flipper-ui
image_processing (~> 1.12.2)
importmap-rails importmap-rails
jbuilder (~> 2.7) jbuilder (~> 2.7)
letter_opener letter_opener
@@ -442,6 +489,7 @@ DEPENDENCIES
listen (~> 3.2) listen (~> 3.2)
lnurl lnurl
lockbox lockbox
manifique!
net-ldap net-ldap
nostr! nostr!
pagy (~> 6.0, >= 6.0.2) pagy (~> 6.0, >= 6.0.2)

View File

@@ -79,6 +79,20 @@ 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 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. with a fresh installation, delete both that directory as well as the container.
#### Minio / RS
If you want to run remoteStorage accounts locally, you will have to create the
respective bucket first:
* `docker compose up web redis minio liquor-cabinet`
* Head to http://localhost:9001 and log in with user `minioadmin`, password
`minioadmin`
* Create a new bucket called `remotestorage` (or whatever you
change the `S3_BUCKET` config to)
* Create a new key with ID "dev-key" and secret "123456789" (or whatever you
change `S3_ACCESS_KEY` and `S3_SECRET_KEY` to). Leave the policy field empty,
as it will automatically allow access to the bucket you created.
### Adding npm modules to use with Stimulus controllers ### Adding npm modules to use with Stimulus controllers
The following command downloads the specified npm module to `vendor/javascript` The following command downloads the specified npm module to `vendor/javascript`

View File

@@ -0,0 +1,26 @@
<div data-controller="dropdown" data-action="click->dropdown#toggle click@window->dropdown#hide">
<div class="relative inline-block">
<div role="button" tabindex="0" data-dropdown-target="button"
class="inline-block select-none">
<span class="appearance-none flex items-center inline-block">
<span class="p-2 bg-gray-50 hover:bg-gray-100 rounded-full">
<%= render partial: "icons/kebab-menu", locals: {
custom_class: "inline text-gray-500 h-6 w-6"
} %>
</span>
</span>
</div>
<div data-dropdown-target="menu"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-from="opacity-0 translate-y-1"
data-transition-enter-to="opacity-100 translate-y-0"
data-transition-leave="transition ease-in duration-150"
data-transition-leave-from="opacity-100 translate-y-0"
data-transition-leave-to="opacity-0 translate-y-1"
class="hidden absolute top-4 right-0 z-10 mt-5 flex w-screen max-w-max">
<div class="bg-white shadow-lg rounded border overflow-hidden w-auto">
<%= content %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
class DropdownComponent < ViewComponent::Base
end

View File

@@ -0,0 +1,6 @@
<%= link_to @href, class: @class, data: {
'dropdown-target': "menuItem",
'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
} do %>
<%= content %>
<% end %>

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class DropdownLinkComponent < ViewComponent::Base
def initialize(href:, separator: false, add_class: nil)
@href = href
@class = class_str(separator, add_class)
end
private
def class_str(separator, add_class)
str = "no-underline block px-5 py-3 text-sm text-gray-900 bg-white
hover:bg-gray-100 focus:bg-gray-100 whitespace-no-wrap"
str = "#{str} border-t" if separator
str = "#{str} #{add_class}" if add_class
str
end
end

View File

@@ -6,7 +6,7 @@ module FormElements
@tag = tag @tag = tag
@positioning = :vertical @positioning = :vertical
@title = title @title = title
@descripton = description @description = description
@key = key.to_sym @key = key.to_sym
@type = type @type = type
@resettable = is_resettable?(@key) @resettable = is_resettable?(@key)

View File

@@ -0,0 +1,26 @@
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= image_tag s3_image_url(@web_app.icon), class: "h-full w-full" %>
</div>
<div class="flex-grow">
<h4 class="mb-1 text-lg font-bold">
<%= @web_app.name %>
</h4>
<p class="text-sm text-gray-500">
<%= @auth.client_id %>
</p>
</div>
<%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new(
href: launch_app_services_storage_rs_auth_url(@auth)
) do %>
Launch app
<% end %>
<%= render DropdownLinkComponent.new(
href: revoke_services_storage_rs_auth_url(@auth),
separator: true, add_class: "text-red-700"
) do %>
Revoke access
<% end %>
<% end %>
</div>

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class RsAuthComponent < ViewComponent::Base
def initialize(auth:)
@auth = auth
@web_app = auth.web_app
end
end

View File

@@ -1,4 +1,8 @@
<%= link_to @path, class: @link_class, title: (@disabled ? "Coming soon" : nil) do %> <%= link_to @path, class: @link_class, title: (@disabled ? "Coming soon" : nil) do %>
<% if @icon.present? %>
<%= render partial: "icons/#{@icon}", locals: { custom_class: @icon_class } %> <%= render partial: "icons/#{@icon}", locals: { custom_class: @icon_class } %>
<% elsif @text_icon.present? %>
<span class="mr-3"><%= @text_icon %></span>
<% end %>
<span class="truncate"><%= @name %></span> <span class="truncate"><%= @name %></span>
<% end %> <% end %>

View File

@@ -1,11 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class SidenavLinkComponent < ViewComponent::Base class SidenavLinkComponent < ViewComponent::Base
def initialize(name:, level: 1, path:, icon:, active: false, disabled: false) def initialize(name:, level: 1, path:, icon: nil, text_icon: nil,
active: false, disabled: false)
@name = name @name = name
@level = level @level = level
@path = path @path = path
@icon = icon @icon = icon
@text_icon = text_icon
@active = active @active = active
@disabled = disabled @disabled = disabled
@link_class = class_names_link(path) @link_class = class_names_link(path)

View File

@@ -0,0 +1,9 @@
class Admin::AppCatalog::WebAppsController < Admin::AppCatalogController
def index
@pagy, @web_apps = pagy(AppCatalog::WebApp.order('created_at desc'))
@stats = {
known_apps: AppCatalog::WebApp.count
}
end
end

View File

@@ -0,0 +1,9 @@
class Admin::AppCatalogController < Admin::BaseController
before_action :set_current_section
private
def set_current_section
@current_section = :app_catalog
end
end

View File

@@ -3,7 +3,7 @@ class Admin::Settings::ServicesController < Admin::SettingsController
@service = params[:s] @service = params[:s]
if @service.blank? if @service.blank?
redirect_to admin_settings_services_path(params: { s: "discourse" }) redirect_to admin_settings_services_path(params: { s: "btcpay" })
end end
end end

View File

@@ -20,6 +20,8 @@ class Admin::UsersController < Admin::BaseController
end end
@services_enabled = @user.services_enabled @services_enabled = @user.services_enabled
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn, ou: @user.ou)
end end
private private

View File

@@ -0,0 +1,29 @@
class Api::BtcpayController < Api::BaseController
before_action :require_feature_enabled
def onchain_btc_balance
balance = BtcpayManager::FetchOnchainWalletBalance.call
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch BTC wallet balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
def lightning_btc_balance
balance = BtcpayManager::FetchLightningWalletBalance.call
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch BTC lightning balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
private
def require_feature_enabled
unless Setting.btcpay_publish_wallet_balances
http_status :not_found and return
end
end
end

View File

@@ -1,13 +0,0 @@
class Api::KreditsController < Api::BaseController
def onchain_btc_balance
btcpay = BtcPay.new
balance = btcpay.onchain_wallet_balance
render json: balance
rescue => error
Rails.logger.warn "Failed to fetch kredits BTC wallet balance: #{error.message}"
render json: { error: 'Failed to fetch wallet balance' },
status: 500
end
end

View File

@@ -37,4 +37,8 @@ class ApplicationController < ActionController::Base
format.any { head status } format.any { head status }
end end
end end
def after_sign_in_path_for(user)
session[:user_return_to] || root_path
end
end end

View File

@@ -3,8 +3,7 @@ class Rs::OauthController < ApplicationController
before_action :authenticate_user!, only: :create before_action :authenticate_user!, only: :create
def new def new
username, org = params[:useraddress].split("@") @user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
@user = User.where(cn: username.downcase, ou: org).first
@scopes = parse_scopes params[:scope] @scopes = parse_scopes params[:scope]
@redirect_uri = params[:redirect_uri] @redirect_uri = params[:redirect_uri]
@client_id = params[:client_id] @client_id = params[:client_id]
@@ -22,7 +21,7 @@ class Rs::OauthController < ApplicationController
unless current_user == @user unless current_user == @user
sign_out :user sign_out :user
redirect_to new_rs_oauth_url(@user.address, redirect_to new_rs_oauth_url(@user.cn,
scope: params[:scope], scope: params[:scope],
redirect_uri: params[:redirect_uri], redirect_uri: params[:redirect_uri],
client_id: params[:client_id], client_id: params[:client_id],
@@ -88,7 +87,7 @@ class Rs::OauthController < ApplicationController
permissions: permissions, permissions: permissions,
client_id: client_id, client_id: client_id,
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
app_name: client_id, #TODO use user-defined name app_name: client_id,
expire_at: expire_at expire_at: expire_at
) )
@@ -96,28 +95,15 @@ class Rs::OauthController < ApplicationController
allow_other_host: true allow_other_host: true
end end
# GET /rs/oauth/token/:id/launch_app
def launch_app
auth = current_user.remote_storage_authorizations.find(params[:id])
redirect_to app_auth_url(auth), allow_other_host: true
end
private private
def require_signed_in_with_username def require_signed_in_with_username
unless user_signed_in? unless user_signed_in?
username, org = params[:useraddress].split("@") session[:user_return_to] = request.url
redirect_to new_user_session_path(cn: username, ou: org) redirect_to new_user_session_path(cn: params[:username], ou: Setting.primary_domain)
end end
end end
def app_auth_url(auth)
url = "#{auth.url}#remotestorage=#{current_user.address}"
url += "&access_token=#{auth.token}"
url
end
def hostname_of(uri) def hostname_of(uri)
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0] uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
end end

View File

@@ -3,10 +3,13 @@ class Services::RemotestorageController < Services::BaseController
before_action :require_feature_enabled before_action :require_feature_enabled
before_action :require_service_available before_action :require_service_available
def dashboard # Dashboard
def show
# unless current_user.services_enabled.include?(:remotestorage) # unless current_user.services_enabled.include?(:remotestorage)
# redirect_to service_remotestorage_info_path # redirect_to service_remotestorage_info_path
# end # end
@rs_auths = current_user.remote_storage_authorizations
# TODO sort by app name
end end
private private

View File

@@ -0,0 +1,42 @@
class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_feature_enabled
before_action :require_service_available
# before_action :require_service_enabled
before_action :find_rs_auth
def destroy
@auth.destroy!
respond_to do |format|
format.html do redirect_to services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.json { head :no_content }
end
end
def launch_app
launch_url = "#{@auth.launch_url}#remotestorage=#{current_user.address}&access_token=#{@auth.token}"
redirect_to launch_url, allow_other_host: true
end
private
def require_feature_enabled
unless Flipper.enabled?(:remotestorage, current_user)
http_status :forbidden
end
end
def require_service_available
http_status :not_found unless Setting.remotestorage_enabled?
end
def find_rs_auth
@auth = current_user.remote_storage_authorizations.find(params[:id])
http_status :not_found unless @auth.present?
end
end

View File

@@ -19,10 +19,15 @@ class SettingsController < ApplicationController
def update def update
@user.preferences.merge!(user_params[:preferences] || {}) @user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name] @user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar]
if @user.save if @user.save
if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name]) if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
LdapManager::UpdateDisplayName.call(@user.dn, user_params[:display_name]) LdapManager::UpdateDisplayName.call(@user.dn, @user.display_name)
end
if @user.avatar_new.present?
LdapManager::UpdateAvatar.call(@user.dn, @user.avatar_new)
end end
redirect_to setting_path(@settings_section), flash: { redirect_to setting_path(@settings_section), flash: {
@@ -117,7 +122,7 @@ class SettingsController < ApplicationController
end end
def user_params def user_params
params.require(:user).permit(:display_name, preferences: [ params.require(:user).permit(:display_name, :avatar, preferences: [
:lightning_notify_sats_received, :lightning_notify_sats_received,
:xmpp_exchange_contacts_with_invitees :xmpp_exchange_contacts_with_invitees
]) ])

View File

@@ -6,15 +6,19 @@ class WebfingerController < ApplicationController
def show def show
resource = params[:resource] resource = params[:resource]
if resource && resource.match(/acct:\w+/) if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
useraddress = resource.split(":").last @username, @org = @useraddress.split("@")
username, org = useraddress.split("@")
username.downcase! unless Rails.env.development?
unless User.where(cn: username, ou: org).any? # Allow different domains (e.g. localhost:3000) in development only
head 404 and return unless @org == Setting.primary_domain
end
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
head 404 and return head 404 and return
end end
render json: webfinger(useraddress).to_json, render json: webfinger.to_json,
content_type: "application/jrd+json" content_type: "application/jrd+json"
else else
head 422 and return head 422 and return
@@ -23,19 +27,18 @@ class WebfingerController < ApplicationController
private private
def webfinger(useraddress) def webfinger
links = []; links = [];
links << remotestorage_link(useraddress) if Setting.remotestorage_enabled # TODO check if storage service is enabled for user, not just globally
links << remotestorage_link if Setting.remotestorage_enabled
{ "links" => links } { "links" => links }
end end
def remotestorage_link(useraddress) def remotestorage_link
# TODO use when OAuth routes are available auth_url = new_rs_oauth_url(@username)
# auth_url = new_rs_oauth_url(useraddress) storage_url = "#{Setting.rs_storage_url}/#{@username}"
auth_url = "https://example.com/rs/oauth"
storage_url = "#{Setting.rs_storage_url}/#{useraddress}"
{ {
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage", "rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",

View File

@@ -30,7 +30,7 @@ class WebhooksController < ApplicationController
def notify_xmpp(address, amt_sats, memo) def notify_xmpp(address, amt_sats, memo)
payload = { payload = {
type: "normal", type: "normal",
from: Setting.primary_domain, from: Setting.xmpp_notifications_from_address,
to: address, to: address,
subject: "Sats received!", subject: "Sats received!",
body: "#{helpers.number_with_delimiter 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}"

View File

@@ -1,8 +1,9 @@
import { Application } from "@hotwired/stimulus" import { Application } from "@hotwired/stimulus"
import { Modal, Tabs } from "tailwindcss-stimulus-components" import { Dropdown, Modal, Tabs } from "tailwindcss-stimulus-components"
const application = Application.start() const application = Application.start()
application.register('dropdown', Dropdown)
application.register('modal', Modal) application.register('modal', Modal)
application.register('tabs', Tabs) application.register('tabs', Tabs)

View File

@@ -0,0 +1,5 @@
module AppCatalog
def self.table_name_prefix
"app_catalog_"
end
end

View File

@@ -0,0 +1,16 @@
class AppCatalog::WebApp < ApplicationRecord
store :metadata, coder: JSON
has_many :remote_storage_authorizations
has_one_attached :icon
has_one_attached :apple_touch_icon
validates :url, presence: true, uniqueness: true
validates :url, format: { with: URI.regexp },
if: Proc.new { |a| a.url.present? }
def update_metadata
AppCatalogManager::UpdateMetadata.call(self)
end
end

View File

@@ -1,5 +1,6 @@
class RemoteStorageAuthorization < ApplicationRecord class RemoteStorageAuthorization < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
serialize :permissions serialize :permissions
@@ -15,22 +16,36 @@ class RemoteStorageAuthorization < ApplicationRecord
before_create :generate_token before_create :generate_token
before_create :store_token_in_redis before_create :store_token_in_redis
before_create :find_or_create_web_app
after_create :schedule_token_expiry after_create :schedule_token_expiry
# after_create :notify_user
before_destroy :delete_token_from_redis before_destroy :delete_token_from_redis
after_destroy :remove_token_expiry_job after_destroy :remove_token_expiry_job
def url def url
if self.redirect_uri uri = URI.parse self.redirect_uri
uri = URI.parse self.redirect_uri "#{uri.scheme}://#{client_id}"
"#{uri.scheme}://#{client_id}" end
def launch_url
return url unless web_app && web_app.metadata[:start_url].present?
start_url = web_app.metadata[:start_url]
if start_url.match("^https?:\/\/")
return start_url.start_with?(url) ? start_url : url
else else
"http://#{client_id}" path = start_url.gsub(/^\.\.\//, "").gsub(/^\.\//, "").gsub(/^\//, "")
"#{url}/#{path}"
end end
end end
def delete_token_from_redis def delete_token_from_redis
key = "rs:authorizations:#{user.address}:#{token}" key = "authorizations:#{user.cn}:#{token}"
redis.srem? key, redis.smembers(key) redis.srem? key, redis.smembers(key)
rescue => e
Rails.logger.error e
Sentry.capture_exception(e) if Setting.sentry_enabled?
end end
private private
@@ -44,7 +59,7 @@ class RemoteStorageAuthorization < ApplicationRecord
end end
def store_token_in_redis def store_token_in_redis
redis.sadd "rs:authorizations:#{user.address}:#{token}", permissions redis.sadd "authorizations:#{user.cn}:#{token}", permissions
end end
def schedule_token_expiry def schedule_token_expiry
@@ -60,4 +75,22 @@ class RemoteStorageAuthorization < ApplicationRecord
job.delete if job.display_args == [id] job.delete if job.display_args == [id]
end end
end end
def find_or_create_web_app
if looks_like_hosted_origin?
web_app = AppCatalog::WebApp.find_or_create_by!(url: self.url)
web_app.update_metadata unless web_app.name.present?
self.web_app = web_app
self.app_name = web_app.name.presence || client_id
else
self.app_name = client_id
end
end
def looks_like_hosted_origin?
uri = URI.parse self.redirect_uri
!!(uri.host =~ /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/)
rescue URI::InvalidURIError
false
end
end end

View File

@@ -36,7 +36,25 @@ class Setting < RailsSettings::Base
# #
field :sentry_enabled, type: :boolean, readonly: true, field :sentry_enabled, type: :boolean, readonly: true,
default: (ENV["SENTRY_DSN"].present?.to_s || false) default: ENV["SENTRY_DSN"].present?
#
# BTCPay Server
#
field :btcpay_api_url, type: :string,
default: ENV["BTCPAY_API_URL"].presence
field :btcpay_enabled, type: :boolean,
default: ENV["BTCPAY_API_URL"].present?
field :btcpay_store_id, type: :string,
default: ENV["BTCPAY_STORE_ID"].presence
field :btcpay_auth_token, type: :string,
default: ENV["BTCPAY_AUTH_TOKEN"].presence
field :btcpay_publish_wallet_balances, type: :boolean, default: true
# #
# Discourse # Discourse
@@ -46,7 +64,7 @@ class Setting < RailsSettings::Base
default: ENV["DISCOURSE_PUBLIC_URL"].presence default: ENV["DISCOURSE_PUBLIC_URL"].presence
field :discourse_enabled, type: :boolean, field :discourse_enabled, type: :boolean,
default: (ENV["DISCOURSE_PUBLIC_URL"].present?.to_s || false) default: ENV["DISCOURSE_PUBLIC_URL"].present?
field :discourse_connect_secret, type: :string, field :discourse_connect_secret, type: :string,
default: ENV["DISCOURSE_CONNECT_SECRET"].presence default: ENV["DISCOURSE_CONNECT_SECRET"].presence
@@ -59,14 +77,14 @@ class Setting < RailsSettings::Base
default: ENV["DRONECI_PUBLIC_URL"].presence default: ENV["DRONECI_PUBLIC_URL"].presence
field :droneci_enabled, type: :boolean, field :droneci_enabled, type: :boolean,
default: (ENV["DRONECI_PUBLIC_URL"].present?.to_s || false) default: ENV["DRONECI_PUBLIC_URL"].present?
# #
# ejabberd # ejabberd
# #
field :ejabberd_enabled, type: :boolean, field :ejabberd_enabled, type: :boolean,
default: (ENV["EJABBERD_API_URL"].present?.to_s || false) default: ENV["EJABBERD_API_URL"].present?
field :ejabberd_api_url, type: :string, field :ejabberd_api_url, type: :string,
default: ENV["EJABBERD_API_URL"].presence default: ENV["EJABBERD_API_URL"].presence
@@ -85,7 +103,7 @@ class Setting < RailsSettings::Base
default: ENV["GITEA_PUBLIC_URL"].presence default: ENV["GITEA_PUBLIC_URL"].presence
field :gitea_enabled, type: :boolean, field :gitea_enabled, type: :boolean,
default: (ENV["GITEA_PUBLIC_URL"].present?.to_s || false) default: ENV["GITEA_PUBLIC_URL"].present?
# #
# Lightning Network # Lightning Network
@@ -95,16 +113,19 @@ class Setting < RailsSettings::Base
default: ENV["LNDHUB_API_URL"].presence default: ENV["LNDHUB_API_URL"].presence
field :lndhub_enabled, type: :boolean, field :lndhub_enabled, type: :boolean,
default: (ENV["LNDHUB_API_URL"].present?.to_s || false) default: ENV["LNDHUB_API_URL"].present?
field :lndhub_admin_token, type: :string,
default: ENV["LNDHUB_ADMIN_TOKEN"].presence
field :lndhub_admin_enabled, type: :boolean, field :lndhub_admin_enabled, type: :boolean,
default: (ENV["LNDHUB_ADMIN_UI"] || false) default: ENV["LNDHUB_ADMIN_UI"] || false
field :lndhub_public_key, type: :string, field :lndhub_public_key, type: :string,
default: (ENV["LNDHUB_PUBLIC_KEY"] || "") default: (ENV["LNDHUB_PUBLIC_KEY"] || "")
field :lndhub_keysend_enabled, type: :boolean, field :lndhub_keysend_enabled, type: :boolean,
default: -> { self.lndhub_public_key.present?.to_s || false } default: -> { self.lndhub_public_key.present? }
# #
# Mastodon # Mastodon
@@ -114,7 +135,7 @@ class Setting < RailsSettings::Base
default: ENV["MASTODON_PUBLIC_URL"].presence default: ENV["MASTODON_PUBLIC_URL"].presence
field :mastodon_enabled, type: :boolean, field :mastodon_enabled, type: :boolean,
default: (ENV["MASTODON_PUBLIC_URL"].present?.to_s || false) default: ENV["MASTODON_PUBLIC_URL"].present?
field :mastodon_address_domain, type: :string, field :mastodon_address_domain, type: :string,
default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain
@@ -127,7 +148,7 @@ class Setting < RailsSettings::Base
default: ENV["MEDIAWIKI_PUBLIC_URL"].presence default: ENV["MEDIAWIKI_PUBLIC_URL"].presence
field :mediawiki_enabled, type: :boolean, field :mediawiki_enabled, type: :boolean,
default: (ENV["MEDIAWIKI_PUBLIC_URL"].present?.to_s || false) default: ENV["MEDIAWIKI_PUBLIC_URL"].present?
# #
# Nostr # Nostr
@@ -140,7 +161,7 @@ class Setting < RailsSettings::Base
# #
field :remotestorage_enabled, type: :boolean, field :remotestorage_enabled, type: :boolean,
default: (ENV["RS_STORAGE_URL"].present?.to_s || false) default: ENV["RS_STORAGE_URL"].present?
field :rs_storage_url, type: :string, field :rs_storage_url, type: :string,
default: ENV["RS_STORAGE_URL"].presence default: ENV["RS_STORAGE_URL"].presence

View File

@@ -2,10 +2,14 @@ class User < ApplicationRecord
include EmailValidatable include EmailValidatable
attr_accessor :display_name attr_accessor :display_name
attr_accessor :avatar_new
serialize :preferences, UserPreferences serialize :preferences, UserPreferences
#
# Relations # Relations
#
has_many :invitations, dependent: :destroy has_many :invitations, dependent: :destroy
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id' has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
has_one :inviter, through: :invitation, source: :user has_one :inviter, through: :invitation, source: :user
@@ -20,6 +24,10 @@ class User < ApplicationRecord
has_many :remote_storage_authorizations has_many :remote_storage_authorizations
#
# Validations
#
validates_uniqueness_of :cn, scope: :ou validates_uniqueness_of :cn, scope: :ou
validates_length_of :cn, minimum: 3 validates_length_of :cn, minimum: 3
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/, validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,
@@ -40,10 +48,20 @@ class User < ApplicationRecord
validates_uniqueness_of :nostr_pubkey, allow_blank: true validates_uniqueness_of :nostr_pubkey, allow_blank: true
validate :acceptable_avatar
#
# Scopes
#
scope :confirmed, -> { where.not(confirmed_at: nil) } scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :pending, -> { where(confirmed_at: nil) } scope :pending, -> { where(confirmed_at: nil) }
scope :all_except, -> (user) { where.not(id: user) } scope :all_except, -> (user) { where.not(id: user) }
#
# Encrypted database columns
#
has_encrypted :ln_login, :ln_password has_encrypted :ln_login, :ln_password
# Include default devise modules. Others available are: # Include default devise modules. Others available are:
@@ -140,6 +158,10 @@ class User < ApplicationRecord
@display_name ||= ldap_entry[:display_name] @display_name ||= ldap_entry[:display_name]
end end
def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn, ou: ou)
end
def services_enabled def services_enabled
ldap_entry[:service] || [] ldap_entry[:service] || []
end end
@@ -168,4 +190,17 @@ class User < ApplicationRecord
return @ldap_service if defined?(@ldap_service) return @ldap_service if defined?(@ldap_service)
@ldap_service = LdapService.new @ldap_service = LdapService.new
end end
def acceptable_avatar
return unless avatar_new.present?
if avatar_new.size > 1.megabyte
errors.add(:avatar, "file size is too large")
end
acceptable_types = ["image/jpeg", "image/png"]
unless acceptable_types.include?(avatar_new.content_type)
errors.add(:avatar, "must be a JPEG or PNG file")
end
end
end end

View File

@@ -0,0 +1,52 @@
require "manifique"
require "down"
module AppCatalogManager
class UpdateMetadata < AppCatalogManagerService
def initialize(app)
@app = app
end
def call
agent = Manifique::Agent.new(url: @app.url)
metadata = agent.fetch_metadata
@app.name = metadata.name
[:name, :short_name, :description, :theme_color, :background_color,
:display, :start_url, :scope, :share_target, :icons].each do |prop|
@app.metadata[prop] = metadata.send(prop) if prop
end
if icon = metadata.select_icon(sizes: "256x256") ||
icon = metadata.select_icon(sizes: "192x192")
attach_remote_image(:icon, icon)
# TODO elsif get whatever is available
end
if apple_touch_icon = metadata.select_icon(purpose: "apple-touch-icon")
attach_remote_image(:apple_touch_icon, apple_touch_icon)
end
@app.save!
rescue Manifique::Error => e
msg = "Fetching web app manifest failed for #{e.url}: #{e.type}"
Rails.logger.warn(msg)
Sentry.capture_message(msg) if Setting.sentry_enabled?
false
end
def attach_remote_image(attachment_name, icon)
if icon['src'].start_with?("http")
download_url = icon['src']
else
download_url = "#{@app.url}/#{icon["src"].gsub(/^\//,'')}"
end
filename = "#{attachment_name}.png"
key = "web_apps/#{@app.id}/icons/#{attachment_name}.png"
tempfile = Down.download(download_url)
@app.send(attachment_name).attach(key: key, io: tempfile, filename: filename)
end
end
end

View File

@@ -0,0 +1,2 @@
class AppCatalogManagerService < ApplicationService
end

View File

@@ -1,32 +0,0 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcPay
def initialize
@base_url = ENV["BTCPAY_API_URL"]
@store_id = Rails.application.credentials.btcpay[:store_id]
@auth_token = Rails.application.credentials.btcpay[:auth_token]
end
def onchain_wallet_balance
res = get "stores/#{@store_id}/payment-methods/onchain/BTC/wallet"
{
balance: res["balance"].to_f,
unconfirmed_balance: res["unconfirmedBalance"].to_f,
confirmed_balance: res["confirmedBalance"].to_f
}
end
private
def get(endpoint)
res = Faraday.get("#{@base_url}/#{endpoint}", {}, {
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{@auth_token}"
})
JSON.parse(res.body)
end
end

View File

@@ -0,0 +1,11 @@
module BtcpayManager
class FetchLightningWalletBalance < BtcpayManagerService
def call
res = get "stores/#{store_id}/lightning/BTC/balance"
{
balance: res["offchain"]["local"].to_i / 1000 # msats to sats
}
end
end
end

View File

@@ -0,0 +1,13 @@
module BtcpayManager
class FetchOnchainWalletBalance < BtcpayManagerService
def call
res = get "stores/#{store_id}/payment-methods/onchain/BTC/wallet"
{
balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats
unconfirmed_balance: (res["unconfirmedBalance"].to_f * 100000000).to_i,
confirmed_balance: (res["confirmedBalance"].to_f * 100000000).to_i
}
end
end
end

View File

@@ -0,0 +1,24 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcpayManagerService < ApplicationService
attr_reader :base_url, :store_id, :auth_token
def initialize
@base_url = Setting.btcpay_api_url
@store_id = Setting.btcpay_store_id
@auth_token = Setting.btcpay_auth_token
end
private
def get(endpoint)
res = Faraday.get("#{base_url}/#{endpoint}", {}, {
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
})
JSON.parse(res.body)
end
end

View File

@@ -0,0 +1,17 @@
module LdapManager
class FetchAvatar < LdapManagerService
def initialize(cn:, ou: nil)
@cn = cn
@ou = ou
end
def call
treebase = @ou ? "ou=#{@ou},cn=users,#{suffix}" : ldap_config["base"]
attributes = %w{ jpegPhoto }
filter = Net::LDAP::Filter.eq("cn", @cn)
entry = ldap_client.search(base: treebase, filter: filter, attributes: attributes).first
entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
end
end
end

View File

@@ -0,0 +1,27 @@
require "image_processing/vips"
module LdapManager
class UpdateAvatar < LdapManagerService
def initialize(dn, file)
@dn = dn
@img_data = process(file)
end
def call
replace_attribute @dn, :jpegPhoto, @img_data
end
private
def process(file)
processed = ImageProcessing::Vips
.resize_to_fill(512, 512)
.source(file)
.convert("jpeg")
.saver(strip: true)
.call
Base64.strict_encode64 processed.read
end
end
end

View File

@@ -1,2 +1,5 @@
class LdapManagerService < LdapService class LdapManagerService < LdapService
def suffix
@suffix ||= ENV["LDAP_SUFFIX"] || "dc=kosmos,dc=org"
end
end end

View File

@@ -14,7 +14,7 @@ class LndhubV2 < Lndhub
end end
def create_account(payload={}) def create_account(payload={})
post "v2/users", payload, admin_token: Rails.application.credentials.lndhub[:admin_token] post "v2/users", payload, admin_token: Setting.lndhub_admin_token
end end
def create_invoice(payload) def create_invoice(payload)

View File

@@ -0,0 +1,56 @@
<%= render HeaderComponent.new(title: "App Catalog") %>
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_app_catalog') do %>
<section>
<%= render QuickstatsContainerComponent.new do %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: 'Known Web Apps',
value: @stats[:known_apps],
) %>
<%# <%= render QuickstatsItemComponent.new(
<%# type: :number,
<%# title: 'Accepted',
<%# value: @stats[:accepted],
<%# ) %>
<%# <%= render QuickstatsItemComponent.new(
<%# type: :number,
<%# title: 'Users with referrals',
<%# value: @stats[:users_with_referrals],
<%# meta: "/ #{User.count}"
<%# ) %>
<% end %>
</section>
<% if @web_apps.any? %>
<section>
<h3>Web Apps</h3>
<table class="divided mb-8">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th class="hidden md:table-cell">RS Auths</th>
<th class="hidden md:table-cell">Created at</th>
</tr>
</thead>
<tbody>
<% @web_apps.each do |web_app| %>
<tr>
<td><%= web_app.name %></td>
<td><%= link_to web_app.url, web_app.url,
target: "_blank", rel: "nofollow noopener",
class: "ks-text-link" %></td>
<td class="hidden md:table-cell"><%= web_app.remote_storage_authorizations.count %></td>
<td class="hidden md:table-cell">
<span title="<%= web_app.created_at %>" class="cursor-help">
<%= time_ago_in_words web_app.created_at, include_seconds: false %> ago
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav @pagy %>
</section>
<% end %>
<% end %>

View File

@@ -0,0 +1,37 @@
<h3>BTCPay Server</h3>
<ul role="list">
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :btcpay_enabled,
enabled: Setting.btcpay_enabled?,
title: "Enable BTCPay integration",
description: "BTCPay configuration present and features enabled"
) %>
<% if Setting.btcpay_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_api_url,
title: "API URL"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_store_id,
title: "Store ID"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :btcpay_auth_token,
type: :password,
title: "Auth Token"
) %>
</ul>
</section>
<section>
<h3>REST API</h3>
<ul role="list">
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :btcpay_publish_wallet_balances,
enabled: Setting.btcpay_publish_wallet_balances?,
title: "Publish wallet balances",
description: "Publish the store's on-chain and Lightning wallet balances"
) %>
<% end %>
</ul>

View File

@@ -9,29 +9,35 @@
) %> ) %>
<% if Setting.lndhub_enabled? %> <% if Setting.lndhub_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new( <%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_api_url, key: :lndhub_api_url,
title: "API URL" title: "API URL"
) %> ) %>
<% end %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_admin_enabled,
enabled: Setting.lndhub_admin_enabled?,
title: "Enable LNDHub admin panel",
description: "LNDHub database configuration present and admin panel enabled"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_keysend_enabled,
enabled: Setting.lndhub_keysend_enabled?,
title: "Enable keysend payments",
description: "Allow users to receive invoice-less payments to their Lightning Address"
) %>
<% if Setting.lndhub_keysend_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new( <%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_public_key, key: :lndhub_admin_token,
title: "Public key", type: :password,
description: "The public key of the Lightning node used by LNDHub" title: "Admin token",
) %> description: "Auth token for creating new lndhub accounts"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_admin_enabled,
enabled: Setting.lndhub_admin_enabled?,
title: "Enable LNDHub admin panel",
description: "LNDHub database configuration present and admin panel enabled"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :lndhub_keysend_enabled,
enabled: Setting.lndhub_keysend_enabled?,
title: "Enable keysend payments",
description: "Allow users to receive invoice-less payments to their Lightning Address"
) %>
<% if Setting.lndhub_keysend_enabled? %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :lndhub_public_key,
title: "Public key",
description: "The public key of the Lightning node used by LNDHub"
) %>
<% end %>
<% end %> <% end %>
</ul> </ul>

View File

@@ -63,6 +63,12 @@
</section> </section>
<section class="sm:flex-1 sm:pt-0"> <section class="sm:flex-1 sm:pt-0">
<% if @avatar.present? %>
<h3>LDAP<h3>
<p>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
</p>
<% end %>
<!-- <h3>Actions</h3> --> <!-- <h3>Actions</h3> -->
</section> </section>
</div> </div>

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe <%= custom_class %>"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 430 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" class="<%= custom_class %>" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Menu</title>
<g id="kebap-menu" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Container" x="0" y="0" width="24" height="24"></rect>
<path d="M12,6 C12.5522847,6 13,5.55228475 13,5 C13,4.44771525 12.5522847,4 12,4 C11.4477153,4 11,4.44771525 11,5 C11,5.55228475 11.4477153,6 12,6 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
<path d="M12,13 C12.5522847,13 13,12.5522847 13,12 C13,11.4477153 12.5522847,11 12,11 C11.4477153,11 11,11.4477153 11,12 C11,12.5522847 11.4477153,13 12,13 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
<path d="M12,20 C12.5522847,20 13,19.5522847 13,19 C13,18.4477153 12.5522847,18 12,18 C11.4477153,18 11,18.4477153 11,19 C11,19.5522847 11.4477153,20 12,20 Z" stroke="#030819" stroke-width="2" stroke-linecap="round" stroke-dasharray="0,0"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star <%= custom_class %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -38,7 +38,7 @@
<h3>Chat Apps</h3> <h3>Chat Apps</h3>
<p> <p>
Use your account with many different apps, and on any devices you wish! Use your account with many different apps, and on any devices you wish!
When opening an app for the first time, just enter your user address and When opening an app for the first time, just enter your address and
password to log in. password to log in.
</p> </p>
</section> </section>

View File

@@ -1,7 +0,0 @@
<%= render HeaderComponent.new(title: "Storage") %>
<%= render MainSimpleComponent.new do %>
<section>
<h3>Feature enabled</h3>
</section>
<% end %>

View File

@@ -0,0 +1,16 @@
<%= render HeaderComponent.new(title: "Storage") %>
<%= render MainSimpleComponent.new do %>
<section>
<h3 class="mb-10">Connected Apps</h3>
<% if @rs_auths.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-10 gap-x-12">
<% @rs_auths.each do |auth| %>
<%= render RsAuthComponent.new(auth: auth) %>
<% end %>
</div>
<% else %>
<p>No apps connected yet.</p>
<% end %>
</section>
<% end %>

View File

@@ -1,33 +1,62 @@
<section> <section>
<h3>Profile</h3> <h3>Profile</h3>
<p class="mb-2"> <div class="mb-6">
<%= label :user_address, 'User address', class: 'font-bold' %> <p class="mb-2">
</p> <%= label :user_address, 'User address', class: 'font-bold' %>
<p data-controller="clipboard" class="flex gap-1 mb-2 sm:w-3/5"> </p>
<input type="text" id="user_address" class="grow" <p data-controller="clipboard" class="flex gap-1 mb-2 sm:w-3/5">
value=<%= @user.address %> disabled="disabled" <input type="text" id="user_address" class="grow"
data-clipboard-target="source" /> value=<%= @user.address %> disabled="disabled"
<button id="copy-user-address" class="btn-md btn-icon btn-outline shrink-0" data-clipboard-target="source" />
data-clipboard-target="trigger" data-action="clipboard#copy" <button id="copy-user-address" class="btn-md btn-icon btn-outline shrink-0"
title="Copy to clipboard"> data-clipboard-target="trigger" data-action="clipboard#copy"
<span class="content-initial"> title="Copy to clipboard">
<%= render partial: "icons/copy", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %> <span class="content-initial">
</span> <%= render partial: "icons/copy", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
<span class="content-active hidden"> </span>
<%= render partial: "icons/check", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %> <span class="content-active hidden">
</span> <%= render partial: "icons/check", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
</button> </span>
</p> </button>
<p class="text-sm text-gray-500"> </p>
Your user address for Chat and Lightning Network. <p class="text-sm text-gray-500">
</p> Your user address for Chat and Lightning Network.
</p>
</div>
<%= form_for(@user, url: setting_path(:profile), html: { :method => :put }) do |f| %> <%= form_for(@user, url: setting_path(:profile), html: { :method => :put }) do |f| %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %> <%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %>
<%= f.text_field :display_name, class: "w-full sm:w-3/5 mb-2" %> <%= f.text_field :display_name, class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:display_name].present? %> <% if @validation_errors.present? && @validation_errors[:display_name].present? %>
<p class="error-msg"><%= @validation_errors[:display_name].first %></p> <p class="error-msg mt-2"><%= @validation_errors[:display_name].first %></p>
<% end %> <% end %>
<% end %> <% end %>
<label class="block">
<p class="font-bold mb-1">
Avatar
</p>
<p class="text-gray-500">
Default profile picture
</p>
<div class="flex items-center gap-6">
<% if current_user.avatar.present? %>
<p class="flex-none">
<%= image_tag "data:image/jpeg;base64,#{current_user.avatar}", class: "h-24 w-24 rounded-lg" %>
</p>
<% end %>
<div class="grow">
<p class="mb-2">
<%= f.file_field :avatar, class: "" %>
<p class="text-sm text-gray-500">
JPEG or PNG image, not larger than 1 megabyte
</p>
<% if @validation_errors.present? && @validation_errors[:avatar].present? %>
<p class="error-msg mb-2"><%= @validation_errors[:avatar].first %></p>
<% end %>
</div>
</div>
</label>
<p class="mt-8 pt-6 border-t border-gray-200 text-right"> <p class="mt-8 pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p> </p>

View File

@@ -10,5 +10,9 @@
<%= link_to "Lightning", admin_lightning_path, <%= link_to "Lightning", admin_lightning_path,
class: main_nav_class(@current_section, :lightning) %> class: main_nav_class(@current_section, :lightning) %>
<% end %> <% end %>
<% if Setting.remotestorage_enabled? %>
<%= link_to "Apps", admin_app_catalog_web_apps_path,
class: main_nav_class(@current_section, :app_catalog) %>
<% end %>
<%= link_to "Settings", admin_settings_registrations_path, <%= link_to "Settings", admin_settings_registrations_path,
class: main_nav_class(@current_section, :settings) %> class: main_nav_class(@current_section, :settings) %>

View File

@@ -0,0 +1,10 @@
<%= render SidenavLinkComponent.new(
name: "Web Apps", path: admin_app_catalog_web_apps_path, icon: "globe",
active: current_page?(admin_app_catalog_web_apps_path)
) %>
<%= render SidenavLinkComponent.new(
name: "Recommended Apps", path: "#", icon: "star", disabled: true
) %>
<%= render SidenavLinkComponent.new(
name: "OAuth Apps", path: "#", icon: "key", disabled: true
) %>

View File

@@ -1,63 +1,70 @@
<%= render SidenavLinkComponent.new(
level: 2,
name: "BTCPay",
path: admin_settings_services_path(params: { s: "btcpay" }),
text_icon: Setting.btcpay_enabled? ? "◉" : "○",
active: current_page?(admin_settings_services_path(params: { s: "btcpay" })),
) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "Discourse", name: "Discourse",
path: admin_settings_services_path(params: { s: "discourse" }), path: admin_settings_services_path(params: { s: "discourse" }),
icon: Setting.discourse_enabled? ? "check" : "x", text_icon: Setting.discourse_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "discourse" })), active: current_page?(admin_settings_services_path(params: { s: "discourse" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "Drone CI", name: "Drone CI",
path: admin_settings_services_path(params: { s: "droneci" }), path: admin_settings_services_path(params: { s: "droneci" }),
icon: Setting.droneci_enabled? ? "check" : "x", text_icon: Setting.droneci_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "droneci" })), active: current_page?(admin_settings_services_path(params: { s: "droneci" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "ejabberd", name: "ejabberd",
path: admin_settings_services_path(params: { s: "ejabberd" }), path: admin_settings_services_path(params: { s: "ejabberd" }),
icon: Setting.ejabberd_enabled? ? "check" : "x", text_icon: Setting.ejabberd_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "ejabberd" })), active: current_page?(admin_settings_services_path(params: { s: "ejabberd" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "Gitea", name: "Gitea",
path: admin_settings_services_path(params: { s: "gitea" }), path: admin_settings_services_path(params: { s: "gitea" }),
icon: Setting.gitea_enabled? ? "check" : "x", text_icon: Setting.gitea_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "gitea" })), active: current_page?(admin_settings_services_path(params: { s: "gitea" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "LNDHub", name: "LNDHub",
path: admin_settings_services_path(params: { s: "lndhub" }), path: admin_settings_services_path(params: { s: "lndhub" }),
icon: Setting.lndhub_enabled? ? "check" : "x", text_icon: Setting.lndhub_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "lndhub" })), active: current_page?(admin_settings_services_path(params: { s: "lndhub" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "Mastodon", name: "Mastodon",
path: admin_settings_services_path(params: { s: "mastodon" }), path: admin_settings_services_path(params: { s: "mastodon" }),
icon: Setting.mastodon_enabled? ? "check" : "x", text_icon: Setting.mastodon_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "mastodon" })), active: current_page?(admin_settings_services_path(params: { s: "mastodon" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "MediaWiki", name: "MediaWiki",
path: admin_settings_services_path(params: { s: "mediawiki" }), path: admin_settings_services_path(params: { s: "mediawiki" }),
icon: Setting.mediawiki_enabled? ? "check" : "x", text_icon: Setting.mediawiki_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "mediawiki" })), active: current_page?(admin_settings_services_path(params: { s: "mediawiki" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "Nostr", name: "Nostr",
path: admin_settings_services_path(params: { s: "nostr" }), path: admin_settings_services_path(params: { s: "nostr" }),
icon: Setting.nostr_enabled? ? "check" : "x", text_icon: Setting.nostr_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "nostr" })), active: current_page?(admin_settings_services_path(params: { s: "nostr" })),
) %> ) %>
<%= render SidenavLinkComponent.new( <%= render SidenavLinkComponent.new(
level: 2, level: 2,
name: "RemoteStorage", name: "RemoteStorage",
path: admin_settings_services_path(params: { s: "remotestorage" }), path: admin_settings_services_path(params: { s: "remotestorage" }),
icon: Setting.remotestorage_enabled? ? "check" : "x", text_icon: Setting.remotestorage_enabled? ? "" : "",
active: current_page?(admin_settings_services_path(params: { s: "remotestorage" })), active: current_page?(admin_settings_services_path(params: { s: "remotestorage" })),
) %> ) %>

4
ci/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
# syntax=docker/dockerfile:1
FROM guildeducation/rails:2.7.2-14.20.0
RUN apt-get update -qq && apt-get install -y --no-install-recommends ldap-utils libvips

View File

@@ -5,7 +5,7 @@ require "rails"
require "active_model/railtie" require "active_model/railtie"
require "active_job/railtie" require "active_job/railtie"
require "active_record/railtie" require "active_record/railtie"
# require "active_storage/engine" require "active_storage/engine"
require "action_controller/railtie" require "action_controller/railtie"
require "action_mailer/railtie" require "action_mailer/railtie"
require "action_mailbox/engine" require "action_mailbox/engine"

View File

@@ -1 +1 @@
yEs5CyuAbqphlDWgtw/YQvkPn+EN4ecen2dAjs7zvYErkRRWp99FinGlQIMe6NRkMLLLSIj2BwR/wlscn1kLpIfwGpxfSZ89srK3do6Mb5QogpxdUsnQB8qv5PTGRQFBcjM47s1Q5m0t+OKxGvOnLyKnQp+cVS2KFJMbSzQarW8wIZSz2gKArn9Ttk0kqUHMlJWNY7Yh6xIrrxlEalaTOVzPdtnF7u8Tobminu15eeWHMormMRz4dYSaDc6hUtfpdy1NzOHaeXIU9A9RY/iytxuIQNgcMAlcWbPe//rVk/unH2F8xqSOfed4h/nC08F/qq4z8va3kEXBSdW/G91aIDMu1mo0kX3YNibq8s25C/CfGpzw39ozJ9erTBH7hy6nfmxU6qZuWcTGDj3NOfKe/XIfDcpOjsqkT2IOFARrYodb67q23IuOufraK1/FD4LXu8l0S8/Oi0cqMjtPPs7tS0M1C3DrbmlEzGKETrHpmoKHqjA0rgOmK4ZZM9LeI+l8Z+fDpYcCak9fLGGxnjf+nKiYMSUtm9+1dwycG2lpBV6fbmIKHJWngO2jVGcycODkc525oUaAO4hdPMqrz1AdU3AzYmLJTxW3aZ4uL5NyEJ7TbUBC0HT7h2gEi/tUry4cfD2EsM9bCrCUNuMBrnPqd4r8AvORoqqYIw1IEsP0RgWa2+hfeG1QCjBRPFHQOcqo+W25CelivMe79qI08w0iC8S4hfOQO4QrmMgtd1BhcR+wVpVE3X9EJZi3Hl7z14hXcSic+gkswJMtVZcnJL4rmZ0iEW1mpqUuegsX5vB/4qPxiQyeB80pg8Q33shvUbixzSBkl6znmLSiIffsiDsGOsnuzfl/MUT+JBs3UswNt4tSp7nEwhUjKFHrZHrAJiGCdtIS6yDPGe3HfQv1JkQ+9A8zv88hRmzeIx2JyT/shtIqGo+4ZTJd5cma--Lij/n0+cpstyZD28--FOUhwW3y+0jdaYkKvG2xrg== tmI5vm7qZhaigr52jEBVWkRdj+EE+9OmPh3vWXC7kA/OHuuucpr7SodychuMkQDPLM0BLk88LFsqvRIR+mqnLWpRC+P9aeUFE6ohxSWzcAd7Y4sgxUD8zpCRPndrwTw0hxXXj1WZSYeWn4BoAB34aV+gYen2MajZF3a95hJGtS5yjgWxvLVkQQKqRDfykkfX6fCS0BPo5X7sT7m4xwCATD/D4219wajm5W3TIdkriHtwt28ZLspaRWA5e0UkzKf8+/Gaj2CrW7UWcvew8R93zQ5RA2/Sp3sDTVN+kLz9I9Q095lQC0ywCAEFYHeKmc2tjrzqRaAAWu06xmWLqGIg21G+A/UU9lUJOkIpxQACWoOfS2IoXR1nXhgXMopkz3aCBXDxKw554v4H2QyOceOsuRf2C685ibMqzQkKMmJ4tcbiOJL77DUc08JTjB8Dq4Ohr8sMzXbV/hATevjYoRP0XarLekqhLv90ZLuIVY16DwB0CzACeNBKeKbeLqJF51upRRWgi+gTbYpV04yUwnXdyssF8mydWocgihrTryBi8F6PsuhBGcaYdP+0yibnGxDCC4x2rupbBfMj2OIX7pYzgtIHB3Eo954Y+bCoggqbE/Qrb9VVXNMgtKgLt8EGWU2tg6wl9QicitIq87uLDAade93zTn6rmcKPywjMDo6jbVIs653ZdUhiKdHGdpnJccbgQ/iLSPB1umNnCeaEX5jM+K9zBvl7ZMCdSk1YIQ==--ekKumqLiSlVJNwMe--K/ecXmmMT1x+WnIXMbHBDw==

View File

@@ -70,4 +70,10 @@ Rails.application.configure do
# Allow requests from any IP # Allow requests from any IP
config.web_console.whiny_requests = false config.web_console.whiny_requests = false
if ENV["S3_ENABLED"]
config.active_storage.service = :s3
else
config.active_storage.service = :local
end
end end

View File

@@ -110,6 +110,12 @@ Rails.application.configure do
# Set this to true and configure the email server for immediate delivery to raise delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.
config.action_mailer.raise_delivery_errors = true config.action_mailer.raise_delivery_errors = true
if ENV["S3_ENABLED"]
config.active_storage.service = :s3
else
config.active_storage.service = :local
end
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found). # the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true config.i18n.fallbacks = true

View File

@@ -51,4 +51,11 @@ Rails.application.configure do
} }
config.active_job.queue_adapter = :test config.active_job.queue_adapter = :test
if ENV["S3_ENABLED"]
config.active_storage.service = :s3
else
# Store attachments on the local disk (in ./tmp)
config.active_storage.service = :test
end
end end

View File

@@ -19,10 +19,10 @@ Rails.application.routes.draw do
resources :invitations, only: ['index', 'show', 'create', 'destroy'] resources :invitations, only: ['index', 'show', 'create', 'destroy']
namespace :services do namespace :services do
get 'storage', to: 'remotestorage#dashboard'
resource :chat, only: [:show], controller: 'chat' resource :chat, only: [:show], controller: 'chat'
resource :mastodon, only: [:show], controller: 'mastodon'
resources :lightning, only: [:index] do resources :lightning, only: [:index] do
collection do collection do
get 'transactions' get 'transactions'
@@ -30,7 +30,14 @@ Rails.application.routes.draw do
end end
end end
resource :mastodon, only: [:show], controller: 'mastodon' resource :storage, controller: 'remotestorage', only: [:show] do
resources :rs_auths, only: [:destroy] do
member do
get :revoke, to: 'rs_auths#destroy'
get :launch_app
end
end
end
end end
resources :settings, param: 'section', only: ['index', 'show', 'update'] do resources :settings, param: 'section', only: ['index', 'show', 'update'] do
@@ -54,16 +61,22 @@ Rails.application.routes.draw do
post 'webhooks/lndhub', to: 'webhooks#lndhub' post 'webhooks/lndhub', to: 'webhooks#lndhub'
namespace :api do namespace :api do
get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance' get 'btcpay/onchain_btc_balance', to: 'btcpay#onchain_btc_balance'
get 'btcpay/lightning_btc_balance', to: 'btcpay#lightning_btc_balance'
end end
namespace :admin do namespace :admin do
root to: 'dashboard#index' root to: 'dashboard#index'
resources 'users', param: 'address', only: ['index', 'show'], constraints: { address: /.*/ } resources 'users', param: 'address', only: ['index', 'show'], constraints: { address: /.*/ }
get 'invitations', to: 'invitations#index' get 'invitations', to: 'invitations#index'
resources :donations resources :donations
get 'lightning', to: 'lightning#index' get 'lightning', to: 'lightning#index'
namespace :app_catalog do
resources 'web_apps', only: ['index']
end
namespace :settings do namespace :settings do
resources 'registrations', only: ['index', 'create'] resources 'registrations', only: ['index', 'create']
resources 'services', only: ['index', 'create'] resources 'services', only: ['index', 'create']
@@ -72,9 +85,8 @@ Rails.application.routes.draw do
namespace :rs do namespace :rs do
resource :oauth, only: [:new, :create], path_names: { resource :oauth, only: [:new, :create], path_names: {
new: ':useraddress', create: ':useraddress' new: ':username', create: ':username'
}, controller: 'oauth', constraints: { useraddress: /[^\/]+/} }, controller: 'oauth'
get 'oauth/token/:id/launch_app' => 'oauth#launch_app', as: :launch_app
end end
get '.well-known/webfinger', to: 'webfinger#show' get '.well-known/webfinger', to: 'webfinger#show'
@@ -94,4 +106,8 @@ Rails.application.routes.draw do
end end
root to: 'dashboard#index' root to: 'dashboard#index'
direct :s3_image do |blob|
File.join(ENV['S3_ALIAS_HOST'], blob.key)
end
end end

View File

@@ -1,7 +1,17 @@
local:
service: Disk
root: <%= Rails.root.join("storage") %>
test: test:
service: Disk service: Disk
root: <%= Rails.root.join("tmp/storage") %> root: <%= Rails.root.join("tmp/storage") %>
local: <% if ENV["S3_ENABLED"] %>
service: Disk s3:
root: <%= Rails.root.join("storage") %> service: S3
endpoint: <%= ENV["S3_ENDPOINT"] %>
region: <%= ENV["S3_REGION"] %>
bucket: <%= ENV["S3_BUCKET"] %>
access_key_id: <%= ENV["S3_ACCESS_KEY"] %>
secret_access_key: <%= ENV["S3_SECRET_KEY"] %>
<% end %>

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[primary_key_type, foreign_key_type]
end
end

View File

@@ -0,0 +1,11 @@
class CreateAppCatalogWebApps < ActiveRecord::Migration[7.0]
def change
create_table :app_catalog_web_apps do |t|
t.string :url
t.string :name
t.text :metadata
t.timestamps
end
end
end

View File

@@ -0,0 +1,7 @@
class AddWebAppIdToRemoteStorageAuthorizations < ActiveRecord::Migration[7.0]
def change
add_reference :remote_storage_authorizations, :web_app, foreign_key: {
to_table: :app_catalog_web_apps
}
end
end

View File

@@ -10,7 +10,43 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do ActiveRecord::Schema[7.0].define(version: 2023_10_24_104909) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "app_catalog_web_apps", force: :cascade do |t|
t.string "url"
t.string "name"
t.text "metadata"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "donations", force: :cascade do |t| create_table "donations", force: :cascade do |t|
t.integer "user_id" t.integer "user_id"
t.integer "amount_sats" t.integer "amount_sats"
@@ -60,8 +96,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
t.datetime "expire_at" t.datetime "expire_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "web_app_id"
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions" t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
t.index ["user_id"], name: "index_remote_storage_authorizations_on_user_id" t.index ["user_id"], name: "index_remote_storage_authorizations_on_user_id"
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
end end
create_table "settings", force: :cascade do |t| create_table "settings", force: :cascade do |t|
@@ -94,5 +132,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users" add_foreign_key "remote_storage_authorizations", "users"
end end

View File

@@ -13,6 +13,7 @@ Sidekiq::Testing.inline! do
35.times do |n| 35.times do |n|
username = Faker::Name.unique.first_name.downcase username = Faker::Name.unique.first_name.downcase
email = Faker::Internet.unique.email email = Faker::Internet.unique.email
next if username.length < 3
CreateAccount.call( CreateAccount.call(
username: username, domain: "kosmos.org", email: email, username: username, domain: "kosmos.org", email: email,

View File

@@ -37,12 +37,13 @@ services:
environment: environment:
RAILS_ENV: development RAILS_ENV: development
PRIMARY_DOMAIN: kosmos.org PRIMARY_DOMAIN: kosmos.org
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
LDAP_HOST: ldap LDAP_HOST: ldap
LDAP_PORT: 3389 LDAP_PORT: 3389
LDAP_ADMIN_PASSWORD: passthebutter LDAP_ADMIN_PASSWORD: passthebutter
LDAP_USE_TLS: "false" LDAP_USE_TLS: "false"
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
RS_STORAGE_URL: "http://localhost:4567"
depends_on: depends_on:
- ldap - ldap
- redis - redis
@@ -57,18 +58,51 @@ services:
environment: environment:
RAILS_ENV: development RAILS_ENV: development
PRIMARY_DOMAIN: kosmos.org PRIMARY_DOMAIN: kosmos.org
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
LDAP_HOST: ldap LDAP_HOST: ldap
LDAP_PORT: 3389 LDAP_PORT: 3389
LDAP_ADMIN_PASSWORD: passthebutter LDAP_ADMIN_PASSWORD: passthebutter
LDAP_USE_TLS: "false" LDAP_USE_TLS: "false"
LAUNCHY_DRY_RUN: true LAUNCHY_DRY_RUN: true
BROWSER: /dev/null BROWSER: /dev/null
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
RS_STORAGE_URL: "http://localhost:4567"
depends_on: depends_on:
- ldap - ldap
- redis - redis
minio:
image: quay.io/minio/minio:latest
command: "server /data --console-address ':9001'"
networks:
- external_network
- internal_network
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./tmp/minio:/data
liquor-cabinet:
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-beta.2
networks:
- external_network
- internal_network
ports:
- "4567:4567"
environment:
RACK_ENV: staging
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_DB: 1
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: dev-key
S3_SECRET_KEY: 123456789
S3_BUCKET: remotestorage
depends_on:
- minio
- redis
# phpldapadmin: # phpldapadmin:
# image: osixia/phpldapadmin:0.9.0 # image: osixia/phpldapadmin:0.9.0
# ports: # ports:

View File

@@ -3,7 +3,11 @@ require 'rails_helper'
RSpec.describe Rs::OauthController, type: :controller do RSpec.describe Rs::OauthController, type: :controller do
let(:user) { create :user } let(:user) { create :user }
describe "GET /rs/oauth/:useraddress" do before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
end
describe "GET /rs/oauth/:username" do
context "when user is signed in" do context "when user is signed in" do
before do before do
sign_in user sign_in user
@@ -14,7 +18,7 @@ RSpec.describe Rs::OauthController, type: :controller do
before do before do
get :new, params: { get :new, params: {
useraddress: other_user.address, username: other_user.cn,
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "example.com", client_id: "example.com",
scope: "examples" scope: "examples"
@@ -22,7 +26,7 @@ RSpec.describe Rs::OauthController, type: :controller do
end end
it "logs out the users and repeats the request" do it "logs out the users and repeats the request" do
url = new_rs_oauth_url other_user.address, url = new_rs_oauth_url other_user.cn,
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "example.com", client_id: "example.com",
scope: "examples" scope: "examples"
@@ -34,7 +38,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when no valid token exists" do context "when no valid token exists" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "example.com", client_id: "example.com",
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
@@ -61,7 +65,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "no redirect_uri" do context "no redirect_uri" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "https://example.com" client_id: "https://example.com"
} }
@@ -75,7 +79,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "no client_id" do context "no client_id" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com" redirect_uri: "https://example.com"
} }
@@ -89,7 +93,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "different host for client_id and redirect_uri" do context "different host for client_id and redirect_uri" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/foobar", redirect_uri: "https://example.com/foobar",
client_id: "https://google.com" client_id: "https://google.com"
@@ -116,7 +120,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "with same host for client_id and redirect_uri" do context "with same host for client_id and redirect_uri" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "https://example.com" client_id: "https://example.com"
@@ -131,7 +135,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "with different host for client_id and redirect_uri" do context "with different host for client_id and redirect_uri" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://app.example.com", redirect_uri: "https://app.example.com",
client_id: "https://example.com" client_id: "https://example.com"
@@ -146,7 +150,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "with different redirect_uri" do context "with different redirect_uri" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/a_new_route", redirect_uri: "https://example.com/a_new_route",
client_id: "https://example.com" client_id: "https://example.com"
@@ -161,7 +165,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "with state param given" do context "with state param given" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "https://example.com", client_id: "https://example.com",
@@ -178,7 +182,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "no scope" do context "no scope" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "https://example.com", client_id: "https://example.com",
state: "foobar123" state: "foobar123"
@@ -193,7 +197,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "empty scope" do context "empty scope" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "", scope: "",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "https://example.com", client_id: "https://example.com",
@@ -210,7 +214,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when user is not signed in" do context "when user is not signed in" do
it "redirects to the signin page with username pre-filled" do it "redirects to the signin page with username pre-filled" do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "documents,photos", scope: "documents,photos",
redirect_uri: "https://example.com" redirect_uri: "https://example.com"
} }
@@ -227,7 +231,7 @@ RSpec.describe Rs::OauthController, type: :controller do
describe "full" do describe "full" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "*:rw", scope: "*:rw",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "example.com" client_id: "example.com"
@@ -243,7 +247,7 @@ RSpec.describe Rs::OauthController, type: :controller do
describe "read-only" do describe "read-only" do
before do before do
get :new, params: { get :new, params: {
useraddress: user.address, username: user.cn,
scope: "*:r", scope: "*:r",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
client_id: "example.com" client_id: "example.com"
@@ -258,7 +262,7 @@ RSpec.describe Rs::OauthController, type: :controller do
end end
end end
describe "POST /rs/oauth/:useraddress" do describe "POST /rs/oauth/:username" do
context "when user is signed in" do context "when user is signed in" do
before do before do
sign_in user sign_in user
@@ -433,33 +437,4 @@ RSpec.describe Rs::OauthController, type: :controller do
end end
end end
end end
describe "GET /rs/oauth/token/:id/launch_app" do
context "when user is signed in" do
before do
sign_in user
end
context "token exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com",
expire_at: 2.days.from_now
)
get :launch_app, params: { id: @auth.id }
end
after do
@auth.destroy
end
it "redirects to the given URL with the correct RS URL fragment params" do
launch_url = "https://app.example.com#remotestorage=#{user.address}&access_token=#{@auth.token}"
expect(response).to redirect_to(launch_url)
end
end
end
end
end end

View File

@@ -0,0 +1,39 @@
require 'rails_helper'
RSpec.describe Services::RsAuthsController, type: :controller do
let(:user) { create :user }
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(Flipper).to receive(:enabled?).and_return(true)
end
describe "GET /services/storage/rs_auths/:id/launch_app" do
context "when user is signed in" do
before do
sign_in user
end
context "token exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com",
expire_at: 2.days.from_now
)
get :launch_app, params: { id: @auth.id }
end
after do
@auth.destroy
end
it "redirects to the given URL with the correct RS URL fragment params" do
launch_url = "https://app.example.com#remotestorage=#{user.address}&access_token=#{@auth.token}"
expect(response).to redirect_to(launch_url)
end
end
end
end
end

View File

@@ -0,0 +1,6 @@
FactoryBot.define do
factory :web_app, class: 'AppCatalog::WebApp' do
url { "https://myfavoritedrinks.remotestorage.io/" }
name { "My Favorite Drinks" }
end
end

View File

@@ -1,9 +1,10 @@
FactoryBot.define do FactoryBot.define do
factory :remote_storage_authorization do factory :remote_storage_authorization do
permissions { ["documents:rw"] } permissions { ["documents:rw"] }
client_id { "some-fancy-app" } client_id { "app.example.com" }
redirect_uri { "https://example.com/some-fancy-app" } redirect_uri { "https://app.example.com" }
app_name { "Fancy App" } app_name { "Fancy App" }
expire_at { nil } expire_at { 1.month.from_now }
web_app
end end
end end

View File

@@ -23,7 +23,7 @@ RSpec.describe 'Admin/global settings', type: :feature do
scenario "Opening service settings shows page for first service" do scenario "Opening service settings shows page for first service" do
visit admin_settings_services_path visit admin_settings_services_path
expect(current_url).to eq(admin_settings_services_url(params: { s: "discourse" })) expect(current_url).to eq(admin_settings_services_url(params: { s: "btcpay" }))
end end
scenario "View service settings" do scenario "View service settings" do

View File

@@ -10,7 +10,7 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
context "with normal permissions" do context "with normal permissions" do
before do before do
visit new_rs_oauth_path(useraddress: user.address, visit new_rs_oauth_path(username: user.cn,
redirect_uri: "http://example.com", redirect_uri: "http://example.com",
client_id: "http://example.com", client_id: "http://example.com",
scope: "documents,[photos], contacts:r") scope: "documents,[photos], contacts:r")
@@ -36,7 +36,7 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
context "root access" do context "root access" do
context "full" do context "full" do
before do before do
visit new_rs_oauth_path(useraddress: user.address, visit new_rs_oauth_path(username: user.cn,
redirect_uri: "http://example.com", redirect_uri: "http://example.com",
client_id: "http://example.com", client_id: "http://example.com",
scope: ":rw") scope: ":rw")
@@ -54,13 +54,32 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
context "when signed out" do context "when signed out" do
let(:user) { create :user } let(:user) { create :user }
before do
allow_any_instance_of(User).to receive(:valid_ldap_authentication?)
.with(user.password).and_return(true)
end
it "prefills the username field in the signin form" do it "prefills the username field in the signin form" do
visit new_rs_oauth_path(useraddress: user.address, visit new_rs_oauth_path(username: user.cn,
redirect_uri: "http://example.com", redirect_uri: "http://example.com",
client_id: "http://example.com", client_id: "http://example.com",
scope: "documents,[photos], contacts:r") scope: "documents,[photos], contacts:r")
expect(find("#user_cn").value).to eq(user.cn) expect(find("#user_cn").value).to eq(user.cn)
end end
it "redirects to the OAuth dialog after sign-in" do
auth_url = new_rs_oauth_url(username: user.cn,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents,[photos], contacts:r")
visit auth_url
fill_in "User", with: user.cn
fill_in "Password", with: user.password
click_button "Log in"
expect(current_url).to eq(auth_url)
end
end end
end end

View File

@@ -2,20 +2,19 @@ require 'rails_helper'
RSpec.describe 'Profile settings', type: :feature do RSpec.describe 'Profile settings', type: :feature do
let(:user) { create :user, cn: "mwahlberg" } let(:user) { create :user, cn: "mwahlberg" }
let(:avatar_base64) { File.read("#{Rails.root}/spec/fixtures/files/avatar-base64.txt") }
before do before do
login_as user, :scope => :user login_as user, :scope => :user
allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: "Mark"
})
allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64)
end end
feature "Update display name" do feature "Update display name" do
before do
allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: "Mark"
})
end
scenario 'fails with validation error' do scenario 'fails with validation error' do
visit setting_path(:profile) visit setting_path(:profile)
fill_in 'Display name', with: "M" fill_in 'Display name', with: "M"
@@ -42,4 +41,59 @@ RSpec.describe 'Profile settings', type: :feature do
end end
end end
end end
feature "Update avatar" do
scenario "fails with validation error for wrong content type" do
visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/bitcoin.pdf"
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".error-msg" do
expect(page).to have_content("must be a JPEG or PNG file")
end
end
scenario "fails with validation error for file size too large" do
visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/fsociety-irc.png"
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".error-msg" do
expect(page).to have_content("file size is too large")
end
end
scenario 'works with valid JPG file' do
file_path = "#{Rails.root}/spec/fixtures/files/taipei.jpg"
expect_any_instance_of(LdapManager::UpdateAvatar).to receive(:replace_attribute)
.with(user.dn, :jpegPhoto, avatar_base64).and_return(true)
visit setting_path(:profile)
attach_file "Avatar", file_path
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".flash-msg" do
expect(page).to have_content("Settings saved")
end
end
scenario 'works with valid PNG file' do
file_path = "#{Rails.root}/spec/fixtures/files/bender.png"
expect(LdapManager::UpdateAvatar).to receive(:call).and_return(true)
visit setting_path(:profile)
attach_file "Avatar", file_path
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".flash-msg" do
expect(page).to have_content("Settings saved")
end
end
end
end end

1
spec/fixtures/files/avatar-base64.txt vendored Normal file

File diff suppressed because one or more lines are too long

BIN
spec/fixtures/files/bender.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
spec/fixtures/files/bitcoin.pdf vendored Normal file

Binary file not shown.

BIN
spec/fixtures/files/fsociety-irc.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
spec/fixtures/files/taipei.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -2,8 +2,13 @@ require 'rails_helper'
RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do
before do before do
allow_any_instance_of(AppCatalog::WebApp).to(
receive(:update_metadata).and_return(true)
)
@user = create :user, cn: "ronald", ou: "kosmos.org" @user = create :user, cn: "ronald", ou: "kosmos.org"
@rs_authorization = create :remote_storage_authorization, user: @user, expire_at: 1.day.ago @rs_authorization = create :remote_storage_authorization,
user: @user, expire_at: 1.day.ago
end end
after do after do
@@ -20,7 +25,7 @@ RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do
} }
it "removes the RS authorization from redis" do it "removes the RS authorization from redis" do
redis_key = "rs:authorizations:#{@user.address}:#{@rs_authorization.token}" redis_key = "authorizations:#{@user.cn}:#{@rs_authorization.token}"
expect(redis.keys(redis_key)).to_not be_empty expect(redis.keys(redis_key)).to_not be_empty
perform_enqueued_jobs { job } perform_enqueued_jobs { job }

View File

@@ -5,9 +5,13 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
let(:user) { create :user } let(:user) { create :user }
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
end
describe "#create" do describe "#create" do
after(:each) { clear_enqueued_jobs } after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("rs:authorizations:*") } after(:all) { redis_rs_delete_keys("authorizations:*") }
let(:auth) do let(:auth) do
user.remote_storage_authorizations.create!( user.remote_storage_authorizations.create!(
@@ -22,7 +26,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end end
it "stores a token in redis" do it "stores a token in redis" do
user_auth_keys = redis_rs.keys("rs:authorizations:#{user.address}:*") user_auth_keys = redis_rs.keys("authorizations:#{user.cn}:*")
expect(user_auth_keys.length).to eq(1) expect(user_auth_keys.length).to eq(1)
authorizations = redis_rs.smembers(user_auth_keys.first) authorizations = redis_rs.smembers(user_auth_keys.first)
@@ -44,7 +48,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
describe "#destroy" do describe "#destroy" do
after(:each) { clear_enqueued_jobs } after(:each) { clear_enqueued_jobs }
after(:all) { redis_rs_delete_keys("rs:authorizations:*") } after(:all) { redis_rs_delete_keys("authorizations:*") }
it "removes the token from redis" do it "removes the token from redis" do
auth = user.remote_storage_authorizations.create!( auth = user.remote_storage_authorizations.create!(
@@ -54,7 +58,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
) )
auth.destroy! auth.destroy!
expect(redis_rs.keys("rs:authorizations:#{user.address}:*")).to be_empty expect(redis_rs.keys("authorizations:#{user.address}:*")).to be_empty
end end
context "with expiry set" do context "with expiry set" do
@@ -72,102 +76,174 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end end
end end
# describe "#find_or_create_web_app" do describe "#find_or_create_web_app" do
# context "with origin that looks hosted" do context "with origin that looks hosted" do
# before do after(:all) { redis_rs_delete_keys("authorizations:*") }
# auth = user.remote_storage_authorizations.create!(
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r), let(:auth) do
# client_id: "example.com", user.remote_storage_authorizations.create!(
# redirect_uri: "https://example.com", permissions: %w(documents:rw),
# expire_at: 1.month.from_now client_id: "example.com",
# ) redirect_uri: "https://example.com"
# end )
# end
# it "generates a web_app" do
# expect(auth.web_app).to be_a(AppCatalog::WebApp) it "generates a web_app" do
# end expect(auth.web_app).to be_a(AppCatalog::WebApp)
# end
# it "uses the Web App's name as app name" do end
# expect(auth.app_name).to eq("Example Domain")
# end context "when creating two authorizations for the same app" do
# end let(:user_2) { create :user, id: 23, cn: "michiel", email: "michiel@example.com" }
#
# context "when creating two authorizations for the same app" do let(:auth_1) do
# before do user.remote_storage_authorizations.create!(
# user_2 = create :user permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
# ResqueSpec.reset! client_id: "example.com",
# auth_1 = user.remote_storage_authorizations.create!( redirect_uri: "https://example.com"
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r), )
# client_id: "example.com", end
# redirect_uri: "https://example.com",
# expire_at: 1.month.from_now let(:auth_2) do
# ) user_2.remote_storage_authorizations.create!(
# auth_2 = user_2.remote_storage_authorizations.create!( permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
# permissions: %w(documents photos contacts:rw videos:r tasks/work:r), client_id: "example.com",
# client_id: "example.com", redirect_uri: "https://example.com"
# redirect_uri: "https://example.com", )
# expire_at: 1.month.from_now end
# )
# end after do
# auth_1.destroy
# after do auth_2.destroy
# auth_1.destroy user_2.destroy
# auth_2.destroy end
# user_2.destroy
# end it "uses the same web app for both authorizations" do
# expect(auth_1.web_app).to eq(auth_2.web_app)
# it "uses the same web app instance for both authorizations" do end
# expect(auth_1.web_app).to be_a(AppCatalog::WebApp) end
# expect(auth_1.web_app).to eq(auth_2.web_app)
# end describe "non-production app origins" do
# end context "when host is not an FQDN" do
# let(:auth) do
# describe "non-production app origins" do user.remote_storage_authorizations.create!(
# context "when host is not an FQDN" do permissions: %w(recipes),
# before do client_id: "localhost:4200",
# auth = user.remote_storage_authorizations.create!( redirect_uri: "http://localhost:4200"
# permissions: %w(recipes), )
# client_id: "localhost:4200", end
# redirect_uri: "http://localhost:4200"
# ) it "does not create a web app" do
# end expect(auth.web_app).to be_nil
# expect(auth.app_name).to eq("localhost:4200")
# it "does not create a web app" do end
# expect(auth.web_app).to be_nil end
# expect(auth.app_name).to eq("localhost:4200")
# end context "when host is an IP address" do
# end let(:auth) do
# user.remote_storage_authorizations.create!(
# context "when host is an IP address" do permissions: %w(recipes),
# before do client_id: "192.168.0.23:3000",
# auth = user.remote_storage_authorizations.create!( redirect_uri: "http://192.168.0.23:3000"
# permissions: %w(recipes), )
# client_id: "192.168.0.23:3000", end
# redirect_uri: "http://192.168.0.23:3000"
# ) it "does not create a web app" do
# end expect(auth.web_app).to be_nil
# expect(auth.app_name).to eq("192.168.0.23:3000")
# it "does not create a web app" do end
# expect(auth.web_app).to be_nil end
# expect(auth.app_name).to eq("192.168.0.23:3000")
# end context "when host is an extension URL" do
# end let(:auth) do
# user.remote_storage_authorizations.create!(
# context "when host is an extension URL" do # before do permissions: %w(bookmarks),
# auth = user.remote_storage_authorizations.create!( client_id: "123.addons.allizom.org",
# permissions: %w(bookmarks), redirect_uri: "123.addons.allizom.org/foo"
# client_id: "123.addons.allizom.org", )
# redirect_uri: "123.addons.allizom.org/foo" end
# )
# end it "does not create a web app" do
# expect(auth.web_app).to be_nil
# it "does not create a web app" do expect(auth.app_name).to eq("123.addons.allizom.org")
# expect(auth.web_app).to be_nil end
# expect(auth.app_name).to eq("123.addons.allizom.org") end
# end end
# end end
# end
# end describe "#launch_url" do
after(:all) { redis_rs_delete_keys("authorizations:*") }
context "without start URL" do
before do
AppCatalog::WebApp.create!(
url: "https://webmarks.5apps.com", name: "Webmarks",
metadata: { name: "Webmarks", start_url: nil, scope: nil }
)
end
let(:auth) do
user.remote_storage_authorizations.create!(
permissions: %w(bookmarks:rw), client_id: "webmarks.5apps.com",
redirect_uri: "https://webmarks.5apps.com/connect"
)
end
it "uses the base URL (from client ID)" do
expect(auth.launch_url).to eq("https://webmarks.5apps.com")
end
end
context "with start URL" do
before do
AppCatalog::WebApp.create!(
url: "https://hyperdraft.rosano.ca", name: "Hyperdraft",
metadata: {
name: "Hyperdraft", scope: nil,
start_url: "https://hyperdraft.rosano.ca/start"
}
)
end
let(:auth) do
user.remote_storage_authorizations.create!(
permissions: %w(notes:rw), client_id: "hyperdraft.rosano.ca",
redirect_uri: "https://hyperdraft.rosano.ca/write/foo"
)
end
describe "full URL" do
it "respects the start URL" do
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start")
end
it "does not respect URLs outside of the client ID scope" do
auth.web_app.metadata[:start_url] = "https://uberdraft.rosano.ca/write"
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca")
end
end
describe "relative paths" do
it "includes the path relative from the base URL" do
auth.web_app.metadata[:start_url] = "start.html"
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
auth.web_app.metadata[:start_url] = "./start.html"
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
auth.web_app.metadata[:start_url] = "../start.html"
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
end
end
describe "absolute path" do
it "includes the path relative from the base URL" do
auth.web_app.metadata[:start_url] = "/write"
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/write")
end
end
end
end
# describe "auth notifications" do # describe "auth notifications" do
# context "with auth notifications enabled" do # context "with auth notifications enabled" do

View File

@@ -0,0 +1,103 @@
require 'rails_helper'
require 'webmock/rspec'
RSpec.describe "/api/btcpay", type: :request do
describe "GET /onchain_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 200, headers: {}, body: {
balance: 0.91108606,
unconfirmedBalance: 0,
confirmedBalance: 0.91108606
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(91108606)
expect(res["unconfirmed_balance"]).to eq(0)
expect(res["confirmed_balance"]).to eq(91108606)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
context "feature disabled" do
before do
Setting.btcpay_publish_wallet_balances = false
end
it "returns a 404 status" do
get api_btcpay_onchain_btc_balance_path
expect(response).to have_http_status(:not_found)
end
end
end
describe "GET /lightning_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/lightning/BTC/balance")
.to_return(status: 200, headers: {}, body: {
offchain: {
local: 4200000000
},
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_btcpay_lightning_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(4200000)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/lightning/BTC/balance")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_btcpay_lightning_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
context "feature disabled" do
before do
Setting.btcpay_publish_wallet_balances = false
end
it "returns a 404 status" do
get api_btcpay_lightning_btc_balance_path
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -1,43 +0,0 @@
require 'rails_helper'
require 'webmock/rspec'
RSpec.describe "/api/kredits", type: :request do
describe "GET /onchain_btc_balance" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 200, headers: {}, body: {
balance: 0.91108606,
unconfirmedBalance: 0,
confirmedBalance: 0.91108606
}.to_json)
end
it "returns a formatted result for the onchain wallet balance" do
get api_kredits_onchain_btc_balance_path
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["balance"]).to eq(0.91108606)
expect(res["unconfirmed_balance"]).to eq(0)
expect(res["confirmed_balance"]).to eq(0.91108606)
end
context "upstream request error" do
before do
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/payment-methods/onchain/BTC/wallet")
.to_return(status: 500, headers: {}, body: "")
end
it "returns a formatted error" do
get api_kredits_onchain_btc_balance_path
expect(response).to have_http_status(:server_error)
res = JSON.parse(response.body)
expect(res["error"]).not_to be_nil
end
end
end
end

View File

@@ -15,10 +15,10 @@ RSpec.describe "WebFinger", type: :request do
res = JSON.parse(response.body) res = JSON.parse(response.body)
rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"} 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") expect(rs_link["href"]).to eql("https://storage.kosmos.org/tony")
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"] oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
expect(oauth_url).to eql("https://example.com/rs/oauth") expect(oauth_url).to eql("http://www.example.com/rs/oauth/tony")
end end
end end

View File

@@ -68,6 +68,7 @@ RSpec.describe "Webhooks", type: :request do
context "notification preference set to 'xmpp'" do context "notification preference set to 'xmpp'" do
before do before do
Setting.xmpp_notifications_from_address = "botka@kosmos.org"
user.update! preferences: { lightning_notify_sats_received: "xmpp" } user.update! preferences: { lightning_notify_sats_received: "xmpp" }
post "/webhooks/lndhub", params: payload.to_json post "/webhooks/lndhub", params: payload.to_json
end end
@@ -78,7 +79,7 @@ RSpec.describe "Webhooks", type: :request do
msg = enqueued_jobs.first["arguments"].first msg = enqueued_jobs.first["arguments"].first
expect(msg["type"]).to eq("normal") expect(msg["type"]).to eq("normal")
expect(msg["from"]).to eq("kosmos.org") expect(msg["from"]).to eq("botka@kosmos.org")
expect(msg["to"]).to eq(user.address) expect(msg["to"]).to eq(user.address)
expect(msg["subject"]).to eq("Sats received!") expect(msg["subject"]).to eq("Sats received!")
expect(msg["body"]).to match(/^12,300 sats received/) expect(msg["body"]).to match(/^12,300 sats received/)