Compare commits
158 Commits
8747ce4eb0
...
docs/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
14c5dd22d6
|
|||
|
f3676949d2
|
|||
|
79952b73c5
|
|||
| 17c419403e | |||
|
6d06312a5c
|
|||
|
acb399b0b7
|
|||
|
bf20b6467e
|
|||
|
b91d90d75c
|
|||
|
3284bbf6ca
|
|||
|
171b84ee81
|
|||
|
54b01dd282
|
|||
| 32dff9c67f | |||
|
126b8b20e0
|
|||
|
5abf69f356
|
|||
|
210a69bd9b
|
|||
|
bbed3cd367
|
|||
|
7943da0f17
|
|||
| 620167eedf | |||
|
e077debfc2
|
|||
|
531b2c3002
|
|||
|
6d2bc729b8
|
|||
|
2630ec2af4
|
|||
| daed5c1eea | |||
| 2e9429bb32 | |||
|
37c15c7a62
|
|||
|
01ecea74ff
|
|||
|
f401a03590
|
|||
|
fff6dea100
|
|||
|
48ab96dda9
|
|||
|
7ac3130c18
|
|||
|
cbfa148051
|
|||
|
87d900b627
|
|||
|
926dc06294
|
|||
|
00b73b06d7
|
|||
|
0daac33915
|
|||
|
0e472bc311
|
|||
| 40b34d0935 | |||
|
61cb8f4941
|
|||
|
433ac4dc8e
|
|||
|
62fe0d8fac
|
|||
|
2a675fd135
|
|||
|
c2c3ebc2e1
|
|||
|
5a5c316c14
|
|||
| f0d5457ec1 | |||
|
5588e3b3e8
|
|||
|
8949d76d26
|
|||
| 8bc9bbdc33 | |||
| d6d09b57b8 | |||
|
1685d6ecf8
|
|||
|
5348a229a6
|
|||
|
bad3b7a2be
|
|||
|
b541e95bb7
|
|||
|
3f43fe8101
|
|||
|
231dfc8404
|
|||
|
eeb9b0a331
|
|||
|
08e783d185
|
|||
|
fa5dc8ca46
|
|||
|
bc34e9c5e0
|
|||
| f388bd0237 | |||
|
48041630ca
|
|||
|
2d1ff29eca
|
|||
| 46fa42e387 | |||
|
c6c5d80fb4
|
|||
|
c0f4e7925e
|
|||
|
49d24990b4
|
|||
|
619bd954b7
|
|||
|
e27c64b5f1
|
|||
|
b36baf26eb
|
|||
|
adedaa5f7b
|
|||
|
596ed7fccc
|
|||
|
5685e1b7bc
|
|||
|
c3b82fc2a9
|
|||
|
77e2fe5792
|
|||
|
bc43082839
|
|||
|
b09225543b
|
|||
|
f2507409a3
|
|||
|
46b4723999
|
|||
|
3f90a011c4
|
|||
|
3ba333e802
|
|||
| d9dff3e872 | |||
| 6ddeacb779 | |||
|
78aff3d796
|
|||
|
8f600f44bd
|
|||
|
819ecf6ad8
|
|||
|
945eaba5e1
|
|||
|
22d362e1a0
|
|||
|
d4e67a830c
|
|||
|
670b2da1ef
|
|||
|
ed5c5b3081
|
|||
| 4ee6bfddfa | |||
|
8b60890061
|
|||
|
0367450c4b
|
|||
|
e6f5623c7f
|
|||
| 367f566ccb | |||
|
80e69df75c
|
|||
|
02af69b055
|
|||
|
5d459e7e7d
|
|||
| 51a3cb60ec | |||
| 43c57c128f | |||
|
5a3adba603
|
|||
|
3715cb518b
|
|||
|
2c9ecc1fef
|
|||
|
095747e89b
|
|||
|
2130369604
|
|||
|
c996351930
|
|||
| 8b897168cc | |||
|
4217ba52e0
|
|||
|
de20931d30
|
|||
|
8de0a2e26e
|
|||
|
06521d1c34
|
|||
|
38b3d68fd5
|
|||
|
eac8fa6edb
|
|||
|
43f918a074
|
|||
| e322867d79 | |||
|
4d6fa318b7
|
|||
|
7f2df3b025
|
|||
|
da22a9d448
|
|||
|
e3b96d5cff
|
|||
| 4e8878a4b5 | |||
|
e65b890880
|
|||
|
f57edd4d3b
|
|||
|
1afd56fb80
|
|||
| 71669a4b96 | |||
|
c312e30c17
|
|||
| 51f4556ede | |||
|
c36cf5eee6
|
|||
|
54220019bb
|
|||
|
079ee8833c
|
|||
|
26d613bdca
|
|||
|
69b3afb8f7
|
|||
|
fee951c05c
|
|||
| 4fa4ae6b54 | |||
| 869ff4691b | |||
|
822a2dc018
|
|||
|
5b7fc3707b
|
|||
| 0e2dc54dc6 | |||
| 87f09c94d0 | |||
|
b33b8104a8
|
|||
| 4a4a222973 | |||
| 8c524abcf5 | |||
|
a852ab75ae
|
|||
|
de1f234c15
|
|||
| 4581900427 | |||
|
56d91083e5
|
|||
|
ba7c3795f8
|
|||
|
bbf3fb91a0
|
|||
| 1754df73cb | |||
|
9a1f9abf84
|
|||
|
2753388e1e
|
|||
|
f3159d30f1
|
|||
|
ca238be6f4
|
|||
|
bd1b177993
|
|||
|
3f110995a4
|
|||
|
a7410058fa
|
|||
|
411587456b
|
|||
|
84e915ece9
|
|||
|
70ac3b0a70
|
|||
|
a7cbd8ce36
|
85
.env.example
85
.env.example
@@ -1,14 +1,14 @@
|
|||||||
PRIMARY_DOMAIN=kosmos.org
|
# PRIMARY_DOMAIN=kosmos.org
|
||||||
AKKOUNTS_DOMAIN=accounts.example.com
|
# AKKOUNTS_DOMAIN=accounts.example.com
|
||||||
|
|
||||||
SMTP_SERVER=smtp.example.com
|
# SMTP_SERVER=smtp.example.com
|
||||||
SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
SMTP_LOGIN=accounts
|
# SMTP_LOGIN=accounts
|
||||||
SMTP_PASSWORD=123abc
|
# SMTP_PASSWORD=123abc
|
||||||
SMTP_FROM_ADDRESS=accounts@example.com
|
# SMTP_FROM_ADDRESS=accounts@example.com
|
||||||
SMTP_DOMAIN=example.com
|
# SMTP_DOMAIN=example.com
|
||||||
SMTP_AUTH_METHOD=plain
|
# SMTP_AUTH_METHOD=plain
|
||||||
SMTP_ENABLE_STARTTLS=auto
|
# SMTP_ENABLE_STARTTLS=auto
|
||||||
|
|
||||||
# S3_ENABLED=true
|
# S3_ENABLED=true
|
||||||
# S3_ENDPOINT=https://s3.kosmos.org
|
# S3_ENDPOINT=https://s3.kosmos.org
|
||||||
@@ -18,47 +18,54 @@ SMTP_ENABLE_STARTTLS=auto
|
|||||||
# S3_ACCESS_KEY=123456abcdefg
|
# S3_ACCESS_KEY=123456abcdefg
|
||||||
# S3_SECRET_KEY=123456789123456789123456789
|
# S3_SECRET_KEY=123456789123456789123456789
|
||||||
|
|
||||||
LDAP_HOST=localhost
|
# LDAP_HOST=localhost
|
||||||
LDAP_PORT=389
|
# LDAP_PORT=389
|
||||||
LDAP_ADMIN_PASSWORD=passthebutter
|
# LDAP_ADMIN_PASSWORD=passthebutter
|
||||||
LDAP_SUFFIX='dc=kosmos,dc=org'
|
# LDAP_SUFFIX='dc=kosmos,dc=org'
|
||||||
|
|
||||||
REDIS_URL='redis://localhost:6379/1'
|
# REDIS_URL='redis://localhost:6379/1'
|
||||||
|
|
||||||
WEBHOOKS_ALLOWED_IPS='10.1.1.163'
|
# WEBHOOKS_ALLOWED_IPS='10.1.1.163'
|
||||||
|
|
||||||
#
|
#
|
||||||
# Service Integrations
|
# Service Integrations
|
||||||
|
# (sorted alphabetically by service name)
|
||||||
#
|
#
|
||||||
|
|
||||||
BTCPAY_API_URL='http://localhost:23001/api/v1'
|
# BTCPAY_PUBLIC_URL='https://btcpay.example.com'
|
||||||
BTCPAY_STORE_ID=''
|
# BTCPAY_API_URL='http://localhost:23001/api/v1'
|
||||||
BTCPAY_AUTH_TOKEN=''
|
# BTCPAY_STORE_ID=''
|
||||||
|
# BTCPAY_AUTH_TOKEN=''
|
||||||
|
|
||||||
DISCOURSE_PUBLIC_URL='https://community.kosmos.org'
|
# DISCOURSE_PUBLIC_URL='https://community.kosmos.org'
|
||||||
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
|
# DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
|
||||||
|
|
||||||
DRONECI_PUBLIC_URL='https://drone.kosmos.org'
|
# DRONECI_PUBLIC_URL='https://drone.kosmos.org'
|
||||||
|
|
||||||
EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin'
|
# EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin'
|
||||||
EJABBERD_API_URL='https://xmpp.kosmos.org/api'
|
# EJABBERD_API_URL='https://xmpp.kosmos.org/api'
|
||||||
|
|
||||||
GITEA_PUBLIC_URL='https://gitea.kosmos.org'
|
# GITEA_PUBLIC_URL='https://gitea.kosmos.org'
|
||||||
|
|
||||||
LNDHUB_API_URL='http://localhost:3023'
|
# LNDHUB_API_URL='http://localhost:3023'
|
||||||
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
|
# LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
|
||||||
LNDHUB_PUBLIC_KEY='0123d3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
# LNDHUB_PUBLIC_KEY='0123d3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
||||||
LNDHUB_ADMIN_UI=true
|
# LNDHUB_ADMIN_UI=true
|
||||||
LNDHUB_ADMIN_TOKEN=123456789
|
# LNDHUB_ADMIN_TOKEN=123456789
|
||||||
LNDHUB_PG_HOST=localhost
|
# LNDHUB_PG_HOST=localhost
|
||||||
LNDHUB_PG_PORT=5432
|
# LNDHUB_PG_PORT=5432
|
||||||
LNDHUB_PG_DATABASE=lndhub
|
# LNDHUB_PG_DATABASE=lndhub
|
||||||
LNDHUB_PG_USERNAME=lndhub
|
# LNDHUB_PG_USERNAME=lndhub
|
||||||
LNDHUB_PG_PASSWORD=''
|
# LNDHUB_PG_PASSWORD=''
|
||||||
|
|
||||||
MASTODON_PUBLIC_URL='https://kosmos.social'
|
# MASTODON_PUBLIC_URL='https://kosmos.social'
|
||||||
|
# MASTODON_ADDRESS_DOMAIN='https://kosmos.org'
|
||||||
|
|
||||||
MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'
|
# MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'
|
||||||
|
|
||||||
RS_STORAGE_URL='https://storage.kosmos.org'
|
# NOSTR_PRIVATE_KEY='123456abcdef...'
|
||||||
RS_REDIS_URL='redis://localhost:6379/2'
|
# NOSTR_PUBLIC_KEY='123456abcdef...'
|
||||||
|
# NOSTR_RELAY_URL='wss://nostr.kosmos.org'
|
||||||
|
|
||||||
|
# RS_STORAGE_URL='https://storage.kosmos.org'
|
||||||
|
# RS_REDIS_URL='redis://localhost:6379/2'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
PRIMARY_DOMAIN=kosmos.org
|
PRIMARY_DOMAIN=kosmos.org
|
||||||
|
AKKOUNTS_DOMAIN=accounts.kosmos.org
|
||||||
|
|
||||||
REDIS_URL='redis://localhost:6379/0'
|
REDIS_URL='redis://localhost:6379/0'
|
||||||
|
|
||||||
|
BTCPAY_PUBLIC_URL='https://btcpay.example.com'
|
||||||
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
|
BTCPAY_API_URL='http://btcpay.example.com/api/v1'
|
||||||
BTCPAY_STORE_ID='123456'
|
BTCPAY_STORE_ID='123456'
|
||||||
|
|
||||||
@@ -10,10 +12,15 @@ DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
|
|||||||
|
|
||||||
EJABBERD_API_URL='http://xmpp.example.com/api'
|
EJABBERD_API_URL='http://xmpp.example.com/api'
|
||||||
|
|
||||||
|
MASTODON_PUBLIC_URL='http://example.social'
|
||||||
|
|
||||||
LNDHUB_API_URL='http://localhost:3026'
|
LNDHUB_API_URL='http://localhost:3026'
|
||||||
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
|
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
|
||||||
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
||||||
|
|
||||||
|
NOSTR_PRIVATE_KEY='7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea'
|
||||||
|
NOSTR_PUBLIC_KEY='bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf'
|
||||||
|
|
||||||
RS_STORAGE_URL='https://storage.kosmos.org'
|
RS_STORAGE_URL='https://storage.kosmos.org'
|
||||||
RS_REDIS_URL='redis://localhost:6379/1'
|
RS_REDIS_URL='redis://localhost:6379/1'
|
||||||
|
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,10 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM ruby:3.3.0
|
FROM debian:bullseye-slim as base
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \
|
# TODO Remove when upstream Ruby works properly on Apple silicon
|
||||||
ldap-utils tini libvips
|
RUN apt update && apt install -y build-essential wget autoconf libpq-dev pkg-config
|
||||||
|
RUN wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz \
|
||||||
|
&& tar -xzvf ruby-install-0.9.3.tar.gz \
|
||||||
|
&& cd ruby-install-0.9.3/ \
|
||||||
|
&& make install
|
||||||
|
RUN ruby-install -p https://github.com/ruby/ruby/pull/9371.diff ruby 3.3.0
|
||||||
|
ENV PATH="/opt/rubies/ruby-3.3.0/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN apt-get install -y --no-install-recommends curl ldap-utils tini libvips
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||||
RUN apt-get update && apt-get install -y nodejs
|
RUN apt-get update && apt-get install -y nodejs
|
||||||
|
|
||||||
|
|||||||
4
Gemfile
4
Gemfile
@@ -61,8 +61,8 @@ gem "sentry-rails"
|
|||||||
# Services
|
# Services
|
||||||
gem 'discourse_api'
|
gem 'discourse_api'
|
||||||
gem "lnurl"
|
gem "lnurl"
|
||||||
gem 'manifique'
|
gem 'manifique', '~> 1.1.0'
|
||||||
gem 'nostr'
|
gem 'nostr', '~> 0.6.0'
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
|
|||||||
12
Gemfile.lock
12
Gemfile.lock
@@ -155,7 +155,7 @@ GEM
|
|||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
e2mmap (0.1.0)
|
e2mmap (0.1.0)
|
||||||
ecdsa (1.2.0)
|
ecdsa (1.2.0)
|
||||||
ecdsa_ext (0.5.0)
|
ecdsa_ext (0.5.1)
|
||||||
ecdsa (~> 1.2.0)
|
ecdsa (~> 1.2.0)
|
||||||
erubi (1.12.0)
|
erubi (1.12.0)
|
||||||
et-orbi (1.2.7)
|
et-orbi (1.2.7)
|
||||||
@@ -245,7 +245,7 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
manifique (1.0.1)
|
manifique (1.1.0)
|
||||||
faraday (~> 2.9.0)
|
faraday (~> 2.9.0)
|
||||||
faraday-follow_redirects (= 0.3.0)
|
faraday-follow_redirects (= 0.3.0)
|
||||||
nokogiri (~> 1.16.0)
|
nokogiri (~> 1.16.0)
|
||||||
@@ -278,9 +278,9 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.16.0-x86_64-linux)
|
nokogiri (1.16.0-x86_64-linux)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nostr (0.5.0)
|
nostr (0.6.0)
|
||||||
bech32 (~> 1.4)
|
bech32 (~> 1.4)
|
||||||
bip-schnorr (~> 0.6)
|
bip-schnorr (~> 0.7)
|
||||||
ecdsa (~> 1.2)
|
ecdsa (~> 1.2)
|
||||||
event_emitter (~> 0.2)
|
event_emitter (~> 0.2)
|
||||||
faye-websocket (~> 0.11)
|
faye-websocket (~> 0.11)
|
||||||
@@ -515,9 +515,9 @@ DEPENDENCIES
|
|||||||
listen (~> 3.2)
|
listen (~> 3.2)
|
||||||
lnurl
|
lnurl
|
||||||
lockbox
|
lockbox
|
||||||
manifique
|
manifique (~> 1.1.0)
|
||||||
net-ldap
|
net-ldap
|
||||||
nostr
|
nostr (~> 0.6.0)
|
||||||
pagy (~> 6.0, >= 6.0.2)
|
pagy (~> 6.0, >= 6.0.2)
|
||||||
pg (~> 1.5)
|
pg (~> 1.5)
|
||||||
puma (~> 4.1)
|
puma (~> 4.1)
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -14,8 +14,10 @@ so:
|
|||||||
|
|
||||||
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
|
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
|
||||||
Docker Desktop)
|
Docker Desktop)
|
||||||
3. Run `docker compose up` and wait until 389ds announces its successful start
|
3. Run `docker compose up --build` and wait until all services have started
|
||||||
in the log output
|
(389ds might take an extra minute to be ready). This will take a while when
|
||||||
|
running for the first time, so you might want to do something else in the
|
||||||
|
meantime.
|
||||||
4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"`
|
4. `docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"`
|
||||||
5. `docker compose run web rails ldap:setup`
|
5. `docker compose run web rails ldap:setup`
|
||||||
6. `docker compose run web rails db:setup`
|
6. `docker compose run web rails db:setup`
|
||||||
@@ -28,38 +30,44 @@ have the password "user is user".
|
|||||||
|
|
||||||
### Rails app
|
### Rails app
|
||||||
|
|
||||||
|
_Note: when using Docker Compose, prefix the following commands with `docker-compose
|
||||||
|
run web`._
|
||||||
|
|
||||||
Installing dependencies:
|
Installing dependencies:
|
||||||
|
|
||||||
bundle install
|
bundle install
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
Setting up local database (SQLite):
|
Migrating the local database (after schema changes):
|
||||||
|
|
||||||
bundle exec rails db:create
|
|
||||||
bundle exec rails db:migrate
|
bundle exec rails db:migrate
|
||||||
|
|
||||||
Running the dev server and auto-building CSS files on change:
|
Running the dev server, and auto-building CSS files on change _(automatic with Docker Compose)_:
|
||||||
|
|
||||||
bin/dev
|
bin/dev
|
||||||
|
|
||||||
Running the background workers (requires Redis):
|
Running the background workers (requires Redis) _(automatic with Docker Compose)_:
|
||||||
|
|
||||||
bundle exec sidekiq -C config/sidekiq.yml
|
bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
|
||||||
Running all specs:
|
Running the test suite:
|
||||||
|
|
||||||
bundle exec rspec
|
bundle exec rspec
|
||||||
|
|
||||||
### Docker (Compose)
|
Running the test suite with Docker Compose requires overriding the Rails
|
||||||
|
environment:
|
||||||
|
|
||||||
There is a working Docker Compose config file, which define a number of services including
|
docker-compose run -e "RAILS_ENV=test" web rspec
|
||||||
an app server for Rails as well as a local 389ds (LDAP) server.
|
|
||||||
|
|
||||||
For Rails developers, you probably just want to start the LDAP server: `docker-compose up ldap`,
|
### Docker Compose
|
||||||
listening on port 389 on your machine.
|
|
||||||
|
|
||||||
You can pick and choose your services adding them by name (listed in `docker-compose.yml`) at
|
Services/containers are configured in `docker-compose.yml`.
|
||||||
the end of the docker compose command. eg. `docker compose up ldap redis`
|
|
||||||
|
You can run services selectively, for example if you want to run the Rails app
|
||||||
|
and test suite on the host machine. Just add the service names of the
|
||||||
|
containers you want to run to the `up` command, like so:
|
||||||
|
|
||||||
|
docker-compose up ldap redis
|
||||||
|
|
||||||
#### LDAP server
|
#### LDAP server
|
||||||
|
|
||||||
@@ -76,13 +84,15 @@ Now you can seed the back-end with data using this Rails task:
|
|||||||
The setup task will first delete any existing entries in the directory tree
|
The setup task will first delete any existing entries in the directory tree
|
||||||
("dc=kosmos,dc=org"), and then create our development entries.
|
("dc=kosmos,dc=org"), and then create our development entries.
|
||||||
|
|
||||||
Note that all 389ds data is stored in `tmp/389ds`. So if you want to start over
|
Note that all 389ds data is stored in the `389ds-data` volume. So if you want
|
||||||
with a fresh installation, delete both that directory as well as the container.
|
to start over with a fresh installation, delete both that volume as well as the
|
||||||
|
container.
|
||||||
|
|
||||||
#### Minio / RS
|
#### Minio / remoteStorage
|
||||||
|
|
||||||
If you want to run remoteStorage accounts locally, you will have to create the
|
If you want to run remoteStorage accounts locally, you will have to create the
|
||||||
respective bucket first:
|
respective bucket first. With the `minio` container running (run by default
|
||||||
|
when using Docker Compose), follow these steps:
|
||||||
|
|
||||||
* `docker compose up web redis minio liquor-cabinet`
|
* `docker compose up web redis minio liquor-cabinet`
|
||||||
* Head to http://localhost:9001 and log in with user `minioadmin`, password
|
* Head to http://localhost:9001 and log in with user `minioadmin`, password
|
||||||
|
|||||||
@@ -32,11 +32,21 @@
|
|||||||
focus:ring-blue-400 focus:ring-opacity-75;
|
focus:ring-blue-400 focus:ring-opacity-75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-emerald {
|
||||||
|
@apply bg-emerald-500 hover:bg-emerald-600 text-white
|
||||||
|
focus:ring-emerald-400 focus:ring-opacity-75;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-red {
|
.btn-red {
|
||||||
@apply bg-red-600 hover:bg-red-700 text-white
|
@apply bg-red-600 hover:bg-red-700 text-white
|
||||||
focus:ring-red-500 focus:ring-opacity-75;
|
focus:ring-red-500 focus:ring-opacity-75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline-purple {
|
||||||
|
@apply border-2 border-purple-500 hover:bg-purple-100
|
||||||
|
focus:ring-purple-400 focus:ring-opacity-75;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
|
||||||
focus:ring-gray-300 focus:ring-opacity-75;
|
focus:ring-gray-300 focus:ring-opacity-75;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
.services > div > a {
|
.services > div > a {
|
||||||
background-image: linear-gradient(110deg, rgba(255,255,255,0.99) 0, rgba(255,255,255,0.88) 100%);
|
background-image: linear-gradient(110deg, rgba(255,255,255,0.99) 20%, rgba(255,255,255,0.88) 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<% if @image_url %>
|
||||||
|
<%= image_tag @image_url, class: "h-full w-full" %>
|
||||||
|
<% else %>
|
||||||
|
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-full w-full p-0.5 text-gray-200" } %>
|
||||||
|
<% end %>
|
||||||
21
app/components/app_catalog/web_app_icon_component.rb
Normal file
21
app/components/app_catalog/web_app_icon_component.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module AppCatalog
|
||||||
|
class WebAppIconComponent < ViewComponent::Base
|
||||||
|
def initialize(web_app:)
|
||||||
|
if web_app&.icon&.attached?
|
||||||
|
@image_url = image_url_for(web_app.icon)
|
||||||
|
elsif web_app&.apple_touch_icon&.attached?
|
||||||
|
@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
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
) do %>
|
) do %>
|
||||||
<%= method("#{@type}_field").call :setting, @key,
|
<%= method("#{@type}_field").call :setting, @key,
|
||||||
value: Setting.public_send(@key),
|
value: Setting.public_send(@key),
|
||||||
|
placeholder: @placeholder,
|
||||||
data: {
|
data: {
|
||||||
:'default-value' => Setting.get_field(@key)[:default]
|
:'default-value' => Setting.get_field(@key)[:default]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
module FormElements
|
module FormElements
|
||||||
class FieldsetResettableSettingComponent < ViewComponent::Base
|
class FieldsetResettableSettingComponent < ViewComponent::Base
|
||||||
def initialize(tag: "li", key:, type: :text, title:, description: nil)
|
def initialize(tag: "li", key:, type: :text, title:, description: nil, placeholder: nil)
|
||||||
@tag = tag
|
@tag = tag
|
||||||
@positioning = :vertical
|
@positioning = :vertical
|
||||||
@title = title
|
@title = title
|
||||||
@@ -10,6 +10,7 @@ module FormElements
|
|||||||
@key = key.to_sym
|
@key = key.to_sym
|
||||||
@type = type
|
@type = type
|
||||||
@resettable = is_resettable?(@key)
|
@resettable = is_resettable?(@key)
|
||||||
|
@placeholder = placeholder
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_resettable?(key)
|
def is_resettable?(key)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
} : nil do %>
|
} : nil do %>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label class="font-bold mb-1"><%= @title %></label>
|
<label class="font-bold mb-1"><%= @title %></label>
|
||||||
<p class="text-gray-500"><%= @descripton %></p>
|
<% if @description.present? %>
|
||||||
|
<p class="text-gray-500"><%= @description %></p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative ml-4 inline-flex flex-shrink-0">
|
<div class="relative ml-4 inline-flex flex-shrink-0">
|
||||||
<%= render FormElements::ToggleComponent.new(
|
<%= render FormElements::ToggleComponent.new(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
module FormElements
|
module FormElements
|
||||||
class FieldsetToggleComponent < ViewComponent::Base
|
class FieldsetToggleComponent < ViewComponent::Base
|
||||||
def initialize(tag: "li", form: nil, attribute: nil, field_name: nil,
|
def initialize(tag: "li", form: nil, attribute: nil, field_name: nil,
|
||||||
enabled: false, input_enabled: true, title:, description:)
|
enabled: false, input_enabled: true, title:, description: nil)
|
||||||
@tag = tag
|
@tag = tag
|
||||||
@form = form
|
@form = form
|
||||||
@attribute = attribute
|
@attribute = attribute
|
||||||
@@ -12,7 +12,7 @@ module FormElements
|
|||||||
@enabled = enabled
|
@enabled = enabled
|
||||||
@input_enabled = input_enabled
|
@input_enabled = input_enabled
|
||||||
@title = title
|
@title = title
|
||||||
@descripton = description
|
@description = description
|
||||||
@button_text = @enabled ? "Switch off" : "Switch on"
|
@button_text = @enabled ? "Switch off" : "Switch on"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<main class="w-full max-w-6xl mx-auto pb-12 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] bg-white rounded-lg shadow">
|
||||||
<div class="px-6 sm:px-12 pt-2 sm:pt-4">
|
<div class="px-6 sm:px-12 pt-2 sm:pt-4">
|
||||||
<%= render partial: @tabnav_partial %>
|
<%= render partial: @tabnav_partial %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,15 +12,17 @@
|
|||||||
|
|
||||||
<!-- Modal Container -->
|
<!-- Modal Container -->
|
||||||
<div data-modal-target="container"
|
<div data-modal-target="container"
|
||||||
class="max-h-screen w-auto max-w-lg relative
|
class="relative m-4 max-h-screen w-auto max-w-full
|
||||||
hidden animate-scale-in fixed inset-0 overflow-y-auto flex items-center justify-center">
|
hidden animate-scale-in fixed inset-0 overflow-y-auto flex items-center justify-center">
|
||||||
<!-- Modal Card -->
|
<!-- Modal Card -->
|
||||||
<div class="m-1 bg-white rounded shadow">
|
<div class="m-1 bg-white rounded shadow">
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<%= content %>
|
<%= content %>
|
||||||
|
<% if @show_close_button %>
|
||||||
<div class="flex justify-end items-center flex-wrap mt-6">
|
<div class="flex justify-end items-center flex-wrap mt-6">
|
||||||
<button class="btn-md btn-blue" data-action="click->modal#close:prevent">Close</button>
|
<button class="btn-md btn-blue" data-action="click->modal#close:prevent">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
class ModalComponent < ViewComponent::Base
|
class ModalComponent < ViewComponent::Base
|
||||||
|
def initialize(show_close_button: true)
|
||||||
|
@show_close_button = show_close_button
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class NotificationComponent < ViewComponent::Base
|
|||||||
'alert-octagon'
|
'alert-octagon'
|
||||||
when 'alert'
|
when 'alert'
|
||||||
'alert-octagon'
|
'alert-octagon'
|
||||||
|
when 'warning'
|
||||||
|
'alert-octagon'
|
||||||
else
|
else
|
||||||
'info'
|
'info'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="h-16 w-16 flex-none">
|
<div class="h-16 w-16 flex-none">
|
||||||
<% if @web_app.icon.attached? %>
|
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
|
||||||
<%= image_tag s3_image_url(@web_app.icon), class: "h-full w-full" %>
|
|
||||||
<% elsif @web_app.apple_touch_icon.attached? %>
|
|
||||||
<%= image_tag s3_image_url(@web_app.apple_touch_icon), class: "h-full w-full" %>
|
|
||||||
<% else %>
|
|
||||||
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-full w-full p-0.5 text-gray-200" } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<h4 class="mb-1 text-lg font-bold">
|
<h4 class="mb-1 text-lg font-bold">
|
||||||
<%= @web_app.name %>
|
<%= @web_app&.name || @auth.app_name %>
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
<%= @auth.client_id %>
|
<%= @auth.client_id %>
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ class Admin::DonationsController < Admin::BaseController
|
|||||||
before_action :set_current_section, only: [:index, :show, :new, :edit]
|
before_action :set_current_section, only: [:index, :show, :new, :edit]
|
||||||
|
|
||||||
# GET /donations
|
# GET /donations
|
||||||
# GET /donations.json
|
|
||||||
def index
|
def index
|
||||||
@pagy, @donations = pagy(Donation.all.order('created_at desc'))
|
@pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
|
||||||
|
|
||||||
@stats = {
|
@stats = {
|
||||||
overall_sats: @donations.all.sum("amount_sats"),
|
overall_sats: @donations.sum("amount_sats"),
|
||||||
donor_count: Donation.distinct.count(:user_id)
|
donor_count: Donation.completed.count(:user_id)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /donations/1
|
# GET /donations/1
|
||||||
# GET /donations/1.json
|
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -28,54 +26,41 @@ class Admin::DonationsController < Admin::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# POST /donations
|
# POST /donations
|
||||||
# POST /donations.json
|
|
||||||
def create
|
def create
|
||||||
@donation = Donation.new(donation_params)
|
@donation = Donation.new(donation_params)
|
||||||
|
|
||||||
respond_to do |format|
|
if @donation.paid_at == nil
|
||||||
if @donation.save
|
@donation.errors.add(:paid_at, message: "is required")
|
||||||
format.html do
|
render :new, status: :unprocessable_entity and return
|
||||||
redirect_to admin_donation_url(@donation), flash: {
|
end
|
||||||
success: 'Donation was successfully created.'
|
|
||||||
}
|
if @donation.save
|
||||||
end
|
redirect_to admin_donation_url(@donation), flash: {
|
||||||
format.json { render :show, status: :created, location: @donation }
|
success: 'Donation was successfully created.'
|
||||||
else
|
}
|
||||||
format.html { render :new, status: :unprocessable_entity }
|
else
|
||||||
format.json { render json: @donation.errors, status: :unprocessable_entity }
|
render :new, status: :unprocessable_entity
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH/PUT /donations/1
|
# PUT /donations/1
|
||||||
# PATCH/PUT /donations/1.json
|
|
||||||
def update
|
def update
|
||||||
respond_to do |format|
|
if @donation.update(donation_params)
|
||||||
if @donation.update(donation_params)
|
redirect_to admin_donation_url(@donation), flash: {
|
||||||
format.html do
|
success: 'Donation was successfully updated.'
|
||||||
redirect_to admin_donation_url(@donation), flash: {
|
}
|
||||||
success: 'Donation was successfully updated.'
|
else
|
||||||
}
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
|
||||||
format.json { render :show, status: :ok, location: @donation }
|
|
||||||
else
|
|
||||||
format.html { render :edit, status: :unprocessable_entity }
|
|
||||||
format.json { render json: @donation.errors, status: :unprocessable_entity }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /donations/1
|
# DELETE /donations/1
|
||||||
# DELETE /donations/1.json
|
|
||||||
def destroy
|
def destroy
|
||||||
@donation.destroy
|
@donation.destroy
|
||||||
respond_to do |format|
|
|
||||||
format.html do redirect_to admin_donations_url, flash: {
|
redirect_to admin_donations_url, flash: {
|
||||||
success: 'Donation was successfully destroyed.'
|
success: 'Donation was successfully destroyed.'
|
||||||
}
|
}
|
||||||
end
|
|
||||||
format.json { head :no_content }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -86,7 +71,10 @@ class Admin::DonationsController < Admin::BaseController
|
|||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
# Only allow a list of trusted parameters through.
|
||||||
def donation_params
|
def donation_params
|
||||||
params.require(:donation).permit(:user_id, :amount_sats, :amount_eur, :amount_usd, :public_name, :paid_at)
|
params.require(:donation).permit(
|
||||||
|
:user_id, :donation_method,
|
||||||
|
:amount_sats, :fiat_amount, :fiat_currency,
|
||||||
|
:public_name, :paid_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_current_section
|
def set_current_section
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class Admin::Settings::RegistrationsController < Admin::SettingsController
|
class Admin::Settings::RegistrationsController < Admin::SettingsController
|
||||||
def index
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def update
|
||||||
update_settings
|
update_settings
|
||||||
|
|
||||||
redirect_to admin_settings_registrations_path, flash: {
|
redirect_to admin_settings_registrations_path, flash: {
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
class Admin::Settings::ServicesController < Admin::SettingsController
|
class Admin::Settings::ServicesController < Admin::SettingsController
|
||||||
def index
|
before_action :set_service, only: [:show, :update]
|
||||||
@service = params[:s]
|
|
||||||
|
|
||||||
if @service.blank?
|
def index
|
||||||
redirect_to admin_settings_services_path(params: { s: "btcpay" })
|
redirect_to admin_settings_service_path("btcpay")
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def show
|
||||||
service = params.require(:service)
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
update_settings
|
update_settings
|
||||||
|
|
||||||
redirect_to admin_settings_services_path(params: { s: service }), flash: {
|
redirect_to admin_settings_service_path(@service), flash: {
|
||||||
success: "Settings saved"
|
success: "Settings saved"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_subsection
|
||||||
|
@subsection = "services"
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_service
|
||||||
|
@service = params[:service]
|
||||||
|
|
||||||
|
if @service.blank?
|
||||||
|
redirect_to admin_settings_services_path and return
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Admin::SettingsController < Admin::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @errors.any?
|
if @errors.any?
|
||||||
render :index and return
|
render :show and return
|
||||||
end
|
end
|
||||||
|
|
||||||
changed_keys.each do |key|
|
changed_keys.each do |key|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class Admin::UsersController < Admin::BaseController
|
class Admin::UsersController < Admin::BaseController
|
||||||
before_action :set_user, only: [:show]
|
before_action :set_user, except: [:index]
|
||||||
before_action :set_current_section
|
before_action :set_current_section
|
||||||
|
|
||||||
|
# GET /admin/users
|
||||||
def index
|
def index
|
||||||
ldap = LdapService.new
|
ldap = LdapService.new
|
||||||
@ou = Setting.primary_domain
|
@ou = Setting.primary_domain
|
||||||
@@ -13,6 +14,7 @@ class Admin::UsersController < Admin::BaseController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /admin/users/:username
|
||||||
def show
|
def show
|
||||||
if Setting.lndhub_admin_enabled?
|
if Setting.lndhub_admin_enabled?
|
||||||
@lndhub_user = @user.lndhub_user
|
@lndhub_user = @user.lndhub_user
|
||||||
@@ -23,6 +25,30 @@ class Admin::UsersController < Admin::BaseController
|
|||||||
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
|
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /admin/users/:username/invitations
|
||||||
|
def create_invitations
|
||||||
|
amount = params[:amount].to_i
|
||||||
|
notify_user = ActiveRecord::Type::Boolean.new.cast(params[: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"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /admin/users/:username/invitations
|
||||||
|
def delete_invitations
|
||||||
|
invitations = @user.invitations.unused
|
||||||
|
amount = invitations.count
|
||||||
|
|
||||||
|
invitations.destroy_all
|
||||||
|
|
||||||
|
redirect_to admin_user_path(@user.cn), flash: {
|
||||||
|
success: "Removed #{amount} invitations from #{@user.cn}'s account"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
|
|||||||
@@ -41,4 +41,31 @@ class ApplicationController < ActionController::Base
|
|||||||
def after_sign_in_path_for(user)
|
def after_sign_in_path_for(user)
|
||||||
session[:user_return_to] || root_path
|
session[:user_return_to] || root_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def lndhub_authenticate(options={})
|
||||||
|
if session[:ln_auth_token].present? && !options[:force_reauth]
|
||||||
|
@ln_auth_token = session[:ln_auth_token]
|
||||||
|
else
|
||||||
|
lndhub = Lndhub.new
|
||||||
|
auth_token = lndhub.authenticate(current_user)
|
||||||
|
session[:ln_auth_token] = auth_token
|
||||||
|
@ln_auth_token = auth_token
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Sentry.capture_exception(e) if Setting.sentry_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
|
def lndhub_fetch_balance
|
||||||
|
@balance = LndhubManager::FetchUserBalance.call(auth_token: @ln_auth_token)
|
||||||
|
rescue AuthError
|
||||||
|
lndhub_authenticate(force_reauth: true)
|
||||||
|
raise if @fetch_balance_retried
|
||||||
|
@fetch_balance_retried = true
|
||||||
|
lndhub_fetch_balance
|
||||||
|
end
|
||||||
|
|
||||||
|
def nostr_event_from_params
|
||||||
|
params.permit!
|
||||||
|
params[:signed_event].to_h.symbolize_keys
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,129 @@
|
|||||||
class Contributions::DonationsController < ApplicationController
|
class Contributions::DonationsController < ApplicationController
|
||||||
before_action :authenticate_user!
|
include BtcpayHelper
|
||||||
|
|
||||||
# GET /donations
|
before_action :authenticate_user!
|
||||||
# GET /donations.json
|
before_action :set_donation_methods, only: [:index, :create]
|
||||||
|
before_action :require_donation_method_enabled, only: [:create]
|
||||||
|
before_action :validate_donation_params, only: [:create]
|
||||||
|
before_action :set_donation, only: [:confirm_btcpay]
|
||||||
|
|
||||||
|
# GET /contributions/donations
|
||||||
def index
|
def index
|
||||||
@donations = current_user.donations.completed
|
|
||||||
@current_section = :contributions
|
@current_section = :contributions
|
||||||
|
@donations_completed = current_user.donations.completed.order('paid_at desc')
|
||||||
|
@donations_pending = current_user.donations.processing.order('created_at desc')
|
||||||
|
|
||||||
|
if Setting.lndhub_enabled?
|
||||||
|
begin
|
||||||
|
lndhub_authenticate
|
||||||
|
lndhub_fetch_balance
|
||||||
|
rescue
|
||||||
|
@balance = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /contributions/donations
|
||||||
|
def create
|
||||||
|
if params[:currency] == "sats"
|
||||||
|
fiat_amount = nil
|
||||||
|
fiat_currency = nil
|
||||||
|
amount_sats = params[:amount]
|
||||||
|
else
|
||||||
|
fiat_amount = params[:amount].to_i
|
||||||
|
fiat_currency = params[:currency]
|
||||||
|
amount_sats = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
@donation = current_user.donations.create!(
|
||||||
|
donation_method: params[:donation_method],
|
||||||
|
payment_method: nil,
|
||||||
|
paid_at: nil,
|
||||||
|
amount_sats: amount_sats,
|
||||||
|
fiat_amount: (fiat_amount.nil? ? nil : fiat_amount * 100), # store in cents
|
||||||
|
fiat_currency: fiat_currency,
|
||||||
|
public_name: params[:public_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
case params[:donation_method]
|
||||||
|
when "btcpay"
|
||||||
|
res = BtcpayManager::CreateInvoice.call(
|
||||||
|
amount: fiat_amount || (amount_sats.to_f / 100000000),
|
||||||
|
currency: fiat_currency || "BTC",
|
||||||
|
redirect_url: confirm_btcpay_contributions_donation_url(@donation)
|
||||||
|
)
|
||||||
|
|
||||||
|
@donation.update! btcpay_invoice_id: res["id"]
|
||||||
|
|
||||||
|
redirect_to btcpay_checkout_url(res["id"]), allow_other_host: true
|
||||||
|
else
|
||||||
|
redirect_to contributions_donations_url, flash: {
|
||||||
|
error: "Donation method currently not available"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def confirm_btcpay
|
||||||
|
redirect_to contributions_donations_url and return if @donation.completed?
|
||||||
|
|
||||||
|
invoice = BtcpayManager::FetchInvoice.call(invoice_id: @donation.btcpay_invoice_id)
|
||||||
|
|
||||||
|
if @donation.amount_sats.present?
|
||||||
|
# TODO make default fiat currency configurable and/or determine from user's
|
||||||
|
# i18n browser settings
|
||||||
|
@donation.fiat_currency = "EUR"
|
||||||
|
exchange_rate = BtcpayManager::FetchExchangeRate.call(fiat_currency: @donation.fiat_currency)
|
||||||
|
@donation.fiat_amount = (((@donation.amount_sats.to_f / 100000000) * exchange_rate) * 100).to_i
|
||||||
|
else
|
||||||
|
amt_str = invoice["paymentMethods"].first["amount"]
|
||||||
|
@donation.amount_sats = amt_str.tr(".","").sub(/0*$/, "").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
case invoice["status"]
|
||||||
|
when "Settled"
|
||||||
|
@donation.paid_at = DateTime.now
|
||||||
|
@donation.payment_status = "settled"
|
||||||
|
@donation.save!
|
||||||
|
flash_message = { success: "Thank you!" }
|
||||||
|
when "Processing"
|
||||||
|
unless @donation.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
|
||||||
|
when "Expired"
|
||||||
|
flash_message = { warning: "The payment request for this donation has expired" }
|
||||||
|
else
|
||||||
|
flash_message = { warning: "Could not determine status of payment" }
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to contributions_donations_url, flash: flash_message
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_donation
|
||||||
|
@donation = current_user.donations.find_by(id: params[:id])
|
||||||
|
http_status :not_found unless @donation.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_donation_methods
|
||||||
|
@donation_methods = []
|
||||||
|
@donation_methods.push :btcpay if Setting.btcpay_enabled?
|
||||||
|
@donation_methods.push :lndhub if Setting.lndhub_enabled?
|
||||||
|
@donation_methods.push :opencollective if Setting.opencollective_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_donation_method_enabled
|
||||||
|
http_status :forbidden unless @donation_methods.include?(
|
||||||
|
params[:donation_method].to_sym
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_donation_params
|
||||||
|
if !%w[EUR USD sats].include?(params[:currency]) || (params[:amount].to_i <= 0)
|
||||||
|
http_status :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
class LnurlpayController < ApplicationController
|
class LnurlpayController < ApplicationController
|
||||||
before_action :check_service_available
|
before_action :check_service_available
|
||||||
before_action :find_user
|
before_action :find_user
|
||||||
|
before_action :set_cors_access_control_headers, only: [:invoice]
|
||||||
|
|
||||||
MIN_SATS = 10
|
MIN_SATS = 10
|
||||||
MAX_SATS = 1_000_000
|
MAX_SATS = 1_000_000
|
||||||
MAX_COMMENT_CHARS = 100
|
MAX_COMMENT_CHARS = 100
|
||||||
|
|
||||||
|
# GET /.well-known/lnurlp/:username
|
||||||
def index
|
def index
|
||||||
render json: {
|
res = {
|
||||||
status: "OK",
|
status: "OK",
|
||||||
callback: "https://#{Setting.accounts_domain}/lnurlpay/#{@user.cn}/invoice",
|
callback: "https://#{Setting.accounts_domain}/lnurlpay/#{@user.cn}/invoice",
|
||||||
tag: "payRequest",
|
tag: "payRequest",
|
||||||
@@ -16,8 +18,16 @@ class LnurlpayController < ApplicationController
|
|||||||
metadata: metadata(@user.address),
|
metadata: metadata(@user.address),
|
||||||
commentAllowed: MAX_COMMENT_CHARS
|
commentAllowed: MAX_COMMENT_CHARS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Setting.nostr_enabled?
|
||||||
|
res[:allowsNostr] = true
|
||||||
|
res[:nostrPubkey] = Setting.nostr_public_key
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: res
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /.well-known/keysend/:username
|
||||||
def keysend
|
def keysend
|
||||||
http_status :not_found and return unless Setting.lndhub_keysend_enabled?
|
http_status :not_found and return unless Setting.lndhub_keysend_enabled?
|
||||||
|
|
||||||
@@ -32,8 +42,9 @@ class LnurlpayController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /lnurlpay/:username/invoice
|
||||||
def invoice
|
def invoice
|
||||||
amount = params[:amount].to_i / 1000 # msats
|
amount = params[:amount].to_i / 1000 # msats to sats
|
||||||
comment = params[:comment] || ""
|
comment = params[:comment] || ""
|
||||||
address = @user.address
|
address = @user.address
|
||||||
|
|
||||||
@@ -42,53 +53,109 @@ class LnurlpayController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if !valid_comment?(comment)
|
if params[:nostr].present? && Setting.nostr_enabled?
|
||||||
render json: { status: "ERROR", reason: "Comment too long" }
|
handle_zap_request amount, params[:nostr], params[:lnurl]
|
||||||
return
|
else
|
||||||
|
handle_pay_request address, amount, comment
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_cors_access_control_headers
|
||||||
|
headers['Access-Control-Allow-Origin'] = "*"
|
||||||
|
headers['Access-Control-Allow-Headers'] = "*"
|
||||||
|
headers['Access-Control-Allow-Methods'] = "GET"
|
||||||
end
|
end
|
||||||
|
|
||||||
memo = "To #{address}"
|
def check_service_available
|
||||||
memo = "#{memo}: \"#{comment}\"" if comment.present?
|
http_status :not_found unless Setting.lndhub_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
payment_request = @user.ln_create_invoice({
|
def find_user
|
||||||
amount: amount, # we create invoices in sats
|
@user = User.where(cn: params[:username], ou: Setting.primary_domain).first
|
||||||
memo: memo,
|
http_status :not_found if @user.nil?
|
||||||
description_hash: Digest::SHA2.hexdigest(metadata(address)),
|
end
|
||||||
})
|
|
||||||
|
|
||||||
render json: {
|
def metadata(address)
|
||||||
status: "OK",
|
"[[\"text/identifier\",\"#{address}\"],[\"text/plain\",\"Sats for #{address}\"]]"
|
||||||
successAction: {
|
end
|
||||||
tag: "message",
|
|
||||||
message: "Sats received. Thank you!"
|
|
||||||
},
|
|
||||||
routes: [],
|
|
||||||
pr: payment_request
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
def valid_amount?(amount_in_sats)
|
||||||
|
amount_in_sats <= MAX_SATS && amount_in_sats >= MIN_SATS
|
||||||
|
end
|
||||||
|
|
||||||
def find_user
|
def valid_comment?(comment)
|
||||||
@user = User.where(cn: params[:username], ou: Setting.primary_domain).first
|
comment.length <= MAX_COMMENT_CHARS
|
||||||
http_status :not_found if @user.nil?
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def metadata(address)
|
def handle_pay_request(address, amount, comment)
|
||||||
"[[\"text/identifier\", \"#{address}\"], [\"text/plain\", \"Send sats, receive thanks.\"]]"
|
if !valid_comment?(comment)
|
||||||
end
|
render json: { status: "ERROR", reason: "Comment too long" }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
def valid_amount?(amount_in_sats)
|
desc = "To #{address}"
|
||||||
amount_in_sats <= MAX_SATS && amount_in_sats >= MIN_SATS
|
desc = "#{desc}: \"#{comment}\"" if comment.present?
|
||||||
end
|
|
||||||
|
|
||||||
def valid_comment?(comment)
|
invoice = LndhubManager::CreateUserInvoice.call(
|
||||||
comment.length <= MAX_COMMENT_CHARS
|
user: @user, payload: {
|
||||||
end
|
amount: amount, # sats
|
||||||
|
description: desc,
|
||||||
|
description_hash: Digest::SHA256.hexdigest(metadata(address)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
private
|
render json: {
|
||||||
|
status: "OK",
|
||||||
|
successAction: {
|
||||||
|
tag: "message",
|
||||||
|
message: "Sats received. Thank you!"
|
||||||
|
},
|
||||||
|
routes: [],
|
||||||
|
pr: invoice["payment_request"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def check_service_available
|
def nostr_event_from_payload(nostr_param)
|
||||||
http_status :not_found unless Setting.lndhub_enabled?
|
event_obj = JSON.parse(nostr_param).transform_keys(&:to_sym)
|
||||||
end
|
Nostr::Event.new(**event_obj)
|
||||||
|
rescue => e
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_zap_request?(amount, event, lnurl)
|
||||||
|
NostrManager::VerifyZapRequest.call(
|
||||||
|
amount: amount, event: event, lnurl: lnurl
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_zap_request(amount, nostr_param, lnurl_param)
|
||||||
|
event = nostr_event_from_payload(nostr_param)
|
||||||
|
|
||||||
|
unless event.present? && valid_zap_request?(amount*1000, event, lnurl_param)
|
||||||
|
render json: { status: "ERROR", reason: "Invalid zap request" }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO might want to use the existing invoice and zap record if there are
|
||||||
|
# multiple calls with the same zap request
|
||||||
|
|
||||||
|
desc = "Zap for #{@user.address}"
|
||||||
|
desc = "#{desc}: \"#{event.content}\"" if event.content.present?
|
||||||
|
|
||||||
|
invoice = LndhubManager::CreateUserInvoice.call(
|
||||||
|
user: @user, payload: {
|
||||||
|
amount: amount, # sats
|
||||||
|
description: desc,
|
||||||
|
description_hash: Digest::SHA256.hexdigest(event.to_json),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@user.zaps.create! request: event,
|
||||||
|
payment_request: invoice["payment_request"],
|
||||||
|
amount: amount
|
||||||
|
|
||||||
|
render json: { status: "OK", pr: invoice["payment_request"] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Services::ChatController < Services::BaseController
|
|||||||
before_action :require_service_available
|
before_action :require_service_available
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@service_enabled = current_user.services_enabled.include?(:xmpp)
|
@service_enabled = current_user.service_enabled?(:xmpp)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ require "rqrcode"
|
|||||||
require "lnurl"
|
require "lnurl"
|
||||||
|
|
||||||
class Services::LightningController < ApplicationController
|
class Services::LightningController < ApplicationController
|
||||||
before_action :authenticate_user!
|
|
||||||
before_action :authenticate_with_lndhub
|
|
||||||
before_action :set_current_section
|
before_action :set_current_section
|
||||||
before_action :fetch_balance
|
before_action :require_service_available
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :lndhub_authenticate
|
||||||
|
before_action :lndhub_fetch_balance
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
|
@wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
|
||||||
@@ -55,32 +56,12 @@ class Services::LightningController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authenticate_with_lndhub(options={})
|
|
||||||
if session[:ln_auth_token].present? && !options[:force_reauth]
|
|
||||||
@ln_auth_token = session[:ln_auth_token]
|
|
||||||
else
|
|
||||||
lndhub = Lndhub.new
|
|
||||||
auth_token = lndhub.authenticate(current_user)
|
|
||||||
session[:ln_auth_token] = auth_token
|
|
||||||
@ln_auth_token = auth_token
|
|
||||||
end
|
|
||||||
rescue => e
|
|
||||||
Sentry.capture_exception(e) if Setting.sentry_enabled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_current_section
|
def set_current_section
|
||||||
@current_section = :services
|
@current_section = :services
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_balance
|
def require_service_available
|
||||||
lndhub = Lndhub.new
|
http_status :not_found unless Setting.lndhub_enabled?
|
||||||
data = lndhub.balance @ln_auth_token
|
|
||||||
@balance = data["BTC"]["AvailableBalance"] rescue nil
|
|
||||||
rescue AuthError
|
|
||||||
authenticate_with_lndhub(force_reauth: true)
|
|
||||||
raise if @fetch_balance_retried
|
|
||||||
@fetch_balance_retried = true
|
|
||||||
fetch_balance
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_transactions
|
def fetch_transactions
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Services::MastodonController < Services::BaseController
|
|||||||
before_action :require_service_available
|
before_action :require_service_available
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@service_enabled = current_user.services_enabled.include?(:mastodon)
|
@service_enabled = current_user.service_enabled?(:mastodon)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ class Services::RemotestorageController < Services::BaseController
|
|||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
def show
|
def show
|
||||||
# unless current_user.services_enabled.include?(:remotestorage)
|
# unless current_user.service_enabled?(:remotestorage)
|
||||||
# redirect_to service_remotestorage_info_path
|
# redirect_to service_remotestorage_info_path
|
||||||
# end
|
# end
|
||||||
@rs_auths = current_user.remote_storage_authorizations
|
# @rs_apps_connected = current_user.remote_storage_authorizations.any?
|
||||||
# TODO sort by app name
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ class Services::RsAuthsController < Services::BaseController
|
|||||||
before_action :require_feature_enabled
|
before_action :require_feature_enabled
|
||||||
before_action :require_service_available
|
before_action :require_service_available
|
||||||
# before_action :require_service_enabled
|
# before_action :require_service_enabled
|
||||||
before_action :find_rs_auth
|
before_action :find_rs_auth, only: [:destroy, :launch_app]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@rs_auths = current_user.remote_storage_authorizations
|
||||||
|
# TODO sort by app name?
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@auth.destroy!
|
@auth.destroy!
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do redirect_to services_storage_url, flash: {
|
format.html do redirect_to apps_services_storage_url, flash: {
|
||||||
success: 'App authorization revoked'
|
success: 'App authorization revoked'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ class SettingsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
if @settings_section == "experiments"
|
case @settings_section
|
||||||
|
when "lightning"
|
||||||
|
@notifications_enabled = @user.preferences[:lightning_notify_sats_received] != "disabled" ||
|
||||||
|
@user.preferences[:lightning_notify_zap_received] != "disabled"
|
||||||
|
when "nostr"
|
||||||
session[:shared_secret] ||= SecureRandom.base64(12)
|
session[:shared_secret] ||= SecureRandom.base64(12)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -87,40 +91,39 @@ class SettingsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_nostr_pubkey
|
def set_nostr_pubkey
|
||||||
signed_event = nostr_event_params[:signed_event].to_h.symbolize_keys
|
signed_event = Nostr::Event.new(**nostr_event_from_params)
|
||||||
is_valid_id = NostrManager::ValidateId.call(event: signed_event)
|
|
||||||
is_valid_sig = NostrManager::VerifySignature.call(event: signed_event)
|
|
||||||
is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})"
|
|
||||||
|
|
||||||
unless is_valid_id && is_valid_sig && is_correct_content
|
is_valid_sig = signed_event.verify_signature
|
||||||
|
is_valid_auth = NostrManager::VerifyAuth.call(
|
||||||
|
event: signed_event,
|
||||||
|
challenge: session[:shared_secret]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless is_valid_sig && is_valid_auth
|
||||||
flash[:alert] = "Public key could not be verified"
|
flash[:alert] = "Public key could not be verified"
|
||||||
http_status :unprocessable_entity and return
|
http_status :unprocessable_entity and return
|
||||||
end
|
end
|
||||||
|
|
||||||
pubkey_taken = User.all_except(current_user).where(
|
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
|
||||||
ou: current_user.ou, nostr_pubkey: signed_event[:pubkey]
|
|
||||||
).any?
|
|
||||||
|
|
||||||
if pubkey_taken
|
if user_with_pubkey.present? && (user_with_pubkey != current_user)
|
||||||
flash[:alert] = "Public key already in use for a different account"
|
flash[:alert] = "Public key already in use for a different account"
|
||||||
http_status :unprocessable_entity and return
|
http_status :unprocessable_entity and return
|
||||||
end
|
end
|
||||||
|
|
||||||
current_user.update! nostr_pubkey: signed_event[:pubkey]
|
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event.pubkey)
|
||||||
session[:shared_secret] = nil
|
session[:shared_secret] = nil
|
||||||
|
|
||||||
flash[:success] = "Public key verification successful"
|
flash[:success] = "Public key verification successful"
|
||||||
http_status :ok
|
http_status :ok
|
||||||
rescue
|
|
||||||
flash[:alert] = "Public key could not be verified"
|
|
||||||
http_status :unprocessable_entity and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /settings/nostr_pubkey
|
# DELETE /settings/nostr_pubkey
|
||||||
def remove_nostr_pubkey
|
def remove_nostr_pubkey
|
||||||
current_user.update! nostr_pubkey: nil
|
# TODO require current pubkey or password to delete
|
||||||
|
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: nil)
|
||||||
|
|
||||||
redirect_to setting_path(:experiments), flash: {
|
redirect_to setting_path(:nostr), flash: {
|
||||||
success: 'Public key removed from account'
|
success: 'Public key removed from account'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -134,8 +137,8 @@ class SettingsController < ApplicationController
|
|||||||
def set_settings_section
|
def set_settings_section
|
||||||
@settings_section = params[:section]
|
@settings_section = params[:section]
|
||||||
allowed_sections = [
|
allowed_sections = [
|
||||||
:profile, :account, :xmpp, :email, :lightning, :remotestorage,
|
:profile, :account, :xmpp, :email,
|
||||||
:experiments
|
:lightning, :remotestorage, :nostr
|
||||||
]
|
]
|
||||||
|
|
||||||
unless allowed_sections.include?(@settings_section.to_sym)
|
unless allowed_sections.include?(@settings_section.to_sym)
|
||||||
@@ -148,11 +151,9 @@ class SettingsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:display_name, :avatar, preferences: [
|
params.require(:user).permit(
|
||||||
:lightning_notify_sats_received,
|
:display_name, :avatar, preferences: UserPreferences.pref_keys
|
||||||
:remotestorage_notify_auth_created,
|
)
|
||||||
:xmpp_exchange_contacts_with_invitees
|
|
||||||
])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def email_params
|
def email_params
|
||||||
@@ -163,12 +164,6 @@ class SettingsController < ApplicationController
|
|||||||
params.require(:user).permit(:current_password)
|
params.require(:user).permit(:current_password)
|
||||||
end
|
end
|
||||||
|
|
||||||
def nostr_event_params
|
|
||||||
params.permit(signed_event: [
|
|
||||||
:id, :pubkey, :created_at, :kind, :tags, :content, :sig
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_email_password
|
def generate_email_password
|
||||||
characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
|
characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
|
||||||
SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join
|
SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join
|
||||||
|
|||||||
62
app/controllers/users/sessions_controller.rb
Normal file
62
app/controllers/users/sessions_controller.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::SessionsController < Devise::SessionsController
|
||||||
|
# before_action :configure_sign_in_params, only: [:create]
|
||||||
|
|
||||||
|
# GET /resource/sign_in
|
||||||
|
def new
|
||||||
|
session[:shared_secret] = SecureRandom.base64(12)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /resource/sign_in
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# DELETE /resource/sign_out
|
||||||
|
# def destroy
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /users/nostr_login
|
||||||
|
def nostr_login
|
||||||
|
signed_event = Nostr::Event.new(**nostr_event_from_params)
|
||||||
|
|
||||||
|
is_valid_sig = signed_event.verify_signature
|
||||||
|
is_valid_auth = NostrManager::VerifyAuth.call(
|
||||||
|
event: signed_event,
|
||||||
|
challenge: session[:shared_secret]
|
||||||
|
)
|
||||||
|
|
||||||
|
session[:shared_secret] = nil
|
||||||
|
|
||||||
|
unless is_valid_sig && is_valid_auth
|
||||||
|
flash[:alert] = "Login verification failed"
|
||||||
|
http_status :unauthorized and return
|
||||||
|
end
|
||||||
|
|
||||||
|
user = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
|
||||||
|
|
||||||
|
if user.present?
|
||||||
|
set_flash_message!(:notice, :signed_in)
|
||||||
|
sign_in("user", user)
|
||||||
|
render json: { redirect_url: after_sign_in_path_for(user) }, status: :ok
|
||||||
|
else
|
||||||
|
flash[:alert] = "Failed to find your account. Nostr login may be disabled."
|
||||||
|
http_status :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def set_flash_message(key, kind, options = {})
|
||||||
|
# Hide flash message after redirecting from a signin route while logged in
|
||||||
|
super unless key == :alert && kind == "already_authenticated"
|
||||||
|
end
|
||||||
|
|
||||||
|
# If you have extra params to permit, append them to the sanitizer.
|
||||||
|
# def configure_sign_in_params
|
||||||
|
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -7,14 +7,15 @@ class WebfingerController < ApplicationController
|
|||||||
resource = params[:resource]
|
resource = params[:resource]
|
||||||
|
|
||||||
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
|
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
|
||||||
@username, @org = @useraddress.split("@")
|
@username, @domain = @useraddress.split("@")
|
||||||
|
|
||||||
unless Rails.env.development?
|
unless Rails.env.development?
|
||||||
# Allow different domains (e.g. localhost:3000) in development only
|
# Allow different domains (e.g. localhost:3000) in development only
|
||||||
head 404 and return unless @org == Setting.primary_domain
|
head 404 and return unless @domain == Setting.primary_domain
|
||||||
end
|
end
|
||||||
|
|
||||||
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
|
unless @user = User.where(ou: Setting.primary_domain)
|
||||||
|
.find_by(cn: @username.downcase)
|
||||||
head 404 and return
|
head 404 and return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -28,12 +29,50 @@ class WebfingerController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def webfinger
|
def webfinger
|
||||||
links = [];
|
jrd = {
|
||||||
|
subject: "acct:#{@user.address}",
|
||||||
|
aliases: [],
|
||||||
|
links: []
|
||||||
|
}
|
||||||
|
|
||||||
# TODO check if storage service is enabled for user, not just globally
|
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
|
||||||
links << remotestorage_link if Setting.remotestorage_enabled
|
# https://docs.joinmastodon.org/spec/webfinger/
|
||||||
|
jrd[:aliases] += mastodon_aliases
|
||||||
|
jrd[:links] += mastodon_links
|
||||||
|
end
|
||||||
|
|
||||||
{ "links" => links }
|
if Setting.remotestorage_enabled && @user.service_enabled?(:remotestorage)
|
||||||
|
# https://datatracker.ietf.org/doc/draft-dejong-remotestorage/
|
||||||
|
jrd[:links] << remotestorage_link
|
||||||
|
end
|
||||||
|
|
||||||
|
jrd
|
||||||
|
end
|
||||||
|
|
||||||
|
def mastodon_aliases
|
||||||
|
[
|
||||||
|
"#{Setting.mastodon_public_url}/@#{@user.cn}",
|
||||||
|
"#{Setting.mastodon_public_url}/users/#{@user.cn}"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def mastodon_links
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rel: "http://webfinger.net/rel/profile-page",
|
||||||
|
type: "text/html",
|
||||||
|
href: "#{Setting.mastodon_public_url}/@#{@user.cn}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "self",
|
||||||
|
type: "application/activity+json",
|
||||||
|
href: "#{Setting.mastodon_public_url}/users/#{@user.cn}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "http://ostatus.org/schema/1.0/subscribe",
|
||||||
|
template: "#{Setting.mastodon_public_url}/authorize_interaction?uri={uri}"
|
||||||
|
}
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def remotestorage_link
|
def remotestorage_link
|
||||||
@@ -41,9 +80,9 @@ class WebfingerController < ApplicationController
|
|||||||
storage_url = "#{Setting.rs_storage_url}/#{@username}"
|
storage_url = "#{Setting.rs_storage_url}/#{@username}"
|
||||||
|
|
||||||
{
|
{
|
||||||
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",
|
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
|
||||||
"href" => storage_url,
|
href: storage_url,
|
||||||
"properties" => {
|
properties: {
|
||||||
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
|
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
|
||||||
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
|
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
|
||||||
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
|
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
|
||||||
|
|||||||
@@ -2,45 +2,76 @@ class WebhooksController < ApplicationController
|
|||||||
skip_forgery_protection
|
skip_forgery_protection
|
||||||
|
|
||||||
before_action :authorize_request
|
before_action :authorize_request
|
||||||
|
before_action :process_payload
|
||||||
|
|
||||||
def lndhub
|
def lndhub
|
||||||
begin
|
@user = User.find_by!(ln_account: @payload[:user_login])
|
||||||
payload = JSON.parse(request.body.read, symbolize_names: true)
|
|
||||||
head :no_content and return unless payload[:type] == "incoming"
|
if @zap = @user.zaps.find_by(payment_request: @payload[:payment_request])
|
||||||
rescue
|
settled_at = Time.parse(@payload[:settled_at])
|
||||||
head :unprocessable_entity and return
|
zap_receipt = NostrManager::CreateZapReceipt.call(
|
||||||
|
zap: @zap,
|
||||||
|
paid_at: settled_at.to_i,
|
||||||
|
preimage: @payload[:preimage]
|
||||||
|
)
|
||||||
|
@zap.update! settled_at: settled_at, receipt: zap_receipt.to_h
|
||||||
|
NostrManager::PublishZapReceipt.call(zap: @zap)
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by!(ln_account: payload[:user_login])
|
send_notifications
|
||||||
notify = user.preferences[:lightning_notify_sats_received]
|
|
||||||
case notify
|
|
||||||
when "xmpp"
|
|
||||||
notify_xmpp(user.address, payload[:amount], payload[:memo])
|
|
||||||
when "email"
|
|
||||||
NotificationMailer.with(user: user, amount_sats: payload[:amount])
|
|
||||||
.lightning_sats_received.deliver_later
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# TODO refactor into mailer-like generic class/service
|
|
||||||
def notify_xmpp(address, amt_sats, memo)
|
|
||||||
payload = {
|
|
||||||
type: "normal",
|
|
||||||
from: Setting.xmpp_notifications_from_address,
|
|
||||||
to: address,
|
|
||||||
subject: "Sats received!",
|
|
||||||
body: "#{helpers.number_with_delimiter amt_sats} sats received in your Lightning wallet:\n> #{memo}"
|
|
||||||
}
|
|
||||||
XmppSendMessageJob.perform_later(payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
def authorize_request
|
def authorize_request
|
||||||
if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip)
|
if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip)
|
||||||
head :forbidden and return
|
head :forbidden and return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_payload
|
||||||
|
@payload = JSON.parse(request.body.read, symbolize_names: true)
|
||||||
|
unless @payload[:type] == "incoming" &&
|
||||||
|
@payload[:state] == "settled"
|
||||||
|
head :no_content and return
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
head :unprocessable_entity and return
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_notifications
|
||||||
|
return if @payload[:amount] < @user.preferences[:lightning_notify_min_sats]
|
||||||
|
|
||||||
|
if @user.preferences[:lightning_notify_only_with_message]
|
||||||
|
return if @payload[:memo].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
target = @zap.present? ? @user.preferences[:lightning_notify_zap_received] :
|
||||||
|
@user.preferences[:lightning_notify_sats_received]
|
||||||
|
|
||||||
|
case target
|
||||||
|
when "xmpp"
|
||||||
|
notify_xmpp
|
||||||
|
when "email"
|
||||||
|
notify_email
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO refactor into mailer-like generic class/service
|
||||||
|
def notify_xmpp
|
||||||
|
XmppSendMessageJob.perform_later({
|
||||||
|
type: "normal",
|
||||||
|
from: Setting.xmpp_notifications_from_address,
|
||||||
|
to: @user.address,
|
||||||
|
subject: "Sats received!",
|
||||||
|
body: "#{helpers.number_with_delimiter @payload[:amount]} sats received in your Lightning wallet:\n> #{@payload[:memo]}"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_email
|
||||||
|
NotificationMailer.with(user: @user, amount_sats: @payload[:amount])
|
||||||
|
.lightning_sats_received.deliver_later
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
class WellKnownController < ApplicationController
|
class WellKnownController < ApplicationController
|
||||||
|
before_action :require_nostr_enabled, only: [ :nostr ]
|
||||||
|
|
||||||
def nostr
|
def nostr
|
||||||
http_status :unprocessable_entity and return if params[:name].blank?
|
http_status :unprocessable_entity and return if params[:name].blank?
|
||||||
domain = request.headers["X-Forwarded-Host"].presence || Setting.primary_domain
|
domain = request.headers["X-Forwarded-Host"].presence || Setting.primary_domain
|
||||||
@user = User.where(cn: params[:name], ou: domain).first
|
relay_url = Setting.nostr_relay_url.presence
|
||||||
http_status :not_found and return if @user.nil? || @user.nostr_pubkey.blank?
|
|
||||||
|
if params[:name] == "_"
|
||||||
|
# pubkey for the primary domain without a username (e.g. kosmos.org)
|
||||||
|
res = { names: { "_": Setting.nostr_public_key } }
|
||||||
|
res[:relays] = { "_" => [ relay_url ] } if relay_url
|
||||||
|
else
|
||||||
|
@user = User.where(cn: params[:name], ou: domain).first
|
||||||
|
http_status :not_found and return if @user.nil? || @user.nostr_pubkey.blank?
|
||||||
|
|
||||||
|
res = { names: { @user.cn => @user.nostr_pubkey } }
|
||||||
|
res[:relays] = { @user.nostr_pubkey => [ relay_url ] } if relay_url
|
||||||
|
end
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.json do
|
format.json do
|
||||||
render json: {
|
render json: res.to_json
|
||||||
names: { "#{@user.cn}": @user.nostr_pubkey }
|
|
||||||
}.to_json
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def require_nostr_enabled
|
||||||
|
http_status :not_found unless Setting.nostr_enabled?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
include Pagy::Frontend
|
include Pagy::Frontend
|
||||||
|
|
||||||
def sats_to_btc(sats)
|
|
||||||
sats.to_f / 100000000
|
|
||||||
end
|
|
||||||
|
|
||||||
def main_nav_class(current_section, link_to_section)
|
def main_nav_class(current_section, link_to_section)
|
||||||
if current_section == link_to_section
|
if current_section == link_to_section
|
||||||
"bg-gray-900/50 text-white px-3 py-2 rounded-md font-medium text-base md:text-sm block md:inline-block"
|
"bg-gray-900/50 text-white px-3 py-2 rounded-md font-medium text-base md:text-sm block md:inline-block"
|
||||||
|
|||||||
7
app/helpers/btcpay_helper.rb
Normal file
7
app/helpers/btcpay_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module BtcpayHelper
|
||||||
|
|
||||||
|
def btcpay_checkout_url(invoice_id)
|
||||||
|
"#{Setting.btcpay_public_url}/i/#{invoice_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
53
app/javascript/controllers/nostr_login_controller.js
Normal file
53
app/javascript/controllers/nostr_login_controller.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="nostr-login"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ "loginForm", "loginButton" ]
|
||||||
|
static values = { site: String, sharedSecret: String }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (window.nostr) {
|
||||||
|
this.loginButtonTarget.disabled = false
|
||||||
|
this.loginFormTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login () {
|
||||||
|
this.loginButtonTarget.disabled = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auth based on NIP-42
|
||||||
|
const signedEvent = await window.nostr.signEvent({
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 22242,
|
||||||
|
tags: [
|
||||||
|
["site", this.siteValue],
|
||||||
|
["challenge", this.sharedSecretValue]
|
||||||
|
],
|
||||||
|
content: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch("/users/nostr_login", {
|
||||||
|
method: "POST", credentials: "include", headers: {
|
||||||
|
"Accept": "application/json", 'Content-Type': 'application/json',
|
||||||
|
"X-CSRF-Token": this.csrfToken
|
||||||
|
}, body: JSON.stringify({ signed_event: signedEvent })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
res.json().then(r => { window.location.href = r.redirect_url })
|
||||||
|
} else {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to authenticate:', error.message)
|
||||||
|
} finally {
|
||||||
|
this.loginButtonTarget.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get csrfToken () {
|
||||||
|
const element = document.head.querySelector('meta[name="csrf-token"]')
|
||||||
|
return element.getAttribute("content")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ import { Controller } from "@hotwired/stimulus"
|
|||||||
// Connects to data-controller="settings--nostr-pubkey"
|
// Connects to data-controller="settings--nostr-pubkey"
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
|
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
|
||||||
static values = { userAddress: String, pubkeyHex: String, sharedSecret: String }
|
static values = {
|
||||||
|
userAddress: String,
|
||||||
|
pubkeyHex: String,
|
||||||
|
site: String,
|
||||||
|
sharedSecret: String
|
||||||
|
}
|
||||||
|
|
||||||
connect () {
|
connect () {
|
||||||
if (window.nostr) {
|
if (window.nostr) {
|
||||||
@@ -19,11 +24,15 @@ export default class extends Controller {
|
|||||||
this.setPubkeyTarget.disabled = true
|
this.setPubkeyTarget.disabled = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Auth based on NIP-42
|
||||||
const signedEvent = await window.nostr.signEvent({
|
const signedEvent = await window.nostr.signEvent({
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
kind: 1,
|
kind: 22242,
|
||||||
tags: [],
|
tags: [
|
||||||
content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})`
|
["site", this.siteValue],
|
||||||
|
["challenge", this.sharedSecretValue]
|
||||||
|
],
|
||||||
|
content: ""
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await fetch("/settings/set_nostr_pubkey", {
|
const res = await fetch("/settings/set_nostr_pubkey", {
|
||||||
|
|||||||
28
app/jobs/btcpay_check_donation_job.rb
Normal file
28
app/jobs/btcpay_check_donation_job.rb
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class BtcpayCheckDonationJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(donation)
|
||||||
|
return if donation.completed?
|
||||||
|
|
||||||
|
invoice = BtcpayManager::FetchInvoice.call(
|
||||||
|
invoice_id: donation.btcpay_invoice_id
|
||||||
|
)
|
||||||
|
|
||||||
|
case invoice["status"]
|
||||||
|
when "Settled"
|
||||||
|
donation.paid_at = DateTime.now
|
||||||
|
donation.payment_status = "settled"
|
||||||
|
donation.save!
|
||||||
|
|
||||||
|
NotificationMailer.with(user: donation.user)
|
||||||
|
.bitcoin_donation_confirmed
|
||||||
|
.deliver_later
|
||||||
|
when "Processing"
|
||||||
|
re_enqueue_job(donation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def re_enqueue_job(donation)
|
||||||
|
self.class.set(wait: 20.seconds).perform_later(donation)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
class CreateLdapUserJob < ApplicationJob
|
class CreateLdapUserJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
def perform(username, domain, email, hashed_pw)
|
def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
|
||||||
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
|
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
|
||||||
attr = {
|
attr = {
|
||||||
objectclass: ["top", "account", "person", "extensibleObject"],
|
objectclass: ["top", "account", "person", "extensibleObject"],
|
||||||
@@ -12,6 +12,10 @@ class CreateLdapUserJob < ApplicationJob
|
|||||||
userPassword: hashed_pw
|
userPassword: hashed_pw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if confirmed
|
||||||
|
attr[:serviceEnabled] = Setting.default_services
|
||||||
|
end
|
||||||
|
|
||||||
ldap_client.add(dn: dn, attributes: attr)
|
ldap_client.add(dn: dn, attributes: attr)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
7
app/jobs/nostr_publish_event_job.rb
Normal file
7
app/jobs/nostr_publish_event_job.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class NostrPublishEventJob < ApplicationJob
|
||||||
|
queue_as :nostr
|
||||||
|
|
||||||
|
def perform(event:, relay_url:)
|
||||||
|
NostrManager::PublishEvent.call(event: event, relay_url: relay_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,8 +2,8 @@ class XmppExchangeContactsJob < ApplicationJob
|
|||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
def perform(inviter, invitee)
|
def perform(inviter, invitee)
|
||||||
return unless inviter.services_enabled.include?("xmpp") &&
|
return unless inviter.service_enabled?(:xmpp) &&
|
||||||
invitee.services_enabled.include?("xmpp") &&
|
invitee.service_enabled?(:xmpp) &&
|
||||||
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
|
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
|
||||||
|
|
||||||
ejabberd = EjabberdApiClient.new
|
ejabberd = EjabberdApiClient.new
|
||||||
|
|||||||
@@ -23,4 +23,11 @@ class NotificationMailer < ApplicationMailer
|
|||||||
@subject = "New invitations added to your account"
|
@subject = "New invitations added to your account"
|
||||||
mail to: @user.email, subject: @subject
|
mail to: @user.email, subject: @subject
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bitcoin_donation_confirmed
|
||||||
|
@user = params[:user]
|
||||||
|
@donation = params[:donation]
|
||||||
|
@subject = "Donation confirmed"
|
||||||
|
mail to: @user.email, subject: @subject
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class AppCatalog::WebApp < ApplicationRecord
|
class AppCatalog::WebApp < ApplicationRecord
|
||||||
store :metadata, coder: JSON
|
store :metadata, coder: JSON
|
||||||
|
|
||||||
has_many :remote_storage_authorizations
|
has_many :remote_storage_authorizations, dependent: :destroy
|
||||||
|
|
||||||
has_one_attached :icon
|
has_one_attached :icon
|
||||||
has_one_attached :apple_touch_icon
|
has_one_attached :apple_touch_icon
|
||||||
|
|||||||
@@ -4,12 +4,25 @@ class Donation < ApplicationRecord
|
|||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates_presence_of :user
|
validates_presence_of :user
|
||||||
validates_presence_of :amount_sats
|
validates_presence_of :donation_method,
|
||||||
validates_presence_of :paid_at
|
inclusion: { in: %w[ custom btcpay lndhub ] }
|
||||||
|
validates_presence_of :payment_status, allow_nil: true,
|
||||||
# Hooks
|
inclusion: { in: %w[ processing settled ] }
|
||||||
# TODO before_create :store_fiat_value
|
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 ] }
|
||||||
|
|
||||||
#Scopes
|
#Scopes
|
||||||
scope :completed, -> { where.not(paid_at: nil) }
|
scope :processing, -> { where(payment_status: "processing") }
|
||||||
|
scope :completed, -> { where(payment_status: "settled") }
|
||||||
|
|
||||||
|
def processing?
|
||||||
|
payment_status == "processing"
|
||||||
|
end
|
||||||
|
|
||||||
|
def completed?
|
||||||
|
payment_status == "settled"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Setting < RailsSettings::Base
|
|||||||
field :redis_url, type: :string,
|
field :redis_url, type: :string,
|
||||||
default: ENV["REDIS_URL"] || "redis://localhost:6379/0"
|
default: ENV["REDIS_URL"] || "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
field :s3_enabled, type: :boolean,
|
||||||
|
default: ENV["S3_ENABLED"] && ENV["S3_ENABLED"].to_s != "false"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Registrations
|
# Registrations
|
||||||
#
|
#
|
||||||
@@ -48,6 +51,9 @@ class Setting < RailsSettings::Base
|
|||||||
field :btcpay_enabled, type: :boolean,
|
field :btcpay_enabled, type: :boolean,
|
||||||
default: ENV["BTCPAY_API_URL"].present?
|
default: ENV["BTCPAY_API_URL"].present?
|
||||||
|
|
||||||
|
field :btcpay_public_url, type: :string,
|
||||||
|
default: ENV["BTCPAY_PUBLIC_URL"].presence
|
||||||
|
|
||||||
field :btcpay_store_id, type: :string,
|
field :btcpay_store_id, type: :string,
|
||||||
default: ENV["BTCPAY_STORE_ID"].presence
|
default: ENV["BTCPAY_STORE_ID"].presence
|
||||||
|
|
||||||
@@ -154,7 +160,26 @@ class Setting < RailsSettings::Base
|
|||||||
# Nostr
|
# Nostr
|
||||||
#
|
#
|
||||||
|
|
||||||
field :nostr_enabled, type: :boolean, default: true
|
field :nostr_enabled, type: :boolean,
|
||||||
|
default: ENV["NOSTR_PRIVATE_KEY"].present?
|
||||||
|
|
||||||
|
field :nostr_private_key, type: :string,
|
||||||
|
default: ENV["NOSTR_PRIVATE_KEY"].presence
|
||||||
|
|
||||||
|
field :nostr_public_key, type: :string,
|
||||||
|
default: ENV["NOSTR_PUBLIC_KEY"].presence
|
||||||
|
|
||||||
|
field :nostr_relay_url, type: :string,
|
||||||
|
default: ENV["NOSTR_RELAY_URL"].presence
|
||||||
|
|
||||||
|
field :nostr_zaps_relay_limit, type: :integer,
|
||||||
|
default: 12
|
||||||
|
|
||||||
|
#
|
||||||
|
# OpenCollective
|
||||||
|
#
|
||||||
|
|
||||||
|
field :opencollective_enabled, type: :boolean, default: true
|
||||||
|
|
||||||
#
|
#
|
||||||
# RemoteStorage
|
# RemoteStorage
|
||||||
@@ -194,4 +219,9 @@ class Setting < RailsSettings::Base
|
|||||||
#
|
#
|
||||||
# field :email_imap_port, type: :string,
|
# field :email_imap_port, type: :string,
|
||||||
# default: ENV["EMAIL_IMAP_PORT"].presence || 993
|
# default: ENV["EMAIL_IMAP_PORT"].presence || 993
|
||||||
|
|
||||||
|
def self.default_services
|
||||||
|
# TODO Make configurable from respective service settings page
|
||||||
|
%w[ discourse gitea mastodon mediawiki xmpp ]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,16 +17,15 @@ class User < ApplicationRecord
|
|||||||
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
|
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
|
||||||
has_one :inviter, through: :invitation, source: :user
|
has_one :inviter, through: :invitation, source: :user
|
||||||
has_many :invitees, through: :invitations
|
has_many :invitees, through: :invitations
|
||||||
|
|
||||||
has_many :donations, dependent: :nullify
|
has_many :donations, dependent: :nullify
|
||||||
|
has_many :remote_storage_authorizations
|
||||||
|
has_many :zaps
|
||||||
|
|
||||||
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
|
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
|
||||||
primary_key: "ln_account", foreign_key: "login"
|
primary_key: "ln_account", foreign_key: "login"
|
||||||
|
|
||||||
has_many :accounts, through: :lndhub_user
|
has_many :accounts, through: :lndhub_user
|
||||||
|
|
||||||
has_many :remote_storage_authorizations
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Validations
|
# Validations
|
||||||
#
|
#
|
||||||
@@ -50,8 +49,6 @@ class User < ApplicationRecord
|
|||||||
validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
|
validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
|
||||||
if: -> { defined?(@display_name) }
|
if: -> { defined?(@display_name) }
|
||||||
|
|
||||||
validates_uniqueness_of :nostr_pubkey, allow_blank: true
|
|
||||||
|
|
||||||
validate :acceptable_avatar
|
validate :acceptable_avatar
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -95,9 +92,7 @@ class User < ApplicationRecord
|
|||||||
LdapManager::UpdateEmail.call(dn: self.dn, address: self.email)
|
LdapManager::UpdateEmail.call(dn: self.dn, address: self.email)
|
||||||
else
|
else
|
||||||
# E-Mail from signup confirmed (i.e. account activation)
|
# E-Mail from signup confirmed (i.e. account activation)
|
||||||
|
enable_default_services
|
||||||
# TODO Make configurable, only activate globally enabled services
|
|
||||||
enable_service %w[ discourse gitea mediawiki xmpp ]
|
|
||||||
|
|
||||||
# TODO enable in development when we have easy setup of ejabberd etc.
|
# TODO enable in development when we have easy setup of ejabberd etc.
|
||||||
return if Rails.env.development? || !Setting.ejabberd_enabled?
|
return if Rails.env.development? || !Setting.ejabberd_enabled?
|
||||||
@@ -135,7 +130,7 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
def mastodon_address
|
def mastodon_address
|
||||||
return nil unless Setting.mastodon_enabled?
|
return nil unless Setting.mastodon_enabled?
|
||||||
"#{self.cn}@#{Setting.mastodon_address_domain}"
|
"#{self.cn.gsub("-", "_")}@#{Setting.mastodon_address_domain}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_attribute?(attribute_name)
|
def valid_attribute?(attribute_name)
|
||||||
@@ -143,10 +138,8 @@ class User < ApplicationRecord
|
|||||||
self.errors[attribute_name].blank?
|
self.errors[attribute_name].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def ln_create_invoice(payload)
|
def enable_default_services
|
||||||
lndhub = Lndhub.new
|
enable_service Setting.default_services
|
||||||
lndhub.authenticate self
|
|
||||||
lndhub.addinvoice payload
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def dn
|
def dn
|
||||||
@@ -163,37 +156,45 @@ class User < ApplicationRecord
|
|||||||
@display_name ||= ldap_entry[:display_name]
|
@display_name ||= ldap_entry[:display_name]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def nostr_pubkey
|
||||||
|
@nostr_pubkey ||= ldap_entry[:nostr_key]
|
||||||
|
end
|
||||||
|
|
||||||
|
def nostr_pubkey_bech32
|
||||||
|
return nil unless nostr_pubkey.present?
|
||||||
|
Nostr::PublicKey.new(nostr_pubkey).to_bech32
|
||||||
|
end
|
||||||
|
|
||||||
def avatar
|
def avatar
|
||||||
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
|
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
|
||||||
end
|
end
|
||||||
|
|
||||||
def services_enabled
|
def services_enabled
|
||||||
ldap_entry[:service] || []
|
ldap_entry[:services_enabled] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_enabled?(name)
|
||||||
|
services_enabled.map(&:to_sym).include?(name.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_service(service)
|
def enable_service(service)
|
||||||
current_services = services_enabled
|
current_services = services_enabled
|
||||||
new_services = Array(service).map(&:to_s)
|
new_services = Array(service).map(&:to_s)
|
||||||
services = (current_services + new_services).uniq
|
services = (current_services + new_services).uniq
|
||||||
ldap.replace_attribute(dn, :service, services)
|
ldap.replace_attribute(dn, :serviceEnabled, services)
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_service(service)
|
def disable_service(service)
|
||||||
current_services = services_enabled
|
current_services = services_enabled
|
||||||
disabled_services = Array(service).map(&:to_s)
|
disabled_services = Array(service).map(&:to_s)
|
||||||
services = (current_services - disabled_services).uniq
|
services = (current_services - disabled_services).uniq
|
||||||
ldap.replace_attribute(dn, :service, services)
|
ldap.replace_attribute(dn, :serviceEnabled, services)
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_all_services
|
def disable_all_services
|
||||||
ldap.delete_attribute(dn,:service)
|
ldap.delete_attribute(dn,:service)
|
||||||
end
|
end
|
||||||
|
|
||||||
def nostr_pubkey_bech32
|
|
||||||
return nil unless nostr_pubkey.present?
|
|
||||||
Nostr::PublicKey.new(nostr_pubkey).to_bech32
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ldap
|
def ldap
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ class UserPreferences
|
|||||||
end
|
end
|
||||||
hash.stringify_keys!.to_h
|
hash.stringify_keys!.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.pref_keys
|
||||||
|
DEFAULT_PREFS.keys.map(&:to_sym)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
20
app/models/zap.rb
Normal file
20
app/models/zap.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class Zap < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
scope :settled, -> { where.not(settled_at: nil) }
|
||||||
|
scope :unpaid, -> { where(settled_at: nil) }
|
||||||
|
|
||||||
|
def request_event
|
||||||
|
nostr_event_from_hash(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
def receipt_event
|
||||||
|
nostr_event_from_hash(receipt)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def nostr_event_from_hash(hash)
|
||||||
|
Nostr::Event.new(**hash.symbolize_keys)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,6 +18,10 @@ module AppCatalogManager
|
|||||||
@app.metadata[prop] = metadata.send(prop) if prop
|
@app.metadata[prop] = metadata.send(prop) if prop
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@app.save!
|
||||||
|
|
||||||
|
# TODO move icon downloads to separate, async job
|
||||||
|
|
||||||
if icon = metadata.select_icon(sizes: "256x256") ||
|
if icon = metadata.select_icon(sizes: "256x256") ||
|
||||||
icon = metadata.select_icon(sizes: "192x192")
|
icon = metadata.select_icon(sizes: "192x192")
|
||||||
attach_remote_image(:icon, icon)
|
attach_remote_image(:icon, icon)
|
||||||
@@ -27,8 +31,6 @@ module AppCatalogManager
|
|||||||
if apple_touch_icon = metadata.select_icon(purpose: "apple-touch-icon")
|
if apple_touch_icon = metadata.select_icon(purpose: "apple-touch-icon")
|
||||||
attach_remote_image(:apple_touch_icon, apple_touch_icon)
|
attach_remote_image(:apple_touch_icon, apple_touch_icon)
|
||||||
end
|
end
|
||||||
|
|
||||||
@app.save!
|
|
||||||
rescue Manifique::Error => e
|
rescue Manifique::Error => e
|
||||||
msg = "Fetching web app manifest failed for #{e.url}: #{e.type}"
|
msg = "Fetching web app manifest failed for #{e.url}: #{e.type}"
|
||||||
Rails.logger.warn(msg)
|
Rails.logger.warn(msg)
|
||||||
@@ -42,14 +44,19 @@ module AppCatalogManager
|
|||||||
else
|
else
|
||||||
download_url = "#{@app.url}/#{icon["src"].gsub(/^\//,'')}"
|
download_url = "#{@app.url}/#{icon["src"].gsub(/^\//,'')}"
|
||||||
end
|
end
|
||||||
filename = "#{attachment_name}.png"
|
filename = "#{attachment_name}-#{Time.now.to_i}.png"
|
||||||
key = "web_apps/#{@app.id}/icons/#{attachment_name}.png"
|
key = "web_apps/#{@app.id}/icons/#{filename}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
tempfile = Down.download(download_url)
|
tempfile = Down.download(download_url)
|
||||||
@app.send(attachment_name).attach(key: key, io: tempfile, filename: filename)
|
@app.send(attachment_name).attach(key: key, io: tempfile, filename: filename)
|
||||||
rescue Down::NotFound
|
rescue Down::NotFound
|
||||||
Rails.logger.warn "Icon download failed: NotFound error for #{download_url}"
|
msg = "Download of \"#{attachment_name}\" failed: NotFound error for #{download_url}"
|
||||||
|
Rails.logger.warn(msg)
|
||||||
|
Sentry.capture_message(msg)
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.warn "Saving attachment \"#{attachment_name}\" failed: \"#{e.message}\""
|
||||||
|
Sentry.capture_exception(e) if Setting.sentry_enabled?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
21
app/services/btcpay_manager/create_invoice.rb
Normal file
21
app/services/btcpay_manager/create_invoice.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module BtcpayManager
|
||||||
|
class CreateInvoice < BtcpayManagerService
|
||||||
|
def initialize(amount:, currency:, redirect_url:)
|
||||||
|
@amount = amount
|
||||||
|
@currency = currency
|
||||||
|
@redirect_url = redirect_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
post "/invoices", {
|
||||||
|
amount: @amount.to_s,
|
||||||
|
currency: @currency,
|
||||||
|
checkout: {
|
||||||
|
redirectURL: @redirect_url,
|
||||||
|
redirectAutomatically: true,
|
||||||
|
requiresRefundEmail: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
14
app/services/btcpay_manager/fetch_exchange_rate.rb
Normal file
14
app/services/btcpay_manager/fetch_exchange_rate.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module BtcpayManager
|
||||||
|
class FetchExchangeRate < BtcpayManagerService
|
||||||
|
def initialize(fiat_currency:)
|
||||||
|
@fiat_currency = fiat_currency
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
pair_str = "BTC_#{@fiat_currency}"
|
||||||
|
res = get "rates", { currencyPair: pair_str }
|
||||||
|
pair = res.find{|p| p["currencyPair"] == pair_str }
|
||||||
|
rate = pair["rate"].to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
14
app/services/btcpay_manager/fetch_invoice.rb
Normal file
14
app/services/btcpay_manager/fetch_invoice.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module BtcpayManager
|
||||||
|
class FetchInvoice < BtcpayManagerService
|
||||||
|
def initialize(invoice_id:)
|
||||||
|
@invoice_id = invoice_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
invoice = get "/invoices/#{@invoice_id}"
|
||||||
|
payment_methods = get "/invoices/#{@invoice_id}/payment-methods"
|
||||||
|
invoice["paymentMethods"] = payment_methods
|
||||||
|
invoice
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module BtcpayManager
|
module BtcpayManager
|
||||||
class FetchLightningWalletBalance < BtcpayManagerService
|
class FetchLightningWalletBalance < BtcpayManagerService
|
||||||
def call
|
def call
|
||||||
res = get "stores/#{store_id}/lightning/BTC/balance"
|
res = get "/lightning/BTC/balance"
|
||||||
|
|
||||||
{
|
{
|
||||||
confirmed_balance: res["offchain"]["local"].to_i / 1000 # msats to sats
|
confirmed_balance: res["offchain"]["local"].to_i / 1000 # msats to sats
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module BtcpayManager
|
module BtcpayManager
|
||||||
class FetchOnchainWalletBalance < BtcpayManagerService
|
class FetchOnchainWalletBalance < BtcpayManagerService
|
||||||
def call
|
def call
|
||||||
res = get "stores/#{store_id}/payment-methods/onchain/BTC/wallet"
|
res = get "/payment-methods/onchain/BTC/wallet"
|
||||||
|
|
||||||
{
|
{
|
||||||
balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats
|
balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats
|
||||||
|
|||||||
@@ -2,23 +2,35 @@
|
|||||||
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
|
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
|
||||||
#
|
#
|
||||||
class BtcpayManagerService < ApplicationService
|
class BtcpayManagerService < ApplicationService
|
||||||
attr_reader :base_url, :store_id, :auth_token
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@base_url = Setting.btcpay_api_url
|
|
||||||
@store_id = Setting.btcpay_store_id
|
|
||||||
@auth_token = Setting.btcpay_auth_token
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def get(endpoint)
|
def base_url
|
||||||
res = Faraday.get("#{base_url}/#{endpoint}", {}, {
|
@base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_token
|
||||||
|
@auth_token ||= Setting.btcpay_auth_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def headers
|
||||||
|
{
|
||||||
"Content-Type" => "application/json",
|
"Content-Type" => "application/json",
|
||||||
"Accept" => "application/json",
|
"Accept" => "application/json",
|
||||||
"Authorization" => "token #{auth_token}"
|
"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)
|
JSON.parse(res.body)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ class CreateAccount < ApplicationService
|
|||||||
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
|
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO move to confirmation
|
|
||||||
# (and/or add email_confirmed to entry and use in login filter)
|
|
||||||
def add_ldap_document
|
def add_ldap_document
|
||||||
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
|
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
|
||||||
CreateLdapUserJob.perform_later(@username, @domain, @email, hashed_pw)
|
CreateLdapUserJob.perform_later(
|
||||||
|
username: @username,
|
||||||
|
domain: @domain,
|
||||||
|
email: @email,
|
||||||
|
hashed_pw: hashed_pw,
|
||||||
|
confirmed: @confirmed
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_lndhub_account(user)
|
def create_lndhub_account(user)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module LdapManager
|
|||||||
attributes = %w{ jpegPhoto }
|
attributes = %w{ jpegPhoto }
|
||||||
filter = Net::LDAP::Filter.eq("cn", @cn)
|
filter = Net::LDAP::Filter.eq("cn", @cn)
|
||||||
|
|
||||||
entry = ldap_client.search(base: treebase, filter: filter, attributes: attributes).first
|
entry = client.search(base: treebase, filter: filter, attributes: attributes).first
|
||||||
entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
|
entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
18
app/services/ldap_manager/fetch_user_by_nostr_key.rb
Normal file
18
app/services/ldap_manager/fetch_user_by_nostr_key.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module LdapManager
|
||||||
|
class FetchUserByNostrKey < LdapManagerService
|
||||||
|
def initialize(pubkey:)
|
||||||
|
@ou = Setting.primary_domain
|
||||||
|
@pubkey = pubkey
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
treebase = "ou=#{@ou},cn=users,#{ldap_suffix}"
|
||||||
|
attributes = %w{ cn }
|
||||||
|
filter = Net::LDAP::Filter.eq("nostrKey", @pubkey)
|
||||||
|
|
||||||
|
entry = client.search(base: treebase, filter: filter, attributes: attributes).first
|
||||||
|
|
||||||
|
User.find_by cn: entry.cn, ou: @ou unless entry.nil?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
app/services/ldap_manager/update_nostr_key.rb
Normal file
16
app/services/ldap_manager/update_nostr_key.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module LdapManager
|
||||||
|
class UpdateNostrKey < LdapManagerService
|
||||||
|
def initialize(dn:, pubkey:)
|
||||||
|
@dn = dn
|
||||||
|
@pubkey = pubkey
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
if @pubkey.present?
|
||||||
|
replace_attribute @dn, :nostrKey, @pubkey
|
||||||
|
else
|
||||||
|
delete_attribute @dn, :nostrKey
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,2 @@
|
|||||||
class LdapManagerService < LdapService
|
class LdapManagerService < LdapService
|
||||||
def suffix
|
|
||||||
@suffix ||= ENV["LDAP_SUFFIX"] || "dc=kosmos,dc=org"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,41 +1,47 @@
|
|||||||
class LdapService < ApplicationService
|
class LdapService < ApplicationService
|
||||||
def initialize
|
def modify(dn, operations=[])
|
||||||
@suffix = ENV["LDAP_SUFFIX"] || "dc=kosmos,dc=org"
|
client.modify dn: dn, operations: operations
|
||||||
|
client.get_operation_result.code
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_attribute(dn, attr, values)
|
def add_attribute(dn, attr, values)
|
||||||
ldap_client.add_attribute dn, attr, values
|
client.add_attribute dn, attr, values
|
||||||
|
client.get_operation_result.code
|
||||||
end
|
end
|
||||||
|
|
||||||
def replace_attribute(dn, attr, values)
|
def replace_attribute(dn, attr, values)
|
||||||
ldap_client.replace_attribute dn, attr, values
|
client.replace_attribute dn, attr, values
|
||||||
|
client.get_operation_result.code
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_attribute(dn, attr)
|
def delete_attribute(dn, attr)
|
||||||
ldap_client.delete_attribute dn, attr
|
client.delete_attribute dn, attr
|
||||||
|
client.get_operation_result.code
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_entry(dn, attrs, interactive=false)
|
def add_entry(dn, attrs, interactive=false)
|
||||||
puts "Adding entry: #{dn}" if interactive
|
puts "Add entry: #{dn}" if interactive
|
||||||
res = ldap_client.add dn: dn, attributes: attrs
|
client.add dn: dn, attributes: attrs
|
||||||
puts res.inspect if interactive && !res
|
client.get_operation_result.code
|
||||||
res
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_entry(dn, interactive=false)
|
def delete_entry(dn, interactive=false)
|
||||||
puts "Deleting entry: #{dn}" if interactive
|
puts "Delete entry: #{dn}" if interactive
|
||||||
res = ldap_client.delete dn: dn
|
client.delete dn: dn
|
||||||
puts res.inspect if interactive && !res
|
client.get_operation_result.code
|
||||||
res
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_all_entries!
|
def delete_all_users!
|
||||||
|
delete_all_entries!(objectclass: "person")
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_all_entries!(objectclass: "*")
|
||||||
if Rails.env.production?
|
if Rails.env.production?
|
||||||
raise "Mass deletion of entries not allowed in production"
|
raise "Mass deletion of entries not allowed in production"
|
||||||
end
|
end
|
||||||
|
|
||||||
filter = Net::LDAP::Filter.eq("objectClass", "*")
|
filter = Net::LDAP::Filter.eq("objectClass", objectclass)
|
||||||
entries = ldap_client.search(base: @suffix, filter: filter, attributes: %w{dn})
|
entries = client.search(base: ldap_suffix, filter: filter, attributes: %w{dn})
|
||||||
entries.sort_by!{ |e| e.dn.length }.reverse!
|
entries.sort_by!{ |e| e.dn.length }.reverse!
|
||||||
|
|
||||||
entries.each do |e|
|
entries.each do |e|
|
||||||
@@ -45,18 +51,18 @@ class LdapService < ApplicationService
|
|||||||
|
|
||||||
def fetch_users(args={})
|
def fetch_users(args={})
|
||||||
if args[:ou]
|
if args[:ou]
|
||||||
treebase = "ou=#{args[:ou]},cn=users,#{@suffix}"
|
treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
|
||||||
else
|
else
|
||||||
treebase = ldap_config["base"]
|
treebase = ldap_config["base"]
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes = %w[
|
attributes = %w[
|
||||||
dn cn uid mail displayName admin service
|
dn cn uid mail displayName admin serviceEnabled
|
||||||
mailRoutingAddress mailpassword
|
mailRoutingAddress mailpassword nostrKey
|
||||||
]
|
]
|
||||||
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
|
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
|
||||||
|
|
||||||
entries = ldap_client.search(base: treebase, filter: filter, attributes: attributes)
|
entries = client.search(base: treebase, filter: filter, attributes: attributes)
|
||||||
entries.sort_by! { |e| e.cn[0] }
|
entries.sort_by! { |e| e.cn[0] }
|
||||||
entries = entries.collect do |e|
|
entries = entries.collect do |e|
|
||||||
{
|
{
|
||||||
@@ -64,9 +70,10 @@ class LdapService < ApplicationService
|
|||||||
mail: e.try(:mail) ? e.mail.first : nil,
|
mail: e.try(:mail) ? e.mail.first : nil,
|
||||||
display_name: e.try(:displayName) ? e.displayName.first : nil,
|
display_name: e.try(:displayName) ? e.displayName.first : nil,
|
||||||
admin: e.try(:admin) ? 'admin' : nil,
|
admin: e.try(:admin) ? 'admin' : nil,
|
||||||
service: e.try(:service),
|
services_enabled: e.try(:serviceEnabled),
|
||||||
email_maildrop: e.try(:mailRoutingAddress),
|
email_maildrop: e.try(:mailRoutingAddress),
|
||||||
email_password: e.try(:mailpassword)
|
email_password: e.try(:mailpassword),
|
||||||
|
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -75,9 +82,9 @@ class LdapService < ApplicationService
|
|||||||
attributes = %w{dn ou description}
|
attributes = %w{dn ou description}
|
||||||
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
|
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
|
||||||
# filter = Net::LDAP::Filter.eq("objectClass", "*")
|
# filter = Net::LDAP::Filter.eq("objectClass", "*")
|
||||||
treebase = "cn=users,#{@suffix}"
|
treebase = "cn=users,#{ldap_suffix}"
|
||||||
|
|
||||||
entries = ldap_client.search(base: treebase, filter: filter, attributes: attributes)
|
entries = client.search(base: treebase, filter: filter, attributes: attributes)
|
||||||
|
|
||||||
entries.sort_by! { |e| e.ou[0] }
|
entries.sort_by! { |e| e.ou[0] }
|
||||||
|
|
||||||
@@ -91,10 +98,10 @@ class LdapService < ApplicationService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def add_organization(ou, description, interactive=false)
|
def add_organization(ou, description, interactive=false)
|
||||||
dn = "ou=#{ou},cn=users,#{@suffix}"
|
dn = "ou=#{ou},cn=users,#{ldap_suffix}"
|
||||||
|
|
||||||
aci = <<-EOS
|
aci = <<-EOS
|
||||||
(target="ldap:///cn=*,ou=#{ou},cn=users,#{@suffix}")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{@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
|
EOS
|
||||||
|
|
||||||
attrs = {
|
attrs = {
|
||||||
@@ -115,22 +122,22 @@ class LdapService < ApplicationService
|
|||||||
delete_all_entries!
|
delete_all_entries!
|
||||||
|
|
||||||
user_read_aci = <<-EOS
|
user_read_aci = <<-EOS
|
||||||
(target="ldap:///#{@suffix}")(targetattr="*") (version 3.0; acl "user-read-search-own-attributes"; allow (read,search) userdn="ldap:///self";)
|
(target="ldap:///#{ldap_suffix}")(targetattr="*") (version 3.0; acl "user-read-search-own-attributes"; allow (read,search) userdn="ldap:///self";)
|
||||||
EOS
|
EOS
|
||||||
|
|
||||||
add_entry @suffix, {
|
add_entry ldap_suffix, {
|
||||||
dc: "kosmos", objectClass: ["top", "domain"], aci: user_read_aci
|
dc: "kosmos", objectClass: ["top", "domain"], aci: user_read_aci
|
||||||
}, true
|
}, true
|
||||||
|
|
||||||
add_entry "cn=users,#{@suffix}", {
|
add_entry "cn=users,#{ldap_suffix}", {
|
||||||
cn: "users", objectClass: ["top", "organizationalRole"]
|
cn: "users", objectClass: ["top", "organizationalRole"]
|
||||||
}, true
|
}, true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ldap_client
|
def client
|
||||||
ldap_client ||= Net::LDAP.new host: ldap_config['host'],
|
client ||= Net::LDAP.new host: ldap_config['host'],
|
||||||
port: ldap_config['port'],
|
port: ldap_config['port'],
|
||||||
# TODO has to be :simple_tls if TLS is enabled
|
# TODO has to be :simple_tls if TLS is enabled
|
||||||
# encryption: ldap_config['ssl'],
|
# encryption: ldap_config['ssl'],
|
||||||
@@ -144,4 +151,8 @@ class LdapService < ApplicationService
|
|||||||
def ldap_config
|
def ldap_config
|
||||||
ldap_config ||= YAML.load(ERB.new(File.read("#{Rails.root}/config/ldap.yml")).result)[Rails.env]
|
ldap_config ||= YAML.load(ERB.new(File.read("#{Rails.root}/config/ldap.yml")).result)[Rails.env]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ldap_suffix
|
||||||
|
@ldap_suffix ||= ENV["LDAP_SUFFIX"] || "dc=kosmos,dc=org"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
class Lndhub
|
class Lndhub < ApplicationService
|
||||||
attr_accessor :auth_token
|
attr_accessor :auth_token
|
||||||
|
|
||||||
def initialize
|
def post(path, payload)
|
||||||
@base_url = ENV["LNDHUB_API_URL"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def post(endpoint, payload)
|
|
||||||
headers = { "Content-Type" => "application/json" }
|
headers = { "Content-Type" => "application/json" }
|
||||||
if auth_token
|
if auth_token
|
||||||
headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
|
headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
|
||||||
end
|
end
|
||||||
|
|
||||||
res = Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, headers
|
res = Faraday.post endpoint_url(path), payload.to_json, headers
|
||||||
log_error(res) if res.status != 200
|
log_error(res) if res.status != 200
|
||||||
|
|
||||||
JSON.parse(res.body)
|
JSON.parse(res.body)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(endpoint, auth_token)
|
def get(path, auth_token)
|
||||||
res = Faraday.get("#{@base_url}/#{endpoint}", {}, {
|
res = Faraday.get(endpoint_url(path), {}, {
|
||||||
"Content-Type" => "application/json",
|
"Content-Type" => "application/json",
|
||||||
"Accept" => "application/json",
|
"Accept" => "application/json",
|
||||||
"Authorization" => "Bearer #{auth_token}"
|
"Authorization" => "Bearer #{auth_token}"
|
||||||
@@ -42,7 +38,7 @@ class Lndhub
|
|||||||
self.auth_token
|
self.auth_token
|
||||||
end
|
end
|
||||||
|
|
||||||
def balance(user_token=nil)
|
def fetch_balance(user_token=nil)
|
||||||
get "balance", user_token || auth_token
|
get "balance", user_token || auth_token
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -72,4 +68,14 @@ class Lndhub
|
|||||||
Sentry.capture_message("Lndhub API request failed: #{res.body}")
|
Sentry.capture_message("Lndhub API request failed: #{res.body}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def base_url
|
||||||
|
@base_url ||= Setting.lndhub_api_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def endpoint_url(path)
|
||||||
|
"#{base_url}/#{path.gsub(/^\//, '')}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
13
app/services/lndhub_manager/create_user_invoice.rb
Normal file
13
app/services/lndhub_manager/create_user_invoice.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module LndhubManager
|
||||||
|
class CreateUserInvoice < LndhubV2
|
||||||
|
def initialize(user:, payload:)
|
||||||
|
@user = user
|
||||||
|
@payload = payload
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
authenticate @user
|
||||||
|
create_invoice @payload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
12
app/services/lndhub_manager/fetch_user_balance.rb
Normal file
12
app/services/lndhub_manager/fetch_user_balance.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module LndhubManager
|
||||||
|
class FetchUserBalance < Lndhub
|
||||||
|
def initialize(auth_token:)
|
||||||
|
@auth_token = auth_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
data = fetch_balance(auth_token)
|
||||||
|
data["BTC"]["AvailableBalance"] rescue nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
class LndhubV2 < Lndhub
|
class LndhubV2 < Lndhub
|
||||||
|
|
||||||
def post(endpoint, payload, options={})
|
def post(path, payload, options={})
|
||||||
headers = { "Content-Type" => "application/json" }
|
headers = { "Content-Type" => "application/json" }
|
||||||
if auth_token
|
if auth_token
|
||||||
headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
|
headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
|
||||||
elsif options[:admin_token]
|
elsif options[:admin_token]
|
||||||
headers.merge!({ "Authorization" => "Bearer #{options[:admin_token]}" })
|
headers.merge!({ "Authorization" => "Bearer #{options[:admin_token]}" })
|
||||||
end
|
end
|
||||||
res = Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, headers
|
res = Faraday.post endpoint_url(path), payload.to_json, headers
|
||||||
log_error(res) if res.status != 200
|
log_error(res) if res.status != 200
|
||||||
|
|
||||||
JSON.parse(res.body)
|
JSON.parse(res.body)
|
||||||
|
|||||||
25
app/services/nostr_manager/create_zap_receipt.rb
Normal file
25
app/services/nostr_manager/create_zap_receipt.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module NostrManager
|
||||||
|
class CreateZapReceipt < NostrManagerService
|
||||||
|
def initialize(zap:, paid_at:, preimage:)
|
||||||
|
@zap, @paid_at, @preimage = zap, paid_at, preimage
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
request_tags = parse_tags(@zap.request_event.tags)
|
||||||
|
|
||||||
|
site_user.create_event(
|
||||||
|
kind: 9735,
|
||||||
|
created_at: @paid_at,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["p", request_tags[:p].first],
|
||||||
|
["e", request_tags[:e]&.first],
|
||||||
|
["a", request_tags[:a]&.first],
|
||||||
|
["bolt11", @zap.payment_request],
|
||||||
|
["preimage", @preimage],
|
||||||
|
["description", @zap.request_event.to_json]
|
||||||
|
].reject { |t| t[1].nil? }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
50
app/services/nostr_manager/publish_event.rb
Normal file
50
app/services/nostr_manager/publish_event.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
module NostrManager
|
||||||
|
class PublishEvent < NostrManagerService
|
||||||
|
def initialize(event:, relay_url:)
|
||||||
|
relay_name = URI.parse(relay_url).host
|
||||||
|
@relay = Nostr::Relay.new(url: relay_url, name: relay_name)
|
||||||
|
|
||||||
|
if event.is_a?(Nostr::Event)
|
||||||
|
@event = event
|
||||||
|
else
|
||||||
|
@event = Nostr::Event.new(**event.symbolize_keys)
|
||||||
|
end
|
||||||
|
|
||||||
|
@client = Nostr::Client.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
client, relay, event = @client, @relay, @event
|
||||||
|
log_prefix = "[nostr][#{relay.name}]"
|
||||||
|
|
||||||
|
thread = Thread.new do
|
||||||
|
client.on :connect do
|
||||||
|
puts "#{log_prefix} Publishing #{event.id}..."
|
||||||
|
client.publish event
|
||||||
|
end
|
||||||
|
|
||||||
|
client.on :error do |e|
|
||||||
|
puts "#{log_prefix} Error: #{e}"
|
||||||
|
puts "#{log_prefix} Closing thread..."
|
||||||
|
thread.exit
|
||||||
|
end
|
||||||
|
|
||||||
|
client.on :message do |m|
|
||||||
|
puts "#{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..."
|
||||||
|
else
|
||||||
|
puts "#{log_prefix} Unexpected message from relay. Closing thread..."
|
||||||
|
end
|
||||||
|
thread.exit
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "#{log_prefix} Connecting to #{relay.url}..."
|
||||||
|
client.connect relay
|
||||||
|
end
|
||||||
|
|
||||||
|
thread.join
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
24
app/services/nostr_manager/publish_zap_receipt.rb
Normal file
24
app/services/nostr_manager/publish_zap_receipt.rb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module NostrManager
|
||||||
|
class PublishZapReceipt < NostrManagerService
|
||||||
|
def initialize(zap:, delayed: true)
|
||||||
|
@zap, @delayed = zap, delayed
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
tags = parse_tags(@zap.request_event.tags)
|
||||||
|
relays = tags[:relays].take(Setting.nostr_zaps_relay_limit)
|
||||||
|
|
||||||
|
if Setting.nostr_relay_url.present?
|
||||||
|
relays << Setting.nostr_relay_url
|
||||||
|
end
|
||||||
|
|
||||||
|
relays.uniq.each do |relay_url|
|
||||||
|
if @delayed
|
||||||
|
NostrPublishEventJob.perform_later(event: @zap.receipt, relay_url: relay_url)
|
||||||
|
else
|
||||||
|
NostrManager::PublishEvent.call(event: @zap.receipt_event, relay_url: relay_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
module NostrManager
|
|
||||||
class ValidateId < NostrManagerService
|
|
||||||
def initialize(event:)
|
|
||||||
@event = Nostr::Event.new(**event)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
@event.id == Digest::SHA256.hexdigest(JSON.generate(@event.serialize))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
18
app/services/nostr_manager/verify_auth.rb
Normal file
18
app/services/nostr_manager/verify_auth.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module NostrManager
|
||||||
|
class VerifyAuth < NostrManagerService
|
||||||
|
def initialize(event:, challenge:)
|
||||||
|
@event = event
|
||||||
|
@challenge_expected = challenge
|
||||||
|
@site_expected = Setting.accounts_domain
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
tags = parse_tags(@event.tags)
|
||||||
|
site_given = tags[:site].first
|
||||||
|
challenge_given = tags[:challenge].first
|
||||||
|
|
||||||
|
site_given == @site_expected &&
|
||||||
|
challenge_given == @challenge_expected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
module NostrManager
|
|
||||||
class VerifySignature < NostrManagerService
|
|
||||||
def initialize(event:)
|
|
||||||
@event = Nostr::Event.new(**event)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
Schnorr.check_sig!(
|
|
||||||
[@event.id].pack('H*'),
|
|
||||||
[@event.pubkey].pack('H*'),
|
|
||||||
[@event.sig].pack('H*')
|
|
||||||
)
|
|
||||||
rescue Schnorr::InvalidSignatureError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
51
app/services/nostr_manager/verify_zap_request.rb
Normal file
51
app/services/nostr_manager/verify_zap_request.rb
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
module NostrManager
|
||||||
|
class VerifyZapRequest < NostrManagerService
|
||||||
|
def initialize(amount:, event:, lnurl: nil)
|
||||||
|
@amount, @event, @lnurl = amount, event, lnurl
|
||||||
|
end
|
||||||
|
|
||||||
|
# https://github.com/nostr-protocol/nips/blob/27fef638e2460139cc9078427a0aec0ce4470517/57.md#appendix-d-lnurl-server-zap-request-validation
|
||||||
|
def call
|
||||||
|
tags = parse_tags(@event.tags)
|
||||||
|
|
||||||
|
@event.verify_signature &&
|
||||||
|
@event.kind == 9734 &&
|
||||||
|
tags.present? &&
|
||||||
|
valid_p_tag?(tags[:p]) &&
|
||||||
|
valid_e_tag?(tags[:e]) &&
|
||||||
|
valid_a_tag?(tags[:a]) &&
|
||||||
|
valid_amount_tag?(tags[:amount]) &&
|
||||||
|
valid_lnurl_tag?(tags[:lnurl])
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_p_tag?(tag)
|
||||||
|
return false unless tag.present? && tag.length == 1
|
||||||
|
key = Nostr::PublicKey.new(tag.first) rescue nil
|
||||||
|
key.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_e_tag?(tag)
|
||||||
|
return true unless tag.present?
|
||||||
|
# TODO validate format of event ID properly
|
||||||
|
tag.length == 1 && tag.first.is_a?(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_a_tag?(tag)
|
||||||
|
return true unless tag.present?
|
||||||
|
# TODO validate format of event coordinate properly
|
||||||
|
tag.length == 1 && tag.first.is_a?(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_amount_tag?(tag)
|
||||||
|
return true unless tag.present?
|
||||||
|
amount = tag.first
|
||||||
|
amount.is_a?(String) && amount.to_i == @amount
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_lnurl_tag?(tag)
|
||||||
|
return true unless tag.present?
|
||||||
|
# TODO validate lnurl matching recipient's lnurlp
|
||||||
|
tag.first.is_a?(String)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,22 @@
|
|||||||
require "nostr"
|
require "nostr"
|
||||||
|
|
||||||
class NostrManagerService < ApplicationService
|
class NostrManagerService < ApplicationService
|
||||||
|
def parse_tags(tags)
|
||||||
|
out = {}
|
||||||
|
tags.each do |tag|
|
||||||
|
out[tag[0].to_sym] = tag[1, tag.length]
|
||||||
|
end
|
||||||
|
out
|
||||||
|
end
|
||||||
|
|
||||||
|
def site_keypair
|
||||||
|
Nostr::KeyPair.new(
|
||||||
|
private_key: Nostr::PrivateKey.new(Setting.nostr_private_key),
|
||||||
|
public_key: Nostr::PublicKey.new(Setting.nostr_public_key)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def site_user
|
||||||
|
Nostr::User.new(keypair: site_keypair)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
json.extract! donation, :id, :user_id, :amount_sats, :amount_eur, :amount_usd, :public_name, :created_at, :updated_at
|
|
||||||
json.url donation_url(donation, format: :json)
|
|
||||||
@@ -14,14 +14,24 @@
|
|||||||
<%= form.label :user_id %>
|
<%= form.label :user_id %>
|
||||||
<%= form.collection_select :user_id, User.where(ou: Setting.primary_domain).order(:cn), :id, :cn, {} %>
|
<%= form.collection_select :user_id, User.where(ou: Setting.primary_domain).order(:cn), :id, :cn, {} %>
|
||||||
|
|
||||||
|
<%= form.label :donation_method, "Donation method" %>
|
||||||
|
<%= form.select :donation_method, options_for_select([
|
||||||
|
["Custom (manual)", "custom"],
|
||||||
|
["BTCPay", "btcpay"],
|
||||||
|
["LndHub account", "lndhub"],
|
||||||
|
["OpenCollective", "opencollective"]
|
||||||
|
], selected: (donation.donation_method || "custom")) %>
|
||||||
|
|
||||||
<%= form.label :amount_sats, "Amount BTC (sats)" %>
|
<%= form.label :amount_sats, "Amount BTC (sats)" %>
|
||||||
<%= form.number_field :amount_sats %>
|
<%= form.number_field :amount_sats %>
|
||||||
|
|
||||||
<%= form.label :amount_eur, "Amount EUR (cents)" %>
|
<%= form.label :fiat_amount, "Fiat Amount (cents)" %>
|
||||||
<%= form.number_field :amount_eur %>
|
<%= form.number_field :fiat_amount %>
|
||||||
|
|
||||||
<%= form.label :amount_usd, "Amount USD (cents)"%>
|
<%= form.label :fiat_currency, "Fiat Currency" %>
|
||||||
<%= form.number_field :amount_usd %>
|
<%= form.select :fiat_currency, options_for_select([
|
||||||
|
["EUR", "EUR"], ["USD", "USD"], ["sats", "sats"]
|
||||||
|
], selected: donation.fiat_currency) %>
|
||||||
|
|
||||||
<%= form.label :public_name %>
|
<%= form.label :public_name %>
|
||||||
<%= form.text_field :public_name %>
|
<%= form.text_field :public_name %>
|
||||||
|
|||||||
@@ -25,9 +25,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th class="text-right">Amount BTC</th>
|
<th class="text-right">Sats</th>
|
||||||
<th class="text-right">in EUR</th>
|
<th class="text-right">Fiat Amount</th>
|
||||||
<th class="text-right">in USD</th>
|
|
||||||
<th class="pl-2">Public name</th>
|
<th class="pl-2">Public name</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -36,10 +35,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<% @donations.each do |donation| %>
|
<% @donations.each do |donation| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= link_to donation.user.address, admin_user_path(donation.user.address), class: 'ks-text-link' %></td>
|
<td><%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %></td>
|
||||||
<td class="text-right"><%= sats_to_btc donation.amount_sats %></td>
|
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
|
||||||
<td class="text-right"><% if donation.amount_eur.present? %><%= number_to_currency donation.amount_eur / 100, unit: "" %><% end %></td>
|
<td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
|
||||||
<td class="text-right"><% if donation.amount_usd.present? %><%= number_to_currency donation.amount_usd / 100, unit: "" %><% end %></td>
|
|
||||||
<td class="pl-2"><%= donation.public_name %></td>
|
<td class="pl-2"><%= donation.public_name %></td>
|
||||||
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
|
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
json.array! @donations, partial: "donations/donation", as: :donation
|
|
||||||
@@ -6,19 +6,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<td><%= link_to @donation.user.address, admin_user_path(@donation.user.address), class: 'ks-text-link' %></td>
|
<td><%= link_to @donation.user.cn, admin_user_path(@donation.user.cn), class: 'ks-text-link' %></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Donation Method</th>
|
||||||
|
<td><%= @donation.donation_method %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Amount sats</th>
|
<th>Amount sats</th>
|
||||||
<td><%= @donation.amount_sats %></td>
|
<td><%= @donation.amount_sats %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Amount EUR</th>
|
<th>Fiat amount</th>
|
||||||
<td><%= @donation.amount_eur %></td>
|
<td><% if @donation.fiat_amount.present? %><%= number_to_currency @donation.fiat_amount.to_f / 100, unit: "" %> <%= @donation.fiat_currency %><% end %></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Amount USD</th>
|
|
||||||
<td><%= @donation.amount_usd %></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Public name</th>
|
<th>Public name</th>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<td><%= @donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
|
<td><%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
json.partial! "donations/donation", donation: @donation
|
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="overflow-ellipsis font-mono"><%= invitation.token %></td>
|
<td class="overflow-ellipsis font-mono"><%= invitation.token %></td>
|
||||||
<td><%= invitation.used_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
|
<td><%= invitation.used_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
|
||||||
<td><%= link_to invitation.user.address, admin_user_path(invitation.user.address), class: "ks-text-link" %></td>
|
<td><%= link_to invitation.user.cn, admin_user_path(invitation.user.cn), class: "ks-text-link" %></td>
|
||||||
<td><%= link_to invitation.invitee.address, admin_user_path(invitation.invitee.address), class: "ks-text-link" %></td>
|
<td><%= link_to invitation.invitee.cn, admin_user_path(invitation.invitee.cn), class: "ks-text-link" %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<% if user = @users.find{ |u| u[2] == account.login } %>
|
<% if user = @users.find{ |u| u[2] == account.login } %>
|
||||||
<%= link_to "#{user[0]}@#{user[1]}", admin_user_path("#{user[0]}@#{user[1]}"), class: "ks-text-link" %>
|
<%= link_to user[0], admin_user_path(user[0]), class: "ks-text-link" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td><%= number_with_delimiter account.balance.to_i.to_s %></td>
|
<td><%= number_with_delimiter account.balance.to_i.to_s %></td>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%= render HeaderComponent.new(title: "Settings") %>
|
<%= render HeaderComponent.new(title: "Settings") %>
|
||||||
|
|
||||||
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
|
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
|
||||||
<%= form_for(Setting.new, url: admin_settings_registrations_path) do |f| %>
|
<%= form_for(Setting.new, url: admin_settings_registrations_path, method: :put) do |f| %>
|
||||||
<section>
|
<section>
|
||||||
<h3>Registrations</h3>
|
<h3>Registrations</h3>
|
||||||
|
|
||||||
@@ -7,4 +7,32 @@
|
|||||||
title: "Enable Nostr integration (experimental)",
|
title: "Enable Nostr integration (experimental)",
|
||||||
description: "Allow adding nostr pubkeys and resolve user addresses via NIP-05"
|
description: "Allow adding nostr pubkeys and resolve user addresses via NIP-05"
|
||||||
) %>
|
) %>
|
||||||
|
<% if Setting.nostr_enabled? %>
|
||||||
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
|
key: :nostr_private_key,
|
||||||
|
type: :password,
|
||||||
|
title: "Private key",
|
||||||
|
description: "The private key of the accounts service, used when publishing events (e.g. zap receipts)"
|
||||||
|
) %>
|
||||||
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
|
key: :nostr_public_key,
|
||||||
|
title: "Public key",
|
||||||
|
description: "The corresponding public key of the accounts service"
|
||||||
|
) %>
|
||||||
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
|
key: :nostr_relay_url,
|
||||||
|
title: "Relay URL",
|
||||||
|
description: "Websockets URL of a relay associated with #{Setting.primary_domain}"
|
||||||
|
) %>
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Zaps</h3>
|
||||||
|
<ul role="list">
|
||||||
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
|
key: :nostr_zaps_relay_limit,
|
||||||
|
title: "Relay limit",
|
||||||
|
description: "The maximum number of relays to publish zap receipts to"
|
||||||
|
) %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<%= render HeaderComponent.new(title: "Settings") %>
|
<%= render HeaderComponent.new(title: "Settings") %>
|
||||||
|
|
||||||
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
|
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
|
||||||
<%= form_for(Setting.new, url: admin_settings_services_path) do |f| %>
|
<%= form_for(Setting.new, url: admin_settings_service_path(@service), method: :put) do |f| %>
|
||||||
<%= hidden_field_tag :service, @service %>
|
|
||||||
|
|
||||||
<% if @errors && @errors.any? %>
|
<% if @errors && @errors.any? %>
|
||||||
<section>
|
<section>
|
||||||
<%= render partial: "admin/settings/errors", locals: { errors: @errors } %>
|
<%= render partial: "admin/settings/errors", locals: { errors: @errors } %>
|
||||||
21
app/views/admin/users/_create_invitations.html.erb
Normal file
21
app/views/admin/users/_create_invitations.html.erb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<h3>Add new invitations to <%= @user.cn %>'s account</h3>
|
||||||
|
<%= form_with(url: invitations_admin_user_path, method: :post) do |form| %>
|
||||||
|
<ul role="list">
|
||||||
|
<%= render FormElements::FieldsetComponent.new(
|
||||||
|
positioning: :horizontal,
|
||||||
|
title: "Amount"
|
||||||
|
) do %>
|
||||||
|
<%= form.select :amount, options_for_select([
|
||||||
|
["3", "3"], ["5", "5"], ["10", "10"], ["20", "20"]
|
||||||
|
]) %>
|
||||||
|
<% end %>
|
||||||
|
<%= render FormElements::FieldsetToggleComponent.new(
|
||||||
|
field_name: "notify_user",
|
||||||
|
enabled: true,
|
||||||
|
title: "Notify user via email"
|
||||||
|
) %>
|
||||||
|
</ul>
|
||||||
|
<p class="pt-6 border-t border-gray-200 text-right">
|
||||||
|
<%= form.submit 'Add', class: "btn-md btn-blue w-full" %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
@@ -36,33 +36,40 @@
|
|||||||
<th>Invited by</th>
|
<th>Invited by</th>
|
||||||
<td>
|
<td>
|
||||||
<% if @user.inviter %>
|
<% if @user.inviter %>
|
||||||
<%= link_to @user.inviter.address, admin_user_path(@user.inviter.address), class: 'ks-text-link' %>
|
<%= link_to @user.inviter.cn, admin_user_path(@user.inviter.cn), class: 'ks-text-link' %>
|
||||||
<% else %>—<% end %>
|
<% else %>—<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Invitations available</th>
|
<th>Invitations available</th>
|
||||||
<td>
|
<td data-controller="modal" data-action="keydown.esc->modal#close">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>
|
<span>
|
||||||
<%= @user.invitations.count %>
|
<%= @user.invitations.count %>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<%= render DropdownComponent.new(size: :small, icon_name: "edit") do %>
|
<button id="add-invitations" data-action="click->modal#open">
|
||||||
<%= render DropdownLinkComponent.new(
|
<%= render partial: "icons/plus-circle", locals: {
|
||||||
href: ""
|
custom_class: "text-green-600 hover:text-green-500 -mt-2 -mb-1 h-6 w-6 inline-block"
|
||||||
) do %>
|
} %>
|
||||||
Add more
|
</button>
|
||||||
<% end %>
|
<% if @user.invitations.unused.count > 0 %>
|
||||||
<%= render DropdownLinkComponent.new(
|
<%= link_to invitations_admin_user_path(@user.cn),
|
||||||
href: "",
|
id: "remove-invitations", data: {
|
||||||
separator: true, add_class: "text-red-700"
|
turbo_method: :delete,
|
||||||
) do %>
|
turbo_confirm: "Delete all of #{@user.cn}'s available invitations?"
|
||||||
Remove all
|
} do %>
|
||||||
|
<%= render partial: "icons/x-circle", locals: {
|
||||||
|
custom_class: "text-red-600 hover:text-red-500 -mt-2 -mb-1 h-6 w-6 inline-block"
|
||||||
|
} %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<%= render ModalComponent.new(show_close_button: false) do %>
|
||||||
|
<%= render partial: "admin/users/create_invitations",
|
||||||
|
locals: { user: @user } %>
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -71,7 +78,7 @@
|
|||||||
<% if @user.invitees.length > 0 %>
|
<% if @user.invitees.length > 0 %>
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
<% @user.invitees.order(cn: :asc).each do |invitee| %>
|
<% @user.invitees.order(cn: :asc).each do |invitee| %>
|
||||||
<li class="leading-none mb-2 last:mb-0"><%= link_to invitee.address, admin_user_path(invitee.address), class: 'ks-text-link' %></li>
|
<li class="leading-none mb-2 last:mb-0"><%= link_to invitee.cn, admin_user_path(invitee.cn), class: 'ks-text-link' %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% else %>—<% end %>
|
<% else %>—<% end %>
|
||||||
@@ -117,6 +124,19 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if Setting.email_enabled %>
|
||||||
|
<tr>
|
||||||
|
<td>E-Mail</td>
|
||||||
|
<td>
|
||||||
|
<%= render FormElements::ToggleComponent.new(
|
||||||
|
enabled: Flipper.enabled?(:email, current_user),
|
||||||
|
input_enabled: false
|
||||||
|
) %>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
<% if Setting.gitea_enabled %>
|
<% if Setting.gitea_enabled %>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Gitea</td>
|
<td>Gitea</td>
|
||||||
@@ -175,6 +195,33 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if Setting.nostr_enabled %>
|
||||||
|
<tr>
|
||||||
|
<td>Nostr</td>
|
||||||
|
<td>
|
||||||
|
<%= render FormElements::ToggleComponent.new(
|
||||||
|
enabled: @user.nostr_pubkey.present?,
|
||||||
|
input_enabled: false
|
||||||
|
) %>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<%= link_to "Open profile", "https://njump.me/#{@user.nostr_pubkey_bech32}", class: "btn-sm btn-gray" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
<% if Setting.remotestorage_enabled %>
|
||||||
|
<tr>
|
||||||
|
<td>remoteStorage</td>
|
||||||
|
<td>
|
||||||
|
<%= render FormElements::ToggleComponent.new(
|
||||||
|
enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
|
||||||
|
input_enabled: false
|
||||||
|
) %>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
36
app/views/contributions/donations/_bitcoin.html.erb
Normal file
36
app/views/contributions/donations/_bitcoin.html.erb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="rounded-lg p-6 bg-emerald-50 hover:bg-emerald-100 transition-colors">
|
||||||
|
<h3 class="mb-4 text-lg font-bold">Donate directly with Bitcoin</h3>
|
||||||
|
<p class="mb-6">
|
||||||
|
Open-source money for open-source services.
|
||||||
|
</p>
|
||||||
|
<div data-controller="modal" data-action="keydown.esc->modal#close">
|
||||||
|
<button class="btn-md btn-emerald w-full lg:w-1/2" data-action="click->modal#open">
|
||||||
|
Donate
|
||||||
|
</button>
|
||||||
|
<%= render ModalComponent.new(show_close_button: false) do %>
|
||||||
|
<div>
|
||||||
|
<h3>Your contribution</h3>
|
||||||
|
|
||||||
|
<%= form_with(url: contributions_donations_url, method: :post) do |f| %>
|
||||||
|
<%= f.hidden_field :donation_method, value: "btcpay" %>
|
||||||
|
|
||||||
|
<div class="mb-6 flex gap-2">
|
||||||
|
<%= f.number_field :amount, required: true %>
|
||||||
|
<%= f.select :currency, options_for_select([
|
||||||
|
["EUR", "EUR"], ["USD", "USD"], ["sats", "sats"]
|
||||||
|
], selected: "EUR"), class: "flex-none" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Public name") do %>
|
||||||
|
<%= f.text_field :public_name, class: "w-full", placeholder: "Anonymous" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<p class="mt-12">
|
||||||
|
<%= f.submit 'Continue', data: { turbo: false },
|
||||||
|
class: "btn-md btn-blue w-full" %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
37
app/views/contributions/donations/_list.html.erb
Normal file
37
app/views/contributions/donations/_list.html.erb
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<ul class="list-none">
|
||||||
|
<% donations.each do |donation| %>
|
||||||
|
<li class="mb-8 grid gap-y-2 grid-cols-2 items-center">
|
||||||
|
<h3 class="mb-0">
|
||||||
|
<% if donation.completed? %>
|
||||||
|
<%= donation.paid_at.strftime("%B %d, %Y") %>
|
||||||
|
<% else %>
|
||||||
|
<%= donation.created_at.strftime("%B %d, %Y") %>
|
||||||
|
<% end %>
|
||||||
|
</h3>
|
||||||
|
<p class="row-span-2 font-mono text-right mb-0">
|
||||||
|
<span class="text-xl">
|
||||||
|
<%= number_with_delimiter donation.amount_sats %> sats
|
||||||
|
</span>
|
||||||
|
<br>
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
(~ <%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %>)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0 text-gray-500">
|
||||||
|
<% if donation.processing? %>
|
||||||
|
Waiting for confirmations
|
||||||
|
<% if donation.donation_method == "btcpay" %>
|
||||||
|
<%= link_to "check status", btcpay_checkout_url(donation.btcpay_invoice_id),
|
||||||
|
class: "ml-2 btn-sm btn-gray" %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<% if donation.public_name.present? %>
|
||||||
|
As: <%= donation.public_name %>
|
||||||
|
<% else %>
|
||||||
|
Anonymous
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="rounded-lg p-6 bg-zinc-100 hover:bg-zinc-200 transition-colors">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-gray-500">Donate via OpenCollective</h3>
|
||||||
|
<p class="text-gray-600 text-gray-500">
|
||||||
|
Coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -2,50 +2,39 @@
|
|||||||
|
|
||||||
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
|
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
|
||||||
<section>
|
<section>
|
||||||
<% if @donations.any? %>
|
<p class="mb-12">
|
||||||
<p class="mb-12">
|
Your financial contributions to the development and upkeep of Kosmos
|
||||||
Your financial contributions to the development and upkeep of Kosmos
|
software and services.
|
||||||
software and services.
|
</p>
|
||||||
</p>
|
|
||||||
<ul class="list-none">
|
|
||||||
<% @donations.each do |donation| %>
|
|
||||||
<li class="mb-8 grid gap-y-2 gap-x-8 grid-cols-2 items-center">
|
|
||||||
<h3 class="mb-0">
|
|
||||||
<%= donation.paid_at.strftime("%B %d, %Y") %>
|
|
||||||
</h3>
|
|
||||||
<p class="row-span-2 font-mono text-right mb-0">
|
|
||||||
<span class="text-xl">
|
|
||||||
<%= number_with_delimiter donation.amount_sats %> sats
|
|
||||||
</span>
|
|
||||||
<br>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
(~ <%= number_to_currency donation.amount_eur / 100, unit: "" %> EUR)
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-0">
|
|
||||||
<% if donation.public_name.present? %>
|
|
||||||
Public name: <%= donation.public_name %>
|
|
||||||
<% else %>
|
|
||||||
Anonymous
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
<% else %>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="mt-8 mb-12 inline-flex align-center items-center">
|
|
||||||
<%= image_tag("/img/illustrations/undraw_savings_re_eq4w.svg", class: 'h-48') %>
|
|
||||||
</p>
|
|
||||||
<h3>
|
|
||||||
No donations yet
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-500">
|
|
||||||
The donation process is not automated yet.<br>Please
|
|
||||||
<a href="https://wiki.kosmos.org/Main_Page#Community_.2F_Getting_in_touch_.2F_Getting_involved" class="ks-text-link" target="_blank">contact us</a>
|
|
||||||
if you'd like to contribute this way right now.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="donation-methods">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<% if @donation_methods.include?(:btcpay) ||
|
||||||
|
@donation_methods.include?(:lndhub) %>
|
||||||
|
<%= render partial: "contributions/donations/bitcoin", locals: {
|
||||||
|
donation_methods: @donation_methods, lndhub_balance: @balance
|
||||||
|
} %>
|
||||||
|
<% end %>
|
||||||
|
<% if @donation_methods.include?(:opencollective) %>
|
||||||
|
<%= render partial: "contributions/donations/opencollective" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if @donations_pending.any? %>
|
||||||
|
<section class="donation-list">
|
||||||
|
<h2>Pending</h2>
|
||||||
|
<%= render partial: "contributions/donations/list",
|
||||||
|
locals: { donations: @donations_pending } %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @donations_completed.any? %>
|
||||||
|
<section class="donation-list">
|
||||||
|
<h2>Past contributions</h2>
|
||||||
|
<%= render partial: "contributions/donations/list",
|
||||||
|
locals: { donations: @donations_completed } %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<p>
|
<p>
|
||||||
There's something to do for everyone, especially non-programmers! For
|
There's something to do for everyone, especially non-programmers! For
|
||||||
example, we need more help with graphics, UI/UX design, and
|
example, we need more help with graphics, UI/UX design, and
|
||||||
content/copywriting. We also need moderators for social media. And beta
|
content/copywriting. Also, testing any of our software and reporting
|
||||||
testers for our software. The list doesn't end there.
|
issues you encounter along the way is very valuable.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
A good way to get started is to join one of our
|
A good way to get started is to join one of our
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We have run two 6-month trials so far, with the next trial period
|
We have run two 6-month trials so far, with the next trial period
|
||||||
starting sometime in Q1 2024. Watch your email for notifications about it!
|
starting sometime soon. Watch your email for notifications about it!
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="services grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
<div class="services grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<% if Setting.ejabberd_enabled? %>
|
<% if Setting.ejabberd_enabled? %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-cover bg-[center_top_-50px] bg-no-repeat
|
bg-[length:86%] bg-[center_top_-40px] bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_xmpp.svg)]">
|
bg-[url(/img/logos/icon_xmpp.svg)]">
|
||||||
<%= link_to services_chat_path,
|
<%= link_to services_chat_path,
|
||||||
class: "block h-full px-6 py-6 rounded-md" do %>
|
class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.mastodon_enabled? %>
|
<% if Setting.mastodon_enabled? %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-[length:80%] bg-[right_top_-30px] bg-no-repeat
|
bg-[length:88%] bg-[center_top_-40px] bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_mastodon.svg)]">
|
bg-[url(/img/logos/icon_mastodon.svg)]">
|
||||||
<%= link_to services_mastodon_path, class: "block h-full px-6 py-6 rounded-md" do %>
|
<%= link_to services_mastodon_path, class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
<h3 class="mb-3.5">Mastodon</h3>
|
<h3 class="mb-3.5">Mastodon</h3>
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.email_enabled? &&
|
<% if Setting.email_enabled? &&
|
||||||
Flipper.enabled?(:email, current_user) %>
|
Flipper.enabled?(:email, current_user) %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400">
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
|
bg-[length:90%] bg-[center_top_-160px] bg-no-repeat
|
||||||
|
bg-[url(/img/logos/icon_mail.svg)]">
|
||||||
<%= link_to services_email_path, class: "block h-full px-6 py-6 rounded-md" do %>
|
<%= link_to services_email_path, class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
<h3 class="mb-3.5">E-Mail</h3>
|
<h3 class="mb-3.5">E-Mail</h3>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
@@ -39,15 +41,16 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.discourse_enabled? %>
|
<% if Setting.remotestorage_enabled? &&
|
||||||
|
Flipper.enabled?(:remotestorage, current_user) %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-[length:95%] bg-center bg-no-repeat
|
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_discourse.svg)]">
|
bg-[url(/img/logos/icon_remotestorage.svg)]">
|
||||||
<%= link_to "#{Setting.discourse_public_url}/session/sso?return_path=/",
|
<%= link_to services_storage_path,
|
||||||
class: "block h-full px-6 py-6 rounded-md" do %>
|
class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
<h3 class="mb-3.5">Discourse</h3>
|
<h3 class="mb-3.5">Storage</h3>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Kosmos community forums and user support/help site
|
Sync your data between apps and devices
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,21 +68,22 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.remotestorage_enabled? &&
|
<% if Setting.discourse_enabled? %>
|
||||||
Flipper.enabled?(:remotestorage, current_user) %>
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400">
|
bg-[length:80%] bg-center bg-no-repeat
|
||||||
<%= link_to services_storage_path,
|
bg-[url(/img/logos/icon_discourse.svg)]">
|
||||||
|
<%= link_to "#{Setting.discourse_public_url}/session/sso?return_path=/",
|
||||||
class: "block h-full px-6 py-6 rounded-md" do %>
|
class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
<h3 class="mb-3.5">Storage</h3>
|
<h3 class="mb-3.5">Discourse</h3>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Sync your data between apps and devices
|
Community forums and support/help site
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.gitea_enabled? %>
|
<% if Setting.gitea_enabled? %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-cover bg-center bg-no-repeat
|
bg-[length:92%] bg-center bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_gitea.png)]">
|
bg-[url(/img/logos/icon_gitea.png)]">
|
||||||
<%= link_to Setting.gitea_public_url,
|
<%= link_to Setting.gitea_public_url,
|
||||||
class: "block h-full px-6 py-6 rounded-md" do %>
|
class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% if Setting.droneci_enabled? %>
|
<% if Setting.droneci_enabled? %>
|
||||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||||
bg-cover bg-[center_top_-70px] bg-no-repeat
|
bg-[length:86%] bg-[center_top_-60px] bg-no-repeat
|
||||||
bg-[url(/img/logos/icon_droneci.svg)]">
|
bg-[url(/img/logos/icon_droneci.svg)]">
|
||||||
<%= link_to Setting.droneci_public_url,
|
<%= link_to Setting.droneci_public_url,
|
||||||
class: "block h-full px-6 py-6 rounded-md" do %>
|
class: "block h-full px-6 py-6 rounded-md" do %>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<%
|
<%
|
||||||
# TODO remove when https://github.com/hotwired/turbo/issues/203 is fixed
|
# TODO remove when https://github.com/hotwired/turbo/issues/203 is fixed
|
||||||
enable_turbo = !session[:user_return_to] || !session[:user_return_to].match?('/discourse/connect')
|
enable_turbo = session[:user_return_to].blank? ||
|
||||||
|
['/discourse/connect', '/rs/oauth'].none? { |s| session[:user_return_to].match(s) }
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<%= render HeaderCompactComponent.new(title: "Log in") %>
|
<%= render HeaderCompactComponent.new(title: "Log in") %>
|
||||||
@@ -54,4 +55,27 @@
|
|||||||
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
|
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div data-controller="nostr-login"
|
||||||
|
data-nostr-login-target="loginForm"
|
||||||
|
data-nostr-login-site-value="<%= Setting.accounts_domain %>"
|
||||||
|
data-nostr-login-shared-secret-value="<%= session[:shared_secret] %>"
|
||||||
|
class="hidden">
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center">
|
||||||
|
<span class="bg-white px-2 text-sm text-gray-500 italic">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<button disabled tabindex="5"
|
||||||
|
class="w-full btn-md btn-gray text-purple-600"
|
||||||
|
data-nostr-login-target="loginButton"
|
||||||
|
data-action="nostr-login#login">
|
||||||
|
Log in with Nostr
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
6
app/views/icons/_nostrich-head.html.erb
Normal file
6
app/views/icons/_nostrich-head.html.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" class="icon-nostrich-head <%= custom_class %>" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.03377 4.84648C2.38935 5.60878 1.88639 6.49681 1.5799 7.4713C3.32454 7.07836 5.64286 6.98406 6.95527 6.88189C7.36392 5.20013 8.52701 3.91915 10.476 4.0056C11.3169 4.04489 12.0556 4.58714 12.5664 5.42017C12.9436 5.01937 13.4466 4.75218 14.1146 4.65787C14.1617 4.65787 14.2639 4.65001 14.3425 4.65001C12.9593 3.14114 10.9868 2.18237 8.77849 2.18237C8.3777 2.18237 7.98476 2.22167 7.59183 2.28454C7.51324 2.28454 7.41108 2.30026 7.27748 2.33169C7.26962 2.33169 7.2539 2.33169 7.24604 2.33169C7.23818 2.33169 7.23032 2.33169 7.21461 2.33169C5.69001 2.70105 4.54264 2.40242 3.89037 1.51438C3.81964 1.42008 3.54458 1.00357 3.45814 0.272705C2.97876 0.767805 2.66441 1.58511 2.9316 2.45743C3.14379 3.149 3.54458 3.51836 3.97681 3.73054C3.31668 3.76984 2.76657 3.6441 2.21646 3.22759C1.89425 2.98396 1.68992 2.71677 1.352 2.01734C1.03765 2.51244 1.06909 3.06255 1.13195 3.34547C1.21054 3.72268 1.40701 4.14706 1.65849 4.39068C2.04357 4.76789 2.59368 4.85434 3.04162 4.84648H3.03377Z" fill="currentColor"/>
|
||||||
|
<path d="M10.4837 11.3458C11.4602 11.3458 12.2519 9.99116 12.2519 8.32016C12.2519 6.64917 11.4602 5.29456 10.4837 5.29456C9.50711 5.29456 8.71545 6.64917 8.71545 8.32016C8.71545 9.99116 9.50711 11.3458 10.4837 11.3458Z" fill="currentColor"/>
|
||||||
|
<path d="M14.3737 10.615C15.1376 10.615 15.7569 9.53831 15.7569 8.21019C15.7569 6.88207 15.1376 5.80542 14.3737 5.80542C13.6099 5.80542 12.9906 6.88207 12.9906 8.21019C12.9906 9.53831 13.6099 10.615 14.3737 10.615Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.52542 23.9833C7.53337 23.6314 7.66454 22.5232 8.7864 20.3047C9.2815 19.3381 10.4053 18.0021 11.2462 17.2791C11.6941 16.8862 12.1421 16.5561 12.5822 16.2496C12.8101 16.116 13.0222 15.9745 13.2266 15.8252C16.9076 13.5684 20.157 14.0396 22.8528 14.4306L22.9321 14.4421C22.9321 14.4421 23.5765 12.5246 20.9203 11.5344C19.4743 11 17.7689 10.5677 16.3465 10.2691C16.1422 10.6385 15.8828 10.9528 15.5763 11.1886C15.5721 11.1917 15.5678 11.195 15.5634 11.1983C15.3354 11.3696 14.795 11.7757 13.816 11.6601C13.313 11.5972 12.9279 11.3929 12.6215 11.0943C12.1028 11.9509 11.3562 12.5088 10.4917 12.5874C8.09483 12.7918 6.88458 10.7799 6.806 8.55591C5.00635 8.7288 2.55443 9.83688 1.24988 10.4813L1.25662 22.0396C2.92115 22.6846 5.41819 23.4807 7.52542 23.9833Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
3
app/views/icons/_nostrich-n.html.erb
Normal file
3
app/views/icons/_nostrich-n.html.erb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" class="icon-nostrich-n <%= custom_class %>" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 10.4604V23.135C24 23.6117 23.6161 23.9985 23.1429 23.9985H12.8578C12.3847 23.9985 12.0008 23.6117 12.0008 23.135V20.7746C12.0476 17.8812 12.3515 15.1096 12.9894 13.8487C13.3718 13.0904 14.0021 12.6777 14.7262 12.4569C16.0942 12.0426 18.4947 12.3259 19.5135 12.2772C19.5135 12.2772 22.5912 12.4005 22.5912 10.6447C22.5912 9.23147 21.2156 9.34264 21.2156 9.34264C19.6994 9.38223 18.5446 9.27868 17.7963 8.98173C16.5432 8.48528 16.5009 7.57462 16.4963 7.27005C16.4343 3.75228 11.2858 3.33046 6.74939 4.20305C1.78976 5.1533 6.80381 12.3152 6.80381 21.8756V23.1518C6.79474 23.6208 6.41834 24 5.94974 24H0.857089C0.383951 24 0 23.6132 0 23.1365V1.21523C0 0.738579 0.383951 0.351777 0.857089 0.351777H5.64439C6.11753 0.351777 6.50148 0.738579 6.50148 1.21523C6.50148 1.92335 7.29206 2.31777 7.86345 1.90508C9.58519 0.662437 11.7952 0 14.2682 0C19.8083 0 23.997 3.25279 23.997 10.4604H24ZM14.8033 7.88832C14.8033 6.86802 13.9825 6.04112 12.9697 6.04112C11.9569 6.04112 11.1361 6.86802 11.1361 7.88832C11.1361 8.90863 11.9569 9.73553 12.9697 9.73553C13.9825 9.73553 14.8033 8.90863 14.8033 7.88832Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user