Compare commits
	
		
			14 Commits
		
	
	
		
			master
			...
			feature/no
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9866cd0404 | |||
| 10d29b6fab | |||
| 6f8f60a9e2 | |||
| c1b4665706 | |||
| 5447150d4d | |||
| bf26703b2d | |||
| 21c6264ea9 | |||
| 79ef9fa6d5 | |||
| 04a9061663 | |||
| 5283f6fce7 | |||
| a08a4746f7 | |||
| 9e3652479b | |||
| 011386fb8d | |||
| 4d77f5d38c | 
							
								
								
									
										23
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								.env.example
									
									
									
									
									
								
							@ -1,23 +1,6 @@
 | 
			
		||||
# PRIMARY_DOMAIN=kosmos.org
 | 
			
		||||
# AKKOUNTS_DOMAIN=accounts.example.com
 | 
			
		||||
 | 
			
		||||
# Generate this using `rails secret`
 | 
			
		||||
# SECRET_KEY_BASE=
 | 
			
		||||
 | 
			
		||||
# Generate these using `rails db:encryption:init`
 | 
			
		||||
# (Optional, needed for LndHub integration)
 | 
			
		||||
# ENCRYPTION_PRIMARY_KEY=
 | 
			
		||||
# ENCRYPTION_KEY_DERIVATION_SALT=
 | 
			
		||||
 | 
			
		||||
# The default backend is SQLite
 | 
			
		||||
# DB_ADAPTER=postgresql
 | 
			
		||||
# PG_HOST=localhost
 | 
			
		||||
# PG_PORT=5432
 | 
			
		||||
# PG_DATABASE=akkounts
 | 
			
		||||
# PG_DATABASE_QUEUE=akkounts_queue
 | 
			
		||||
# PG_USERNAME=akkounts
 | 
			
		||||
# PG_PASSWORD=
 | 
			
		||||
 | 
			
		||||
# SMTP_SERVER=smtp.example.com
 | 
			
		||||
# SMTP_PORT=587
 | 
			
		||||
# SMTP_LOGIN=accounts
 | 
			
		||||
@ -37,12 +20,8 @@
 | 
			
		||||
 | 
			
		||||
# LDAP_HOST=localhost
 | 
			
		||||
# LDAP_PORT=389
 | 
			
		||||
# LDAP_USE_TLS=false
 | 
			
		||||
# LDAP_UID_ATTR=cn
 | 
			
		||||
# LDAP_BASE="ou=kosmos.org,cn=users,dc=kosmos,dc=org"
 | 
			
		||||
# LDAP_ADMIN_USER="cn=Directory Manager"
 | 
			
		||||
# LDAP_ADMIN_PASSWORD=passthebutter
 | 
			
		||||
# LDAP_SUFFIX="dc=kosmos,dc=org"
 | 
			
		||||
# LDAP_SUFFIX='dc=kosmos,dc=org'
 | 
			
		||||
 | 
			
		||||
# REDIS_URL='redis://localhost:6379/1'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,6 @@
 | 
			
		||||
PRIMARY_DOMAIN=kosmos.org
 | 
			
		||||
AKKOUNTS_DOMAIN=accounts.kosmos.org
 | 
			
		||||
 | 
			
		||||
ENCRYPTION_PRIMARY_KEY=YhNLBgCFMAzw5dV3gISxnGrhNDMQwRdn
 | 
			
		||||
ENCRYPTION_KEY_DERIVATION_SALT=h28g16MRZ1sghF2jTCos1DiLZXUswinR
 | 
			
		||||
 | 
			
		||||
REDIS_URL='redis://localhost:6379/0'
 | 
			
		||||
 | 
			
		||||
BTCPAY_PUBLIC_URL='https://btcpay.example.com'
 | 
			
		||||
@ -24,8 +21,7 @@ LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de55648
 | 
			
		||||
NOSTR_PRIVATE_KEY='7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea'
 | 
			
		||||
NOSTR_PUBLIC_KEY='bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf'
 | 
			
		||||
 | 
			
		||||
RS_REDIS_URL='redis://localhost:6379/1'
 | 
			
		||||
RS_STORAGE_URL='https://storage.kosmos.org'
 | 
			
		||||
RS_AKKOUNTS_DOMAIN=localhost
 | 
			
		||||
RS_REDIS_URL='redis://localhost:6379/1'
 | 
			
		||||
 | 
			
		||||
WEBHOOKS_ALLOWED_IPS='10.1.1.23'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -37,7 +37,6 @@
 | 
			
		||||
/yarn-error.log
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
.yarn-integrity
 | 
			
		||||
bun.lock
 | 
			
		||||
 | 
			
		||||
# Ignore local dotenv config file
 | 
			
		||||
.env
 | 
			
		||||
@ -48,6 +47,3 @@ dump.rdb
 | 
			
		||||
 | 
			
		||||
/app/assets/builds/*
 | 
			
		||||
!/app/assets/builds/.keep
 | 
			
		||||
 | 
			
		||||
# Ignore generated ctags
 | 
			
		||||
*.tags
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										24
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								Gemfile
									
									
									
									
									
								
							@ -2,13 +2,13 @@ source 'https://rubygems.org'
 | 
			
		||||
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
 | 
			
		||||
 | 
			
		||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
 | 
			
		||||
gem 'rails', '~> 8.0'
 | 
			
		||||
gem 'rails', '~> 7.1'
 | 
			
		||||
# Use Puma as the app server
 | 
			
		||||
gem 'puma', '~> 6.6'
 | 
			
		||||
gem 'puma', '~> 4.1'
 | 
			
		||||
# View components
 | 
			
		||||
gem "view_component"
 | 
			
		||||
# Asset bundler
 | 
			
		||||
gem 'propshaft'
 | 
			
		||||
# Separate dependency since Rails 7.0
 | 
			
		||||
gem 'sprockets-rails'
 | 
			
		||||
# Allows custom JS build tasks to integrate with the asset pipeline
 | 
			
		||||
gem 'cssbundling-rails'
 | 
			
		||||
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
 | 
			
		||||
@ -19,12 +19,17 @@ gem "turbo-rails"
 | 
			
		||||
gem "stimulus-rails"
 | 
			
		||||
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
 | 
			
		||||
gem 'jbuilder', '~> 2.7'
 | 
			
		||||
# Use Redis adapter to run Action Cable in production
 | 
			
		||||
# gem 'redis', '~> 4.0'
 | 
			
		||||
# Use Active Model has_secure_password
 | 
			
		||||
gem 'bcrypt', '~> 3.1'
 | 
			
		||||
 | 
			
		||||
# Configuration
 | 
			
		||||
gem 'dotenv-rails'
 | 
			
		||||
 | 
			
		||||
# Security
 | 
			
		||||
gem 'lockbox'
 | 
			
		||||
 | 
			
		||||
# Authentication
 | 
			
		||||
gem 'warden'
 | 
			
		||||
gem 'devise', '~> 4.9.0'
 | 
			
		||||
@ -32,7 +37,6 @@ gem 'devise_ldap_authenticatable'
 | 
			
		||||
gem 'net-ldap'
 | 
			
		||||
 | 
			
		||||
# Utilities
 | 
			
		||||
gem 'aasm'
 | 
			
		||||
gem "image_processing", "~> 1.12.2"
 | 
			
		||||
gem "rqrcode", "~> 2.0"
 | 
			
		||||
gem 'rails-settings-cached', '~> 2.8.3'
 | 
			
		||||
@ -40,9 +44,6 @@ gem 'pagy', '~> 6.0', '>= 6.0.2'
 | 
			
		||||
gem 'flipper'
 | 
			
		||||
gem 'flipper-active_record'
 | 
			
		||||
gem 'flipper-ui'
 | 
			
		||||
gem 'gpgme', '~> 2.0.24'
 | 
			
		||||
gem 'zbase32', '~> 0.1.1'
 | 
			
		||||
gem 'kramdown'
 | 
			
		||||
 | 
			
		||||
# HTTP requests
 | 
			
		||||
gem 'faraday'
 | 
			
		||||
@ -50,8 +51,8 @@ gem 'down'
 | 
			
		||||
gem 'aws-sdk-s3', require: false
 | 
			
		||||
 | 
			
		||||
# Background/scheduled jobs
 | 
			
		||||
gem 'solid_queue'
 | 
			
		||||
gem "mission_control-jobs"
 | 
			
		||||
gem 'sidekiq', '< 7'
 | 
			
		||||
gem 'sidekiq-scheduler'
 | 
			
		||||
 | 
			
		||||
# Monitoring
 | 
			
		||||
gem "sentry-ruby"
 | 
			
		||||
@ -62,11 +63,10 @@ gem 'discourse_api'
 | 
			
		||||
gem "lnurl"
 | 
			
		||||
gem 'manifique', '~> 1.1.0'
 | 
			
		||||
gem 'nostr', '~> 0.6.0'
 | 
			
		||||
gem "redis", "~> 5.4"
 | 
			
		||||
 | 
			
		||||
group :development, :test do
 | 
			
		||||
  # Use sqlite3 as the database for Active Record
 | 
			
		||||
  gem 'sqlite3', '>= 2.1'
 | 
			
		||||
  gem 'sqlite3', '~> 1.7.2'
 | 
			
		||||
  gem 'rspec-rails'
 | 
			
		||||
  gem 'rails-controller-testing'
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										571
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										571
									
								
								Gemfile.lock
									
									
									
									
									
								
							@ -1,111 +1,110 @@
 | 
			
		||||
GEM
 | 
			
		||||
  remote: https://rubygems.org/
 | 
			
		||||
  specs:
 | 
			
		||||
    aasm (5.5.0)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
    actioncable (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    actioncable (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      nio4r (~> 2.0)
 | 
			
		||||
      websocket-driver (>= 0.6.1)
 | 
			
		||||
      zeitwerk (~> 2.6)
 | 
			
		||||
    actionmailbox (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      activejob (= 8.0.2)
 | 
			
		||||
      activerecord (= 8.0.2)
 | 
			
		||||
      activestorage (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
      mail (>= 2.8.0)
 | 
			
		||||
    actionmailer (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      actionview (= 8.0.2)
 | 
			
		||||
      activejob (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
      mail (>= 2.8.0)
 | 
			
		||||
    actionmailbox (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      activejob (= 7.1.3)
 | 
			
		||||
      activerecord (= 7.1.3)
 | 
			
		||||
      activestorage (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      mail (>= 2.7.1)
 | 
			
		||||
      net-imap
 | 
			
		||||
      net-pop
 | 
			
		||||
      net-smtp
 | 
			
		||||
    actionmailer (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      actionview (= 7.1.3)
 | 
			
		||||
      activejob (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      mail (~> 2.5, >= 2.5.4)
 | 
			
		||||
      net-imap
 | 
			
		||||
      net-pop
 | 
			
		||||
      net-smtp
 | 
			
		||||
      rails-dom-testing (~> 2.2)
 | 
			
		||||
    actionpack (8.0.2)
 | 
			
		||||
      actionview (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    actionpack (7.1.3)
 | 
			
		||||
      actionview (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      nokogiri (>= 1.8.5)
 | 
			
		||||
      racc
 | 
			
		||||
      rack (>= 2.2.4)
 | 
			
		||||
      rack-session (>= 1.0.1)
 | 
			
		||||
      rack-test (>= 0.6.3)
 | 
			
		||||
      rails-dom-testing (~> 2.2)
 | 
			
		||||
      rails-html-sanitizer (~> 1.6)
 | 
			
		||||
      useragent (~> 0.16)
 | 
			
		||||
    actiontext (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      activerecord (= 8.0.2)
 | 
			
		||||
      activestorage (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    actiontext (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      activerecord (= 7.1.3)
 | 
			
		||||
      activestorage (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      globalid (>= 0.6.0)
 | 
			
		||||
      nokogiri (>= 1.8.5)
 | 
			
		||||
    actionview (8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    actionview (7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      builder (~> 3.1)
 | 
			
		||||
      erubi (~> 1.11)
 | 
			
		||||
      rails-dom-testing (~> 2.2)
 | 
			
		||||
      rails-html-sanitizer (~> 1.6)
 | 
			
		||||
    activejob (8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    activejob (7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      globalid (>= 0.3.6)
 | 
			
		||||
    activemodel (8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    activerecord (8.0.2)
 | 
			
		||||
      activemodel (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    activemodel (7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
    activerecord (7.1.3)
 | 
			
		||||
      activemodel (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      timeout (>= 0.4.0)
 | 
			
		||||
    activestorage (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      activejob (= 8.0.2)
 | 
			
		||||
      activerecord (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    activestorage (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      activejob (= 7.1.3)
 | 
			
		||||
      activerecord (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      marcel (~> 1.0)
 | 
			
		||||
    activesupport (8.0.2)
 | 
			
		||||
    activesupport (7.1.3)
 | 
			
		||||
      base64
 | 
			
		||||
      benchmark (>= 0.3)
 | 
			
		||||
      bigdecimal
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.3.1)
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
			
		||||
      connection_pool (>= 2.2.5)
 | 
			
		||||
      drb
 | 
			
		||||
      i18n (>= 1.6, < 2)
 | 
			
		||||
      logger (>= 1.4.2)
 | 
			
		||||
      minitest (>= 5.1)
 | 
			
		||||
      securerandom (>= 0.3)
 | 
			
		||||
      tzinfo (~> 2.0, >= 2.0.5)
 | 
			
		||||
      uri (>= 0.13.1)
 | 
			
		||||
    addressable (2.8.7)
 | 
			
		||||
      public_suffix (>= 2.0.2, < 7.0)
 | 
			
		||||
    ast (2.4.3)
 | 
			
		||||
    aws-eventstream (1.3.2)
 | 
			
		||||
    aws-partitions (1.1092.0)
 | 
			
		||||
    aws-sdk-core (3.222.2)
 | 
			
		||||
      mutex_m
 | 
			
		||||
      tzinfo (~> 2.0)
 | 
			
		||||
    addressable (2.8.6)
 | 
			
		||||
      public_suffix (>= 2.0.2, < 6.0)
 | 
			
		||||
    ast (2.4.2)
 | 
			
		||||
    aws-eventstream (1.3.0)
 | 
			
		||||
    aws-partitions (1.886.0)
 | 
			
		||||
    aws-sdk-core (3.191.0)
 | 
			
		||||
      aws-eventstream (~> 1, >= 1.3.0)
 | 
			
		||||
      aws-partitions (~> 1, >= 1.992.0)
 | 
			
		||||
      aws-sigv4 (~> 1.9)
 | 
			
		||||
      base64
 | 
			
		||||
      aws-partitions (~> 1, >= 1.651.0)
 | 
			
		||||
      aws-sigv4 (~> 1.8)
 | 
			
		||||
      jmespath (~> 1, >= 1.6.1)
 | 
			
		||||
      logger
 | 
			
		||||
    aws-sdk-kms (1.99.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.216.0)
 | 
			
		||||
      aws-sigv4 (~> 1.5)
 | 
			
		||||
    aws-sdk-s3 (1.183.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.216.0)
 | 
			
		||||
    aws-sdk-kms (1.77.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.191.0)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
    aws-sdk-s3 (1.143.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.191.0)
 | 
			
		||||
      aws-sdk-kms (~> 1)
 | 
			
		||||
      aws-sigv4 (~> 1.5)
 | 
			
		||||
    aws-sigv4 (1.11.0)
 | 
			
		||||
      aws-sigv4 (~> 1.8)
 | 
			
		||||
    aws-sigv4 (1.8.0)
 | 
			
		||||
      aws-eventstream (~> 1, >= 1.0.2)
 | 
			
		||||
    backport (1.2.0)
 | 
			
		||||
    base64 (0.2.0)
 | 
			
		||||
    bcrypt (3.1.20)
 | 
			
		||||
    bech32 (1.5.0)
 | 
			
		||||
    bech32 (1.4.2)
 | 
			
		||||
      thor (>= 1.1.0)
 | 
			
		||||
    benchmark (0.4.0)
 | 
			
		||||
    bigdecimal (3.1.9)
 | 
			
		||||
    benchmark (0.3.0)
 | 
			
		||||
    bigdecimal (3.1.6)
 | 
			
		||||
    bindex (0.8.1)
 | 
			
		||||
    bip-schnorr (0.7.0)
 | 
			
		||||
      ecdsa_ext (~> 0.5.0)
 | 
			
		||||
    builder (3.3.0)
 | 
			
		||||
    builder (3.2.4)
 | 
			
		||||
    capybara (3.40.0)
 | 
			
		||||
      addressable
 | 
			
		||||
      matrix
 | 
			
		||||
@ -115,25 +114,23 @@ GEM
 | 
			
		||||
      rack-test (>= 0.6.3)
 | 
			
		||||
      regexp_parser (>= 1.5, < 3.0)
 | 
			
		||||
      xpath (~> 3.2)
 | 
			
		||||
    childprocess (5.1.0)
 | 
			
		||||
      logger (~> 1.5)
 | 
			
		||||
    chunky_png (1.4.0)
 | 
			
		||||
    concurrent-ruby (1.3.4)
 | 
			
		||||
    connection_pool (2.5.2)
 | 
			
		||||
    crack (1.0.0)
 | 
			
		||||
    concurrent-ruby (1.2.3)
 | 
			
		||||
    connection_pool (2.4.1)
 | 
			
		||||
    crack (0.4.6)
 | 
			
		||||
      bigdecimal
 | 
			
		||||
      rexml
 | 
			
		||||
    crass (1.0.6)
 | 
			
		||||
    cssbundling-rails (1.4.3)
 | 
			
		||||
    cssbundling-rails (1.4.0)
 | 
			
		||||
      railties (>= 6.0.0)
 | 
			
		||||
    database_cleaner (2.1.0)
 | 
			
		||||
    database_cleaner (2.0.2)
 | 
			
		||||
      database_cleaner-active_record (>= 2, < 3)
 | 
			
		||||
    database_cleaner-active_record (2.2.0)
 | 
			
		||||
    database_cleaner-active_record (2.1.0)
 | 
			
		||||
      activerecord (>= 5.a)
 | 
			
		||||
      database_cleaner-core (~> 2.0.0)
 | 
			
		||||
    database_cleaner-core (2.0.1)
 | 
			
		||||
    date (3.4.1)
 | 
			
		||||
    devise (4.9.4)
 | 
			
		||||
    date (3.3.4)
 | 
			
		||||
    devise (4.9.3)
 | 
			
		||||
      bcrypt (~> 3.0)
 | 
			
		||||
      orm_adapter (~> 0.1)
 | 
			
		||||
      railties (>= 4.1.0)
 | 
			
		||||
@ -142,112 +139,105 @@ GEM
 | 
			
		||||
    devise_ldap_authenticatable (0.8.7)
 | 
			
		||||
      devise (>= 3.4.1)
 | 
			
		||||
      net-ldap (>= 0.16.0)
 | 
			
		||||
    diff-lcs (1.6.1)
 | 
			
		||||
    diff-lcs (1.5.1)
 | 
			
		||||
    discourse_api (2.0.1)
 | 
			
		||||
      faraday (~> 2.7)
 | 
			
		||||
      faraday-follow_redirects
 | 
			
		||||
      faraday-multipart
 | 
			
		||||
      rack (>= 1.6)
 | 
			
		||||
    dotenv (3.1.8)
 | 
			
		||||
    dotenv-rails (3.1.8)
 | 
			
		||||
      dotenv (= 3.1.8)
 | 
			
		||||
      railties (>= 6.1)
 | 
			
		||||
    down (5.4.2)
 | 
			
		||||
    dotenv (2.8.1)
 | 
			
		||||
    dotenv-rails (2.8.1)
 | 
			
		||||
      dotenv (= 2.8.1)
 | 
			
		||||
      railties (>= 3.2)
 | 
			
		||||
    down (5.4.1)
 | 
			
		||||
      addressable (~> 2.8)
 | 
			
		||||
    drb (2.2.1)
 | 
			
		||||
    drb (2.2.0)
 | 
			
		||||
      ruby2_keywords
 | 
			
		||||
    e2mmap (0.1.0)
 | 
			
		||||
    ecdsa (1.2.0)
 | 
			
		||||
    ecdsa_ext (0.5.1)
 | 
			
		||||
      ecdsa (~> 1.2.0)
 | 
			
		||||
    erubi (1.13.1)
 | 
			
		||||
    et-orbi (1.2.11)
 | 
			
		||||
    erubi (1.12.0)
 | 
			
		||||
    et-orbi (1.2.7)
 | 
			
		||||
      tzinfo
 | 
			
		||||
    event_emitter (0.2.6)
 | 
			
		||||
    eventmachine (1.2.7)
 | 
			
		||||
    factory_bot (6.5.1)
 | 
			
		||||
      activesupport (>= 6.1.0)
 | 
			
		||||
    factory_bot_rails (6.4.4)
 | 
			
		||||
      factory_bot (~> 6.5)
 | 
			
		||||
    factory_bot (6.4.6)
 | 
			
		||||
      activesupport (>= 5.0.0)
 | 
			
		||||
    factory_bot_rails (6.4.3)
 | 
			
		||||
      factory_bot (~> 6.4)
 | 
			
		||||
      railties (>= 5.0.0)
 | 
			
		||||
    faker (3.5.1)
 | 
			
		||||
    faker (3.2.3)
 | 
			
		||||
      i18n (>= 1.8.11, < 2)
 | 
			
		||||
    faraday (2.9.2)
 | 
			
		||||
    faraday (2.9.0)
 | 
			
		||||
      faraday-net_http (>= 2.0, < 3.2)
 | 
			
		||||
    faraday-follow_redirects (0.3.0)
 | 
			
		||||
      faraday (>= 1, < 3)
 | 
			
		||||
    faraday-multipart (1.1.0)
 | 
			
		||||
      multipart-post (~> 2.0)
 | 
			
		||||
    faraday-net_http (3.1.1)
 | 
			
		||||
    faraday-multipart (1.0.4)
 | 
			
		||||
      multipart-post (~> 2)
 | 
			
		||||
    faraday-net_http (3.1.0)
 | 
			
		||||
      net-http
 | 
			
		||||
    faye-websocket (0.11.3)
 | 
			
		||||
      eventmachine (>= 0.12.0)
 | 
			
		||||
      websocket-driver (>= 0.5.1)
 | 
			
		||||
    ffi (1.17.2)
 | 
			
		||||
    ffi (1.17.2-arm64-darwin)
 | 
			
		||||
    ffi (1.17.2-x86_64-linux-gnu)
 | 
			
		||||
    flipper (1.3.4)
 | 
			
		||||
    ffi (1.16.3)
 | 
			
		||||
    flipper (1.2.2)
 | 
			
		||||
      concurrent-ruby (< 2)
 | 
			
		||||
    flipper-active_record (1.3.4)
 | 
			
		||||
      activerecord (>= 4.2, < 9)
 | 
			
		||||
      flipper (~> 1.3.4)
 | 
			
		||||
    flipper-ui (1.3.4)
 | 
			
		||||
    flipper-active_record (1.2.2)
 | 
			
		||||
      activerecord (>= 4.2, < 8)
 | 
			
		||||
      flipper (~> 1.2.2)
 | 
			
		||||
    flipper-ui (1.2.2)
 | 
			
		||||
      erubi (>= 1.0.0, < 2.0.0)
 | 
			
		||||
      flipper (~> 1.3.4)
 | 
			
		||||
      flipper (~> 1.2.2)
 | 
			
		||||
      rack (>= 1.4, < 4)
 | 
			
		||||
      rack-protection (>= 1.5.3, < 5.0.0)
 | 
			
		||||
      rack-session (>= 1.0.2, < 3.0.0)
 | 
			
		||||
      sanitize (< 8)
 | 
			
		||||
    fugit (1.11.1)
 | 
			
		||||
      et-orbi (~> 1, >= 1.2.11)
 | 
			
		||||
      rack-protection (>= 1.5.3, <= 4.0.0)
 | 
			
		||||
      sanitize (< 7)
 | 
			
		||||
    fugit (1.9.0)
 | 
			
		||||
      et-orbi (~> 1, >= 1.2.7)
 | 
			
		||||
      raabro (~> 1.4)
 | 
			
		||||
    globalid (1.2.1)
 | 
			
		||||
      activesupport (>= 6.1)
 | 
			
		||||
    gpgme (2.0.24)
 | 
			
		||||
      mini_portile2 (~> 2.7)
 | 
			
		||||
    hashdiff (1.1.2)
 | 
			
		||||
    i18n (1.14.7)
 | 
			
		||||
    hashdiff (1.1.0)
 | 
			
		||||
    i18n (1.14.1)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
    image_processing (1.12.2)
 | 
			
		||||
      mini_magick (>= 4.9.5, < 5)
 | 
			
		||||
      ruby-vips (>= 2.0.17, < 3)
 | 
			
		||||
    importmap-rails (2.1.0)
 | 
			
		||||
    importmap-rails (2.0.1)
 | 
			
		||||
      actionpack (>= 6.0.0)
 | 
			
		||||
      activesupport (>= 6.0.0)
 | 
			
		||||
      railties (>= 6.0.0)
 | 
			
		||||
    io-console (0.8.0)
 | 
			
		||||
    irb (1.15.2)
 | 
			
		||||
      pp (>= 0.6.0)
 | 
			
		||||
      rdoc (>= 4.0.0)
 | 
			
		||||
    io-console (0.7.2)
 | 
			
		||||
    irb (1.11.1)
 | 
			
		||||
      rdoc
 | 
			
		||||
      reline (>= 0.4.2)
 | 
			
		||||
    jaro_winkler (1.6.0)
 | 
			
		||||
    jbuilder (2.13.0)
 | 
			
		||||
    jaro_winkler (1.5.6)
 | 
			
		||||
    jbuilder (2.11.5)
 | 
			
		||||
      actionview (>= 5.0.0)
 | 
			
		||||
      activesupport (>= 5.0.0)
 | 
			
		||||
    jmespath (1.6.2)
 | 
			
		||||
    json (2.11.3)
 | 
			
		||||
    kramdown (2.5.1)
 | 
			
		||||
      rexml (>= 3.3.9)
 | 
			
		||||
    json (2.7.1)
 | 
			
		||||
    kramdown (2.4.0)
 | 
			
		||||
      rexml
 | 
			
		||||
    kramdown-parser-gfm (1.1.0)
 | 
			
		||||
      kramdown (~> 2.0)
 | 
			
		||||
    language_server-protocol (3.17.0.4)
 | 
			
		||||
    launchy (3.1.1)
 | 
			
		||||
    language_server-protocol (3.17.0.3)
 | 
			
		||||
    launchy (2.5.2)
 | 
			
		||||
      addressable (~> 2.8)
 | 
			
		||||
      childprocess (~> 5.0)
 | 
			
		||||
      logger (~> 1.6)
 | 
			
		||||
    letter_opener (1.10.0)
 | 
			
		||||
      launchy (>= 2.2, < 4)
 | 
			
		||||
    letter_opener_web (3.0.0)
 | 
			
		||||
      actionmailer (>= 6.1)
 | 
			
		||||
      letter_opener (~> 1.9)
 | 
			
		||||
      railties (>= 6.1)
 | 
			
		||||
    letter_opener (1.8.1)
 | 
			
		||||
      launchy (>= 2.2, < 3)
 | 
			
		||||
    letter_opener_web (2.0.0)
 | 
			
		||||
      actionmailer (>= 5.2)
 | 
			
		||||
      letter_opener (~> 1.7)
 | 
			
		||||
      railties (>= 5.2)
 | 
			
		||||
      rexml
 | 
			
		||||
    lint_roller (1.1.0)
 | 
			
		||||
    listen (3.9.0)
 | 
			
		||||
    listen (3.8.0)
 | 
			
		||||
      rb-fsevent (~> 0.10, >= 0.10.3)
 | 
			
		||||
      rb-inotify (~> 0.9, >= 0.9.10)
 | 
			
		||||
    lnurl (1.1.1)
 | 
			
		||||
    lnurl (1.1.0)
 | 
			
		||||
      bech32 (~> 1.1)
 | 
			
		||||
    logger (1.7.0)
 | 
			
		||||
    loofah (2.24.0)
 | 
			
		||||
    lockbox (1.3.2)
 | 
			
		||||
    loofah (2.22.0)
 | 
			
		||||
      crass (~> 1.0.2)
 | 
			
		||||
      nokogiri (>= 1.12.0)
 | 
			
		||||
    mail (2.8.1)
 | 
			
		||||
@ -259,27 +249,18 @@ GEM
 | 
			
		||||
      faraday (~> 2.9.0)
 | 
			
		||||
      faraday-follow_redirects (= 0.3.0)
 | 
			
		||||
      nokogiri (~> 1.16.0)
 | 
			
		||||
    marcel (1.0.4)
 | 
			
		||||
    marcel (1.0.2)
 | 
			
		||||
    matrix (0.4.2)
 | 
			
		||||
    method_source (1.1.0)
 | 
			
		||||
    mini_magick (4.13.2)
 | 
			
		||||
    method_source (1.0.0)
 | 
			
		||||
    mini_magick (4.12.0)
 | 
			
		||||
    mini_mime (1.1.5)
 | 
			
		||||
    mini_portile2 (2.8.8)
 | 
			
		||||
    minitest (5.25.5)
 | 
			
		||||
    mission_control-jobs (1.0.2)
 | 
			
		||||
      actioncable (>= 7.1)
 | 
			
		||||
      actionpack (>= 7.1)
 | 
			
		||||
      activejob (>= 7.1)
 | 
			
		||||
      activerecord (>= 7.1)
 | 
			
		||||
      importmap-rails (>= 1.2.1)
 | 
			
		||||
      irb (~> 1.13)
 | 
			
		||||
      railties (>= 7.1)
 | 
			
		||||
      stimulus-rails
 | 
			
		||||
      turbo-rails
 | 
			
		||||
    multipart-post (2.4.1)
 | 
			
		||||
    net-http (0.6.0)
 | 
			
		||||
    mini_portile2 (2.8.5)
 | 
			
		||||
    minitest (5.21.2)
 | 
			
		||||
    multipart-post (2.3.0)
 | 
			
		||||
    mutex_m (0.2.0)
 | 
			
		||||
    net-http (0.4.1)
 | 
			
		||||
      uri
 | 
			
		||||
    net-imap (0.5.7)
 | 
			
		||||
    net-imap (0.4.9.1)
 | 
			
		||||
      date
 | 
			
		||||
      net-protocol
 | 
			
		||||
    net-ldap (0.19.0)
 | 
			
		||||
@ -287,15 +268,15 @@ GEM
 | 
			
		||||
      net-protocol
 | 
			
		||||
    net-protocol (0.2.2)
 | 
			
		||||
      timeout
 | 
			
		||||
    net-smtp (0.5.1)
 | 
			
		||||
    net-smtp (0.4.0.1)
 | 
			
		||||
      net-protocol
 | 
			
		||||
    nio4r (2.7.4)
 | 
			
		||||
    nokogiri (1.16.8)
 | 
			
		||||
    nio4r (2.7.0)
 | 
			
		||||
    nokogiri (1.16.0)
 | 
			
		||||
      mini_portile2 (~> 2.8.2)
 | 
			
		||||
      racc (~> 1.4)
 | 
			
		||||
    nokogiri (1.16.8-arm64-darwin)
 | 
			
		||||
    nokogiri (1.16.0-arm64-darwin)
 | 
			
		||||
      racc (~> 1.4)
 | 
			
		||||
    nokogiri (1.16.8-x86_64-linux)
 | 
			
		||||
    nokogiri (1.16.0-x86_64-linux)
 | 
			
		||||
      racc (~> 1.4)
 | 
			
		||||
    nostr (0.6.0)
 | 
			
		||||
      bech32 (~> 1.4)
 | 
			
		||||
@ -304,57 +285,45 @@ GEM
 | 
			
		||||
      event_emitter (~> 0.2)
 | 
			
		||||
      faye-websocket (~> 0.11)
 | 
			
		||||
      json (~> 2.6)
 | 
			
		||||
    observer (0.1.2)
 | 
			
		||||
    orm_adapter (0.5.0)
 | 
			
		||||
    ostruct (0.6.1)
 | 
			
		||||
    pagy (6.5.0)
 | 
			
		||||
    parallel (1.27.0)
 | 
			
		||||
    parser (3.3.8.0)
 | 
			
		||||
    pagy (6.4.3)
 | 
			
		||||
    parallel (1.24.0)
 | 
			
		||||
    parser (3.3.0.5)
 | 
			
		||||
      ast (~> 2.4.1)
 | 
			
		||||
      racc
 | 
			
		||||
    pg (1.5.9)
 | 
			
		||||
    pp (0.6.2)
 | 
			
		||||
      prettyprint
 | 
			
		||||
    prettyprint (0.2.0)
 | 
			
		||||
    prism (1.4.0)
 | 
			
		||||
    propshaft (1.1.0)
 | 
			
		||||
      actionpack (>= 7.0.0)
 | 
			
		||||
      activesupport (>= 7.0.0)
 | 
			
		||||
      rack
 | 
			
		||||
      railties (>= 7.0.0)
 | 
			
		||||
    psych (5.2.3)
 | 
			
		||||
      date
 | 
			
		||||
    pg (1.5.4)
 | 
			
		||||
    psych (5.1.2)
 | 
			
		||||
      stringio
 | 
			
		||||
    public_suffix (6.0.1)
 | 
			
		||||
    puma (6.6.0)
 | 
			
		||||
    public_suffix (5.0.4)
 | 
			
		||||
    puma (4.3.12)
 | 
			
		||||
      nio4r (~> 2.0)
 | 
			
		||||
    raabro (1.4.0)
 | 
			
		||||
    racc (1.8.1)
 | 
			
		||||
    rack (2.2.13)
 | 
			
		||||
    racc (1.7.3)
 | 
			
		||||
    rack (2.2.8)
 | 
			
		||||
    rack-protection (3.2.0)
 | 
			
		||||
      base64 (>= 0.1.0)
 | 
			
		||||
      rack (~> 2.2, >= 2.2.4)
 | 
			
		||||
    rack-session (1.0.2)
 | 
			
		||||
      rack (< 3)
 | 
			
		||||
    rack-test (2.2.0)
 | 
			
		||||
    rack-test (2.1.0)
 | 
			
		||||
      rack (>= 1.3)
 | 
			
		||||
    rackup (1.0.1)
 | 
			
		||||
    rackup (1.0.0)
 | 
			
		||||
      rack (< 3)
 | 
			
		||||
      webrick
 | 
			
		||||
    rails (8.0.2)
 | 
			
		||||
      actioncable (= 8.0.2)
 | 
			
		||||
      actionmailbox (= 8.0.2)
 | 
			
		||||
      actionmailer (= 8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      actiontext (= 8.0.2)
 | 
			
		||||
      actionview (= 8.0.2)
 | 
			
		||||
      activejob (= 8.0.2)
 | 
			
		||||
      activemodel (= 8.0.2)
 | 
			
		||||
      activerecord (= 8.0.2)
 | 
			
		||||
      activestorage (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
    rails (7.1.3)
 | 
			
		||||
      actioncable (= 7.1.3)
 | 
			
		||||
      actionmailbox (= 7.1.3)
 | 
			
		||||
      actionmailer (= 7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      actiontext (= 7.1.3)
 | 
			
		||||
      actionview (= 7.1.3)
 | 
			
		||||
      activejob (= 7.1.3)
 | 
			
		||||
      activemodel (= 7.1.3)
 | 
			
		||||
      activerecord (= 7.1.3)
 | 
			
		||||
      activestorage (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      bundler (>= 1.15.0)
 | 
			
		||||
      railties (= 8.0.2)
 | 
			
		||||
      railties (= 7.1.3)
 | 
			
		||||
    rails-controller-testing (1.0.5)
 | 
			
		||||
      actionpack (>= 5.0.1.rc1)
 | 
			
		||||
      actionview (>= 5.0.1.rc1)
 | 
			
		||||
@ -363,140 +332,138 @@ GEM
 | 
			
		||||
      activesupport (>= 5.0.0)
 | 
			
		||||
      minitest
 | 
			
		||||
      nokogiri (>= 1.6)
 | 
			
		||||
    rails-html-sanitizer (1.6.2)
 | 
			
		||||
    rails-html-sanitizer (1.6.0)
 | 
			
		||||
      loofah (~> 2.21)
 | 
			
		||||
      nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
 | 
			
		||||
      nokogiri (~> 1.14)
 | 
			
		||||
    rails-settings-cached (2.8.3)
 | 
			
		||||
      activerecord (>= 5.0.0)
 | 
			
		||||
      railties (>= 5.0.0)
 | 
			
		||||
    railties (8.0.2)
 | 
			
		||||
      actionpack (= 8.0.2)
 | 
			
		||||
      activesupport (= 8.0.2)
 | 
			
		||||
      irb (~> 1.13)
 | 
			
		||||
    railties (7.1.3)
 | 
			
		||||
      actionpack (= 7.1.3)
 | 
			
		||||
      activesupport (= 7.1.3)
 | 
			
		||||
      irb
 | 
			
		||||
      rackup (>= 1.0.0)
 | 
			
		||||
      rake (>= 12.2)
 | 
			
		||||
      thor (~> 1.0, >= 1.2.2)
 | 
			
		||||
      zeitwerk (~> 2.6)
 | 
			
		||||
    rainbow (3.1.1)
 | 
			
		||||
    rake (13.2.1)
 | 
			
		||||
    rake (13.1.0)
 | 
			
		||||
    rb-fsevent (0.11.2)
 | 
			
		||||
    rb-inotify (0.11.1)
 | 
			
		||||
    rb-inotify (0.10.1)
 | 
			
		||||
      ffi (~> 1.0)
 | 
			
		||||
    rbs (3.9.2)
 | 
			
		||||
      logger
 | 
			
		||||
    rdoc (6.13.1)
 | 
			
		||||
    rbs (2.8.4)
 | 
			
		||||
    rdoc (6.6.2)
 | 
			
		||||
      psych (>= 4.0.0)
 | 
			
		||||
    redis (5.4.0)
 | 
			
		||||
      redis-client (>= 0.22.0)
 | 
			
		||||
    redis-client (0.24.0)
 | 
			
		||||
      connection_pool
 | 
			
		||||
    regexp_parser (2.10.0)
 | 
			
		||||
    reline (0.6.1)
 | 
			
		||||
    redis (4.8.1)
 | 
			
		||||
    regexp_parser (2.9.0)
 | 
			
		||||
    reline (0.4.2)
 | 
			
		||||
      io-console (~> 0.5)
 | 
			
		||||
    responders (3.1.1)
 | 
			
		||||
      actionpack (>= 5.2)
 | 
			
		||||
      railties (>= 5.2)
 | 
			
		||||
    reverse_markdown (3.0.0)
 | 
			
		||||
    reverse_markdown (2.1.1)
 | 
			
		||||
      nokogiri
 | 
			
		||||
    rexml (3.4.1)
 | 
			
		||||
    rexml (3.2.6)
 | 
			
		||||
    rqrcode (2.2.0)
 | 
			
		||||
      chunky_png (~> 1.0)
 | 
			
		||||
      rqrcode_core (~> 1.0)
 | 
			
		||||
    rqrcode_core (1.2.0)
 | 
			
		||||
    rspec-core (3.13.3)
 | 
			
		||||
      rspec-support (~> 3.13.0)
 | 
			
		||||
    rspec-expectations (3.13.3)
 | 
			
		||||
    rspec-core (3.12.2)
 | 
			
		||||
      rspec-support (~> 3.12.0)
 | 
			
		||||
    rspec-expectations (3.12.3)
 | 
			
		||||
      diff-lcs (>= 1.2.0, < 2.0)
 | 
			
		||||
      rspec-support (~> 3.13.0)
 | 
			
		||||
    rspec-mocks (3.13.2)
 | 
			
		||||
      rspec-support (~> 3.12.0)
 | 
			
		||||
    rspec-mocks (3.12.6)
 | 
			
		||||
      diff-lcs (>= 1.2.0, < 2.0)
 | 
			
		||||
      rspec-support (~> 3.13.0)
 | 
			
		||||
    rspec-rails (7.1.1)
 | 
			
		||||
      actionpack (>= 7.0)
 | 
			
		||||
      activesupport (>= 7.0)
 | 
			
		||||
      railties (>= 7.0)
 | 
			
		||||
      rspec-core (~> 3.13)
 | 
			
		||||
      rspec-expectations (~> 3.13)
 | 
			
		||||
      rspec-mocks (~> 3.13)
 | 
			
		||||
      rspec-support (~> 3.13)
 | 
			
		||||
    rspec-support (3.13.2)
 | 
			
		||||
    rubocop (1.75.3)
 | 
			
		||||
      rspec-support (~> 3.12.0)
 | 
			
		||||
    rspec-rails (6.1.1)
 | 
			
		||||
      actionpack (>= 6.1)
 | 
			
		||||
      activesupport (>= 6.1)
 | 
			
		||||
      railties (>= 6.1)
 | 
			
		||||
      rspec-core (~> 3.12)
 | 
			
		||||
      rspec-expectations (~> 3.12)
 | 
			
		||||
      rspec-mocks (~> 3.12)
 | 
			
		||||
      rspec-support (~> 3.12)
 | 
			
		||||
    rspec-support (3.12.1)
 | 
			
		||||
    rubocop (1.60.2)
 | 
			
		||||
      json (~> 2.3)
 | 
			
		||||
      language_server-protocol (~> 3.17.0.2)
 | 
			
		||||
      lint_roller (~> 1.1.0)
 | 
			
		||||
      language_server-protocol (>= 3.17.0)
 | 
			
		||||
      parallel (~> 1.10)
 | 
			
		||||
      parser (>= 3.3.0.2)
 | 
			
		||||
      rainbow (>= 2.2.2, < 4.0)
 | 
			
		||||
      regexp_parser (>= 2.9.3, < 3.0)
 | 
			
		||||
      rubocop-ast (>= 1.44.0, < 2.0)
 | 
			
		||||
      regexp_parser (>= 1.8, < 3.0)
 | 
			
		||||
      rexml (>= 3.2.5, < 4.0)
 | 
			
		||||
      rubocop-ast (>= 1.30.0, < 2.0)
 | 
			
		||||
      ruby-progressbar (~> 1.7)
 | 
			
		||||
      unicode-display_width (>= 2.4.0, < 4.0)
 | 
			
		||||
    rubocop-ast (1.44.1)
 | 
			
		||||
      parser (>= 3.3.7.2)
 | 
			
		||||
      prism (~> 1.4)
 | 
			
		||||
      unicode-display_width (>= 2.4.0, < 3.0)
 | 
			
		||||
    rubocop-ast (1.30.0)
 | 
			
		||||
      parser (>= 3.2.1.0)
 | 
			
		||||
    ruby-progressbar (1.13.0)
 | 
			
		||||
    ruby-vips (2.2.3)
 | 
			
		||||
    ruby-vips (2.2.0)
 | 
			
		||||
      ffi (~> 1.12)
 | 
			
		||||
      logger
 | 
			
		||||
    sanitize (7.0.0)
 | 
			
		||||
    ruby2_keywords (0.0.5)
 | 
			
		||||
    rufus-scheduler (3.9.1)
 | 
			
		||||
      fugit (~> 1.1, >= 1.1.6)
 | 
			
		||||
    sanitize (6.1.0)
 | 
			
		||||
      crass (~> 1.0.2)
 | 
			
		||||
      nokogiri (>= 1.16.8)
 | 
			
		||||
    securerandom (0.4.1)
 | 
			
		||||
    sentry-rails (5.23.0)
 | 
			
		||||
      nokogiri (>= 1.12.0)
 | 
			
		||||
    sentry-rails (5.16.1)
 | 
			
		||||
      railties (>= 5.0)
 | 
			
		||||
      sentry-ruby (~> 5.23.0)
 | 
			
		||||
    sentry-ruby (5.23.0)
 | 
			
		||||
      bigdecimal
 | 
			
		||||
      sentry-ruby (~> 5.16.1)
 | 
			
		||||
    sentry-ruby (5.16.1)
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
			
		||||
    solargraph (0.54.2)
 | 
			
		||||
    sidekiq (6.5.12)
 | 
			
		||||
      connection_pool (>= 2.2.5, < 3)
 | 
			
		||||
      rack (~> 2.0)
 | 
			
		||||
      redis (>= 4.5.0, < 5)
 | 
			
		||||
    sidekiq-scheduler (5.0.3)
 | 
			
		||||
      rufus-scheduler (~> 3.2)
 | 
			
		||||
      sidekiq (>= 6, < 8)
 | 
			
		||||
      tilt (>= 1.4.0)
 | 
			
		||||
    solargraph (0.50.0)
 | 
			
		||||
      backport (~> 1.2)
 | 
			
		||||
      benchmark (~> 0.4)
 | 
			
		||||
      benchmark
 | 
			
		||||
      bundler (~> 2.0)
 | 
			
		||||
      diff-lcs (~> 1.4)
 | 
			
		||||
      jaro_winkler (~> 1.6)
 | 
			
		||||
      e2mmap
 | 
			
		||||
      jaro_winkler (~> 1.5)
 | 
			
		||||
      kramdown (~> 2.3)
 | 
			
		||||
      kramdown-parser-gfm (~> 1.1)
 | 
			
		||||
      logger (~> 1.6)
 | 
			
		||||
      observer (~> 0.1)
 | 
			
		||||
      ostruct (~> 0.6)
 | 
			
		||||
      parser (~> 3.0)
 | 
			
		||||
      rbs (~> 3.3)
 | 
			
		||||
      reverse_markdown (~> 3.0)
 | 
			
		||||
      rbs (~> 2.0)
 | 
			
		||||
      reverse_markdown (~> 2.0)
 | 
			
		||||
      rubocop (~> 1.38)
 | 
			
		||||
      thor (~> 1.0)
 | 
			
		||||
      tilt (~> 2.0)
 | 
			
		||||
      yard (~> 0.9, >= 0.9.24)
 | 
			
		||||
      yard-solargraph (~> 0.1)
 | 
			
		||||
    solid_queue (1.1.5)
 | 
			
		||||
      activejob (>= 7.1)
 | 
			
		||||
      activerecord (>= 7.1)
 | 
			
		||||
      concurrent-ruby (>= 1.3.1)
 | 
			
		||||
      fugit (~> 1.11.0)
 | 
			
		||||
      railties (>= 7.1)
 | 
			
		||||
      thor (~> 1.3.1)
 | 
			
		||||
    sqlite3 (2.6.0)
 | 
			
		||||
    sprockets (4.2.1)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
      rack (>= 2.2.4, < 4)
 | 
			
		||||
    sprockets-rails (3.4.2)
 | 
			
		||||
      actionpack (>= 5.2)
 | 
			
		||||
      activesupport (>= 5.2)
 | 
			
		||||
      sprockets (>= 3.0.0)
 | 
			
		||||
    sqlite3 (1.7.2)
 | 
			
		||||
      mini_portile2 (~> 2.8.0)
 | 
			
		||||
    sqlite3 (2.6.0-arm64-darwin)
 | 
			
		||||
    sqlite3 (2.6.0-x86_64-linux-gnu)
 | 
			
		||||
    stimulus-rails (1.3.4)
 | 
			
		||||
    sqlite3 (1.7.2-arm64-darwin)
 | 
			
		||||
    sqlite3 (1.7.2-x86_64-linux)
 | 
			
		||||
    stimulus-rails (1.3.3)
 | 
			
		||||
      railties (>= 6.0.0)
 | 
			
		||||
    stringio (3.1.0)
 | 
			
		||||
    thor (1.3.0)
 | 
			
		||||
    tilt (2.3.0)
 | 
			
		||||
    timeout (0.4.1)
 | 
			
		||||
    turbo-rails (1.5.0)
 | 
			
		||||
      actionpack (>= 6.0.0)
 | 
			
		||||
      activejob (>= 6.0.0)
 | 
			
		||||
      railties (>= 6.0.0)
 | 
			
		||||
    stringio (3.1.7)
 | 
			
		||||
    thor (1.3.2)
 | 
			
		||||
    tilt (2.6.0)
 | 
			
		||||
    timeout (0.4.3)
 | 
			
		||||
    turbo-rails (2.0.13)
 | 
			
		||||
      actionpack (>= 7.1.0)
 | 
			
		||||
      railties (>= 7.1.0)
 | 
			
		||||
    tzinfo (2.0.6)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
    unicode-display_width (3.1.4)
 | 
			
		||||
      unicode-emoji (~> 4.0, >= 4.0.4)
 | 
			
		||||
    unicode-emoji (4.0.4)
 | 
			
		||||
    uri (1.0.3)
 | 
			
		||||
    useragent (0.16.11)
 | 
			
		||||
    view_component (3.22.0)
 | 
			
		||||
      activesupport (>= 5.2.0, < 8.1)
 | 
			
		||||
      concurrent-ruby (= 1.3.4)
 | 
			
		||||
    unicode-display_width (2.5.0)
 | 
			
		||||
    uri (0.13.0)
 | 
			
		||||
    view_component (3.10.0)
 | 
			
		||||
      activesupport (>= 5.2.0, < 8.0)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
      method_source (~> 1.0)
 | 
			
		||||
    warden (1.2.9)
 | 
			
		||||
      rack (>= 2.0.9)
 | 
			
		||||
@ -505,22 +472,18 @@ GEM
 | 
			
		||||
      activemodel (>= 6.0.0)
 | 
			
		||||
      bindex (>= 0.4.0)
 | 
			
		||||
      railties (>= 6.0.0)
 | 
			
		||||
    webmock (3.25.1)
 | 
			
		||||
    webmock (3.19.1)
 | 
			
		||||
      addressable (>= 2.8.0)
 | 
			
		||||
      crack (>= 0.3.2)
 | 
			
		||||
      hashdiff (>= 0.4.0, < 2.0.0)
 | 
			
		||||
    webrick (1.9.1)
 | 
			
		||||
    websocket-driver (0.7.7)
 | 
			
		||||
      base64
 | 
			
		||||
    webrick (1.8.1)
 | 
			
		||||
    websocket-driver (0.7.6)
 | 
			
		||||
      websocket-extensions (>= 0.1.0)
 | 
			
		||||
    websocket-extensions (0.1.5)
 | 
			
		||||
    xpath (3.2.0)
 | 
			
		||||
      nokogiri (~> 1.8)
 | 
			
		||||
    yard (0.9.37)
 | 
			
		||||
    yard-solargraph (0.1.0)
 | 
			
		||||
      yard (~> 0.9)
 | 
			
		||||
    zbase32 (0.1.1)
 | 
			
		||||
    zeitwerk (2.7.2)
 | 
			
		||||
    yard (0.9.34)
 | 
			
		||||
    zeitwerk (2.6.12)
 | 
			
		||||
 | 
			
		||||
PLATFORMS
 | 
			
		||||
  arm64-darwin-22
 | 
			
		||||
@ -528,7 +491,6 @@ PLATFORMS
 | 
			
		||||
  x86_64-linux
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES
 | 
			
		||||
  aasm
 | 
			
		||||
  aws-sdk-s3
 | 
			
		||||
  bcrypt (~> 3.1)
 | 
			
		||||
  capybara
 | 
			
		||||
@ -545,34 +507,32 @@ DEPENDENCIES
 | 
			
		||||
  flipper
 | 
			
		||||
  flipper-active_record
 | 
			
		||||
  flipper-ui
 | 
			
		||||
  gpgme (~> 2.0.24)
 | 
			
		||||
  image_processing (~> 1.12.2)
 | 
			
		||||
  importmap-rails
 | 
			
		||||
  jbuilder (~> 2.7)
 | 
			
		||||
  kramdown
 | 
			
		||||
  letter_opener
 | 
			
		||||
  letter_opener_web
 | 
			
		||||
  listen (~> 3.2)
 | 
			
		||||
  lnurl
 | 
			
		||||
  lockbox
 | 
			
		||||
  manifique (~> 1.1.0)
 | 
			
		||||
  mission_control-jobs
 | 
			
		||||
  net-ldap
 | 
			
		||||
  nostr (~> 0.6.0)
 | 
			
		||||
  pagy (~> 6.0, >= 6.0.2)
 | 
			
		||||
  pg (~> 1.5)
 | 
			
		||||
  propshaft
 | 
			
		||||
  puma (~> 6.6)
 | 
			
		||||
  rails (~> 8.0)
 | 
			
		||||
  puma (~> 4.1)
 | 
			
		||||
  rails (~> 7.1)
 | 
			
		||||
  rails-controller-testing
 | 
			
		||||
  rails-settings-cached (~> 2.8.3)
 | 
			
		||||
  redis (~> 5.4)
 | 
			
		||||
  rqrcode (~> 2.0)
 | 
			
		||||
  rspec-rails
 | 
			
		||||
  sentry-rails
 | 
			
		||||
  sentry-ruby
 | 
			
		||||
  sidekiq (< 7)
 | 
			
		||||
  sidekiq-scheduler
 | 
			
		||||
  solargraph
 | 
			
		||||
  solid_queue
 | 
			
		||||
  sqlite3 (>= 2.1)
 | 
			
		||||
  sprockets-rails
 | 
			
		||||
  sqlite3 (~> 1.7.2)
 | 
			
		||||
  stimulus-rails
 | 
			
		||||
  turbo-rails
 | 
			
		||||
  tzinfo-data
 | 
			
		||||
@ -580,7 +540,6 @@ DEPENDENCIES
 | 
			
		||||
  warden
 | 
			
		||||
  web-console (~> 4.2)
 | 
			
		||||
  webmock
 | 
			
		||||
  zbase32 (~> 0.1.1)
 | 
			
		||||
 | 
			
		||||
BUNDLED WITH
 | 
			
		||||
   2.5.5
 | 
			
		||||
 | 
			
		||||
@ -57,7 +57,7 @@ Running the test suite:
 | 
			
		||||
Running the test suite with Docker Compose requires overriding the Rails
 | 
			
		||||
environment:
 | 
			
		||||
 | 
			
		||||
    docker-compose exec -e "RAILS_ENV=test" web rspec
 | 
			
		||||
    docker-compose run -e "RAILS_ENV=test" web rspec
 | 
			
		||||
 | 
			
		||||
### Docker Compose
 | 
			
		||||
 | 
			
		||||
@ -128,7 +128,6 @@ command:
 | 
			
		||||
 | 
			
		||||
### Front-end
 | 
			
		||||
 | 
			
		||||
* [Icons](https://feathericons.com)
 | 
			
		||||
* [Tailwind CSS](https://tailwindcss.com/)
 | 
			
		||||
* [Sass](https://sass-lang.com/documentation)
 | 
			
		||||
* [Stimulus](https://stimulus.hotwired.dev/handbook/)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								app/assets/config/manifest.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/assets/config/manifest.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
//= link_tree ../images
 | 
			
		||||
//= link_tree ../../javascript .js
 | 
			
		||||
//= link_tree ../builds
 | 
			
		||||
//= link_tree ../../../vendor/javascript .js
 | 
			
		||||
@ -7,6 +7,7 @@
 | 
			
		||||
@import "components/buttons";
 | 
			
		||||
@import "components/dashboard_services";
 | 
			
		||||
@import "components/forms";
 | 
			
		||||
@import "components/links";
 | 
			
		||||
@import "components/notifications";
 | 
			
		||||
@import "components/pagination";
 | 
			
		||||
@import "components/tables";
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,6 @@
 | 
			
		||||
  body {
 | 
			
		||||
    @apply leading-none bg-cover bg-fixed;
 | 
			
		||||
    background-image: linear-gradient(35deg, rgba(255,0,255,0.2) 0, rgba(13,79,153,0.8) 100%), url('/img/bg-1.jpg');
 | 
			
		||||
    color: black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  body#admin {
 | 
			
		||||
@ -33,10 +32,6 @@
 | 
			
		||||
    @apply pt-8 sm:pt-12;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  main section h3:not(:first-child) {
 | 
			
		||||
    @apply mt-8;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  main section:first-of-type {
 | 
			
		||||
    @apply pt-0;
 | 
			
		||||
  }
 | 
			
		||||
@ -60,11 +55,4 @@
 | 
			
		||||
  main ul li {
 | 
			
		||||
    @apply leading-6;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  main a:not(nav > *) {
 | 
			
		||||
    @apply text-blue-600;
 | 
			
		||||
    &:hover   { @apply underline; }
 | 
			
		||||
    &:visited { @apply text-indigo-600; }
 | 
			
		||||
    &:active  { @apply text-red-600; }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,5 @@
 | 
			
		||||
@layer components {
 | 
			
		||||
  .btn-text-dark          { @apply text-black; }
 | 
			
		||||
  .btn-text-dark:hover    { @apply text-black no-underline; }
 | 
			
		||||
  .btn-text-dark:visited  { @apply text-black; }
 | 
			
		||||
  .btn-text-dark:active   { @apply text-black; }
 | 
			
		||||
  .btn-text-light         { @apply text-white; }
 | 
			
		||||
  .btn-text-light:hover   { @apply text-white no-underline; }
 | 
			
		||||
  .btn-text-light:visited { @apply text-white; }
 | 
			
		||||
  .btn-text-light:active  { @apply text-white; }
 | 
			
		||||
 | 
			
		||||
  .btn {
 | 
			
		||||
    @apply btn-text-dark;
 | 
			
		||||
    @apply inline-block font-semibold rounded-md leading-none cursor-pointer text-center
 | 
			
		||||
           transition-colors duration-75 focus:outline-none focus:ring-4;
 | 
			
		||||
  }
 | 
			
		||||
@ -38,20 +28,17 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn-blue {
 | 
			
		||||
    @apply btn-text-light;
 | 
			
		||||
    @apply bg-blue-500 hover:bg-blue-600
 | 
			
		||||
    @apply bg-blue-500 hover:bg-blue-600 text-white
 | 
			
		||||
           focus:ring-blue-400 focus:ring-opacity-75;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn-emerald {
 | 
			
		||||
    @apply btn-text-light;
 | 
			
		||||
    @apply bg-emerald-500 hover:bg-emerald-600
 | 
			
		||||
    @apply bg-emerald-500 hover:bg-emerald-600 text-white
 | 
			
		||||
           focus:ring-emerald-400 focus:ring-opacity-75;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn-red {
 | 
			
		||||
    @apply btn-text-light;
 | 
			
		||||
    @apply bg-red-600 hover:bg-red-700
 | 
			
		||||
    @apply bg-red-600 hover:bg-red-700 text-white
 | 
			
		||||
           focus:ring-red-500 focus:ring-opacity-75;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								app/assets/stylesheets/components/links.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/assets/stylesheets/components/links.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
@layer components {
 | 
			
		||||
  .ks-text-link {
 | 
			
		||||
    @apply text-blue-600;
 | 
			
		||||
    &:hover   { @apply underline; }
 | 
			
		||||
    &:visited { @apply text-indigo-600; }
 | 
			
		||||
    &:active  { @apply text-red-600; }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -34,7 +34,7 @@
 | 
			
		||||
  .pagy-nav .page a, .page.gap {
 | 
			
		||||
    @apply bg-white border-gray-300 text-gray-500 hover:bg-gray-100 relative
 | 
			
		||||
           inline-flex items-center border px-4 py-2 text-sm font-medium
 | 
			
		||||
           no-underline focus:z-20;
 | 
			
		||||
           focus:z-20;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .pagy-nav .page.active {
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,6 @@
 | 
			
		||||
 | 
			
		||||
module AppCatalog
 | 
			
		||||
  class WebAppIconComponent < ViewComponent::Base
 | 
			
		||||
    include ApplicationHelper
 | 
			
		||||
 | 
			
		||||
    def initialize(web_app:)
 | 
			
		||||
      if web_app&.icon&.attached?
 | 
			
		||||
        @image_url = image_url_for(web_app.icon)
 | 
			
		||||
@ -11,5 +9,13 @@ module AppCatalog
 | 
			
		||||
        @image_url = image_url_for(web_app.apple_touch_icon)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def image_url_for(attachment)
 | 
			
		||||
      if Setting.s3_enabled?
 | 
			
		||||
        s3_image_url(attachment)
 | 
			
		||||
      else
 | 
			
		||||
        Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<%= link_to @href, class: @class, target: @target, data: {
 | 
			
		||||
<%= link_to @href, class: @class, data: {
 | 
			
		||||
      'dropdown-target': "menuItem",
 | 
			
		||||
      'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
 | 
			
		||||
    } do %>
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,8 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class DropdownLinkComponent < ViewComponent::Base
 | 
			
		||||
  def initialize(href:, open_in_new_tab: false, separator: false, add_class: nil)
 | 
			
		||||
  def initialize(href:, separator: false, add_class: nil)
 | 
			
		||||
    @href = href
 | 
			
		||||
    @target = open_in_new_tab ? "_blank" : nil
 | 
			
		||||
    @class = class_str(separator, add_class)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,30 +0,0 @@
 | 
			
		||||
<div class="inline-block text-left" data-controller="modal" data-action="keydown.esc->modal#close">
 | 
			
		||||
  <button class="btn-md btn-outline text-red-600" data-action="click->modal#open" title="Edit">
 | 
			
		||||
    <%= content || "Edit" %>
 | 
			
		||||
  </button>
 | 
			
		||||
 | 
			
		||||
  <%= render ModalComponent.new(show_close_button: false) do %>
 | 
			
		||||
    <%= form_with model: [:admin, @editable_content],
 | 
			
		||||
                  html: { autocomplete: "off" } do |form| %>
 | 
			
		||||
      <%= form.hidden_field :redirect_to, value: @redirect_to  %>
 | 
			
		||||
      <p class="mb-2">
 | 
			
		||||
        <%= form.label :content, @editable_content.key.capitalize, class: 'font-bold' %>
 | 
			
		||||
      </p>
 | 
			
		||||
      <% if @editable_content.rich_text %>
 | 
			
		||||
        <p>
 | 
			
		||||
          <%= form.textarea :content, class: "md:w-[56rem] md:h-[28rem]" %>
 | 
			
		||||
        </p>
 | 
			
		||||
        <p class="text-right">
 | 
			
		||||
          <%= form.submit "Save", class: "ml-2 btn-md btn-blue" %>
 | 
			
		||||
        </p>
 | 
			
		||||
      <% else %>
 | 
			
		||||
        <p class="">
 | 
			
		||||
          <%= form.text_field :content, class: "w-80" %>
 | 
			
		||||
        </p>
 | 
			
		||||
        <p>
 | 
			
		||||
          <%= form.submit "Save", class: "btn-md btn-blue w-full" %>
 | 
			
		||||
        </p>
 | 
			
		||||
      <% end %>
 | 
			
		||||
    <% end %>
 | 
			
		||||
  <% end %>
 | 
			
		||||
</div>
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
class EditContentButtonComponent < ViewComponent::Base
 | 
			
		||||
  def initialize(context:, key:, rich_text: false, redirect_to: nil)
 | 
			
		||||
    @editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
 | 
			
		||||
    @redirect_to = redirect_to
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,9 +0,0 @@
 | 
			
		||||
<% if @editable_content.has_content? %>
 | 
			
		||||
  <% if @editable_content.rich_text %>
 | 
			
		||||
    <%= helpers.markdown_to_html @editable_content.content %>
 | 
			
		||||
  <% else %>
 | 
			
		||||
    <%= @editable_content.content %>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% else %>
 | 
			
		||||
  <%= @default %>
 | 
			
		||||
<% end %>
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
class EditableContentComponent < ViewComponent::Base
 | 
			
		||||
  def initialize(context:, key:, rich_text: false, default: nil)
 | 
			
		||||
    @editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
 | 
			
		||||
    @default = default
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<main class="w-full max-w-xl mx-auto px-4 sm:px-6 lg:px-8">
 | 
			
		||||
<main class="w-full max-w-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
 | 
			
		||||
  <div class="bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
 | 
			
		||||
    <%= content %>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
 | 
			
		||||
  <div class="md:min-h-[50vh] bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
 | 
			
		||||
    <%= content %>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
 | 
			
		||||
  <div class="bg-white rounded-lg shadow">
 | 
			
		||||
    <div class="md:min-h-[50vh] divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
 | 
			
		||||
      <aside class="py-6 sm:py-8 lg:col-span-3">
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
 | 
			
		||||
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
 | 
			
		||||
  <div class="md:min-h-[50vh] bg-white rounded-lg shadow">
 | 
			
		||||
    <div class="px-6 sm:px-12 pt-2 sm:pt-4">
 | 
			
		||||
      <%= render partial: @tabnav_partial %>
 | 
			
		||||
 | 
			
		||||
@ -12,8 +12,7 @@
 | 
			
		||||
  </div>
 | 
			
		||||
  <%= render DropdownComponent.new do %>
 | 
			
		||||
    <%= render DropdownLinkComponent.new(
 | 
			
		||||
          href: launch_app_services_storage_rs_auth_url(@auth),
 | 
			
		||||
          open_in_new_tab: true
 | 
			
		||||
          href: launch_app_services_storage_rs_auth_url(@auth)
 | 
			
		||||
        ) do %>
 | 
			
		||||
      Launch app
 | 
			
		||||
    <% end %>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,44 @@
 | 
			
		||||
<div class="w-[72vw] md:w-[500px]">
 | 
			
		||||
  <header class="absolute z-10 h-36 sm:h-44 inset-x-1 top-1 rounded-t
 | 
			
		||||
                 bg-cover bg-center bg-gray-50"
 | 
			
		||||
          style="background-image: url('<%= @profile["banner"]%>');">
 | 
			
		||||
    <div class="inline-block z-20 size-28 sm:size-32 ml-4 mt-16 sm:mt-20">
 | 
			
		||||
    <% if @profile["picture"].present? %>
 | 
			
		||||
      <img src="<%= @profile["picture"] %>"
 | 
			
		||||
           class="inline-block size:28 sm:size-32 rounded-full border-2 border-white" />
 | 
			
		||||
    <% else %>
 | 
			
		||||
      <span class="inline-block size:28 sm:size-32 overflow-hidden rounded-full border-2 border-white bg-gray-100">
 | 
			
		||||
        <svg class="size-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
 | 
			
		||||
          <path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
 | 
			
		||||
        </svg>
 | 
			
		||||
      </span>
 | 
			
		||||
    <% end %>
 | 
			
		||||
    </div>
 | 
			
		||||
  </header>
 | 
			
		||||
  <main class="mt-44 sm:mt-52">
 | 
			
		||||
    <%= form_for(@user, url: setting_path(:nostr), html: { :method => :put }) do |f| %>
 | 
			
		||||
      <%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %>
 | 
			
		||||
        <%= f.text_field :display_name, value: @display_name, class: "w-full sm:w-3/5" %>
 | 
			
		||||
        <% if @validation_errors.present? && @validation_errors[:display_name].present? %>
 | 
			
		||||
          <p class="error-msg mt-2"><%= @validation_errors[:display_name].first %></p>
 | 
			
		||||
        <% end %>
 | 
			
		||||
      <% end %>
 | 
			
		||||
      <%= render FormElements::FieldsetComponent.new(tag: "div", title: "Nostr address (NIP-05)") do %>
 | 
			
		||||
        <%= f.text_field :nip05_address, value: @profile["nip05"], class: "w-full sm:w-3/5" %>
 | 
			
		||||
        <% if @validation_errors.present? && @validation_errors[:nip05_address].present? %>
 | 
			
		||||
          <p class="error-msg mt-2"><%= @validation_errors[:nip05_address].first %></p>
 | 
			
		||||
        <% end %>
 | 
			
		||||
      <% end %>
 | 
			
		||||
      <%= render FormElements::FieldsetComponent.new(tag: "div", title: "Ligtning address for Zaps") do %>
 | 
			
		||||
        <%= f.text_field :lud16_address, value: @profile["lud16"], class: "w-full sm:w-3/5" %>
 | 
			
		||||
        <% if @validation_errors.present? && @validation_errors[:lud16_address].present? %>
 | 
			
		||||
          <p class="error-msg mt-2"><%= @validation_errors[:lud16_address].first %></p>
 | 
			
		||||
        <% end %>
 | 
			
		||||
      <% end %>
 | 
			
		||||
    <% end %>
 | 
			
		||||
  </main>
 | 
			
		||||
  <footer>
 | 
			
		||||
    <%# <%= @profile.inspect %>
 | 
			
		||||
    <%# <%= @profile_event.inspect %>
 | 
			
		||||
  </footer>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										28
									
								
								app/components/settings/nostr_edit_profile_component.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/components/settings/nostr_edit_profile_component.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Settings
 | 
			
		||||
  class NostrEditProfileComponent < ViewComponent::Base
 | 
			
		||||
    def initialize(user:, profile_event:)
 | 
			
		||||
      if profile_event.present?
 | 
			
		||||
        @user = user
 | 
			
		||||
        @profile_event = profile_event
 | 
			
		||||
        @profile = JSON.parse(profile_event["content"])
 | 
			
		||||
        @display_name = @profile["display_name"] || @profile["displayName"]
 | 
			
		||||
 | 
			
		||||
        if @profile["nip05"].present? && @profile["nip05"] == @user.address
 | 
			
		||||
          # "Your profile's Nostr address is set to <strong>#{ user_address }</strong>"
 | 
			
		||||
        else
 | 
			
		||||
          # "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if @profile["lud16"].present? && @profile["lud16"] == @user.address
 | 
			
		||||
          # "Your profile's Lightning address is set to <strong>#{ user_address }</strong>"
 | 
			
		||||
        else
 | 
			
		||||
          # "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet"
 | 
			
		||||
        end
 | 
			
		||||
      else
 | 
			
		||||
        # "We could not find a profile for your public key"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -0,0 +1,21 @@
 | 
			
		||||
<% @statuses.each do |status| %>
 | 
			
		||||
  <%= render StatusTextComponent.new(
 | 
			
		||||
    text: status[:text],
 | 
			
		||||
    icon_name: status[:icon_name],
 | 
			
		||||
    icon_color: status[:icon_color]
 | 
			
		||||
  ) %>
 | 
			
		||||
<% end %>
 | 
			
		||||
 | 
			
		||||
<% if @status == 1 %>
 | 
			
		||||
  <p class="mt-8">
 | 
			
		||||
    <button class="btn-md btn-blue">
 | 
			
		||||
      Edit my profile
 | 
			
		||||
    </button>
 | 
			
		||||
  </p>
 | 
			
		||||
<% elsif @status == 2 %>
 | 
			
		||||
  <p class="mt-8">
 | 
			
		||||
    <button class="btn-md btn-blue">
 | 
			
		||||
      Create my profile
 | 
			
		||||
    </button>
 | 
			
		||||
  </p>
 | 
			
		||||
<% end %>
 | 
			
		||||
							
								
								
									
										53
									
								
								app/components/settings/nostr_profile_status_component.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/components/settings/nostr_profile_status_component.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,53 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Settings
 | 
			
		||||
  class NostrProfileStatusComponent < ViewComponent::Base
 | 
			
		||||
    def initialize(profile_event:, user_address:)
 | 
			
		||||
      @statuses = []
 | 
			
		||||
 | 
			
		||||
      if profile_event.present?
 | 
			
		||||
        profile = JSON.parse(profile_event["content"])
 | 
			
		||||
 | 
			
		||||
        @statuses.push({
 | 
			
		||||
          text: "You have a public Nostr profile",
 | 
			
		||||
          icon_name: "check-circle",
 | 
			
		||||
          icon_color: "emerald-500"
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        if profile["nip05"].present? && profile["nip05"] == user_address
 | 
			
		||||
          @statuses.push({
 | 
			
		||||
            text: "Your profile's Nostr address is set to <strong>#{ user_address }</strong>",
 | 
			
		||||
            icon_name: "check-circle",
 | 
			
		||||
            icon_color: "emerald-500"
 | 
			
		||||
          })
 | 
			
		||||
        else
 | 
			
		||||
          @statuses.push({
 | 
			
		||||
            text: "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet",
 | 
			
		||||
            icon_name: "alert-octagon",
 | 
			
		||||
            icon_color: "amber-500"
 | 
			
		||||
          })
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if profile["lud16"].present? && profile["lud16"] == user_address
 | 
			
		||||
          @statuses.push({
 | 
			
		||||
            text: "Your profile's Lightning address is set to <strong>#{ user_address }</strong>",
 | 
			
		||||
            icon_name: "check-circle",
 | 
			
		||||
            icon_color: "emerald-500"
 | 
			
		||||
          })
 | 
			
		||||
        else
 | 
			
		||||
          @statuses.push({
 | 
			
		||||
            text: "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet",
 | 
			
		||||
            icon_name: "alert-octagon",
 | 
			
		||||
            icon_color: "amber-500"
 | 
			
		||||
          })
 | 
			
		||||
        end
 | 
			
		||||
      else
 | 
			
		||||
        @statuses.push({
 | 
			
		||||
          text: "We could not find a profile for your public key",
 | 
			
		||||
          icon_name: "alert-octagon",
 | 
			
		||||
          icon_color: "amber-500"
 | 
			
		||||
        })
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -0,0 +1,18 @@
 | 
			
		||||
<%= render StatusTextComponent.new(
 | 
			
		||||
  text: @text,
 | 
			
		||||
  icon_name: @icon_name,
 | 
			
		||||
  icon_color: @icon_color) %>
 | 
			
		||||
 | 
			
		||||
<% if @status == 1 %>
 | 
			
		||||
  <p class="mt-8">
 | 
			
		||||
    <button class="btn-md btn-blue">
 | 
			
		||||
      Add the relay to my list
 | 
			
		||||
    </button>
 | 
			
		||||
  </p>
 | 
			
		||||
<% elsif @status == 2 %>
 | 
			
		||||
  <p class="mt-8">
 | 
			
		||||
    <button class="btn-md btn-blue">
 | 
			
		||||
      Set up default relays
 | 
			
		||||
    </button>
 | 
			
		||||
  </p>
 | 
			
		||||
<% end %>
 | 
			
		||||
							
								
								
									
										34
									
								
								app/components/settings/nostr_relay_status_component.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/components/settings/nostr_relay_status_component.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Settings
 | 
			
		||||
  class NostrRelayStatusComponent < ViewComponent::Base
 | 
			
		||||
    def initialize(nip65_event:)
 | 
			
		||||
      if nip65_event.present?
 | 
			
		||||
        if relay_urls(nip65_event).any? { |r| r.include?("wss://nostr.kosmos.org") }
 | 
			
		||||
          @text = "You have a relay list, and the Kosmos relay is part of it"
 | 
			
		||||
          @icon_name = "check-circle"
 | 
			
		||||
          @icon_color = "emerald-500"
 | 
			
		||||
          @status = 0
 | 
			
		||||
        else
 | 
			
		||||
          @text = "The Kosmos relay is missing from your relay list"
 | 
			
		||||
          @icon_name = "alert-octagon"
 | 
			
		||||
          @icon_color = "amber-500"
 | 
			
		||||
          @status = 1
 | 
			
		||||
        end
 | 
			
		||||
      else
 | 
			
		||||
        @text = "We could not find a relay list for your public key"
 | 
			
		||||
        @icon_name = "alert-octagon"
 | 
			
		||||
        @icon_color = "amber-500"
 | 
			
		||||
        @status = 2
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def relay_urls(nip65_event)
 | 
			
		||||
      nip65_event["tags"].select{ |t| t[0] == "r" }.map{ |t| t[1] }
 | 
			
		||||
      # @inbox_relay_urls  = relay_tags&.select{ |t| t[2] == "read" }&.map{ |t| t[1] }
 | 
			
		||||
      # @outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base
 | 
			
		||||
 | 
			
		||||
  def class_names_icon(path)
 | 
			
		||||
    if @active
 | 
			
		||||
      "text-teal-600 group-hover:text-teal-600 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
 | 
			
		||||
      "text-teal-500 group-hover:text-teal-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
 | 
			
		||||
    elsif @disabled
 | 
			
		||||
      "text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
 | 
			
		||||
    else
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								app/components/status_text_component.html.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/components/status_text_component.html.erb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
<p class="flex gap-x-4 items-center">
 | 
			
		||||
  <span class="inline-block h-6 w-6 grow-0 text-<%= @icon_color %>">
 | 
			
		||||
    <%= render "icons/#{@icon_name}" %>
 | 
			
		||||
  </span>
 | 
			
		||||
  <span>
 | 
			
		||||
    <%= raw @text %>
 | 
			
		||||
  </span>
 | 
			
		||||
</p>
 | 
			
		||||
							
								
								
									
										7
									
								
								app/components/status_text_component.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/components/status_text_component.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
class StatusTextComponent < ViewComponent::Base
 | 
			
		||||
  def initialize(text:, icon_name:, icon_color:)
 | 
			
		||||
    @text = text
 | 
			
		||||
    @icon_name = icon_name
 | 
			
		||||
    @icon_color = icon_color
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -4,22 +4,11 @@ class Admin::DonationsController < Admin::BaseController
 | 
			
		||||
 | 
			
		||||
  # GET /donations
 | 
			
		||||
  def index
 | 
			
		||||
    @username = params[:username].presence
 | 
			
		||||
 | 
			
		||||
    pending_scope   = Donation.incomplete.joins(:user).order('paid_at desc')
 | 
			
		||||
    completed_scope = Donation.completed.joins(:user).order('paid_at desc')
 | 
			
		||||
 | 
			
		||||
    if @username
 | 
			
		||||
      pending_scope   = pending_scope.where(users: { cn: @username })
 | 
			
		||||
      completed_scope = completed_scope.where(users: { cn: @username })
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @pending_donations = pending_scope
 | 
			
		||||
    @pagy, @donations  = pagy(completed_scope)
 | 
			
		||||
    @pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
 | 
			
		||||
 | 
			
		||||
    @stats = {
 | 
			
		||||
      overall_sats: completed_scope.sum("amount_sats"),
 | 
			
		||||
      donor_count: completed_scope.distinct.count(:user_id)
 | 
			
		||||
      overall_sats: @donations.sum("amount_sats"),
 | 
			
		||||
      donor_count: Donation.completed.count(:user_id)
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,45 +0,0 @@
 | 
			
		||||
class Admin::EditableContentsController < Admin::BaseController
 | 
			
		||||
  before_action :set_content, only: [:show, :edit, :update]
 | 
			
		||||
  before_action :set_current_section, only: [:index, :show, :edit]
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    @path = params[:path].presence
 | 
			
		||||
    scope = EditableContent.order(path: :asc)
 | 
			
		||||
    scope = scope.where(path: @path) if @path
 | 
			
		||||
    @pagy, @contents = pagy(scope)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def show
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def edit
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update
 | 
			
		||||
    return_to = params[:editable_content][:redirect_to].presence
 | 
			
		||||
 | 
			
		||||
    if @editable_content.update(content_params)
 | 
			
		||||
      if return_to
 | 
			
		||||
        redirect_to return_to
 | 
			
		||||
      else
 | 
			
		||||
        render status: :ok
 | 
			
		||||
      end
 | 
			
		||||
    else
 | 
			
		||||
      render :edit, status: :unprocessable_entity
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_content
 | 
			
		||||
    @editable_content = EditableContent.find(params[:id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def content_params
 | 
			
		||||
    params.require(:editable_content).permit(:path, :key, :lang, :content, :rich_text)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def set_current_section
 | 
			
		||||
    @current_section = :content
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,28 +1,12 @@
 | 
			
		||||
class Admin::InvitationsController < Admin::BaseController
 | 
			
		||||
  before_action :set_current_section
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    @username = params[:username].presence
 | 
			
		||||
    accepted_scope = Invitation.used.order('used_at desc')
 | 
			
		||||
    unused_scope = Invitation.unused
 | 
			
		||||
 | 
			
		||||
    if @username
 | 
			
		||||
      accepted_scope = accepted_scope.joins(:user).where(users: { cn: @username })
 | 
			
		||||
      unused_scope = unused_scope.joins(:user).where(users: { cn: @username })
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @pagy, @invitations_used = pagy(accepted_scope)
 | 
			
		||||
    @current_section = :invitations
 | 
			
		||||
    @pagy, @invitations_used = pagy(Invitation.used.order('used_at desc'))
 | 
			
		||||
 | 
			
		||||
    @stats = {
 | 
			
		||||
      available: unused_scope.count,
 | 
			
		||||
      accepted: accepted_scope.count,
 | 
			
		||||
      users_with_referrals: accepted_scope.distinct.count(:user_id)
 | 
			
		||||
      available: Invitation.unused.count,
 | 
			
		||||
      accepted: @invitations_used.length,
 | 
			
		||||
      users_with_referrals: Invitation.used.distinct.count(:user_id)
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def set_current_section
 | 
			
		||||
      @current_section = :invitations
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ class Admin::LightningController < Admin::BaseController
 | 
			
		||||
  def index
 | 
			
		||||
    @current_section = :lightning
 | 
			
		||||
 | 
			
		||||
    @users = User.pluck(:cn, :ou, :lndhub_username)
 | 
			
		||||
    @users = User.pluck(:cn, :ou, :ln_account)
 | 
			
		||||
    @accounts = LndhubAccount.with_balances.order(balance: :desc).to_a
 | 
			
		||||
 | 
			
		||||
    @ln = {}
 | 
			
		||||
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
class Admin::Settings::MembershipController < Admin::SettingsController
 | 
			
		||||
  def show
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update
 | 
			
		||||
    update_settings
 | 
			
		||||
 | 
			
		||||
    redirect_to admin_settings_membership_path, flash: {
 | 
			
		||||
      success: "Settings saved"
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def setting_params
 | 
			
		||||
      params.require(:setting).permit([
 | 
			
		||||
        :member_status_contributor,
 | 
			
		||||
        :member_status_sustainer,
 | 
			
		||||
        :user_index_show_contributors,
 | 
			
		||||
        :user_index_show_sustainers
 | 
			
		||||
      ])
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
@ -4,37 +4,25 @@ class Admin::UsersController < Admin::BaseController
 | 
			
		||||
 | 
			
		||||
  # GET /admin/users
 | 
			
		||||
  def index
 | 
			
		||||
    ldap = LdapService.new
 | 
			
		||||
    ou   = Setting.primary_domain
 | 
			
		||||
    @show_contributors = Setting.user_index_show_contributors
 | 
			
		||||
    @show_sustainers = Setting.user_index_show_sustainers
 | 
			
		||||
 | 
			
		||||
    @contributors = ldap.search_users(:memberStatus, :contributor, :cn) if @show_contributors
 | 
			
		||||
    @sustainers   = ldap.search_users(:memberStatus, :sustainer, :cn) if @show_sustainers
 | 
			
		||||
    @admins       = ldap.search_users(:admin, true, :cn)
 | 
			
		||||
    @pagy, @users = pagy(User.where(ou: ou).order(cn: :asc))
 | 
			
		||||
    ldap   = LdapService.new
 | 
			
		||||
    @ou    = Setting.primary_domain
 | 
			
		||||
    @pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc))
 | 
			
		||||
 | 
			
		||||
    @stats = {
 | 
			
		||||
      users_confirmed: User.where(ou: ou).confirmed.count,
 | 
			
		||||
      users_pending: User.where(ou: ou).pending.count
 | 
			
		||||
      users_confirmed: User.where(ou: @ou).confirmed.count,
 | 
			
		||||
      users_pending: User.where(ou: @ou).pending.count
 | 
			
		||||
    }
 | 
			
		||||
    @stats[:users_contributing] = @contributors.size if @show_contributors
 | 
			
		||||
    @stats[:users_paying] = @sustainers.size if @show_sustainers
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # GET /admin/users/:username
 | 
			
		||||
  def show
 | 
			
		||||
    @invitees = @user.invitees
 | 
			
		||||
    @recent_invitees = @user.invitees.order(created_at: :desc).limit(5)
 | 
			
		||||
    @more_invitees = (@invitees - @recent_invitees).count
 | 
			
		||||
 | 
			
		||||
    if Setting.lndhub_admin_enabled?
 | 
			
		||||
      @lndhub_user = @user.lndhub_user
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @services_enabled = @user.services_enabled
 | 
			
		||||
 | 
			
		||||
    @ldap_avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
 | 
			
		||||
    @avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # POST /admin/users/:username/invitations
 | 
			
		||||
@ -42,7 +30,7 @@ class Admin::UsersController < Admin::BaseController
 | 
			
		||||
    amount = params[:amount].to_i
 | 
			
		||||
    notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user])
 | 
			
		||||
 | 
			
		||||
    UserManager::CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
 | 
			
		||||
    CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
 | 
			
		||||
 | 
			
		||||
    redirect_to admin_user_path(@user.cn), flash: {
 | 
			
		||||
      success: "Added #{amount} invitations to #{@user.cn}'s account"
 | 
			
		||||
 | 
			
		||||
@ -1,27 +0,0 @@
 | 
			
		||||
class AvatarsController < ApplicationController
 | 
			
		||||
  def show
 | 
			
		||||
    if user = User.find_by(cn: params[:username])
 | 
			
		||||
      http_status :not_found and return unless user.avatar.attached?
 | 
			
		||||
 | 
			
		||||
      sha256_hash = params[:hash]
 | 
			
		||||
      format = params[:format]&.to_sym || :png
 | 
			
		||||
      # size = params[:size]&.to_sym || :original
 | 
			
		||||
 | 
			
		||||
      unless user.avatar.filename.to_s == "#{sha256_hash}.#{format}"
 | 
			
		||||
        http_status :not_found and return
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # TODO See note for avatar_variant in user model
 | 
			
		||||
      # blob = if size == :original
 | 
			
		||||
      #          user.avatar.blob
 | 
			
		||||
      #        else
 | 
			
		||||
      #          user.avatar_variant(size: size)&.blob
 | 
			
		||||
      #        end
 | 
			
		||||
 | 
			
		||||
      data = user.avatar.blob.download
 | 
			
		||||
      send_data data, type: "image/#{format}", disposition: "inline"
 | 
			
		||||
    else
 | 
			
		||||
      http_status :not_found
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController
 | 
			
		||||
  def index
 | 
			
		||||
    @current_section = :contributions
 | 
			
		||||
    @donations_completed = current_user.donations.completed.order('paid_at desc')
 | 
			
		||||
    @donations_processing = current_user.donations.processing.order('created_at desc')
 | 
			
		||||
    @donations_pending = current_user.donations.processing.order('created_at desc')
 | 
			
		||||
 | 
			
		||||
    if Setting.lndhub_enabled?
 | 
			
		||||
      begin
 | 
			
		||||
@ -81,11 +81,14 @@ class Contributions::DonationsController < ApplicationController
 | 
			
		||||
 | 
			
		||||
    case invoice["status"]
 | 
			
		||||
    when "Settled"
 | 
			
		||||
      @donation.complete!
 | 
			
		||||
      @donation.paid_at = DateTime.now
 | 
			
		||||
      @donation.payment_status = "settled"
 | 
			
		||||
      @donation.save!
 | 
			
		||||
      flash_message = { success: "Thank you!" }
 | 
			
		||||
    when "Processing"
 | 
			
		||||
      unless @donation.processing?
 | 
			
		||||
        @donation.start_processing!
 | 
			
		||||
        @donation.payment_status = "processing"
 | 
			
		||||
        @donation.save!
 | 
			
		||||
        flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
 | 
			
		||||
        BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
class Contributions::OtherController < ApplicationController
 | 
			
		||||
  before_action :authenticate_user!
 | 
			
		||||
  before_action :set_content_editing
 | 
			
		||||
 | 
			
		||||
  # GET /contributions/other
 | 
			
		||||
  def index
 | 
			
		||||
    @current_section = :contributions
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def set_content_editing
 | 
			
		||||
      return unless params[:edit] && current_user.is_admin?
 | 
			
		||||
      @edit_content = true
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										8
									
								
								app/controllers/contributions/projects_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/controllers/contributions/projects_controller.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
class Contributions::ProjectsController < ApplicationController
 | 
			
		||||
  before_action :authenticate_user!
 | 
			
		||||
 | 
			
		||||
  # GET /contributions
 | 
			
		||||
  def index
 | 
			
		||||
    @current_section = :contributions
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -8,9 +8,6 @@ class Discourse::SsoController < ApplicationController
 | 
			
		||||
    sso.email = current_user.email
 | 
			
		||||
    sso.username = current_user.cn
 | 
			
		||||
    sso.name = current_user.display_name
 | 
			
		||||
    if current_user.avatar.attached?
 | 
			
		||||
      sso.avatar_url = helpers.image_url_for(current_user.avatar)
 | 
			
		||||
    end
 | 
			
		||||
    sso.admin = current_user.is_admin?
 | 
			
		||||
    sso.sso_secret = secret
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,7 @@ class LnurlpayController < ApplicationController
 | 
			
		||||
      pubkey: Setting.lndhub_public_key,
 | 
			
		||||
      customData: [{
 | 
			
		||||
        customKey: "696969",
 | 
			
		||||
        customValue: @user.lndhub_username
 | 
			
		||||
        customValue: @user.ln_account
 | 
			
		||||
      }]
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@ -1,9 +0,0 @@
 | 
			
		||||
class PagesController < ApplicationController
 | 
			
		||||
  def privacy
 | 
			
		||||
    @current_section = :privacy
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def tos
 | 
			
		||||
    @current_section = :tos
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -9,7 +9,7 @@ class Services::LightningController < ApplicationController
 | 
			
		||||
  before_action :lndhub_fetch_balance
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    @wallet_setup_url = "lndhub://#{current_user.lndhub_username}:#{current_user.lndhub_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
 | 
			
		||||
    @wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def transactions
 | 
			
		||||
 | 
			
		||||
@ -23,11 +23,7 @@ class Services::RsAuthsController < Services::BaseController
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def launch_app
 | 
			
		||||
    user_address = Rails.env.development? ?
 | 
			
		||||
      "#{current_user.cn}@localhost:3000" :
 | 
			
		||||
      current_user.address
 | 
			
		||||
 | 
			
		||||
    launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
 | 
			
		||||
    launch_url = "#{@auth.launch_url}#remotestorage=#{current_user.address}&access_token=#{@auth.token}"
 | 
			
		||||
 | 
			
		||||
    redirect_to launch_url, allow_other_host: true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@ -4,8 +4,13 @@ require "bcrypt"
 | 
			
		||||
class SettingsController < ApplicationController
 | 
			
		||||
  before_action :authenticate_user!
 | 
			
		||||
  before_action :set_main_nav_section
 | 
			
		||||
  before_action :set_settings_section, only: [:show, :update, :update_email, :reset_email_password]
 | 
			
		||||
  before_action :set_user, only: [:show, :update, :update_email, :reset_email_password]
 | 
			
		||||
  before_action :set_settings_section, only: [
 | 
			
		||||
    :show, :update, :update_email, :reset_email_password
 | 
			
		||||
  ]
 | 
			
		||||
  before_action :set_user, only: [
 | 
			
		||||
    :show, :update, :update_email, :reset_email_password,
 | 
			
		||||
    :fetch_nostr_user_metadata
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    redirect_to setting_path(:profile)
 | 
			
		||||
@ -21,12 +26,10 @@ class SettingsController < ApplicationController
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # PUT /settings/:section
 | 
			
		||||
  def update
 | 
			
		||||
    @user.preferences.merge!(user_params[:preferences] || {})
 | 
			
		||||
    @user.display_name = user_params[:display_name]
 | 
			
		||||
    @user.avatar_new   = user_params[:avatar_new]
 | 
			
		||||
    @user.pgp_pubkey   = user_params[:pgp_pubkey]
 | 
			
		||||
    @user.avatar_new = user_params[:avatar]
 | 
			
		||||
 | 
			
		||||
    if @user.save
 | 
			
		||||
      if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
 | 
			
		||||
@ -34,16 +37,7 @@ class SettingsController < ApplicationController
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if @user.avatar_new.present?
 | 
			
		||||
        if store_user_avatar
 | 
			
		||||
          UserManager::UpdateAvatar.call(user: @user)
 | 
			
		||||
        else
 | 
			
		||||
          @validation_errors = @user.errors
 | 
			
		||||
          render :show, status: :unprocessable_entity and return
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
 | 
			
		||||
        UserManager::UpdatePgpKey.call(user: @user)
 | 
			
		||||
        LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      redirect_to setting_path(@settings_section), flash: {
 | 
			
		||||
@ -55,7 +49,6 @@ class SettingsController < ApplicationController
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # POST /settings/update_email
 | 
			
		||||
  def update_email
 | 
			
		||||
    if @user.valid_ldap_authentication?(security_params[:current_password])
 | 
			
		||||
      if @user.update email: email_params[:email]
 | 
			
		||||
@ -73,7 +66,6 @@ class SettingsController < ApplicationController
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # POST /settings/reset_email_password
 | 
			
		||||
  def reset_email_password
 | 
			
		||||
    @user.current_password = security_params[:current_password]
 | 
			
		||||
 | 
			
		||||
@ -96,7 +88,6 @@ class SettingsController < ApplicationController
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # POST /settings/reset_password
 | 
			
		||||
  def reset_password
 | 
			
		||||
    current_user.send_reset_password_instructions
 | 
			
		||||
    sign_out current_user
 | 
			
		||||
@ -104,7 +95,6 @@ class SettingsController < ApplicationController
 | 
			
		||||
    redirect_to check_your_email_path, notice: msg
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # POST /settings/set_nostr_pubkey
 | 
			
		||||
  def set_nostr_pubkey
 | 
			
		||||
    signed_event = Nostr::Event.new(**nostr_event_from_params)
 | 
			
		||||
 | 
			
		||||
@ -143,6 +133,28 @@ class SettingsController < ApplicationController
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def fetch_nostr_user_metadata
 | 
			
		||||
    if @user.nostr_pubkey.present?
 | 
			
		||||
      outbox_relay_urls = nil
 | 
			
		||||
 | 
			
		||||
      # if @nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
 | 
			
		||||
      #   relay_tags = @nip65_event["tags"].select{ |t| t[0] == "r" }
 | 
			
		||||
      #   outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
 | 
			
		||||
      # end
 | 
			
		||||
 | 
			
		||||
      # @profile = NostrManager::DiscoverUserProfile.call(
 | 
			
		||||
      #   pubkey: @user.nostr_pubkey,
 | 
			
		||||
      #   relays: outbox_relay_urls
 | 
			
		||||
      # )
 | 
			
		||||
      @profile = {"content"=>"{\"name\":\"jimmy\",\"picture\":\"https://storage.kosmos.org/jimmy/public/shares/241028-1117-tony.jpg\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
 | 
			
		||||
      # @profile = {"content"=>"{\"name\":\"jimmy\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
 | 
			
		||||
    else
 | 
			
		||||
      @relays, @profile = [nil, nil]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    render partial: 'nostr_user_metadata'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def set_main_nav_section
 | 
			
		||||
@ -167,8 +179,7 @@ class SettingsController < ApplicationController
 | 
			
		||||
 | 
			
		||||
    def user_params
 | 
			
		||||
      params.require(:user).permit(
 | 
			
		||||
        :display_name, :avatar_new, :pgp_pubkey,
 | 
			
		||||
        preferences: UserPreferences.pref_keys
 | 
			
		||||
        :display_name, :avatar, preferences: UserPreferences.pref_keys
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
@ -189,30 +200,4 @@ class SettingsController < ApplicationController
 | 
			
		||||
      salt = BCrypt::Engine.generate_salt
 | 
			
		||||
      BCrypt::Engine.hash_secret(password, salt)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def store_user_avatar
 | 
			
		||||
      io = @user.avatar_new.tempfile
 | 
			
		||||
      img_data = UserManager::ProcessAvatar.call(io: io)
 | 
			
		||||
      if img_data.blank?
 | 
			
		||||
        @user.errors.add(:avatar, "failed to process file")
 | 
			
		||||
        false
 | 
			
		||||
      end
 | 
			
		||||
      tempfile = Tempfile.create
 | 
			
		||||
      tempfile.binmode
 | 
			
		||||
      tempfile.write(img_data)
 | 
			
		||||
      tempfile.rewind
 | 
			
		||||
 | 
			
		||||
      hash = Digest::SHA256.hexdigest(img_data)
 | 
			
		||||
      ext = @user.avatar_new.content_type == "image/png" ? "png" : "jpg"
 | 
			
		||||
      filename = "#{hash}.#{ext}"
 | 
			
		||||
 | 
			
		||||
      if filename == @user.avatar.filename.to_s
 | 
			
		||||
        @user.errors.add(:avatar, "must be a new file/picture")
 | 
			
		||||
        false
 | 
			
		||||
      else
 | 
			
		||||
        key = "users/#{@user.cn}/avatars/#{filename}"
 | 
			
		||||
        @user.avatar.attach io: tempfile, key: key, filename: filename
 | 
			
		||||
        @user.save
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -96,7 +96,7 @@ class SignupController < ApplicationController
 | 
			
		||||
    session[:new_user] = nil
 | 
			
		||||
    session[:validation_error] = nil
 | 
			
		||||
 | 
			
		||||
    UserManager::CreateAccount.call(account: {
 | 
			
		||||
    CreateAccount.call(account: {
 | 
			
		||||
      username: @user.cn,
 | 
			
		||||
      domain: Setting.primary_domain,
 | 
			
		||||
      email: @user.email,
 | 
			
		||||
 | 
			
		||||
@ -1,43 +0,0 @@
 | 
			
		||||
class WebKeyDirectoryController < WellKnownController
 | 
			
		||||
  before_action :allow_cross_origin_requests
 | 
			
		||||
 | 
			
		||||
  # /.well-known/openpgpkey/hu/:hashed_username(.txt)?l=username
 | 
			
		||||
  def show
 | 
			
		||||
    if params[:l].blank?
 | 
			
		||||
      # TODO store hashed username in db if existing implementations trigger
 | 
			
		||||
      # this a lot
 | 
			
		||||
      msg = "WKD request with \"l\" param omitted for hu: #{params[:hashed_username]}"
 | 
			
		||||
      Sentry.capture_message(msg) if Setting.sentry_enabled?
 | 
			
		||||
      http_status :bad_request and return
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @user = User.find_by(cn: params[:l].downcase)
 | 
			
		||||
 | 
			
		||||
    if @user.nil? ||
 | 
			
		||||
       @user.pgp_pubkey.blank? ||
 | 
			
		||||
       !@user.pgp_pubkey_contains_user_address?
 | 
			
		||||
      http_status :not_found and return
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if params[:hashed_username] != @user.wkd_hash
 | 
			
		||||
      http_status :unprocessable_entity and return
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    respond_to do |format|
 | 
			
		||||
      format.text do
 | 
			
		||||
        response.headers['Content-Type'] = 'text/plain'
 | 
			
		||||
        render plain: @user.pgp_pubkey
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      format.any do
 | 
			
		||||
        key = @user.gnupg_key.export
 | 
			
		||||
        send_data key, filename: "#{@user.wkd_hash}.pem",
 | 
			
		||||
                       type: "application/octet-stream"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def policy
 | 
			
		||||
    head :ok
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -33,10 +33,6 @@ class WebfingerController < WellKnownController
 | 
			
		||||
      links: []
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if @user.avatar.attached?
 | 
			
		||||
      jrd[:links] += avatar_link
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
 | 
			
		||||
      # https://docs.joinmastodon.org/spec/webfinger/
 | 
			
		||||
      jrd[:aliases] += mastodon_aliases
 | 
			
		||||
@ -51,16 +47,6 @@ class WebfingerController < WellKnownController
 | 
			
		||||
    jrd
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def avatar_link
 | 
			
		||||
    [
 | 
			
		||||
      {
 | 
			
		||||
        rel: "http://webfinger.net/rel/avatar",
 | 
			
		||||
        type: @user.avatar.content_type,
 | 
			
		||||
        href: helpers.image_url_for(@user.avatar)
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mastodon_aliases
 | 
			
		||||
    [
 | 
			
		||||
      "#{Setting.mastodon_public_url}/@#{@user.cn}",
 | 
			
		||||
@ -88,7 +74,7 @@ class WebfingerController < WellKnownController
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remotestorage_link
 | 
			
		||||
    auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
 | 
			
		||||
    auth_url = new_rs_oauth_url(@username, host: Setting.accounts_domain)
 | 
			
		||||
    storage_url = "#{Setting.rs_storage_url}/#{@username}"
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ class WebhooksController < ApplicationController
 | 
			
		||||
  before_action :process_payload
 | 
			
		||||
 | 
			
		||||
  def lndhub
 | 
			
		||||
    @user = User.find_by!(lndhub_username: @payload[:user_login])
 | 
			
		||||
    @user = User.find_by!(ln_account: @payload[:user_login])
 | 
			
		||||
 | 
			
		||||
    if @zap = @user.zaps.find_by(payment_request: @payload[:payment_request])
 | 
			
		||||
      settled_at = Time.parse(@payload[:settled_at])
 | 
			
		||||
 | 
			
		||||
@ -14,23 +14,4 @@ module ApplicationHelper
 | 
			
		||||
  def badge(text, color)
 | 
			
		||||
    tag.span text, class: "inline-flex items-center rounded-full bg-#{color}-100 px-2.5 py-0.5 text-xs font-medium text-#{color}-800"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def markdown_to_html(string)
 | 
			
		||||
    raw Kramdown::Document.new(string, { input: "GFM" }).to_html
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def image_url_for(attachment)
 | 
			
		||||
    return s3_image_url(attachment) if Setting.s3_enabled?
 | 
			
		||||
 | 
			
		||||
    if attachment.record.is_a?(User) && attachment.name == "avatar"
 | 
			
		||||
      hash, format = attachment.blob.filename.to_s.split(".", 2)
 | 
			
		||||
      user_avatar_url(
 | 
			
		||||
        username: attachment.record.cn,
 | 
			
		||||
        hash: hash,
 | 
			
		||||
        format: format
 | 
			
		||||
      )
 | 
			
		||||
    else
 | 
			
		||||
      Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,9 @@ class BtcpayCheckDonationJob < ApplicationJob
 | 
			
		||||
 | 
			
		||||
    case invoice["status"]
 | 
			
		||||
    when "Settled"
 | 
			
		||||
      donation.complete!
 | 
			
		||||
      donation.paid_at = DateTime.now
 | 
			
		||||
      donation.payment_status = "settled"
 | 
			
		||||
      donation.save!
 | 
			
		||||
 | 
			
		||||
      NotificationMailer.with(user: donation.user)
 | 
			
		||||
                        .bitcoin_donation_confirmed
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ class CreateLdapUserJob < ApplicationJob
 | 
			
		||||
  def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
 | 
			
		||||
    dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
 | 
			
		||||
    attr = {
 | 
			
		||||
      objectclass: ["top", "account", "person", "inetOrgPerson", "extensibleObject"],
 | 
			
		||||
      objectclass: ["top", "account", "person", "extensibleObject"],
 | 
			
		||||
      cn: username,
 | 
			
		||||
      sn: username,
 | 
			
		||||
      uid: username,
 | 
			
		||||
 | 
			
		||||
@ -2,12 +2,12 @@ class CreateLndhubAccountJob < ApplicationJob
 | 
			
		||||
  queue_as :default
 | 
			
		||||
 | 
			
		||||
  def perform(user)
 | 
			
		||||
    return if user.lndhub_username.present? && user.lndhub_password.present?
 | 
			
		||||
    return if user.ln_account.present? && user.ln_password.present?
 | 
			
		||||
 | 
			
		||||
    lndhub = LndhubV2.new
 | 
			
		||||
    credentials = lndhub.create_account
 | 
			
		||||
 | 
			
		||||
    user.update! lndhub_username: credentials["login"],
 | 
			
		||||
                 lndhub_password: credentials["password"]
 | 
			
		||||
    user.update! ln_account: credentials["login"],
 | 
			
		||||
                 ln_password: credentials["password"]
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,8 @@ class RemoteStorageExpireAuthorizationJob < ApplicationJob
 | 
			
		||||
 | 
			
		||||
  def perform(rs_auth_id)
 | 
			
		||||
    rs_auth = RemoteStorageAuthorization.find rs_auth_id
 | 
			
		||||
    return unless rs_auth.expire_at.nil? || rs_auth.expire_at <= DateTime.now
 | 
			
		||||
 | 
			
		||||
    rs_auth.destroy!
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,21 @@ class XmppExchangeContactsJob < ApplicationJob
 | 
			
		||||
  queue_as :default
 | 
			
		||||
 | 
			
		||||
  def perform(inviter, invitee)
 | 
			
		||||
    EjabberdManager::ExchangeContacts.call(inviter:, invitee:)
 | 
			
		||||
    return unless inviter.service_enabled?(:ejabberd) &&
 | 
			
		||||
                  invitee.service_enabled?(:ejabberd) &&
 | 
			
		||||
                  inviter.preferences[:xmpp_exchange_contacts_with_invitees]
 | 
			
		||||
 | 
			
		||||
    ejabberd = EjabberdApiClient.new
 | 
			
		||||
 | 
			
		||||
    ejabberd.add_rosteritem({
 | 
			
		||||
      "localuser": invitee.cn, "localhost": invitee.ou,
 | 
			
		||||
      "user": inviter.cn, "host": inviter.ou,
 | 
			
		||||
      "nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
 | 
			
		||||
    })
 | 
			
		||||
    ejabberd.add_rosteritem({
 | 
			
		||||
      "localuser": inviter.cn, "localhost": inviter.ou,
 | 
			
		||||
      "user": invitee.cn, "host": invitee.ou,
 | 
			
		||||
      "nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
 | 
			
		||||
    })
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ class XmppSendMessageJob < ApplicationJob
 | 
			
		||||
  queue_as :default
 | 
			
		||||
 | 
			
		||||
  def perform(payload)
 | 
			
		||||
    EjabberdManager::SendMessage.call(payload:)
 | 
			
		||||
    ejabberd = EjabberdApiClient.new
 | 
			
		||||
    ejabberd.send_message payload
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,7 +0,0 @@
 | 
			
		||||
class XmppSetAvatarJob < ApplicationJob
 | 
			
		||||
  queue_as :default
 | 
			
		||||
 | 
			
		||||
  def perform(user:, overwrite: false)
 | 
			
		||||
    EjabberdManager::SetAvatar.call(user:, overwrite:)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -2,6 +2,25 @@ class XmppSetDefaultBookmarksJob < ApplicationJob
 | 
			
		||||
  queue_as :default
 | 
			
		||||
 | 
			
		||||
  def perform(user)
 | 
			
		||||
    EjabberdManager::SetDefaultBookmarks.call(user:)
 | 
			
		||||
    return unless Setting.xmpp_default_rooms.any?
 | 
			
		||||
    @user = user
 | 
			
		||||
    ejabberd = EjabberdApiClient.new
 | 
			
		||||
    ejabberd.private_set user, storage_content
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def storage_content
 | 
			
		||||
    bookmarks = ""
 | 
			
		||||
    Setting.xmpp_default_rooms.each do |r|
 | 
			
		||||
      bookmarks << conference_element(
 | 
			
		||||
        jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
 | 
			
		||||
        autojoin: Setting.xmpp_autojoin_default_rooms
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    "<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def conference_element(jid:, name:, autojoin: false, nick:)
 | 
			
		||||
    "<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,90 +1,3 @@
 | 
			
		||||
class ApplicationMailer < ActionMailer::Base
 | 
			
		||||
  default Rails.application.config.action_mailer.default_options
 | 
			
		||||
  layout 'mailer'
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def send_mail
 | 
			
		||||
      @template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}"
 | 
			
		||||
      headers['Message-ID'] = message_id
 | 
			
		||||
 | 
			
		||||
      if @user.pgp_pubkey.present?
 | 
			
		||||
        mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format|
 | 
			
		||||
          format.text { render plain: pgp_content }
 | 
			
		||||
        end
 | 
			
		||||
      else
 | 
			
		||||
        mail(to: @user.email, subject: @subject) do |format|
 | 
			
		||||
          format.text { render @template }
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def from_address
 | 
			
		||||
      self.class.default[:from]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def from_domain
 | 
			
		||||
      Mail::Address.new(from_address).domain
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def message_id
 | 
			
		||||
      @message_id ||= "#{SecureRandom.uuid}@#{from_domain}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def boundary
 | 
			
		||||
      @boundary ||= SecureRandom.hex(8)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pgp_content_type
 | 
			
		||||
      "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\""
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pgp_nested_content
 | 
			
		||||
      message_content = render_to_string(template: @template)
 | 
			
		||||
      message_content_base64 = Base64.encode64(message_content)
 | 
			
		||||
      nested_boundary = SecureRandom.hex(8)
 | 
			
		||||
 | 
			
		||||
      <<~NESTED_CONTENT
 | 
			
		||||
        Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1"
 | 
			
		||||
        Subject: #{@subject}
 | 
			
		||||
        From: <#{from_address}>
 | 
			
		||||
        To: #{@user.display_name || @user.cn} <#{@user.email}>
 | 
			
		||||
        Message-ID: <#{message_id}>
 | 
			
		||||
 | 
			
		||||
        --------------#{nested_boundary}
 | 
			
		||||
        Content-Type: text/plain; charset=UTF-8; format=flowed
 | 
			
		||||
        Content-Transfer-Encoding: base64
 | 
			
		||||
 | 
			
		||||
        #{message_content_base64}
 | 
			
		||||
 | 
			
		||||
        --------------#{nested_boundary}--
 | 
			
		||||
      NESTED_CONTENT
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pgp_content
 | 
			
		||||
      encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content)
 | 
			
		||||
      encrypted_base64 = Base64.encode64(encrypted_content.to_s)
 | 
			
		||||
 | 
			
		||||
      <<~EMAIL_CONTENT
 | 
			
		||||
        This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
 | 
			
		||||
        --------------#{boundary}
 | 
			
		||||
        Content-Type: application/pgp-encrypted
 | 
			
		||||
        Content-Description: PGP/MIME version identification
 | 
			
		||||
 | 
			
		||||
        Version: 1
 | 
			
		||||
 | 
			
		||||
        --------------#{boundary}
 | 
			
		||||
        Content-Type: application/octet-stream; name="encrypted.asc"
 | 
			
		||||
        Content-Description: OpenPGP encrypted message
 | 
			
		||||
        Content-Disposition: inline; filename="encrypted.asc"
 | 
			
		||||
 | 
			
		||||
        -----BEGIN PGP MESSAGE-----
 | 
			
		||||
 | 
			
		||||
        #{encrypted_base64}
 | 
			
		||||
 | 
			
		||||
        -----END PGP MESSAGE-----
 | 
			
		||||
 | 
			
		||||
        --------------#{boundary}--
 | 
			
		||||
      EMAIL_CONTENT
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer
 | 
			
		||||
    @user = params[:user]
 | 
			
		||||
    @subject = params[:subject]
 | 
			
		||||
    @body = params[:body]
 | 
			
		||||
    send_mail
 | 
			
		||||
    mail(to: @user.email, subject: @subject)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer
 | 
			
		||||
    @user = params[:user]
 | 
			
		||||
    @amount_sats = params[:amount_sats]
 | 
			
		||||
    @subject = "Sats received"
 | 
			
		||||
    send_mail
 | 
			
		||||
    mail to: @user.email, subject: @subject
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remotestorage_auth_created
 | 
			
		||||
@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer
 | 
			
		||||
      "#{access} #{directory}"
 | 
			
		||||
    end
 | 
			
		||||
    @subject = "New app connected to your storage"
 | 
			
		||||
    send_mail
 | 
			
		||||
    mail to: @user.email, subject: @subject
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def new_invitations_available
 | 
			
		||||
    @user = params[:user]
 | 
			
		||||
    @subject = "New invitations added to your account"
 | 
			
		||||
    send_mail
 | 
			
		||||
    mail to: @user.email, subject: @subject
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def bitcoin_donation_confirmed
 | 
			
		||||
    @user = params[:user]
 | 
			
		||||
    @donation = params[:donation]
 | 
			
		||||
    @subject = "Donation confirmed"
 | 
			
		||||
    send_mail
 | 
			
		||||
    mail to: @user.email, subject: @subject
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -11,9 +11,6 @@ module Settings
 | 
			
		||||
 | 
			
		||||
      field :mastodon_address_domain, type: :string,
 | 
			
		||||
        default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain
 | 
			
		||||
 | 
			
		||||
      field :mastodon_auth_token, type: :string,
 | 
			
		||||
        default: ENV["MASTODON_AUTH_TOKEN"].presence
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
module Settings
 | 
			
		||||
  module MembershipSettings
 | 
			
		||||
    extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
    included do
 | 
			
		||||
      field :member_status_contributor, type: :string,
 | 
			
		||||
        default: "Contributor"
 | 
			
		||||
      field :member_status_sustainer, type: :string,
 | 
			
		||||
        default: "Sustainer"
 | 
			
		||||
 | 
			
		||||
      # Admin panel
 | 
			
		||||
      field :user_index_show_contributors, type: :boolean,
 | 
			
		||||
        default: false
 | 
			
		||||
      field :user_index_show_sustainers, type: :boolean,
 | 
			
		||||
        default: false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -20,6 +20,19 @@ module Settings
 | 
			
		||||
 | 
			
		||||
      field :nostr_zaps_relay_limit, type: :integer,
 | 
			
		||||
        default: 12
 | 
			
		||||
 | 
			
		||||
      field :nostr_discovery_relays, type: :array, default: %w[
 | 
			
		||||
        wss://nostr.kosmos.org
 | 
			
		||||
        wss://purplepag.es
 | 
			
		||||
        wss://relay.nostr.band
 | 
			
		||||
        wss://njump.me
 | 
			
		||||
        wss://relay.damus.io
 | 
			
		||||
      ]
 | 
			
		||||
 | 
			
		||||
      def self.nostr_relay_url_http
 | 
			
		||||
        self.nostr_relay_url.gsub(/^ws:/, "http:")
 | 
			
		||||
                            .gsub(/^wss:/, "https:")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,6 @@ module Settings
 | 
			
		||||
      field :remotestorage_enabled, type: :boolean,
 | 
			
		||||
        default: ENV["RS_STORAGE_URL"].present?
 | 
			
		||||
 | 
			
		||||
      field :rs_accounts_domain, type: :string,
 | 
			
		||||
        default: ENV["RS_AKKOUNTS_DOMAIN"] || ENV["AKKOUNTS_DOMAIN"]
 | 
			
		||||
 | 
			
		||||
      field :rs_storage_url, type: :string,
 | 
			
		||||
        default: ENV["RS_STORAGE_URL"].presence
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,42 +1,22 @@
 | 
			
		||||
class Donation < ApplicationRecord
 | 
			
		||||
  include AASM
 | 
			
		||||
 | 
			
		||||
  # Relations
 | 
			
		||||
  belongs_to :user
 | 
			
		||||
 | 
			
		||||
  # Validations
 | 
			
		||||
  validates_presence_of :user
 | 
			
		||||
  validates_presence_of :donation_method,
 | 
			
		||||
    inclusion: { in: %w[ custom btcpay lndhub ] }
 | 
			
		||||
  validates_presence_of :payment_status, allow_nil: true,
 | 
			
		||||
    inclusion: { in: %w[ pending processing settled ] }
 | 
			
		||||
    inclusion: { in: %w[ processing settled ] }
 | 
			
		||||
  validates_presence_of :paid_at, allow_nil: true
 | 
			
		||||
  validates_presence_of :amount_sats, allow_nil: true
 | 
			
		||||
  validates_presence_of :fiat_amount, allow_nil: true
 | 
			
		||||
  validates_presence_of :fiat_currency, allow_nil: true,
 | 
			
		||||
    inclusion: { in: %w[ EUR USD ] }
 | 
			
		||||
 | 
			
		||||
  scope :pending,    -> { where(payment_status: "pending") }
 | 
			
		||||
  #Scopes
 | 
			
		||||
  scope :processing, -> { where(payment_status: "processing") }
 | 
			
		||||
  scope :completed,  -> { where(payment_status: "settled") }
 | 
			
		||||
  scope :incomplete, -> { where.not(payment_status: "settled") }
 | 
			
		||||
 | 
			
		||||
  aasm column: :payment_status do
 | 
			
		||||
    state :pending, initial: true
 | 
			
		||||
    state :processing
 | 
			
		||||
    state :settled
 | 
			
		||||
 | 
			
		||||
    event :start_processing do
 | 
			
		||||
      transitions from: :pending, to: :processing
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    event :complete do
 | 
			
		||||
      transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status]
 | 
			
		||||
      transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status]
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def pending?
 | 
			
		||||
    payment_status == "pending"
 | 
			
		||||
  end
 | 
			
		||||
  scope :completed, -> { where(payment_status: "settled") }
 | 
			
		||||
 | 
			
		||||
  def processing?
 | 
			
		||||
    payment_status == "processing"
 | 
			
		||||
@ -45,17 +25,4 @@ class Donation < ApplicationRecord
 | 
			
		||||
  def completed?
 | 
			
		||||
    payment_status == "settled"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def set_paid_at
 | 
			
		||||
      update paid_at: DateTime.now if paid_at.nil?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def set_sustainer_status
 | 
			
		||||
      user.add_member_status :sustainer
 | 
			
		||||
    rescue => e
 | 
			
		||||
      Sentry.capture_exception(e) if Setting.sentry_enabled?
 | 
			
		||||
      Rails.logger.error("Failed to set memberStatus: #{e.message}")
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
class EditableContent < ApplicationRecord
 | 
			
		||||
  validates :key, presence: true,
 | 
			
		||||
                  uniqueness: { scope: :context }
 | 
			
		||||
 | 
			
		||||
  def has_content?
 | 
			
		||||
    content.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def is_empty?
 | 
			
		||||
    content.blank?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -6,7 +6,7 @@ class LndhubUser < LndhubBase
 | 
			
		||||
                      foreign_key: "user_id"
 | 
			
		||||
 | 
			
		||||
  belongs_to :user, class_name: "User",
 | 
			
		||||
                    primary_key: "lndhub_username",
 | 
			
		||||
                    primary_key: "ln_account",
 | 
			
		||||
                    foreign_key: "login"
 | 
			
		||||
 | 
			
		||||
  def balance
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ class RemoteStorageAuthorization < ApplicationRecord
 | 
			
		||||
  belongs_to :user
 | 
			
		||||
  belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
 | 
			
		||||
 | 
			
		||||
  serialize :permissions, coder: YAML unless Rails.env.production?
 | 
			
		||||
  serialize :permissions unless Rails.env.production?
 | 
			
		||||
 | 
			
		||||
  validates_presence_of :permissions
 | 
			
		||||
  validates_presence_of :client_id
 | 
			
		||||
@ -69,19 +69,11 @@ class RemoteStorageAuthorization < ApplicationRecord
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def remove_token_expiry_job
 | 
			
		||||
      job_class = RemoteStorageExpireAuthorizationJob
 | 
			
		||||
      job_args = [id]
 | 
			
		||||
 | 
			
		||||
      query = SolidQueue::Job.where(class_name: job_class.to_s)
 | 
			
		||||
 | 
			
		||||
      case ActiveRecord::Base.connection.adapter_name.downcase
 | 
			
		||||
      when /sqlite/
 | 
			
		||||
        query.where("json_extract(arguments, '$.arguments') = ?", job_args.to_json)
 | 
			
		||||
      when /postgres/
 | 
			
		||||
        query.where("CAST(arguments AS jsonb)->>'arguments' = ?", job_args.to_json)
 | 
			
		||||
      else
 | 
			
		||||
        raise "Unsupported database adapter"
 | 
			
		||||
      end.destroy_all
 | 
			
		||||
      queue = Sidekiq::Queue.new(RemoteStorageExpireAuthorizationJob.queue_name)
 | 
			
		||||
      queue.each do |job|
 | 
			
		||||
        next unless job.display_class == "RemoteStorageExpireAuthorizationJob"
 | 
			
		||||
        job.delete if job.display_args == [id]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def find_or_create_web_app
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,6 @@ class Setting < RailsSettings::Base
 | 
			
		||||
  include Settings::LightningNetworkSettings
 | 
			
		||||
  include Settings::MastodonSettings
 | 
			
		||||
  include Settings::MediaWikiSettings
 | 
			
		||||
  include Settings::MembershipSettings
 | 
			
		||||
  include Settings::NostrSettings
 | 
			
		||||
  include Settings::OpenCollectiveSettings
 | 
			
		||||
  include Settings::RemoteStorageSettings
 | 
			
		||||
 | 
			
		||||
@ -3,10 +3,9 @@ require 'nostr'
 | 
			
		||||
class User < ApplicationRecord
 | 
			
		||||
  include EmailValidatable
 | 
			
		||||
 | 
			
		||||
  attr_accessor :current_password
 | 
			
		||||
  attr_accessor :display_name
 | 
			
		||||
  attr_accessor :avatar_new
 | 
			
		||||
  attr_accessor :pgp_pubkey
 | 
			
		||||
  attr_accessor :current_password
 | 
			
		||||
 | 
			
		||||
  serialize :preferences, coder: UserPreferences
 | 
			
		||||
 | 
			
		||||
@ -23,16 +22,10 @@ class User < ApplicationRecord
 | 
			
		||||
  has_many :zaps
 | 
			
		||||
 | 
			
		||||
  has_one  :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
 | 
			
		||||
                         primary_key: "lndhub_username", foreign_key: "login"
 | 
			
		||||
                         primary_key: "ln_account", foreign_key: "login"
 | 
			
		||||
 | 
			
		||||
  has_many :accounts, through: :lndhub_user
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  # Attachments
 | 
			
		||||
  #
 | 
			
		||||
 | 
			
		||||
  has_one_attached :avatar
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  # Validations
 | 
			
		||||
  #
 | 
			
		||||
@ -56,11 +49,8 @@ class User < ApplicationRecord
 | 
			
		||||
  validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
 | 
			
		||||
                                     if: -> { defined?(@display_name) }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  validate :acceptable_avatar
 | 
			
		||||
 | 
			
		||||
  validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  # Scopes
 | 
			
		||||
  #
 | 
			
		||||
@ -73,7 +63,7 @@ class User < ApplicationRecord
 | 
			
		||||
  # Encrypted database columns
 | 
			
		||||
  #
 | 
			
		||||
 | 
			
		||||
  encrypts :lndhub_password
 | 
			
		||||
  has_encrypted :ln_login, :ln_password
 | 
			
		||||
 | 
			
		||||
  # Include default devise modules. Others available are:
 | 
			
		||||
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
 | 
			
		||||
@ -84,10 +74,6 @@ class User < ApplicationRecord
 | 
			
		||||
         :timeoutable,
 | 
			
		||||
         :rememberable
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  # Methods
 | 
			
		||||
  #
 | 
			
		||||
 | 
			
		||||
  def ldap_before_save
 | 
			
		||||
    self.email = Devise::LDAP::Adapter.get_ldap_param(self.cn, "mail").first
 | 
			
		||||
    self.ou    = dn.split(',')
 | 
			
		||||
@ -131,11 +117,11 @@ class User < ApplicationRecord
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def is_admin?
 | 
			
		||||
    @admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
 | 
			
		||||
                 !!admin.first
 | 
			
		||||
               else
 | 
			
		||||
                 false
 | 
			
		||||
               end
 | 
			
		||||
    admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
 | 
			
		||||
                !!admin.first
 | 
			
		||||
              else
 | 
			
		||||
                false
 | 
			
		||||
              end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def address
 | 
			
		||||
@ -163,41 +149,13 @@ class User < ApplicationRecord
 | 
			
		||||
 | 
			
		||||
  def ldap_entry(reload: false)
 | 
			
		||||
    return @ldap_entry if defined?(@ldap_entry) && !reload
 | 
			
		||||
    @ldap_entry = ldap.fetch_users(cn: self.cn).first
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def add_to_ldap_array(attr_key, ldap_attr, value)
 | 
			
		||||
    current_entries = ldap_entry[attr_key.to_sym] || []
 | 
			
		||||
    new_entries = Array(value).map(&:to_s)
 | 
			
		||||
    entries = (current_entries + new_entries).uniq.sort
 | 
			
		||||
    ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_from_ldap_array(attr_key, ldap_attr, value)
 | 
			
		||||
    current_entries = ldap_entry[attr_key.to_sym] || []
 | 
			
		||||
    entries_to_remove = Array(value).map(&:to_s)
 | 
			
		||||
    entries = (current_entries - entries_to_remove).uniq.sort
 | 
			
		||||
    ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
 | 
			
		||||
    @ldap_entry = ldap.fetch_users(uid: self.cn, ou: self.ou).first
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def display_name
 | 
			
		||||
    @display_name ||= ldap_entry[:display_name]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # TODO Variant keys are currently broken for some reason
 | 
			
		||||
  # (They use the same key as the main blob, when it should be
 | 
			
		||||
  # "/variants/#{key)"
 | 
			
		||||
  # def avatar_variant(size: :medium)
 | 
			
		||||
  #   dimensions = case size
 | 
			
		||||
  #                when :large  then [400, 400]
 | 
			
		||||
  #                when :medium then [256, 256]
 | 
			
		||||
  #                when :small  then [64, 64]
 | 
			
		||||
  #                else [256, 256]
 | 
			
		||||
  #                end
 | 
			
		||||
  #   format = avatar.content_type == "image/png" ? :png : :jpeg
 | 
			
		||||
  #   avatar.variant(resize_to_fill: dimensions, format: format)
 | 
			
		||||
  # end
 | 
			
		||||
 | 
			
		||||
  def nostr_pubkey
 | 
			
		||||
    @nostr_pubkey ||= ldap_entry[:nostr_key]
 | 
			
		||||
  end
 | 
			
		||||
@ -207,22 +165,8 @@ class User < ApplicationRecord
 | 
			
		||||
    Nostr::PublicKey.new(nostr_pubkey).to_bech32
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def pgp_pubkey
 | 
			
		||||
    @pgp_pubkey ||= ldap_entry[:pgp_key]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def gnupg_key
 | 
			
		||||
    return nil unless pgp_pubkey.present?
 | 
			
		||||
    GPGME::Key.import(pgp_pubkey)
 | 
			
		||||
    GPGME::Key.get(pgp_fpr)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def pgp_pubkey_contains_user_address?
 | 
			
		||||
    gnupg_key.uids.map(&:email).include?(address)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def wkd_hash
 | 
			
		||||
    ZBase32.encode(Digest::SHA1.digest(cn))
 | 
			
		||||
  def avatar
 | 
			
		||||
    @avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def services_enabled
 | 
			
		||||
@ -234,39 +178,21 @@ class User < ApplicationRecord
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def enable_service(service)
 | 
			
		||||
    add_to_ldap_array :services_enabled, :serviceEnabled, service
 | 
			
		||||
    ldap_entry(reload: true)[:services_enabled]
 | 
			
		||||
    current_services = services_enabled
 | 
			
		||||
    new_services = Array(service).map(&:to_s)
 | 
			
		||||
    services = (current_services + new_services).uniq.sort
 | 
			
		||||
    ldap.replace_attribute(dn, :serviceEnabled, services)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def disable_service(service)
 | 
			
		||||
    remove_from_ldap_array :services_enabled, :serviceEnabled, service
 | 
			
		||||
    ldap_entry(reload: true)[:services_enabled]
 | 
			
		||||
    current_services = services_enabled
 | 
			
		||||
    disabled_services = Array(service).map(&:to_s)
 | 
			
		||||
    services = (current_services - disabled_services).uniq.sort
 | 
			
		||||
    ldap.replace_attribute(dn, :serviceEnabled, services)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def disable_all_services
 | 
			
		||||
    ldap.delete_attribute(dn, :serviceEnabled)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def member_status
 | 
			
		||||
    ldap_entry[:member_status] || []
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def add_member_status(status)
 | 
			
		||||
    add_to_ldap_array :member_status, :memberStatus, status
 | 
			
		||||
    ldap_entry(reload: true)[:member_status]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_member_status(status)
 | 
			
		||||
    remove_from_ldap_array :member_status, :memberStatus, status
 | 
			
		||||
    ldap_entry(reload: true)[:member_status]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def is_contributing_member?
 | 
			
		||||
    member_status.map(&:to_sym).include?(:contributor)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def is_paying_member?
 | 
			
		||||
    member_status.map(&:to_sym).include?(:sustainer)
 | 
			
		||||
    ldap.delete_attribute(dn,:service)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
@ -280,7 +206,7 @@ class User < ApplicationRecord
 | 
			
		||||
      return unless avatar_new.present?
 | 
			
		||||
 | 
			
		||||
      if avatar_new.size > 1.megabyte
 | 
			
		||||
        errors.add(:avatar, "must be less than 1MB file size")
 | 
			
		||||
        errors.add(:avatar, "file size is too large")
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      acceptable_types = ["image/jpeg", "image/png"]
 | 
			
		||||
@ -288,10 +214,4 @@ class User < ApplicationRecord
 | 
			
		||||
        errors.add(:avatar, "must be a JPEG or PNG file")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def acceptable_pgp_key_format
 | 
			
		||||
      unless GPGME::Key.valid?(pgp_pubkey)
 | 
			
		||||
        errors.add(:pgp_pubkey, 'is not a valid armored PGP public key block')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,22 +1,36 @@
 | 
			
		||||
#
 | 
			
		||||
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
 | 
			
		||||
#
 | 
			
		||||
class BtcpayManagerService < RestApiService
 | 
			
		||||
class BtcpayManagerService < ApplicationService
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def base_url
 | 
			
		||||
    @base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
 | 
			
		||||
  end
 | 
			
		||||
    def base_url
 | 
			
		||||
      @base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
  def auth_token
 | 
			
		||||
    @auth_token ||= Setting.btcpay_auth_token
 | 
			
		||||
  end
 | 
			
		||||
    def auth_token
 | 
			
		||||
      @auth_token ||= Setting.btcpay_auth_token
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
  def headers
 | 
			
		||||
    {
 | 
			
		||||
      "Content-Type" => "application/json",
 | 
			
		||||
      "Accept" => "application/json",
 | 
			
		||||
      "Authorization" => "token #{auth_token}"
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
    def headers
 | 
			
		||||
      {
 | 
			
		||||
        "Content-Type" => "application/json",
 | 
			
		||||
        "Accept" => "application/json",
 | 
			
		||||
        "Authorization" => "token #{auth_token}"
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def endpoint_url(path)
 | 
			
		||||
      "#{base_url}/#{path.gsub(/^\//, '')}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def get(path, params = {})
 | 
			
		||||
      res = Faraday.get endpoint_url(path), params, headers
 | 
			
		||||
      JSON.parse(res.body)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def post(path, payload)
 | 
			
		||||
      res = Faraday.post endpoint_url(path), payload.to_json, headers
 | 
			
		||||
      JSON.parse(res.body)
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								app/services/create_account.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								app/services/create_account.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
class CreateAccount < ApplicationService
 | 
			
		||||
  def initialize(account:)
 | 
			
		||||
    @username   = account[:username]
 | 
			
		||||
    @domain     = account[:ou] || Setting.primary_domain
 | 
			
		||||
    @email      = account[:email]
 | 
			
		||||
    @password   = account[:password]
 | 
			
		||||
    @invitation = account[:invitation]
 | 
			
		||||
    @confirmed  = account[:confirmed]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def call
 | 
			
		||||
    user = create_user_in_database
 | 
			
		||||
    add_ldap_document
 | 
			
		||||
    create_lndhub_account(user) if Setting.lndhub_enabled
 | 
			
		||||
 | 
			
		||||
    if @invitation.present?
 | 
			
		||||
      update_invitation(user.id)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def create_user_in_database
 | 
			
		||||
    User.create!(
 | 
			
		||||
      cn: @username,
 | 
			
		||||
      ou: @domain,
 | 
			
		||||
      email: @email,
 | 
			
		||||
      password: @password,
 | 
			
		||||
      password_confirmation: @password,
 | 
			
		||||
      confirmed_at: @confirmed ? DateTime.now : nil
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update_invitation(user_id)
 | 
			
		||||
    @invitation.update! invited_user_id: user_id, used_at: DateTime.now
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def add_ldap_document
 | 
			
		||||
    hashed_pw = Devise.ldap_auth_password_builder.call(@password)
 | 
			
		||||
    CreateLdapUserJob.perform_later(
 | 
			
		||||
      username: @username,
 | 
			
		||||
      domain: @domain,
 | 
			
		||||
      email: @email,
 | 
			
		||||
      hashed_pw: hashed_pw,
 | 
			
		||||
      confirmed: @confirmed
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create_lndhub_account(user)
 | 
			
		||||
    #TODO enable in development when we have a local lndhub (mock?) API
 | 
			
		||||
    return if Rails.env.development?
 | 
			
		||||
    CreateLndhubAccountJob.perform_later(user)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										17
									
								
								app/services/create_invitations.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/services/create_invitations.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
class CreateInvitations < ApplicationService
 | 
			
		||||
  def initialize(user:, amount:, notify: true)
 | 
			
		||||
    @user = user
 | 
			
		||||
    @amount = amount
 | 
			
		||||
    @notify = notify
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def call
 | 
			
		||||
    @amount.times do
 | 
			
		||||
      Invitation.create(user: @user)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if @notify
 | 
			
		||||
      NotificationMailer.with(user: @user).new_invitations_available.deliver_later
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										29
									
								
								app/services/ejabberd_api_client.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/services/ejabberd_api_client.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
			
		||||
class EjabberdApiClient
 | 
			
		||||
  def initialize
 | 
			
		||||
    @base_url = Setting.ejabberd_api_url
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def post(endpoint, payload)
 | 
			
		||||
    res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json,
 | 
			
		||||
                       "Content-Type" => "application/json")
 | 
			
		||||
 | 
			
		||||
    if res.status != 200
 | 
			
		||||
      Rails.logger.error "[ejabberd] API request failed:"
 | 
			
		||||
      Rails.logger.error res.body
 | 
			
		||||
      #TODO Send custom event to Sentry
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def add_rosteritem(payload)
 | 
			
		||||
    post "add_rosteritem", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def send_message(payload)
 | 
			
		||||
    post "send_message", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def private_set(user, content)
 | 
			
		||||
    payload = { user: user.cn, host: user.ou, element: content }
 | 
			
		||||
    post "private_set", payload
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,25 +0,0 @@
 | 
			
		||||
module EjabberdManager
 | 
			
		||||
  class ExchangeContacts < EjabberdManagerService
 | 
			
		||||
    def initialize(inviter:, invitee:)
 | 
			
		||||
      @inviter = inviter
 | 
			
		||||
      @invitee = invitee
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      return unless @inviter.service_enabled?(:ejabberd) &&
 | 
			
		||||
                    @invitee.service_enabled?(:ejabberd) &&
 | 
			
		||||
                    @inviter.preferences[:xmpp_exchange_contacts_with_invitees]
 | 
			
		||||
 | 
			
		||||
      add_rosteritem({
 | 
			
		||||
        "localuser": @invitee.cn, "localhost": @invitee.ou,
 | 
			
		||||
        "user": @inviter.cn, "host": @inviter.ou,
 | 
			
		||||
        "nick": @inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
 | 
			
		||||
      })
 | 
			
		||||
      add_rosteritem({
 | 
			
		||||
        "localuser": @inviter.cn, "localhost": @inviter.ou,
 | 
			
		||||
        "user": @invitee.cn, "host": @invitee.ou,
 | 
			
		||||
        "nick": @invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
 | 
			
		||||
      })
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,25 +0,0 @@
 | 
			
		||||
module EjabberdManager
 | 
			
		||||
  class GetAvatar < EjabberdManagerService
 | 
			
		||||
    def initialize(user:)
 | 
			
		||||
      @user = user
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      res = get_vcard2 @user, "PHOTO", "BINVAL"
 | 
			
		||||
 | 
			
		||||
      if res.status == 200
 | 
			
		||||
        # VCARD PHOTO/BINVAL prop exists
 | 
			
		||||
        img_base64 = JSON.parse(res.body)["content"]
 | 
			
		||||
        ct_res = get_vcard2 @user, "PHOTO", "TYPE"
 | 
			
		||||
        content_type = JSON.parse(ct_res.body)["content"]
 | 
			
		||||
        { content_type:, img_base64: }
 | 
			
		||||
      elsif res.status == 400
 | 
			
		||||
        # VCARD or PHOTO/BINVAL prop does not exist
 | 
			
		||||
        nil
 | 
			
		||||
      else
 | 
			
		||||
        # Unexpected error, let job fail
 | 
			
		||||
        raise res.inspect
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,11 +0,0 @@
 | 
			
		||||
module EjabberdManager
 | 
			
		||||
  class SendMessage < EjabberdManagerService
 | 
			
		||||
    def initialize(payload:)
 | 
			
		||||
      @payload = payload
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      send_message @payload
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,80 +0,0 @@
 | 
			
		||||
require 'digest'
 | 
			
		||||
require "image_processing/vips"
 | 
			
		||||
 | 
			
		||||
module EjabberdManager
 | 
			
		||||
  class SetAvatar < EjabberdManagerService
 | 
			
		||||
    def initialize(user:, overwrite: false)
 | 
			
		||||
      @user = user
 | 
			
		||||
      @overwrite = overwrite
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      unless @overwrite
 | 
			
		||||
        current_avatar = EjabberdManager::GetAvatar.call(user: @user)
 | 
			
		||||
        Rails.logger.info { "User #{@user.cn} already has an avatar set" }
 | 
			
		||||
        return if current_avatar.present?
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      Rails.logger.debug { "Setting XMPP avatar for user #{@user.cn}" }
 | 
			
		||||
 | 
			
		||||
      stanzas = build_xep0084_stanzas
 | 
			
		||||
 | 
			
		||||
      stanzas.each do |stanza|
 | 
			
		||||
        payload = { from: @user.address, to: @user.address, stanza: stanza }
 | 
			
		||||
        res = send_stanza payload
 | 
			
		||||
        raise res.inspect if res.status != 200
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
    def process_avatar
 | 
			
		||||
      @user.avatar.blob.open do |file|
 | 
			
		||||
        processed = ImageProcessing::Vips
 | 
			
		||||
          .source(file)
 | 
			
		||||
          .resize_to_fill(256, 256)
 | 
			
		||||
          .convert("png")
 | 
			
		||||
          .call
 | 
			
		||||
        processed.read
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # See https://xmpp.org/extensions/xep-0084.html
 | 
			
		||||
    def build_xep0084_stanzas
 | 
			
		||||
      img_data    = process_avatar
 | 
			
		||||
      sha1_hash   = Digest::SHA1.hexdigest(img_data)
 | 
			
		||||
      base64_data = Base64.strict_encode64(img_data)
 | 
			
		||||
 | 
			
		||||
      [
 | 
			
		||||
        """
 | 
			
		||||
<iq type='set' from='#{@user.address}' id='avatar-data-#{rand(101)}'>
 | 
			
		||||
  <pubsub xmlns='http://jabber.org/protocol/pubsub'>
 | 
			
		||||
    <publish node='urn:xmpp:avatar:data'>
 | 
			
		||||
      <item id='#{sha1_hash}'>
 | 
			
		||||
        <data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>
 | 
			
		||||
      </item>
 | 
			
		||||
    </publish>
 | 
			
		||||
  </pubsub>
 | 
			
		||||
</iq>
 | 
			
		||||
        """.strip,
 | 
			
		||||
        """
 | 
			
		||||
<iq type='set' from='#{@user.address}' id='avatar-metadata-#{rand(101)}'>
 | 
			
		||||
  <pubsub xmlns='http://jabber.org/protocol/pubsub'>
 | 
			
		||||
    <publish node='urn:xmpp:avatar:metadata'>
 | 
			
		||||
      <item id='#{sha1_hash}'>
 | 
			
		||||
        <metadata xmlns='urn:xmpp:avatar:metadata'>
 | 
			
		||||
          <info bytes='#{img_data.size}'
 | 
			
		||||
                id='#{sha1_hash}'
 | 
			
		||||
                height='256'
 | 
			
		||||
                type='image/png'
 | 
			
		||||
                width='256'/>
 | 
			
		||||
        </metadata>
 | 
			
		||||
      </item>
 | 
			
		||||
    </publish>
 | 
			
		||||
  </pubsub>
 | 
			
		||||
</iq>
 | 
			
		||||
        """.strip,
 | 
			
		||||
      ]
 | 
			
		||||
    end
 | 
			
		||||
end
 | 
			
		||||
@ -1,31 +0,0 @@
 | 
			
		||||
module EjabberdManager
 | 
			
		||||
  class SetDefaultBookmarks < EjabberdManagerService
 | 
			
		||||
    def initialize(user:)
 | 
			
		||||
      @user = user
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      return unless Setting.xmpp_default_rooms.any?
 | 
			
		||||
 | 
			
		||||
      private_set @user, storage_content
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def storage_content
 | 
			
		||||
      bookmarks = ""
 | 
			
		||||
      Setting.xmpp_default_rooms.each do |r|
 | 
			
		||||
        bookmarks << conference_element(
 | 
			
		||||
          jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
 | 
			
		||||
          autojoin: Setting.xmpp_autojoin_default_rooms
 | 
			
		||||
        )
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      "<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def conference_element(jid:, name:, autojoin: false, nick:)
 | 
			
		||||
      "<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,55 +0,0 @@
 | 
			
		||||
class EjabberdManagerService < RestApiService
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def base_url
 | 
			
		||||
    @base_url ||= Setting.ejabberd_api_url
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def headers
 | 
			
		||||
    { "Content-Type" => "application/json" }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def parse_responses?
 | 
			
		||||
    false
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  #
 | 
			
		||||
  # API endpoints
 | 
			
		||||
  #
 | 
			
		||||
 | 
			
		||||
  def add_rosteritem(payload)
 | 
			
		||||
    post "add_rosteritem", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def send_message(payload)
 | 
			
		||||
    post "send_message", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def send_stanza(payload)
 | 
			
		||||
    post "send_stanza", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def get_vcard2(user, name, subname)
 | 
			
		||||
    payload = {
 | 
			
		||||
      user: user.cn, host: user.ou,
 | 
			
		||||
      name: name, subname: subname
 | 
			
		||||
    }
 | 
			
		||||
    post "get_vcard2", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def private_get(user, element_name, namespace)
 | 
			
		||||
    payload = {
 | 
			
		||||
      user: user.cn, host: user.ou,
 | 
			
		||||
      element: element_name, ns: namespace
 | 
			
		||||
    }
 | 
			
		||||
    post "private_get", payload
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def private_set(user, content)
 | 
			
		||||
    payload = {
 | 
			
		||||
      user: user.cn, host: user.ou,
 | 
			
		||||
      element: content
 | 
			
		||||
    }
 | 
			
		||||
    post "private_set", payload
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -5,12 +5,12 @@ module LdapManager
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      treebase = ldap_config["base"]
 | 
			
		||||
      treebase   = ldap_config["base"]
 | 
			
		||||
      attributes = %w{ jpegPhoto }
 | 
			
		||||
      filter = Net::LDAP::Filter.eq("cn", @cn)
 | 
			
		||||
      filter     = Net::LDAP::Filter.eq("cn", @cn)
 | 
			
		||||
 | 
			
		||||
      entry = client.search(base: treebase, filter: filter, attributes: attributes).first
 | 
			
		||||
      entry[:jpegPhoto].present? ? entry.jpegPhoto.first : nil
 | 
			
		||||
      entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -2,41 +2,26 @@ require "image_processing/vips"
 | 
			
		||||
 | 
			
		||||
module LdapManager
 | 
			
		||||
  class UpdateAvatar < LdapManagerService
 | 
			
		||||
    def initialize(user:)
 | 
			
		||||
      @user = user
 | 
			
		||||
      @dn = user.dn
 | 
			
		||||
    def initialize(dn:, file:)
 | 
			
		||||
      @dn = dn
 | 
			
		||||
      @img_data = process(file)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      unless @user.avatar.attached?
 | 
			
		||||
        Rails.logger.error { "Cannot store empty jpegPhoto for user #{@user.cn}" }
 | 
			
		||||
        return false
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      img_data = @user.avatar.blob.download
 | 
			
		||||
      jpg_data = process_avatar
 | 
			
		||||
 | 
			
		||||
      Rails.logger.debug { "Storing new jpegPhoto for user #{@user.cn} in LDAP" }
 | 
			
		||||
      result = replace_attribute(@dn, :jpegPhoto, jpg_data)
 | 
			
		||||
      result == 0
 | 
			
		||||
      replace_attribute @dn, :jpegPhoto, @img_data
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def process_avatar
 | 
			
		||||
      @user.avatar.blob.open do |file|
 | 
			
		||||
        processed = ImageProcessing::Vips
 | 
			
		||||
          .source(file)
 | 
			
		||||
          .resize_to_fill(256, 256)
 | 
			
		||||
          .convert("jpeg")
 | 
			
		||||
          .saver(strip: true)
 | 
			
		||||
          .call
 | 
			
		||||
        processed.read
 | 
			
		||||
      end
 | 
			
		||||
    rescue Vips::Error => e
 | 
			
		||||
      Sentry.capture_exception(e) if Setting.sentry_enabled?
 | 
			
		||||
      Rails.logger.error { "Image processing failed for LDAP avatar: #{e.message}" }
 | 
			
		||||
      nil
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
@ -6,11 +6,7 @@ module LdapManager
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      if @display_name.present?
 | 
			
		||||
        replace_attribute @dn, :displayName, @display_name
 | 
			
		||||
      else
 | 
			
		||||
        delete_attribute @dn, :displayName
 | 
			
		||||
      end
 | 
			
		||||
      replace_attribute @dn, :displayName, @display_name
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
module LdapManager
 | 
			
		||||
  class UpdatePgpKey < LdapManagerService
 | 
			
		||||
    def initialize(dn:, pubkey:)
 | 
			
		||||
      @dn = dn
 | 
			
		||||
      @pubkey = pubkey
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      if @pubkey.present?
 | 
			
		||||
        replace_attribute @dn, :pgpKey, @pubkey
 | 
			
		||||
      else
 | 
			
		||||
        delete_attribute @dn, :pgpKey
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -50,17 +50,19 @@ class LdapService < ApplicationService
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def fetch_users(args={})
 | 
			
		||||
    attributes = %w[
 | 
			
		||||
      dn cn uid mail displayName admin serviceEnabled memberStatus
 | 
			
		||||
      mailRoutingAddress mailpassword nostrKey pgpKey
 | 
			
		||||
    ]
 | 
			
		||||
    filter = Net::LDAP::Filter.eq('objectClass', 'person') &
 | 
			
		||||
             Net::LDAP::Filter.eq("cn", args[:cn] || "*")
 | 
			
		||||
    if args[:ou]
 | 
			
		||||
      treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
 | 
			
		||||
    else
 | 
			
		||||
      treebase = ldap_config["base"]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    entries = client.search(
 | 
			
		||||
      base: ldap_config["base"], filter: filter,
 | 
			
		||||
      attributes: attributes
 | 
			
		||||
    )
 | 
			
		||||
    attributes = %w[
 | 
			
		||||
      dn cn uid mail displayName admin serviceEnabled
 | 
			
		||||
      mailRoutingAddress mailpassword nostrKey
 | 
			
		||||
    ]
 | 
			
		||||
    filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
 | 
			
		||||
 | 
			
		||||
    entries = client.search(base: treebase, filter: filter, attributes: attributes)
 | 
			
		||||
    entries.sort_by! { |e| e.cn[0] }
 | 
			
		||||
    entries = entries.collect do |e|
 | 
			
		||||
      {
 | 
			
		||||
@ -69,29 +71,17 @@ class LdapService < ApplicationService
 | 
			
		||||
        display_name: e.try(:displayName) ? e.displayName.first : nil,
 | 
			
		||||
        admin: e.try(:admin) ? 'admin' : nil,
 | 
			
		||||
        services_enabled: e.try(:serviceEnabled),
 | 
			
		||||
        member_status: e.try(:memberStatus),
 | 
			
		||||
        email_maildrop: e.try(:mailRoutingAddress),
 | 
			
		||||
        email_password: e.try(:mailpassword),
 | 
			
		||||
        nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
 | 
			
		||||
        pgp_key: e.try(:pgpKey) ? e.pgpKey.first : nil
 | 
			
		||||
        nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def search_users(search_attr, value, return_attr)
 | 
			
		||||
    filter = Net::LDAP::Filter.eq('objectClass', 'person') &
 | 
			
		||||
             Net::LDAP::Filter.eq(search_attr.to_s, value.to_s) &
 | 
			
		||||
             Net::LDAP::Filter.present('cn')
 | 
			
		||||
    entries = client.search(
 | 
			
		||||
      base: ldap_config["base"], filter: filter,
 | 
			
		||||
      attributes: [return_attr]
 | 
			
		||||
    )
 | 
			
		||||
    entries.map { |entry| entry[return_attr].first }.compact
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def fetch_organizations
 | 
			
		||||
    attributes = %w{dn ou description}
 | 
			
		||||
    filter     = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
 | 
			
		||||
    # filter     = Net::LDAP::Filter.eq("objectClass", "*")
 | 
			
		||||
    treebase   = "cn=users,#{ldap_suffix}"
 | 
			
		||||
 | 
			
		||||
    entries = client.search(base: treebase, filter: filter, attributes: attributes)
 | 
			
		||||
@ -111,7 +101,7 @@ class LdapService < ApplicationService
 | 
			
		||||
    dn = "ou=#{ou},cn=users,#{ldap_suffix}"
 | 
			
		||||
 | 
			
		||||
    aci = <<-EOS
 | 
			
		||||
(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || pgpKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
 | 
			
		||||
(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
 | 
			
		||||
    EOS
 | 
			
		||||
 | 
			
		||||
    attrs = {
 | 
			
		||||
 | 
			
		||||
@ -33,10 +33,7 @@ class Lndhub < ApplicationService
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate(user)
 | 
			
		||||
    credentials = post "auth?type=auth", {
 | 
			
		||||
      login: user.lndhub_username,
 | 
			
		||||
      password: user.lndhub_password
 | 
			
		||||
    }
 | 
			
		||||
    credentials = post "auth?type=auth", { login: user.ln_account, password: user.ln_password }
 | 
			
		||||
    self.auth_token = credentials["access_token"]
 | 
			
		||||
    self.auth_token
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
module MastodonManager
 | 
			
		||||
  class FetchUser < MastodonManagerService
 | 
			
		||||
    def initialize(mastodon_id:)
 | 
			
		||||
      @mastodon_id = mastodon_id
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      user = get "v1/admin/accounts/#{@mastodon_id}"
 | 
			
		||||
      user.with_indifferent_access
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,14 +0,0 @@
 | 
			
		||||
module MastodonManager
 | 
			
		||||
  class FindUser < MastodonManagerService
 | 
			
		||||
    def initialize(username:)
 | 
			
		||||
      @username = username
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      users = get "v2/admin/accounts?username=#{@username}&origin=local"
 | 
			
		||||
      users = users.map { |u| u.with_indifferent_access }
 | 
			
		||||
      # Results may contain partial matches
 | 
			
		||||
      users.find { |u| u.dig(:username).downcase == @username.downcase }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,64 +0,0 @@
 | 
			
		||||
module MastodonManager
 | 
			
		||||
  class SyncAccountProfiles < MastodonManagerService
 | 
			
		||||
    def initialize(direction: "down", overwrite: false, user: nil)
 | 
			
		||||
      @direction = direction
 | 
			
		||||
      @overwrite = overwrite
 | 
			
		||||
      @user = user
 | 
			
		||||
 | 
			
		||||
      if @direction != "down"
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      if @user
 | 
			
		||||
        Rails.logger.debug { "Syncing account profile for user #{@user.cn} (direction: #{@direction}, overwrite: #{@overwrite})"}
 | 
			
		||||
        users = User.where(cn: @user.cn)
 | 
			
		||||
      else
 | 
			
		||||
        Rails.logger.debug { "Syncing account profiles (direction: #{@direction}, overwrite: #{@overwrite})"}
 | 
			
		||||
        users = User
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      users.find_each do |user|
 | 
			
		||||
        if user.mastodon_id.blank?
 | 
			
		||||
          mastodon_user = MastodonManager::FindUser.call username: user.cn
 | 
			
		||||
          if mastodon_user
 | 
			
		||||
            Rails.logger.debug { "Setting mastodon_id for user #{user.cn}" }
 | 
			
		||||
            user.update! mastodon_id: mastodon_user.dig(:account, :id).to_i
 | 
			
		||||
          else
 | 
			
		||||
            Rails.logger.debug { "No Mastodon user found for username #{user.cn}" }
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        next if user.avatar.attached? && user.display_name.present?
 | 
			
		||||
 | 
			
		||||
        unless mastodon_user
 | 
			
		||||
          Rails.logger.debug { "Fetching Mastodon account with ID #{user.mastodon_id} for #{user.cn}" }
 | 
			
		||||
          mastodon_user = MastodonManager::FetchUser.call mastodon_id: user.mastodon_id
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if user.display_name.blank?
 | 
			
		||||
          if mastodon_display_name = mastodon_user.dig(:account, :display_name)
 | 
			
		||||
            Rails.logger.debug { "Setting display name for user #{user.cn} from Mastodon" }
 | 
			
		||||
            LdapManager::UpdateDisplayName.call(
 | 
			
		||||
              dn: user.dn, display_name: mastodon_display_name
 | 
			
		||||
            )
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if !user.avatar.attached?
 | 
			
		||||
          if avatar_url = mastodon_user.dig(:account, :avatar_static)
 | 
			
		||||
            Rails.logger.debug { "Importing Mastodon avatar for user #{user.cn}" }
 | 
			
		||||
            UserManager::ImportRemoteAvatar.call(
 | 
			
		||||
              user: user, avatar_url: avatar_url
 | 
			
		||||
            )
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      rescue => e
 | 
			
		||||
        Sentry.capture_exception(e) if Setting.sentry_enabled?
 | 
			
		||||
        Rails.logger.error e
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# API Docs: https://docs.joinmastodon.org/methods/
 | 
			
		||||
#
 | 
			
		||||
class MastodonManagerService < RestApiService
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def base_url
 | 
			
		||||
    @base_url ||= "#{Setting.mastodon_public_url}/api"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def auth_token
 | 
			
		||||
    @auth_token ||= Setting.mastodon_auth_token
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def headers
 | 
			
		||||
    {
 | 
			
		||||
      "Content-Type" => "application/json",
 | 
			
		||||
      "Accept" => "application/json",
 | 
			
		||||
      "Authorization" => "Bearer #{auth_token}"
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										21
									
								
								app/services/nostr_manager/discover_user_profile.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/services/nostr_manager/discover_user_profile.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
module NostrManager
 | 
			
		||||
  class DiscoverUserProfile < NostrManagerService
 | 
			
		||||
    def initialize(pubkey:, relays: nil)
 | 
			
		||||
      @pubkey = pubkey
 | 
			
		||||
      @relays = relays.present? ? relays : Setting.nostr_discovery_relays
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      filter = Nostr::Filter.new(
 | 
			
		||||
        authors: [@pubkey],
 | 
			
		||||
        kinds: [0],
 | 
			
		||||
        limit: 1,
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      NostrManager::FetchLatestEvent.call(
 | 
			
		||||
        relays: @relays,
 | 
			
		||||
        filter: filter
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										21
									
								
								app/services/nostr_manager/discover_user_relays.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/services/nostr_manager/discover_user_relays.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
module NostrManager
 | 
			
		||||
  class DiscoverUserRelays < NostrManagerService
 | 
			
		||||
    def initialize(pubkey:)
 | 
			
		||||
      @pubkey = pubkey
 | 
			
		||||
      @relays = Setting.nostr_discovery_relays
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      filter = Nostr::Filter.new(
 | 
			
		||||
        authors: [@pubkey],
 | 
			
		||||
        kinds: [10002],
 | 
			
		||||
        limit: 1,
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      NostrManager::FetchLatestEvent.call(
 | 
			
		||||
        relays: @relays,
 | 
			
		||||
        filter: filter
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										59
									
								
								app/services/nostr_manager/fetch_event.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								app/services/nostr_manager/fetch_event.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,59 @@
 | 
			
		||||
module NostrManager
 | 
			
		||||
  class FetchEvent < NostrManagerService
 | 
			
		||||
    TIMEOUT = 10
 | 
			
		||||
 | 
			
		||||
    def initialize(filter:, relay_url:)
 | 
			
		||||
      @filter = filter
 | 
			
		||||
      @relay = new_relay(relay_url)
 | 
			
		||||
      @client = Nostr::Client.new
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      filter, client, relay = @filter, @client, @relay
 | 
			
		||||
      event = nil
 | 
			
		||||
      mutex = Mutex.new
 | 
			
		||||
      received_event = ConditionVariable.new
 | 
			
		||||
      log_prefix = "[nostr][#{@relay.name}]"
 | 
			
		||||
 | 
			
		||||
      thread = Thread.new do
 | 
			
		||||
        client.on :connect do
 | 
			
		||||
          client.subscribe(filter: filter)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        client.on :error do |e|
 | 
			
		||||
          Rails.logger.info "#{log_prefix} Error: #{e}"
 | 
			
		||||
          Thread.current.exit
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        client.on :message do |m|
 | 
			
		||||
          msg = JSON.parse(m) rescue nil
 | 
			
		||||
          if msg && msg[0] == "EVENT" && msg[2]
 | 
			
		||||
            Rails.logger.debug "#{log_prefix} Event received: #{msg[2]["id"]}"
 | 
			
		||||
            mutex.synchronize do
 | 
			
		||||
              event = msg[2]
 | 
			
		||||
              received_event.signal
 | 
			
		||||
            end
 | 
			
		||||
          elsif msg && msg[0] == "EOSE"
 | 
			
		||||
            Thread.current.exit
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        client.connect relay
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        Timeout.timeout(TIMEOUT) do
 | 
			
		||||
          mutex.synchronize do
 | 
			
		||||
            received_event.wait(mutex) if event.nil?
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      rescue Timeout::Error
 | 
			
		||||
        Rails.logger.debug "#{log_prefix} Timeout: No event received within #{TIMEOUT} seconds"
 | 
			
		||||
      ensure
 | 
			
		||||
        thread.exit
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      event
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										44
									
								
								app/services/nostr_manager/fetch_latest_event.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								app/services/nostr_manager/fetch_latest_event.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
module NostrManager
 | 
			
		||||
  class FetchLatestEvent < NostrManagerService
 | 
			
		||||
    TIMEOUT = 20
 | 
			
		||||
 | 
			
		||||
    def initialize(relays:, filter:, max_events: 2)
 | 
			
		||||
      @relays = relays
 | 
			
		||||
      @filter = filter
 | 
			
		||||
      @max_events = max_events
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def call
 | 
			
		||||
      received_events = 0
 | 
			
		||||
      events = []
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        Timeout.timeout(TIMEOUT) do
 | 
			
		||||
          @relays.each do |url|
 | 
			
		||||
            event = NostrManager::FetchEvent.call(filter: @filter, relay_url: url)
 | 
			
		||||
 | 
			
		||||
            if event.present?
 | 
			
		||||
              events << event if events.none? { |e| e["id"] == event["id"] }
 | 
			
		||||
              received_events += 1
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            if received_events >= @max_events
 | 
			
		||||
              Rails.logger.debug "Found #{@max_events} events, ending the search"
 | 
			
		||||
              break
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          events.min_by { |e| e["created_at"] }
 | 
			
		||||
        end
 | 
			
		||||
      rescue Timeout::Error
 | 
			
		||||
        if events.size == 1
 | 
			
		||||
          Rails.logger.debug "[nostr] Timeout: only found 1 event within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
 | 
			
		||||
          events.first
 | 
			
		||||
        else
 | 
			
		||||
          Rails.logger.debug "[nostr] Timeout: no events found within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
 | 
			
		||||
          nil
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@ -19,28 +19,28 @@ module NostrManager
 | 
			
		||||
 | 
			
		||||
      thread = Thread.new do
 | 
			
		||||
        client.on :connect do
 | 
			
		||||
          puts "#{log_prefix} Publishing #{event.id}..."
 | 
			
		||||
          Rails.logger.debug "#{log_prefix} Publishing #{event.id}..."
 | 
			
		||||
          client.publish event
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        client.on :error do |e|
 | 
			
		||||
          puts "#{log_prefix} Error: #{e}"
 | 
			
		||||
          puts "#{log_prefix} Closing thread..."
 | 
			
		||||
          Rails.logger.debug "#{log_prefix} Error: #{e}"
 | 
			
		||||
          Rails.logger.debug "#{log_prefix} Closing thread..."
 | 
			
		||||
          thread.exit
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        client.on :message do |m|
 | 
			
		||||
          puts "#{log_prefix} Message: #{m}"
 | 
			
		||||
          Rails.logger.debug "#{log_prefix} Message: #{m}"
 | 
			
		||||
          msg = JSON.parse(m) rescue []
 | 
			
		||||
          if msg[0] == "OK" && msg[1] == event.id && msg[2]
 | 
			
		||||
            puts "#{log_prefix} Event published. Closing thread..."
 | 
			
		||||
            Rails.logger.debug "#{log_prefix} Event published. Closing thread..."
 | 
			
		||||
          else
 | 
			
		||||
            puts "#{log_prefix} Unexpected message from relay. Closing thread..."
 | 
			
		||||
            Rails.logger.debug "#{log_prefix} Unexpected message from relay. Closing thread..."
 | 
			
		||||
          end
 | 
			
		||||
          thread.exit
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        puts "#{log_prefix} Connecting to #{relay.url}..."
 | 
			
		||||
        Rails.logger.debug "#{log_prefix} Connecting to #{relay.url}..."
 | 
			
		||||
        client.connect relay
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user