Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b850f5ad21
|
||
|
|
cd7dd6a22f
|
||
|
|
b663131311
|
||
|
|
a2273f0c79
|
||
|
|
4b3994ada7
|
||
|
|
1e69957777
|
||
|
|
8dd0bf10ce
|
||
|
|
bf23931822
|
||
|
|
1ac5c7725a
|
||
|
|
dbf76b64b6
|
||
|
|
8c395ce737
|
||
|
|
2093f67a64
|
||
|
|
695977172f
|
||
|
|
bc37df52a0
|
||
|
|
6279699c11
|
||
|
|
0d9c580a0a
|
||
|
|
49c2c9feea
|
||
|
|
465563d6a6
|
||
|
|
911d2a5855
|
||
|
|
d8bba02636
|
||
|
|
2419982ef5
|
||
|
|
8fae099c12
|
||
|
|
4536b6505c
|
||
|
|
8453c38f41
|
||
|
|
1957df3bbd
|
||
|
|
d196c161df
|
||
|
|
44b5a0b36b
|
||
|
|
5ebf496096
|
||
|
|
7a63c87b03
|
||
|
|
6cc35f07eb
|
||
|
|
89423ecf84
|
||
|
|
c79c421b89
|
||
|
|
8a8d407bcc
|
||
|
|
045df94c29
|
No files matched your search
@@ -7,13 +7,16 @@ ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay.
|
||||
## Development environment
|
||||
|
||||
Development runs in Docker Compose — run all commands against the `web` container.
|
||||
Start services: `docker compose up` (web, ldap, redis, minio, liquor-cabinet, strfry).
|
||||
Start services: `docker compose up` (web, ldap, redis, garage, liquor-cabinet, strfry).
|
||||
The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds
|
||||
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
|
||||
|
||||
First-time LDAP setup (after creating the 389ds backend once):
|
||||
`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
|
||||
then `docker compose run web bin/rails ldap:setup`.
|
||||
First-time LDAP and database setup is automated: the `ldap-init` service
|
||||
creates the 389ds back-end, then the `web` entrypoint seeds LDAP and the
|
||||
databases on first start and runs `db:prepare` on every boot. Manual
|
||||
equivalents: `docker compose exec ldap dsconf localhost backend create
|
||||
--suffix="dc=kosmos,dc=org" --be-name="dev"` and `docker compose run --rm web
|
||||
bin/rails ldap:setup`.
|
||||
|
||||
## Common commands (prefix with `docker compose exec web`)
|
||||
|
||||
@@ -63,7 +66,6 @@ Warden `Test::Helpers`, DatabaseCleaner (transactional fixtures **off**), ViewCo
|
||||
and assert on DB rows / `enqueued_jobs`.
|
||||
- **Request/component/mailer/job/model/helper** specs in matching `spec/` subdirs.
|
||||
Fixtures live in `spec/fixtures/`; factories in `spec/factories/`.
|
||||
- `spec_helper.rb` reloads routes before each `type: :controller` spec (Devise + Rails 8 workaround).
|
||||
|
||||
## Notes & gotchas
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ source 'https://rubygems.org'
|
||||
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails', '~> 8.1'
|
||||
# Use Puma as the app server
|
||||
gem 'puma', '~> 6.6'
|
||||
gem 'puma', '~> 8.0'
|
||||
# View components
|
||||
gem "view_component"
|
||||
# Asset bundler
|
||||
@@ -18,7 +18,7 @@ gem "turbo-rails"
|
||||
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
|
||||
gem "stimulus-rails"
|
||||
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
|
||||
gem 'jbuilder', '~> 2.7'
|
||||
gem 'jbuilder', '~> 2.15'
|
||||
# Use Active Model has_secure_password
|
||||
gem 'bcrypt', '~> 3.1'
|
||||
|
||||
@@ -27,7 +27,7 @@ gem 'dotenv-rails'
|
||||
|
||||
# Authentication
|
||||
gem 'warden'
|
||||
gem 'devise', '~> 4.9.0'
|
||||
gem 'devise', '~> 5.0'
|
||||
gem 'devise_ldap_authenticatable'
|
||||
gem 'net-ldap'
|
||||
|
||||
@@ -43,6 +43,7 @@ gem 'flipper-ui'
|
||||
gem 'gpgme', '~> 2.0.24'
|
||||
gem 'zbase32', '~> 0.1.1'
|
||||
gem 'kramdown'
|
||||
gem 'device_detector'
|
||||
|
||||
# HTTP requests
|
||||
gem 'faraday'
|
||||
@@ -73,7 +74,7 @@ end
|
||||
|
||||
group :development do
|
||||
# Access an interactive console on exception pages or by calling 'console' anywhere in the code.
|
||||
gem 'web-console', '~> 4.2'
|
||||
gem 'web-console', '~> 4.3'
|
||||
gem 'listen', '~> 3.2'
|
||||
gem 'letter_opener'
|
||||
gem 'letter_opener_web'
|
||||
|
||||
+151
-140
@@ -3,29 +3,31 @@ GEM
|
||||
specs:
|
||||
aasm (5.5.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,42 +35,43 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.1.3.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.1.3.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
@@ -96,12 +99,12 @@ GEM
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
backport (1.2.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
bech32 (1.5.0)
|
||||
thor (>= 1.1.0)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
bindex (0.8.1)
|
||||
bip-schnorr (0.7.0)
|
||||
ecdsa_ext (~> 0.5.0)
|
||||
@@ -118,12 +121,12 @@ GEM
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.5.2)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
crass (1.0.7)
|
||||
cssbundling-rails (1.4.3)
|
||||
railties (>= 6.0.0)
|
||||
database_cleaner (2.1.0)
|
||||
@@ -132,17 +135,18 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
devise (4.9.4)
|
||||
date (3.5.1)
|
||||
device_detector (1.1.3)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
railties (>= 7.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise_ldap_authenticatable (0.8.7)
|
||||
devise (>= 3.4.1)
|
||||
net-ldap (>= 0.16.0)
|
||||
diff-lcs (1.6.1)
|
||||
diff-lcs (1.6.2)
|
||||
discourse_api (2.0.1)
|
||||
faraday (~> 2.7)
|
||||
faraday-follow_redirects
|
||||
@@ -154,12 +158,13 @@ GEM
|
||||
railties (>= 6.1)
|
||||
down (5.4.2)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.1)
|
||||
drb (2.2.3)
|
||||
ecdsa (1.2.0)
|
||||
ecdsa_ext (0.5.1)
|
||||
ecdsa (~> 1.2.0)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.1)
|
||||
tzinfo
|
||||
event_emitter (0.2.6)
|
||||
eventmachine (1.2.7)
|
||||
@@ -196,34 +201,35 @@ GEM
|
||||
rack-protection (>= 1.5.3, < 5.0.0)
|
||||
rack-session (>= 1.0.2, < 3.0.0)
|
||||
sanitize (< 8)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.13.0)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
gpgme (2.0.24)
|
||||
mini_portile2 (~> 2.7)
|
||||
hashdiff (1.1.2)
|
||||
i18n (1.14.7)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.1.0)
|
||||
importmap-rails (2.2.3)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
io-console (0.9.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
jbuilder (2.13.0)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jbuilder (2.15.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.11.3)
|
||||
json (2.21.2)
|
||||
kramdown (2.5.1)
|
||||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
@@ -247,10 +253,11 @@ GEM
|
||||
lnurl (1.1.1)
|
||||
bech32 (~> 1.1)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.0)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
@@ -259,14 +266,15 @@ GEM
|
||||
faraday (~> 2.9.0)
|
||||
faraday-follow_redirects (= 0.3.0)
|
||||
nokogiri (~> 1.16.0)
|
||||
marcel (1.0.4)
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.2)
|
||||
method_source (1.1.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
mission_control-jobs (1.0.2)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mission_control-jobs (1.1.0)
|
||||
actioncable (>= 7.1)
|
||||
actionpack (>= 7.1)
|
||||
activejob (>= 7.1)
|
||||
@@ -279,7 +287,7 @@ GEM
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.7)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
@@ -289,7 +297,7 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.16.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
@@ -313,24 +321,23 @@ GEM
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pp (0.6.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.1.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.2)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
psych (5.4.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
raabro (1.5.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.13)
|
||||
rack (2.2.23)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
@@ -341,61 +348,64 @@ GEM
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.1.3.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.2.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-settings-cached (2.8.3)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.4.2)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.9.2)
|
||||
logger
|
||||
rdoc (6.13.1)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
reverse_markdown (3.0.0)
|
||||
nokogiri
|
||||
rexml (3.4.1)
|
||||
@@ -403,23 +413,23 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.2)
|
||||
rspec-rails (8.0.4)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (>= 3.13.0, < 5.0.0)
|
||||
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.75.3)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
@@ -467,24 +477,25 @@ GEM
|
||||
tilt (~> 2.0)
|
||||
yard (~> 0.9, >= 0.9.24)
|
||||
yard-solargraph (~> 0.1)
|
||||
solid_queue (1.1.5)
|
||||
solid_queue (1.6.0)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11.0)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (~> 1.3.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.6.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
sqlite3 (2.6.0-arm64-darwin)
|
||||
sqlite3 (2.6.0-x86_64-linux-gnu)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
thor (1.3.2)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.13)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -492,25 +503,24 @@ GEM
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
view_component (3.22.0)
|
||||
activesupport (>= 5.2.0, < 8.1)
|
||||
concurrent-ruby (= 1.3.4)
|
||||
method_source (~> 1.0)
|
||||
view_component (4.12.0)
|
||||
actionview (>= 7.1.0)
|
||||
activesupport (>= 7.1.0)
|
||||
concurrent-ruby (~> 1)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
web-console (4.3.0)
|
||||
actionview (>= 8.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
railties (>= 8.0.0)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -520,7 +530,7 @@ GEM
|
||||
yard-solargraph (0.1.0)
|
||||
yard (~> 0.9)
|
||||
zbase32 (0.1.1)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
@@ -534,7 +544,8 @@ DEPENDENCIES
|
||||
capybara
|
||||
cssbundling-rails
|
||||
database_cleaner
|
||||
devise (~> 4.9.0)
|
||||
device_detector
|
||||
devise (~> 5.0)
|
||||
devise_ldap_authenticatable
|
||||
discourse_api
|
||||
dotenv-rails
|
||||
@@ -548,7 +559,7 @@ DEPENDENCIES
|
||||
gpgme (~> 2.0.24)
|
||||
image_processing (~> 1.12.2)
|
||||
importmap-rails
|
||||
jbuilder (~> 2.7)
|
||||
jbuilder (~> 2.15)
|
||||
kramdown
|
||||
letter_opener
|
||||
letter_opener_web
|
||||
@@ -561,8 +572,8 @@ DEPENDENCIES
|
||||
pagy (~> 6.0, >= 6.0.2)
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (~> 6.6)
|
||||
rails (~> 8.0)
|
||||
puma (~> 8.0)
|
||||
rails (~> 8.1)
|
||||
rails-controller-testing
|
||||
rails-settings-cached (~> 2.8.3)
|
||||
redis (~> 5.4)
|
||||
@@ -578,7 +589,7 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
view_component
|
||||
warden
|
||||
web-console (~> 4.2)
|
||||
web-console (~> 4.3)
|
||||
webmock
|
||||
zbase32 (~> 0.1.1)
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ so:
|
||||
|
||||
1. Make sure [Docker Compose is installed][1] and Docker is running (included in
|
||||
Docker Desktop)
|
||||
3. Run `docker compose up --build` and wait until all services have started
|
||||
2. Run `docker compose up --build` and wait until all services have started
|
||||
(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"`
|
||||
5. `docker compose run web rails ldap:setup`
|
||||
6. `docker compose run web rails db:setup`
|
||||
|
||||
On the first start, the `ldap-init` service creates the 389ds back-end, and the
|
||||
`web` container then seeds the LDAP directory and the databases automatically.
|
||||
On every start, `web` also applies any pending database migrations.
|
||||
|
||||
After these steps, you should have a working Rails app with a handful of test
|
||||
users running on [http://localhost:3000](http://localhost:3000).
|
||||
@@ -71,15 +72,12 @@ containers you want to run to the `up` command, like so:
|
||||
|
||||
#### LDAP server
|
||||
|
||||
After creating the Docker container for the first time (or after deleting it),
|
||||
you need to run the following command once, in order to create the dirsrv
|
||||
back-end:
|
||||
On first start, the `ldap-init` service creates the dirsrv back-end
|
||||
automatically, and the `web` container then seeds it with development entries.
|
||||
To do either step manually (for example, after changing the setup), run:
|
||||
|
||||
docker-compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
|
||||
|
||||
Now you can seed the back-end with data using this Rails task:
|
||||
|
||||
bundle exec rails ldap:setup
|
||||
docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be-name="dev"
|
||||
docker compose run --rm web bin/rails ldap:setup
|
||||
|
||||
The setup task will first delete any existing entries in the directory tree
|
||||
("dc=kosmos,dc=org"), and then create our development entries.
|
||||
@@ -88,20 +86,42 @@ Note that all 389ds data is stored in the `389ds-data` volume. So if you want
|
||||
to start over with a fresh installation, delete both that volume as well as the
|
||||
container.
|
||||
|
||||
#### Minio / remoteStorage
|
||||
To reset the development environment completely, remove all volumes plus the
|
||||
generated database files and the first-run marker, then start over:
|
||||
|
||||
If you want to run remoteStorage accounts locally, you will have to create the
|
||||
respective bucket first. With the `minio` container running (run by default
|
||||
when using Docker Compose), follow these steps:
|
||||
docker compose down -v
|
||||
rm -f db/*.sqlite3 tmp/.setup-complete
|
||||
docker compose up --build
|
||||
|
||||
* `docker compose up web redis minio liquor-cabinet`
|
||||
* Head to http://localhost:9001 and log in with user `minioadmin`, password
|
||||
`minioadmin`
|
||||
* Create a new bucket called `remotestorage` (or whatever you
|
||||
change the `S3_BUCKET` config to)
|
||||
* Create a new key with ID "dev-key" and secret "123456789" (or whatever you
|
||||
change `S3_ACCESS_KEY` and `S3_SECRET_KEY` to). Leave the policy field empty,
|
||||
as it will automatically allow access to the bucket you created.
|
||||
#### Garage / remoteStorage
|
||||
|
||||
remoteStorage accounts use the `garage` S3-compatible object store. On first
|
||||
start, Garage automatically configures a single-node cluster and creates the
|
||||
`remotestorage` bucket together with a `dev-key1` access key (secret
|
||||
`1234567890123456`), so no manual setup is required.
|
||||
|
||||
If you want to run remoteStorage accounts locally, the `garage` container is
|
||||
started by default when using Docker Compose. To run just the remoteStorage
|
||||
stack:
|
||||
|
||||
* `docker compose up web redis garage liquor-cabinet`
|
||||
|
||||
The S3 API is available at http://localhost:3900 (region `garage`). If you want
|
||||
to start over with a fresh storage, delete the `garage-data` volume as well as
|
||||
the container.
|
||||
|
||||
#### Accessing remoteStorage from another machine
|
||||
|
||||
remoteStorage clients force HTTPS for any host except `localhost`, and browsers
|
||||
block plain-HTTP requests to a LAN IP as mixed content. To connect to the dev
|
||||
remoteStorage from a browser on another machine (including production apps such
|
||||
as Inspektor), forward the ports over SSH and connect as `localhost`:
|
||||
|
||||
ssh -N -L 3000:localhost:3000 -L 4567:localhost:4567 <user>@<dev-host>
|
||||
|
||||
Then use `<user>@localhost:3000` as the remoteStorage address in the client.
|
||||
WeFinger discovery and storage requests are served through the forwarded ports,
|
||||
so no TLS setup is needed.
|
||||
|
||||
### Adding npm modules to use with Stimulus controllers
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-16 w-16 flex-none">
|
||||
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="mb-1 text-lg font-bold">
|
||||
<%= @web_app&.name || @auth.app_name %>
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500">
|
||||
<%= @auth.client_id %>
|
||||
</p>
|
||||
</div>
|
||||
<%= render DropdownComponent.new do %>
|
||||
<%= render DropdownLinkComponent.new(
|
||||
href: launch_app_services_storage_rs_auth_url(@auth),
|
||||
open_in_new_tab: true
|
||||
) do %>
|
||||
Launch app
|
||||
<% end %>
|
||||
<%= render DropdownLinkComponent.new(
|
||||
href: revoke_services_storage_rs_auth_url(@auth),
|
||||
separator: true, add_class: "text-red-700"
|
||||
) do %>
|
||||
Revoke access
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
|
||||
<button type="button"
|
||||
class="rs-auth-card group flex h-full w-full items-center gap-4 rounded-lg border
|
||||
border-gray-200 bg-white p-5 text-left transition
|
||||
hover:border-gray-400 hover:bg-gray-50
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
data-action="click->modal#open">
|
||||
<div class="h-14 w-14 flex-none">
|
||||
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-grow">
|
||||
<h4 class="text-lg font-bold truncate"><%= name %></h4>
|
||||
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<%= device_summary %>
|
||||
<span class="mx-1 text-gray-300">·</span>
|
||||
<%= category_summary %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="flex-none text-gray-300 transition group-hover:text-gray-500" aria-hidden="true">
|
||||
<%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
|
||||
</div>
|
||||
@@ -1,8 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsAuthComponent < ViewComponent::Base
|
||||
def initialize(auth:)
|
||||
@auth = auth
|
||||
@web_app = auth.web_app
|
||||
include OauthHelper
|
||||
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
@auth = authorizations.first
|
||||
@web_app = @auth.web_app
|
||||
end
|
||||
|
||||
def name
|
||||
@web_app&.name.presence || @auth.app_name.presence || @auth.client_id
|
||||
end
|
||||
|
||||
def root_access?
|
||||
(@auth.permissions & [":r", ":rw"]).any?
|
||||
end
|
||||
|
||||
def category_summary
|
||||
return "All categories and files" if root_access?
|
||||
|
||||
count = @auth.permissions.size
|
||||
"#{count} #{'category'.pluralize(count)}"
|
||||
end
|
||||
|
||||
def device_count
|
||||
@authorizations.map { |auth| auth.device_name.presence || "Unknown device" }.uniq.size
|
||||
end
|
||||
|
||||
def device_summary
|
||||
"#{device_count} #{'device'.pluralize(device_count)}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
<%= render ModalComponent.new do %>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-14 w-14 flex-none">
|
||||
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-lg font-bold truncate"><%= name %></h4>
|
||||
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-6 mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Access
|
||||
</h5>
|
||||
<%= render RsScopeListComponent.new(scopes: @auth.permissions, root: root_access?) %>
|
||||
|
||||
<h5 class="mt-6 mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Connected devices
|
||||
</h5>
|
||||
<ul class="divide-y divide-gray-100 rounded border border-gray-200" data-turbo-prefetch="false">
|
||||
<% @authorizations.each do |auth| %>
|
||||
<li class="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-gray-900"><%= device_name(auth) %></p>
|
||||
<p class="truncate text-xs text-gray-500">
|
||||
Connected <%= auth.created_at.strftime('%-d %b %Y') %>
|
||||
<span class="mx-1">·</span>
|
||||
<span class="<%= "text-red-700" if auth.expired? %>"><%= status_label(auth) %></span>
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to "Revoke access", services_storage_rs_auth_path(auth), method: :delete,
|
||||
form: { class: "flex-none" },
|
||||
class: "whitespace-nowrap text-sm text-red-700 hover:underline bg-transparent border-0 p-0 cursor-pointer" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2 sm:justify-end">
|
||||
<%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id),
|
||||
class: "btn-md btn-blue", target: "_blank", rel: "noopener", data: { turbo: false } %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsAuthDetailsModalComponent < ViewComponent::Base
|
||||
include OauthHelper
|
||||
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
@auth = authorizations.first
|
||||
@web_app = @auth.web_app
|
||||
end
|
||||
|
||||
def name
|
||||
@web_app&.name.presence || @auth.app_name.presence || @auth.client_id
|
||||
end
|
||||
|
||||
def root_access?
|
||||
(@auth.permissions & [":r", ":rw"]).any?
|
||||
end
|
||||
|
||||
def device_name(auth)
|
||||
auth.device_name.presence || "Unknown device"
|
||||
end
|
||||
|
||||
def status_label(auth)
|
||||
return "Never expires" if auth.expire_at.blank?
|
||||
return "Expired on #{auth.expire_at.strftime('%-d %b %Y')}" if auth.expired?
|
||||
|
||||
"Expires #{auth.expire_at.strftime('%-d %b %Y')}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
<% if @root %>
|
||||
<p class="scope text-lg">
|
||||
<span class="text-red-700">
|
||||
<%= render partial: "icons/alert-triangle",
|
||||
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||
All categories and files
|
||||
</span>
|
||||
<% if read_only? %>
|
||||
<span class="text-sm text-gray-500">(read only)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<% @scopes.each do |scope| %>
|
||||
<p class="scope text-gray-600">
|
||||
<span class="text-lg">
|
||||
<%= render partial: "icons/folder",
|
||||
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||
<%= scope_name(scope) %>
|
||||
</span>
|
||||
<% if scope_permissions(scope) == "r" %>
|
||||
<span>(read only)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsScopeListComponent < ViewComponent::Base
|
||||
include OauthHelper
|
||||
|
||||
def initialize(scopes:, root: false)
|
||||
@scopes = scopes
|
||||
@root = root
|
||||
end
|
||||
|
||||
def read_only?
|
||||
@scopes.include?(":r")
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,10 @@
|
||||
class Rs::OauthController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, only: :token
|
||||
|
||||
prepend_before_action :assert_redirect_uri, only: [:new, :create]
|
||||
before_action :require_signed_in_with_username, only: :new
|
||||
before_action :authenticate_user!, only: :create
|
||||
before_action :set_cors_headers, only: :token
|
||||
|
||||
def new
|
||||
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
|
||||
@@ -9,9 +12,12 @@ class Rs::OauthController < ApplicationController
|
||||
@redirect_uri = params[:redirect_uri]
|
||||
@client_id = params[:client_id]
|
||||
@state = params[:state]
|
||||
@response_type = params[:response_type].presence || "token"
|
||||
@code_challenge = params[:code_challenge]
|
||||
@code_challenge_method = params[:code_challenge_method].presence || "S256"
|
||||
@root_access_requested = (@scopes & [":r",":rw"]).any?
|
||||
|
||||
@denial_url = url_with_state("#{@redirect_uri}#error=access_denied", @state)
|
||||
@denial_url = error_url(@redirect_uri, "error=access_denied", @state)
|
||||
|
||||
@expire_at_dates = [["Never", nil],
|
||||
["In 1 month", 1.month.from_now],
|
||||
@@ -20,35 +26,41 @@ class Rs::OauthController < ApplicationController
|
||||
unless current_user == @user
|
||||
sign_out :user
|
||||
|
||||
redirect_to new_rs_oauth_url(@user.cn,
|
||||
scope: params[:scope],
|
||||
redirect_uri: params[:redirect_uri],
|
||||
client_id: params[:client_id],
|
||||
state: params[:state])
|
||||
redirect_params = {
|
||||
scope: params[:scope],
|
||||
redirect_uri: params[:redirect_uri],
|
||||
client_id: params[:client_id],
|
||||
state: params[:state]
|
||||
}
|
||||
|
||||
if @response_type == "code"
|
||||
redirect_params[:response_type] = "code"
|
||||
redirect_params[:code_challenge] = @code_challenge
|
||||
redirect_params[:code_challenge_method] = @code_challenge_method
|
||||
end
|
||||
|
||||
redirect_to new_rs_oauth_url(@user.cn, redirect_params)
|
||||
return
|
||||
end
|
||||
|
||||
unless @client_id.present?
|
||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
|
||||
end
|
||||
|
||||
if @scopes.empty?
|
||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(@redirect_uri, "error=invalid_scope", @state) and return
|
||||
end
|
||||
|
||||
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
|
||||
redirect_to(url_with_state("#{@redirect_uri}#error=invalid_client", @state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(@redirect_uri, "error=invalid_client", @state) and return
|
||||
end
|
||||
|
||||
if @response_type == "code" &&
|
||||
(@code_challenge.blank? || @code_challenge_method != "S256")
|
||||
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
|
||||
end
|
||||
|
||||
@client_id.gsub!(/http(s)?:\/\//, "")
|
||||
|
||||
if auth = current_user.remote_storage_authorizations.valid.where(permissions: @scopes, client_id: @client_id).first
|
||||
redirect_to(url_with_state("#{@redirect_uri}#access_token=#{auth.token}", @state),
|
||||
allow_other_host: true) and return
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -57,6 +69,7 @@ class Rs::OauthController < ApplicationController
|
||||
http_status :forbidden and return
|
||||
end
|
||||
|
||||
response_type = params[:response_type].presence || "token"
|
||||
permissions = parse_scopes params[:scope]
|
||||
redirect_uri = params[:redirect_uri].presence
|
||||
client_id = params[:client_id].presence
|
||||
@@ -64,32 +77,73 @@ class Rs::OauthController < ApplicationController
|
||||
expire_at = params[:expire_at].presence
|
||||
|
||||
if permissions.empty?
|
||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(redirect_uri, "error=invalid_scope", state, response_type) and return
|
||||
end
|
||||
|
||||
unless client_id.present?
|
||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
|
||||
end
|
||||
|
||||
unless hostname_of(client_id) == hostname_of(redirect_uri)
|
||||
redirect_to(url_with_state("#{redirect_uri}#error=invalid_client", state),
|
||||
allow_other_host: true) and return
|
||||
error_redirect(redirect_uri, "error=invalid_client", state, response_type) and return
|
||||
end
|
||||
|
||||
client_id.gsub!(/http(s)?:\/\//, "")
|
||||
|
||||
auth = current_user.remote_storage_authorizations.create!(
|
||||
permissions: permissions,
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
app_name: client_id,
|
||||
expire_at: expire_at
|
||||
if response_type == "code"
|
||||
challenge = params[:code_challenge].presence
|
||||
method = params[:code_challenge_method].presence || "S256"
|
||||
|
||||
if challenge.blank? || method != "S256"
|
||||
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
|
||||
end
|
||||
|
||||
code = RsOauthManager::IssueCode.call(
|
||||
user: current_user,
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
permissions: permissions,
|
||||
expire_at: expire_at,
|
||||
user_agent: request.user_agent,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: method
|
||||
)
|
||||
|
||||
redirect_to url_with_state("#{redirect_uri}?code=#{CGI.escape(code.code)}", state),
|
||||
allow_other_host: true
|
||||
else
|
||||
auth = current_user.remote_storage_authorizations.create!(
|
||||
permissions: permissions,
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
app_name: client_id,
|
||||
expire_at: expire_at,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
|
||||
allow_other_host: true
|
||||
end
|
||||
end
|
||||
|
||||
def token
|
||||
return head :ok if request.options?
|
||||
|
||||
unless params[:grant_type] == "authorization_code"
|
||||
return render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||
end
|
||||
|
||||
auth = RsOauthManager::ExchangeCode.call(
|
||||
code: params[:code],
|
||||
client_id: params[:client_id],
|
||||
redirect_uri: params[:redirect_uri],
|
||||
code_verifier: params[:code_verifier]
|
||||
)
|
||||
|
||||
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
|
||||
allow_other_host: true
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
render json: token_response(auth)
|
||||
rescue RsOauthManagerService::InvalidGrant
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
@@ -105,24 +159,42 @@ class Rs::OauthController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||
headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
end
|
||||
|
||||
def token_response(auth)
|
||||
body = {
|
||||
access_token: auth.token,
|
||||
token_type: "bearer",
|
||||
scope: auth.permissions.join(" ")
|
||||
}
|
||||
|
||||
if auth.expire_at.present?
|
||||
body[:expires_in] = (auth.expire_at - Time.current).to_i
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
def hostname_of(uri)
|
||||
uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
|
||||
RsOauthManagerService.hostname_of(uri)
|
||||
end
|
||||
|
||||
def parse_scopes(scope_string)
|
||||
return [] if scope_string.blank?
|
||||
RsOauthManagerService.parse_scopes(scope_string)
|
||||
end
|
||||
|
||||
scopes = scope_string.
|
||||
gsub(/\[|\]/, "").
|
||||
gsub(/\,/, " ").
|
||||
gsub(/\/:/, ":").
|
||||
split(/\s/).map(&:strip).
|
||||
reject(&:empty?)
|
||||
def error_redirect(redirect_uri, error, state, response_type = @response_type)
|
||||
redirect_to error_url(redirect_uri, error, state, response_type),
|
||||
allow_other_host: true
|
||||
end
|
||||
|
||||
scopes = [":r"] if scopes.include?("*:r")
|
||||
scopes = [":rw"] if scopes.include?("*:rw")
|
||||
|
||||
scopes
|
||||
def error_url(redirect_uri, error, state, response_type = @response_type)
|
||||
separator = response_type == "code" ? "?" : "#"
|
||||
url_with_state("#{redirect_uri}#{separator}#{error}", state)
|
||||
end
|
||||
|
||||
def url_with_state(url, state)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Services::RemotestorageController < Services::BaseController
|
||||
before_action :authenticate_user!
|
||||
before_action :require_service_available
|
||||
before_action :require_feature_enabled
|
||||
before_action :require_service_enabled
|
||||
|
||||
# Dashboard
|
||||
def show
|
||||
@@ -17,8 +17,8 @@ class Services::RemotestorageController < Services::BaseController
|
||||
http_status :not_found unless Setting.remotestorage_enabled?
|
||||
end
|
||||
|
||||
def require_feature_enabled
|
||||
unless Flipper.enabled?(:remotestorage, current_user)
|
||||
def require_service_enabled
|
||||
unless current_user.service_enabled?(:remotestorage)
|
||||
http_status :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
class Services::RsAuthsController < Services::BaseController
|
||||
before_action :authenticate_user!
|
||||
before_action :require_feature_enabled
|
||||
before_action :require_service_enabled
|
||||
before_action :require_service_available
|
||||
# before_action :require_service_enabled
|
||||
before_action :find_rs_auth, only: [:destroy, :launch_app]
|
||||
before_action :find_rs_auth, only: [:destroy]
|
||||
|
||||
def index
|
||||
@rs_auths = current_user.remote_storage_authorizations
|
||||
# TODO sort by app name?
|
||||
@rs_apps = current_user.remote_storage_authorizations
|
||||
.includes(:web_app)
|
||||
.group_by(&:client_id)
|
||||
.values
|
||||
.map { |auths| auths.sort_by(&:created_at).reverse }
|
||||
.sort_by { |auths| app_name_for(auths.first).downcase }
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -23,19 +26,34 @@ class Services::RsAuthsController < Services::BaseController
|
||||
end
|
||||
|
||||
def launch_app
|
||||
user_address = Rails.env.development? ?
|
||||
"#{current_user.cn}@localhost:3000" :
|
||||
current_user.address
|
||||
auth = current_user.remote_storage_authorizations
|
||||
.where(client_id: params[:client_id])
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
http_status(:not_found) and return unless auth
|
||||
|
||||
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
|
||||
user_address =
|
||||
if Rails.env.development?
|
||||
"#{current_user.cn}@#{request.host_with_port}"
|
||||
else
|
||||
current_user.address
|
||||
end
|
||||
|
||||
# Storage-first bearer token issuance (remoteStorage spec §11): the app
|
||||
# receives the address and runs the application-first flow itself.
|
||||
launch_url = "#{auth.launch_url}#remotestorage=#{user_address}"
|
||||
|
||||
redirect_to launch_url, allow_other_host: true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_feature_enabled
|
||||
unless Flipper.enabled?(:remotestorage, current_user)
|
||||
def app_name_for(auth)
|
||||
auth.web_app&.name.presence || auth.app_name.presence || auth.client_id
|
||||
end
|
||||
|
||||
def require_service_enabled
|
||||
unless current_user.service_enabled?(:remotestorage)
|
||||
http_status :forbidden
|
||||
end
|
||||
end
|
||||
@@ -45,7 +63,7 @@ class Services::RsAuthsController < Services::BaseController
|
||||
end
|
||||
|
||||
def find_rs_auth
|
||||
@auth = current_user.remote_storage_authorizations.find(params[:id])
|
||||
http_status :not_found unless @auth.present?
|
||||
@auth = current_user.remote_storage_authorizations.find_by(id: params[:id])
|
||||
http_status :not_found unless @auth
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
class TurboController < ApplicationController
|
||||
class Responder < ActionController::Responder
|
||||
def to_turbo_stream
|
||||
controller.render(options.merge(formats: :html))
|
||||
rescue ActionView::MissingTemplate => error
|
||||
if get?
|
||||
raise error
|
||||
elsif has_errors? && default_action
|
||||
render rendering_options.merge(formats: :html, status: :unprocessable_entity)
|
||||
else
|
||||
redirect_to navigation_location
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self.responder = Responder
|
||||
respond_to :html, :turbo_stream
|
||||
end
|
||||
@@ -6,7 +6,7 @@ class Users::DeviseController < ApplicationController
|
||||
if get?
|
||||
raise error
|
||||
elsif has_errors? && default_action
|
||||
render rendering_options.merge(formats: :html, status: :unprocessable_entity)
|
||||
render error_rendering_options.merge(formats: :html, status: :unprocessable_entity)
|
||||
else
|
||||
redirect_to navigation_location
|
||||
end
|
||||
|
||||
@@ -88,19 +88,42 @@ class WebfingerController < WellKnownController
|
||||
end
|
||||
|
||||
def remotestorage_link
|
||||
auth_url = new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
|
||||
storage_url = "#{Setting.rs_storage_url}/#{@username}"
|
||||
auth_url =
|
||||
if Rails.env.development?
|
||||
new_rs_oauth_url(@username)
|
||||
else
|
||||
new_rs_oauth_url(@username, host: Setting.rs_accounts_domain)
|
||||
end
|
||||
|
||||
storage_url = "#{remotestorage_storage_base_url}/#{@username}"
|
||||
|
||||
{
|
||||
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
|
||||
href: storage_url,
|
||||
properties: {
|
||||
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
|
||||
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-27",
|
||||
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
|
||||
"http://tools.ietf.org/html/rfc6749#section-3.1" => auth_url,
|
||||
"http://tools.ietf.org/html/rfc6749#section-3.2" => token_endpoint_url,
|
||||
"http://tools.ietf.org/html/rfc7636" => "S256",
|
||||
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter
|
||||
"http://tools.ietf.org/html/rfc7233": "GET", # content range requests
|
||||
"http://remotestorage.io/spec/web-authoring": nil
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def token_endpoint_url
|
||||
return rs_oauth_token_url if Rails.env.development?
|
||||
|
||||
rs_oauth_token_url(host: Setting.rs_accounts_domain)
|
||||
end
|
||||
|
||||
def remotestorage_storage_base_url
|
||||
return Setting.rs_storage_url unless Rails.env.development?
|
||||
|
||||
uri = URI.parse(Setting.rs_storage_url)
|
||||
uri.host = request.host
|
||||
uri.to_s
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ class NotificationMailer < ApplicationMailer
|
||||
@auth = params[:auth]
|
||||
@permissions = @auth.permissions.map do |p|
|
||||
access = p.split(":")[1] == 'r' ? 'read' : 'read/write'
|
||||
directory = p.split(':')[0] == '' ? 'all folders and files' : p.split(':')[0]
|
||||
directory = p.split(':')[0] == '' ? 'all categories and files' : p.split(':')[0]
|
||||
"#{access} #{directory}"
|
||||
end
|
||||
@subject = "New app connected to your storage"
|
||||
|
||||
@@ -10,18 +10,18 @@ class LndhubUser < LndhubBase
|
||||
foreign_key: "login"
|
||||
|
||||
def balance
|
||||
accounts.current.first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
accounts.current.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
end
|
||||
|
||||
def sum_outgoing
|
||||
accounts.outgoing.first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
accounts.outgoing.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
end
|
||||
|
||||
def sum_incoming
|
||||
accounts.incoming.first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
accounts.incoming.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
end
|
||||
|
||||
def sum_fees
|
||||
accounts.fees.first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
accounts.fees.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,26 @@ class RemoteStorageAuthorization < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def expired?
|
||||
expire_at.present? && expire_at.past?
|
||||
end
|
||||
|
||||
def device_name
|
||||
return nil if user_agent.blank?
|
||||
|
||||
client = DeviceDetector.new(user_agent)
|
||||
name = client.name.presence
|
||||
os = client.os_name.presence
|
||||
os = "Linux" if os == "GNU/Linux"
|
||||
os = "macOS" if os == "Mac"
|
||||
|
||||
# Safari never runs on Linux. A WebKit "Safari" user agent on Linux is a
|
||||
# WebKitGTK browser (e.g. GNOME Web), which sends a Safari-compatible UA.
|
||||
name = "WebKit" if name == "Safari" && os == "Linux"
|
||||
|
||||
[name, os].compact.join(" on ").presence
|
||||
end
|
||||
|
||||
def delete_token_from_redis
|
||||
key = "authorizations:#{user.cn}:#{token}"
|
||||
redis.srem? key, redis.smembers(key)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class RsAuthorizationCode < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
CODE_TTL = 60.seconds
|
||||
|
||||
serialize :permissions, coder: YAML
|
||||
|
||||
attr_accessor :code
|
||||
|
||||
before_validation :generate_code
|
||||
|
||||
scope :active, -> { where(expires_at: Time.current..) }
|
||||
|
||||
def self.find_by_code(raw_code)
|
||||
return nil if raw_code.blank?
|
||||
|
||||
find_by(code_digest: Digest::SHA256.hexdigest(raw_code))
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def consume!
|
||||
destroy!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
return if code_digest.present?
|
||||
|
||||
self.code = SecureRandom.urlsafe_base64(32)
|
||||
self.code_digest = Digest::SHA256.hexdigest(code)
|
||||
self.expires_at ||= CODE_TTL.from_now
|
||||
end
|
||||
end
|
||||
@@ -25,9 +25,8 @@ module EjabberdManager
|
||||
raise res.inspect if res.status != 200
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
private
|
||||
|
||||
def process_avatar
|
||||
@user.avatar.blob.open do |file|
|
||||
@@ -77,4 +76,5 @@ module EjabberdManager
|
||||
""".strip,
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
module RsOauthManager
|
||||
class ExchangeCode < RsOauthManagerService
|
||||
def initialize(code:, client_id:, redirect_uri:, code_verifier:)
|
||||
@code = code
|
||||
@client_id = client_id
|
||||
@redirect_uri = redirect_uri
|
||||
@code_verifier = code_verifier
|
||||
end
|
||||
|
||||
def call
|
||||
auth_code = RsAuthorizationCode.find_by_code(@code)
|
||||
raise InvalidGrant if auth_code.nil? || auth_code.expired?
|
||||
|
||||
raise InvalidGrant unless hostname_of(@client_id) == hostname_of(@redirect_uri)
|
||||
raise InvalidGrant unless @redirect_uri == auth_code.redirect_uri
|
||||
|
||||
raise InvalidGrant unless VerifyPkce.call(
|
||||
code_challenge: auth_code.code_challenge,
|
||||
code_challenge_method: auth_code.code_challenge_method,
|
||||
code_verifier: @code_verifier
|
||||
)
|
||||
|
||||
auth = auth_code.user.remote_storage_authorizations.create!(
|
||||
permissions: auth_code.permissions,
|
||||
client_id: auth_code.client_id,
|
||||
redirect_uri: auth_code.redirect_uri,
|
||||
app_name: auth_code.client_id,
|
||||
expire_at: auth_code.permission_expire_at,
|
||||
user_agent: auth_code.user_agent
|
||||
)
|
||||
|
||||
auth_code.consume!
|
||||
auth
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module RsOauthManager
|
||||
class IssueCode < RsOauthManagerService
|
||||
def initialize(user:, client_id:, redirect_uri:, permissions:,
|
||||
code_challenge:, code_challenge_method: "S256",
|
||||
expire_at: nil, user_agent: nil)
|
||||
@user = user
|
||||
@client_id = client_id
|
||||
@redirect_uri = redirect_uri
|
||||
@permissions = permissions
|
||||
@code_challenge = code_challenge
|
||||
@code_challenge_method = code_challenge_method
|
||||
@expire_at = expire_at
|
||||
@user_agent = user_agent
|
||||
end
|
||||
|
||||
def call
|
||||
RsAuthorizationCode.create!(
|
||||
user: @user,
|
||||
client_id: @client_id,
|
||||
redirect_uri: @redirect_uri,
|
||||
permissions: @permissions,
|
||||
permission_expire_at: @expire_at,
|
||||
user_agent: @user_agent,
|
||||
code_challenge: @code_challenge,
|
||||
code_challenge_method: @code_challenge_method
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
module RsOauthManager
|
||||
class VerifyPkce < RsOauthManagerService
|
||||
def initialize(code_challenge:, code_challenge_method:, code_verifier:)
|
||||
@code_challenge = code_challenge
|
||||
@code_challenge_method = code_challenge_method
|
||||
@code_verifier = code_verifier.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless @code_challenge_method == "S256"
|
||||
|
||||
expected = Base64.urlsafe_encode64(
|
||||
Digest::SHA256.digest(@code_verifier), padding: false
|
||||
)
|
||||
|
||||
ActiveSupport::SecurityUtils.secure_compare(
|
||||
expected, @code_challenge.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class RsOauthManagerService < ApplicationService
|
||||
InvalidGrant = Class.new(StandardError)
|
||||
|
||||
def self.parse_scopes(scope_string)
|
||||
return [] if scope_string.blank?
|
||||
|
||||
scopes = scope_string.
|
||||
gsub(/\[|\]/, "").
|
||||
gsub(/\,/, " ").
|
||||
gsub(/\/:/, ":").
|
||||
split(/\s/).map(&:strip).
|
||||
reject(&:empty?)
|
||||
|
||||
scopes = [":r"] if scopes.include?("*:r")
|
||||
scopes = [":rw"] if scopes.include?("*:rw")
|
||||
|
||||
scopes
|
||||
end
|
||||
|
||||
def self.hostname_of(uri)
|
||||
uri.to_s.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0]
|
||||
end
|
||||
|
||||
def hostname_of(uri)
|
||||
self.class.hostname_of(uri)
|
||||
end
|
||||
end
|
||||
@@ -305,7 +305,7 @@
|
||||
<td>remoteStorage</td>
|
||||
<td>
|
||||
<%= render FormElements::ToggleComponent.new(
|
||||
enabled: Flipper.enabled?(:remotestorage, current_user) && @services_enabled.include?("remotestorage"),
|
||||
enabled: @services_enabled.include?("remotestorage"),
|
||||
input_enabled: false
|
||||
) %>
|
||||
</td>
|
||||
|
||||
@@ -43,8 +43,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if Setting.remotestorage_enabled? &&
|
||||
Flipper.enabled?(:remotestorage, current_user) %>
|
||||
<% if Setting.remotestorage_enabled? %>
|
||||
<div class="border border-gray-300 rounded-md hover:border-gray-400
|
||||
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
|
||||
bg-[url(/img/logos/icon_remotestorage.svg)]">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% if resource.errors.any? %>
|
||||
<div id="error_explanation">
|
||||
<div id="error_explanation" class="mb-6">
|
||||
<ul>
|
||||
<% resource.errors.full_messages.each do |message| %>
|
||||
<li class="text-red-600"><%= message %></li>
|
||||
|
||||
@@ -5,34 +5,10 @@
|
||||
<p class="mb-8">
|
||||
The app on
|
||||
<%= link_to @client_id, "https://#{@client_id}" %>
|
||||
is asking for access to these folders:
|
||||
is asking for access to these categories:
|
||||
</p>
|
||||
|
||||
<% if @root_access_requested %>
|
||||
<p class="scope text-lg">
|
||||
<span class="text-red-700">
|
||||
<%= render partial: "icons/alert-triangle",
|
||||
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||
All files and directories
|
||||
</span>
|
||||
<% if (@scopes & [":r"]).any? %>
|
||||
<span class="text-sm text-gray-500">(read only)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<% @scopes.each do |scope| %>
|
||||
<p class="scope text-gray-600">
|
||||
<span class="text-lg">
|
||||
<%= render partial: "icons/folder",
|
||||
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||
<%= scope_name(scope) %>
|
||||
</span>
|
||||
<% if scope_permissions(scope) == "r" %>
|
||||
<span>(read only)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= render RsScopeListComponent.new(scopes: @scopes, root: @root_access_requested) %>
|
||||
|
||||
<%= form_with(url: rs_oauth_path, method: :post, data: { turbo: false }) do |f| %>
|
||||
<%= f.hidden_field :redirect_uri, value: @redirect_uri %>
|
||||
@@ -40,6 +16,9 @@
|
||||
<%= f.hidden_field :user_id, value: @user.id %>
|
||||
<%= f.hidden_field :client_id, value: @client_id %>
|
||||
<%= f.hidden_field :state, value: @state %>
|
||||
<%= f.hidden_field :response_type, value: @response_type %>
|
||||
<%= f.hidden_field :code_challenge, value: @code_challenge %>
|
||||
<%= f.hidden_field :code_challenge_method, value: @code_challenge_method %>
|
||||
<p class="mt-8 mb-6">
|
||||
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
|
||||
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<%= render partial: "shared/tabnav_remotestorage" %>
|
||||
|
||||
<section>
|
||||
<% if @rs_auths.any? %>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-10 gap-x-12 mt-4">
|
||||
<% @rs_auths.each do |auth| %>
|
||||
<%= render RsAuthComponent.new(auth: auth) %>
|
||||
<% if @rs_apps.any? %>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-4">
|
||||
<% @rs_apps.each do |authorizations| %>
|
||||
<%= render RsAuthComponent.new(authorizations: authorizations) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
active: @settings_section.to_s == "lightning"
|
||||
) %>
|
||||
<% end %>
|
||||
<% if Setting.remotestorage_enabled? &&
|
||||
Flipper.enabled?(:remotestorage, current_user) %>
|
||||
<% if Setting.remotestorage_enabled? %>
|
||||
<%= render SidenavLinkComponent.new(
|
||||
name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage",
|
||||
active: @settings_section.to_s == "remotestorage"
|
||||
|
||||
@@ -21,7 +21,7 @@ Bundler.require(*Rails.groups)
|
||||
module Akkounts
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 8.0
|
||||
config.load_defaults 8.1
|
||||
|
||||
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
||||
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
||||
|
||||
@@ -6,6 +6,7 @@ require 'securerandom'
|
||||
# Many of these configuration options can be set straight in your model.
|
||||
Devise.setup do |config|
|
||||
# Hotwire/Turbo
|
||||
config.parent_controller = 'Users::DeviseController'
|
||||
config.responder.error_status = :unprocessable_entity
|
||||
config.responder.redirect_status = :see_other
|
||||
|
||||
@@ -325,9 +326,9 @@ Devise.setup do |config|
|
||||
# config.sign_in_after_change_password = true
|
||||
end
|
||||
|
||||
# https://github.com/heartcombo/devise/issues/5644
|
||||
class Devise::SecretKeyFinder
|
||||
def find
|
||||
@application.secret_key_base
|
||||
end
|
||||
# Turbo-aware responder for Devise controllers
|
||||
ActiveSupport.on_load(:devise_controller) do
|
||||
self.responder = Users::DeviseController::Responder
|
||||
Users::DeviseController::Responder.error_status = :unprocessable_entity
|
||||
Users::DeviseController::Responder.redirect_status = :see_other
|
||||
end
|
||||
@@ -1,11 +0,0 @@
|
||||
# See https://alvincrespo.hashnode.dev/rails-8s-lazy-route-loading-devise
|
||||
# TODO remove when Devise is fixed
|
||||
require 'devise'
|
||||
Devise # make sure it's already loaded
|
||||
|
||||
module Devise
|
||||
def self.mappings
|
||||
Rails.application.try(:reload_routes_unless_loaded)
|
||||
@@mappings
|
||||
end
|
||||
end
|
||||
@@ -38,6 +38,8 @@ plugin :tmp_restart
|
||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments
|
||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
||||
|
||||
force_shutdown_after 25
|
||||
|
||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||
# In other environments, only set the PID file if requested.
|
||||
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
|
||||
+5
-6
@@ -52,12 +52,9 @@ Rails.application.routes.draw do
|
||||
|
||||
resource :storage, controller: 'remotestorage', only: [:show] do
|
||||
get :apps, to: "rs_auths#index"
|
||||
resources :rs_auths, only: [:index, :destroy] do
|
||||
member do
|
||||
get :revoke, to: 'rs_auths#destroy'
|
||||
get :launch_app
|
||||
end
|
||||
end
|
||||
get "apps/:client_id/launch", to: "rs_auths#launch_app", as: :launch_app,
|
||||
constraints: { client_id: /[^\/]+/ }
|
||||
resources :rs_auths, only: [:index, :destroy]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,6 +112,8 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
namespace :rs do
|
||||
post 'oauth/token', to: 'oauth#token', as: :oauth_token
|
||||
match 'oauth/token', to: 'oauth#token', via: :options
|
||||
resource :oauth, only: [:new, :create], path_names: {
|
||||
new: ':username', create: ':username'
|
||||
}, controller: 'oauth'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateRsAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :rs_authorization_codes do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :client_id, null: false
|
||||
t.string :redirect_uri, null: false
|
||||
t.text :permissions, default: [].to_yaml
|
||||
t.datetime :permission_expire_at
|
||||
t.string :code_digest, null: false
|
||||
t.string :code_challenge, null: false
|
||||
t.string :code_challenge_method, null: false, default: "S256"
|
||||
t.datetime :expires_at, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :rs_authorization_codes, :code_digest, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddUserAgentToRsAuthorizations < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :remote_storage_authorizations, :user_agent, :string
|
||||
add_column :rs_authorization_codes, :user_agent, :string
|
||||
end
|
||||
end
|
||||
+40
-40
@@ -10,44 +10,44 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 1) do
|
||||
ActiveRecord::Schema[8.1].define(version: 1) do
|
||||
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "concurrency_key", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.bigint "job_id", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "queue_name", null: false
|
||||
t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
|
||||
t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
|
||||
t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "job_id", null: false
|
||||
t.bigint "process_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
||||
t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.text "error"
|
||||
t.datetime "created_at", null: false
|
||||
t.text "error"
|
||||
t.bigint "job_id", null: false
|
||||
t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_jobs", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.string "class_name", null: false
|
||||
t.text "arguments"
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "active_job_id"
|
||||
t.datetime "scheduled_at"
|
||||
t.datetime "finished_at"
|
||||
t.text "arguments"
|
||||
t.string "class_name", null: false
|
||||
t.string "concurrency_key"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "finished_at"
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "queue_name", null: false
|
||||
t.datetime "scheduled_at"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
|
||||
t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
|
||||
@@ -57,76 +57,76 @@ ActiveRecord::Schema[8.0].define(version: 1) do
|
||||
end
|
||||
|
||||
create_table "solid_queue_pauses", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_processes", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "hostname"
|
||||
t.string "kind", null: false
|
||||
t.datetime "last_heartbeat_at", null: false
|
||||
t.bigint "supervisor_id"
|
||||
t.integer "pid", null: false
|
||||
t.string "hostname"
|
||||
t.text "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.integer "pid", null: false
|
||||
t.bigint "supervisor_id"
|
||||
t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
||||
t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
||||
t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "job_id", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "queue_name", null: false
|
||||
t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
||||
t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
|
||||
t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "task_key", null: false
|
||||
t.datetime "run_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "job_id", null: false
|
||||
t.datetime "run_at", null: false
|
||||
t.string "task_key", null: false
|
||||
t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
||||
t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "schedule", null: false
|
||||
t.string "command", limit: 2048
|
||||
t.string "class_name"
|
||||
t.text "arguments"
|
||||
t.string "queue_name"
|
||||
t.integer "priority", default: 0
|
||||
t.boolean "static", default: true, null: false
|
||||
t.text "description"
|
||||
t.string "class_name"
|
||||
t.string "command", limit: 2048
|
||||
t.datetime "created_at", null: false
|
||||
t.text "description"
|
||||
t.string "key", null: false
|
||||
t.integer "priority", default: 0
|
||||
t.string "queue_name"
|
||||
t.string "schedule", null: false
|
||||
t.boolean "static", default: true, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
||||
t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
|
||||
end
|
||||
|
||||
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "scheduled_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "job_id", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "queue_name", null: false
|
||||
t.datetime "scheduled_at", null: false
|
||||
t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
||||
t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
|
||||
end
|
||||
|
||||
create_table "solid_queue_semaphores", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.integer "value", default: 1, null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "value", default: 1, null: false
|
||||
t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
|
||||
t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
|
||||
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
|
||||
|
||||
+75
-56
@@ -10,26 +10,26 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_28_092931) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.string "record_type", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "content_type"
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.string "content_type"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "key", null: false
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
@@ -40,123 +40,141 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_28_092931) do
|
||||
end
|
||||
|
||||
create_table "app_catalog_web_apps", force: :cascade do |t|
|
||||
t.string "url"
|
||||
t.string "name"
|
||||
t.text "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.text "metadata"
|
||||
t.string "name"
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "url"
|
||||
end
|
||||
|
||||
create_table "donations", force: :cascade do |t|
|
||||
t.integer "user_id"
|
||||
t.integer "amount_sats"
|
||||
t.integer "fiat_amount"
|
||||
t.string "public_name"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "paid_at", precision: nil
|
||||
t.string "fiat_currency", default: "USD"
|
||||
t.string "donation_method"
|
||||
t.string "payment_method"
|
||||
t.string "btcpay_invoice_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "donation_method"
|
||||
t.integer "fiat_amount"
|
||||
t.string "fiat_currency", default: "USD"
|
||||
t.datetime "paid_at", precision: nil
|
||||
t.string "payment_method"
|
||||
t.string "payment_status"
|
||||
t.string "public_name"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id"
|
||||
t.index ["payment_status"], name: "index_donations_on_payment_status"
|
||||
t.index ["user_id"], name: "index_donations_on_user_id"
|
||||
end
|
||||
|
||||
create_table "editable_contents", force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.string "context"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key"
|
||||
t.string "lang", default: "en"
|
||||
t.text "content"
|
||||
t.boolean "rich_text", default: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "flipper_features", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["key"], name: "index_flipper_features_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "flipper_gates", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "feature_key", null: false
|
||||
t.string "key", null: false
|
||||
t.text "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "value"
|
||||
t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
|
||||
end
|
||||
|
||||
create_table "invitations", force: :cascade do |t|
|
||||
t.string "token"
|
||||
t.integer "user_id"
|
||||
t.integer "invited_user_id"
|
||||
t.datetime "used_at", precision: nil
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "invited_user_id"
|
||||
t.string "token"
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "used_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.index ["invited_user_id"], name: "index_invitations_on_invited_user_id"
|
||||
t.index ["user_id"], name: "index_invitations_on_user_id"
|
||||
end
|
||||
|
||||
create_table "remote_storage_authorizations", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.string "token"
|
||||
t.text "permissions", default: "--- []\n"
|
||||
t.string "client_id"
|
||||
t.string "redirect_uri"
|
||||
t.string "app_name"
|
||||
t.datetime "expire_at"
|
||||
t.string "client_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expire_at"
|
||||
t.text "permissions", default: "--- []\n"
|
||||
t.string "redirect_uri"
|
||||
t.string "token"
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "user_agent"
|
||||
t.integer "user_id", null: false
|
||||
t.integer "web_app_id"
|
||||
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
|
||||
t.index ["user_id"], name: "index_remote_storage_authorizations_on_user_id"
|
||||
t.index ["web_app_id"], name: "index_remote_storage_authorizations_on_web_app_id"
|
||||
end
|
||||
|
||||
create_table "rs_authorization_codes", force: :cascade do |t|
|
||||
t.string "client_id", null: false
|
||||
t.string "code_challenge", null: false
|
||||
t.string "code_challenge_method", default: "S256", null: false
|
||||
t.string "code_digest", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "permission_expire_at"
|
||||
t.text "permissions", default: "--- []\n"
|
||||
t.string "redirect_uri", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "user_agent"
|
||||
t.integer "user_id", null: false
|
||||
t.index ["code_digest"], name: "index_rs_authorization_codes_on_code_digest", unique: true
|
||||
t.index ["user_id"], name: "index_rs_authorization_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "settings", force: :cascade do |t|
|
||||
t.string "var", null: false
|
||||
t.text "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "value"
|
||||
t.string "var", null: false
|
||||
t.index ["var"], name: "index_settings_on_var", unique: true
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "cn"
|
||||
t.string "ou"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "email", default: "", null: false
|
||||
t.string "reset_password_token"
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.datetime "confirmation_sent_at", precision: nil
|
||||
t.string "confirmation_token"
|
||||
t.datetime "confirmed_at", precision: nil
|
||||
t.datetime "confirmation_sent_at", precision: nil
|
||||
t.string "unconfirmed_email"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "email", default: "", null: false
|
||||
t.text "lndhub_password"
|
||||
t.string "lndhub_username"
|
||||
t.bigint "mastodon_id"
|
||||
t.string "ou"
|
||||
t.string "pgp_fpr"
|
||||
t.text "preferences"
|
||||
t.datetime "remember_created_at"
|
||||
t.string "remember_token"
|
||||
t.text "preferences"
|
||||
t.string "pgp_fpr"
|
||||
t.string "lndhub_username"
|
||||
t.text "lndhub_password"
|
||||
t.bigint "mastodon_id"
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.string "reset_password_token"
|
||||
t.string "unconfirmed_email"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
create_table "zaps", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.json "request"
|
||||
t.json "receipt"
|
||||
t.text "payment_request"
|
||||
t.bigint "amount"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "payment_request"
|
||||
t.json "receipt"
|
||||
t.json "request"
|
||||
t.datetime "settled_at"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["user_id"], name: "index_zaps_on_user_id"
|
||||
end
|
||||
|
||||
@@ -164,5 +182,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_28_092931) do
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
|
||||
add_foreign_key "remote_storage_authorizations", "users"
|
||||
add_foreign_key "rs_authorization_codes", "users"
|
||||
add_foreign_key "zaps", "users"
|
||||
end
|
||||
+6
-3
@@ -1,8 +1,9 @@
|
||||
require 'sidekiq/testing'
|
||||
|
||||
ldap = LdapService.new
|
||||
|
||||
Sidekiq::Testing.inline! do
|
||||
original_queue_adapter = ActiveJob::Base.queue_adapter
|
||||
ActiveJob::Base.queue_adapter = :inline
|
||||
|
||||
begin
|
||||
ldap.delete_all_users!
|
||||
|
||||
puts "Create user: admin"
|
||||
@@ -25,4 +26,6 @@ Sidekiq::Testing.inline! do
|
||||
password: "user is user", confirmed: true
|
||||
})
|
||||
end
|
||||
ensure
|
||||
ActiveJob::Base.queue_adapter = original_queue_adapter
|
||||
end
|
||||
+42
-14
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
ldap:
|
||||
image: 4teamwork/389ds:latest
|
||||
platform: linux/amd64
|
||||
volumes:
|
||||
- 389ds-data:/data
|
||||
networks:
|
||||
@@ -12,6 +13,21 @@ services:
|
||||
DS_DM_PASSWORD: passthebutter
|
||||
SUFFIX_NAME: "dc=kosmos,dc=org"
|
||||
|
||||
ldap-init:
|
||||
image: 4teamwork/389ds:latest
|
||||
platform: linux/amd64
|
||||
networks:
|
||||
- internal_network
|
||||
volumes:
|
||||
- ./docker/ldap-init.sh:/ldap-init.sh:ro
|
||||
environment:
|
||||
LDAP_ADMIN_PASSWORD: passthebutter
|
||||
LDAP_SUFFIX: "dc=kosmos,dc=org"
|
||||
depends_on:
|
||||
ldap:
|
||||
condition: service_healthy
|
||||
command: ["/bin/sh", "/ldap-init.sh"]
|
||||
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:7-alpine
|
||||
@@ -26,7 +42,7 @@ services:
|
||||
web:
|
||||
build: .
|
||||
tty: true
|
||||
command: bash -c "rm -f /akkounts/tmp/pids/server.pid; bin/dev"
|
||||
command: ["bash", "docker/web-entrypoint.sh"]
|
||||
volumes:
|
||||
- .:/akkounts
|
||||
- /akkounts/node_modules
|
||||
@@ -57,20 +73,28 @@ services:
|
||||
NOSTR_PRIVATE_KEY: 7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea
|
||||
NOSTR_RELAY_URL: ws://strfry:7777
|
||||
depends_on:
|
||||
- ldap
|
||||
- redis
|
||||
ldap:
|
||||
condition: service_started
|
||||
ldap-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
minio:
|
||||
image: quay.io/minio/minio:latest
|
||||
command: "server /data --console-address ':9001'"
|
||||
garage:
|
||||
image: dxflrs/garage:v2.4.1
|
||||
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
- "3900:3900"
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
- ./docker/garage/garage.toml:/etc/garage.toml:ro
|
||||
- garage-data:/var/lib/garage
|
||||
environment:
|
||||
GARAGE_DEFAULT_ACCESS_KEY: dev-key1
|
||||
GARAGE_DEFAULT_SECRET_KEY: "1234567890123456"
|
||||
GARAGE_DEFAULT_BUCKET: remotestorage
|
||||
|
||||
liquor-cabinet:
|
||||
image: gitea.kosmos.org/5apps/liquor-cabinet:2.0.0-rc.1
|
||||
@@ -79,17 +103,21 @@ services:
|
||||
- internal_network
|
||||
ports:
|
||||
- "4567:4567"
|
||||
volumes:
|
||||
- ./docker/liquor-cabinet/rainbows.conf.rb:/etc/liquor-cabinet/rainbows.conf.rb:ro
|
||||
command: ["bundle", "exec", "rainbows", "-c", "/etc/liquor-cabinet/rainbows.conf.rb", "--listen", "0.0.0.0:4567"]
|
||||
environment:
|
||||
RACK_ENV: staging
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_DB: 1
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: dev-key
|
||||
S3_SECRET_KEY: 123456789
|
||||
S3_ENDPOINT: http://garage:3900
|
||||
S3_REGION: garage
|
||||
S3_ACCESS_KEY: dev-key1
|
||||
S3_SECRET_KEY: "1234567890123456"
|
||||
S3_BUCKET: remotestorage
|
||||
depends_on:
|
||||
- minio
|
||||
- garage
|
||||
- redis
|
||||
|
||||
strfry:
|
||||
@@ -127,7 +155,7 @@ networks:
|
||||
volumes:
|
||||
389ds-data:
|
||||
driver: local
|
||||
minio-data:
|
||||
garage-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "sqlite"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
rpc_secret = "1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec"
|
||||
|
||||
[s3_api]
|
||||
s3_region = "garage"
|
||||
api_bind_addr = "[::]:3900"
|
||||
root_domain = ".s3.garage.localhost"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
SUFFIX="${LDAP_SUFFIX:-dc=kosmos,dc=org}"
|
||||
URI="ldap://ldap:3389"
|
||||
|
||||
if dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
|
||||
backend suffix list --suffix 2>/dev/null | grep -Fqx "$SUFFIX"; then
|
||||
echo "LDAP backend for $SUFFIX already exists, skipping."
|
||||
else
|
||||
echo "Creating LDAP backend for $SUFFIX..."
|
||||
dsconf -D "cn=Directory Manager" -w "$LDAP_ADMIN_PASSWORD" "$URI" \
|
||||
backend create --suffix "$SUFFIX" --be-name dev
|
||||
fi
|
||||
@@ -0,0 +1,5 @@
|
||||
Rainbows! do
|
||||
use :ThreadPool
|
||||
worker_connections 16
|
||||
client_max_body_size 100 * 1024 * 1024
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if [ ! -f tmp/.setup-complete ]; then
|
||||
echo "First start: setting up LDAP entries and databases..."
|
||||
bin/rails ldap:setup
|
||||
bin/rails db:setup
|
||||
touch tmp/.setup-complete
|
||||
else
|
||||
bin/rails db:prepare
|
||||
fi
|
||||
|
||||
rm -f tmp/pids/server.pid
|
||||
exec bin/dev
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"postcss-preset-env": "^7.8.3",
|
||||
"tailwindcss": "^3.4.0"
|
||||
},
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.1",
|
||||
"scripts": {
|
||||
"build:css:tailwind": "bun ./node_modules/tailwindcss/lib/cli.js --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css",
|
||||
"build:css": "bun run build:css:tailwind"
|
||||
|
||||
@@ -128,8 +128,8 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
}
|
||||
end
|
||||
|
||||
it "redirects to the redirect_uri with the existing token" do
|
||||
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}")
|
||||
it "does not reuse the existing token (renders the consent dialog)" do
|
||||
expect(response.response_code).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -147,36 +147,66 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with different redirect_uri" do
|
||||
before do
|
||||
get :new, params: {
|
||||
username: user.cn,
|
||||
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
|
||||
redirect_uri: "https://example.com/a_new_route",
|
||||
client_id: "https://example.com"
|
||||
}
|
||||
end
|
||||
|
||||
it "redirects to the new redirect_uri" do
|
||||
expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}")
|
||||
end
|
||||
context "when response_type is code" do
|
||||
before do
|
||||
get :new, params: {
|
||||
username: user.cn,
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "example.com",
|
||||
scope: "documents",
|
||||
response_type: "code",
|
||||
code_challenge: "challenge123",
|
||||
code_challenge_method: "S256",
|
||||
state: "foobar123"
|
||||
}
|
||||
end
|
||||
|
||||
context "with state param given" do
|
||||
before do
|
||||
get :new, params: {
|
||||
username: user.cn,
|
||||
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "https://example.com",
|
||||
state: "foobar123"
|
||||
}
|
||||
end
|
||||
it "renders the consent dialog and exposes the code challenge" do
|
||||
expect(response.response_code).to eq(200)
|
||||
expect(assigns["response_type"]).to eq("code")
|
||||
expect(assigns["code_challenge"]).to eq("challenge123")
|
||||
expect(assigns["code_challenge_method"]).to eq("S256")
|
||||
end
|
||||
|
||||
it "redirects to the redirect_uri with token and state" do
|
||||
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
|
||||
end
|
||||
it "uses a query-based denial url" do
|
||||
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
|
||||
end
|
||||
end
|
||||
|
||||
context "when response_type is code without a code_challenge" do
|
||||
before do
|
||||
get :new, params: {
|
||||
username: user.cn,
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "example.com",
|
||||
scope: "documents",
|
||||
response_type: "code",
|
||||
state: "foobar123"
|
||||
}
|
||||
end
|
||||
|
||||
it "redirects with invalid_request using a query param" do
|
||||
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
|
||||
end
|
||||
end
|
||||
|
||||
context "when response_type is code with the plain method" do
|
||||
before do
|
||||
get :new, params: {
|
||||
username: user.cn,
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "example.com",
|
||||
scope: "documents",
|
||||
response_type: "code",
|
||||
code_challenge: "challenge123",
|
||||
code_challenge_method: "plain"
|
||||
}
|
||||
end
|
||||
|
||||
it "redirects with invalid_request" do
|
||||
expect(response).to redirect_to("https://example.com?error=invalid_request")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -280,10 +310,12 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
context "when user is signed in" do
|
||||
before do
|
||||
sign_in user
|
||||
request.user_agent = "RSpec Device"
|
||||
end
|
||||
|
||||
after do
|
||||
user.remote_storage_authorizations.destroy_all
|
||||
RsAuthorizationCode.destroy_all
|
||||
end
|
||||
|
||||
context "when no valid token exists" do
|
||||
@@ -303,6 +335,10 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
|
||||
end
|
||||
|
||||
it "stores the device's user agent" do
|
||||
expect(@auth.user_agent).to eq("RSpec Device")
|
||||
end
|
||||
|
||||
it "redirects to the redirect_uri" do
|
||||
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
|
||||
end
|
||||
@@ -412,6 +448,64 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
end
|
||||
end
|
||||
|
||||
context "with response_type=code" do
|
||||
before do
|
||||
post :create, params: {
|
||||
user_id: user.id,
|
||||
scope: "documents,[photos], contacts:rw",
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "example.com",
|
||||
state: "foobar123",
|
||||
response_type: "code",
|
||||
code_challenge: "challenge123",
|
||||
code_challenge_method: "S256",
|
||||
expire_at: 1.month.from_now
|
||||
}
|
||||
@code = RsAuthorizationCode.last
|
||||
end
|
||||
|
||||
it "creates an authorization code" do
|
||||
expect(@code.permissions).to eq(%w(documents photos contacts:rw))
|
||||
expect(@code.code_challenge).to eq("challenge123")
|
||||
expect(@code.code_challenge_method).to eq("S256")
|
||||
expect(@code.user_agent).to eq("RSpec Device")
|
||||
end
|
||||
|
||||
it "does not create a remote storage authorization" do
|
||||
expect(user.reload.remote_storage_authorizations.count).to eq(0)
|
||||
end
|
||||
|
||||
it "redirects with the code in the query string" do
|
||||
location = response.location
|
||||
expect(location).to match(%r{\Ahttps://example\.com\?code=.+&state=foobar123\z})
|
||||
|
||||
raw_code = CGI.parse(URI.parse(location).query)["code"].first
|
||||
expect(RsAuthorizationCode.find_by_code(raw_code)).to eq(@code)
|
||||
end
|
||||
end
|
||||
|
||||
context "with response_type=code but no code_challenge" do
|
||||
before do
|
||||
post :create, params: {
|
||||
user_id: user.id,
|
||||
scope: "documents",
|
||||
redirect_uri: "https://example.com",
|
||||
client_id: "example.com",
|
||||
state: "foobar123",
|
||||
response_type: "code",
|
||||
expire_at: 1.month.from_now
|
||||
}
|
||||
end
|
||||
|
||||
it "redirects with invalid_request using a query param" do
|
||||
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
|
||||
end
|
||||
|
||||
it "does not create an authorization code" do
|
||||
expect(RsAuthorizationCode.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user_id is different from the signed in user" do
|
||||
before do
|
||||
post :create, params: {
|
||||
@@ -451,4 +545,81 @@ RSpec.describe Rs::OauthController, type: :controller do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /rs/oauth/token" do
|
||||
# Example values from RFC 7636, Appendix B
|
||||
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
|
||||
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
|
||||
|
||||
def issue_code
|
||||
RsOauthManager::IssueCode.call(
|
||||
user: user,
|
||||
client_id: "example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
permissions: %w(documents:rw),
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256"
|
||||
)
|
||||
end
|
||||
|
||||
def token_params(code, **overrides)
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
code: code.code,
|
||||
client_id: "https://example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
code_verifier: code_verifier
|
||||
}.merge(overrides)
|
||||
end
|
||||
|
||||
after do
|
||||
user.remote_storage_authorizations.destroy_all
|
||||
RsAuthorizationCode.destroy_all
|
||||
end
|
||||
|
||||
context "with a valid code" do
|
||||
before do
|
||||
@code = issue_code
|
||||
post :token, params: token_params(@code)
|
||||
end
|
||||
|
||||
it "returns an access token" do
|
||||
expect(response.response_code).to eq(200)
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["access_token"]).to be_present
|
||||
expect(json["token_type"]).to eq("bearer")
|
||||
expect(json["scope"]).to eq("documents:rw")
|
||||
end
|
||||
|
||||
it "does not cache the response" do
|
||||
expect(response.headers["Cache-Control"]).to eq("no-store")
|
||||
end
|
||||
|
||||
it "sets CORS headers" do
|
||||
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a bad code_verifier" do
|
||||
before do
|
||||
@code = issue_code
|
||||
post :token, params: token_params(@code, code_verifier: "wrong-verifier")
|
||||
end
|
||||
|
||||
it "returns invalid_grant" do
|
||||
expect(response.response_code).to eq(400)
|
||||
expect(JSON.parse(response.body)["error"]).to eq("invalid_grant")
|
||||
end
|
||||
end
|
||||
|
||||
context "with an unsupported grant_type" do
|
||||
before { post :token, params: { grant_type: "password" } }
|
||||
|
||||
it "returns unsupported_grant_type" do
|
||||
expect(response.response_code).to eq(400)
|
||||
expect(JSON.parse(response.body)["error"]).to eq("unsupported_grant_type")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,16 +6,18 @@ RSpec.describe Services::RsAuthsController, type: :controller do
|
||||
before do
|
||||
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
||||
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
|
||||
allow_any_instance_of(Flipper).to receive(:enabled?).and_return(true)
|
||||
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([
|
||||
{ services_enabled: ["remotestorage"] }
|
||||
])
|
||||
end
|
||||
|
||||
describe "GET /services/storage/rs_auths/:id/launch_app" do
|
||||
describe "GET /services/storage/apps/:client_id/launch (storage-first)" do
|
||||
context "when user is signed in" do
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
context "token exists" do
|
||||
context "authorization exists" do
|
||||
before do
|
||||
@auth = user.remote_storage_authorizations.create!(
|
||||
permissions: %w(documents), client_id: "app.example.com",
|
||||
@@ -23,17 +25,83 @@ RSpec.describe Services::RsAuthsController, type: :controller do
|
||||
expire_at: 2.days.from_now
|
||||
)
|
||||
|
||||
get :launch_app, params: { id: @auth.id }
|
||||
get :launch_app, params: { client_id: @auth.client_id }
|
||||
end
|
||||
|
||||
after do
|
||||
@auth.destroy
|
||||
end
|
||||
|
||||
it "redirects to the given URL with the correct RS URL fragment params" do
|
||||
it "hands the app the user address in the URL fragment" do
|
||||
launch_url = "https://app.example.com#remotestorage=#{user.address}"
|
||||
expect(response).to redirect_to(launch_url)
|
||||
end
|
||||
|
||||
it "does not hand over an access token" do
|
||||
expect(response.location).not_to include("access_token")
|
||||
end
|
||||
end
|
||||
|
||||
context "when remoteStorage is not enabled for the user" do
|
||||
before do
|
||||
allow_any_instance_of(LdapService).to receive(:fetch_users).and_return([
|
||||
{ services_enabled: [] }
|
||||
])
|
||||
|
||||
get :launch_app, params: { client_id: "app.example.com" }
|
||||
end
|
||||
|
||||
it "responds with forbidden" do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /services/storage/apps/:client_id/launch for an unknown app" do
|
||||
before { sign_in user }
|
||||
|
||||
it "returns 404 instead of raising" do
|
||||
get :launch_app, params: { client_id: "nonexistent.example.com" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /services/storage/rs_auths/:id" do
|
||||
context "when user is signed in" do
|
||||
before { sign_in user }
|
||||
|
||||
it "revokes the authorization" do
|
||||
auth = user.remote_storage_authorizations.create!(
|
||||
permissions: %w(documents), client_id: "app.example.com",
|
||||
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
|
||||
)
|
||||
|
||||
expect {
|
||||
delete :destroy, params: { id: auth.id }
|
||||
}.to change(RemoteStorageAuthorization, :count).by(-1)
|
||||
|
||||
expect(response).to redirect_to(apps_services_storage_url)
|
||||
end
|
||||
|
||||
it "returns 404 for an unknown authorization" do
|
||||
delete :destroy, params: { id: 0 }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "returns 404 for another user's authorization" do
|
||||
other = create :user, id: 99, cn: "otheruser", email: "other@example.com"
|
||||
auth = other.remote_storage_authorizations.create!(
|
||||
permissions: %w(documents), client_id: "app.example.com",
|
||||
redirect_uri: "https://app.example.com"
|
||||
)
|
||||
|
||||
delete :destroy, params: { id: auth.id }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(RemoteStorageAuthorization.exists?(auth.id)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,11 +44,29 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
|
||||
|
||||
it "shows a special permission for all files and dirs" do
|
||||
within ".scope" do
|
||||
expect(page).to have_content("All files and directories")
|
||||
expect(page).to have_content("All categories and files")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with PKCE parameters" do
|
||||
before do
|
||||
visit new_rs_oauth_path(username: user.cn,
|
||||
redirect_uri: "http://example.com",
|
||||
client_id: "http://example.com",
|
||||
scope: "documents:rw",
|
||||
response_type: "code",
|
||||
code_challenge: "challenge123",
|
||||
code_challenge_method: "S256")
|
||||
end
|
||||
|
||||
it "includes the PKCE parameters in the consent form" do
|
||||
expect(page).to have_selector("input[name='response_type'][value='code']", visible: false)
|
||||
expect(page).to have_selector("input[name='code_challenge'][value='challenge123']", visible: false)
|
||||
expect(page).to have_selector("input[name='code_challenge_method'][value='S256']", visible: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when signed out" do
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "Storage connected apps", type: :feature do
|
||||
let(:user) { create :user }
|
||||
|
||||
let(:firefox_ua) do
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
||||
end
|
||||
|
||||
let(:chrome_ua) do
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
|
||||
"(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
||||
end
|
||||
|
||||
before do
|
||||
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
||||
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
|
||||
allow_any_instance_of(User).to receive(:ldap_entry).and_return(
|
||||
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
|
||||
services_enabled: ["remotestorage"]
|
||||
)
|
||||
login_as user, scope: :user
|
||||
end
|
||||
|
||||
def create_auth(client:, device:, permissions: %w(documents:rw))
|
||||
user.remote_storage_authorizations.create!(
|
||||
permissions: permissions,
|
||||
client_id: client,
|
||||
redirect_uri: "https://#{client}",
|
||||
expire_at: 1.month.from_now,
|
||||
user_agent: device
|
||||
)
|
||||
end
|
||||
|
||||
context "with a connected app" do
|
||||
before do
|
||||
AppCatalog::WebApp.create!(url: "https://notes.example.com", name: "Notes", metadata: {})
|
||||
end
|
||||
|
||||
let!(:auth) do
|
||||
create_auth(client: "notes.example.com", device: firefox_ua,
|
||||
permissions: %w(documents:rw photos:r))
|
||||
end
|
||||
|
||||
after { auth.destroy }
|
||||
|
||||
it "shows the app with device and category counts" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
within ".rs-auth-card" do
|
||||
expect(page).to have_content("Notes")
|
||||
expect(page).to have_content("notes.example.com")
|
||||
expect(page).to have_content("1 device")
|
||||
expect(page).to have_content("2 categories")
|
||||
end
|
||||
end
|
||||
|
||||
it "does not show the permission expiry on the card" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
within ".rs-auth-card" do
|
||||
expect(page).not_to have_content("Expires")
|
||||
end
|
||||
end
|
||||
|
||||
it "renders access and per-token details in the modal" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
expect(page).to have_selector("p.scope", text: "documents", visible: false)
|
||||
expect(page).to have_selector("p.scope", text: "photos", visible: false)
|
||||
expect(page).to have_selector("h5", text: "Connected devices", visible: false)
|
||||
expect(page).to have_selector("p", text: "Firefox on Linux", visible: false)
|
||||
expect(page).to have_selector("p", text: /Expires/, visible: false)
|
||||
expect(page).to have_button("Revoke access", visible: false)
|
||||
expect(page).to have_link("Launch app", visible: false)
|
||||
end
|
||||
end
|
||||
|
||||
context "with multiple apps" do
|
||||
let!(:notes) { create_auth(client: "notes.example.com", device: firefox_ua) }
|
||||
let!(:photos) { create_auth(client: "photos.example.com", device: firefox_ua) }
|
||||
|
||||
after do
|
||||
notes.destroy
|
||||
photos.destroy
|
||||
end
|
||||
|
||||
it "shows one card per app" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
expect(page).to have_selector(".rs-auth-card", count: 2)
|
||||
end
|
||||
end
|
||||
|
||||
context "with one app on multiple devices" do
|
||||
let!(:laptop) { create_auth(client: "notes.example.com", device: firefox_ua) }
|
||||
let!(:phone) { create_auth(client: "notes.example.com", device: chrome_ua) }
|
||||
|
||||
after do
|
||||
laptop.destroy
|
||||
phone.destroy
|
||||
end
|
||||
|
||||
it "shows a single card with the device count" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
expect(page).to have_selector(".rs-auth-card", count: 1)
|
||||
|
||||
within ".rs-auth-card" do
|
||||
expect(page).to have_content("2 devices")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "without connected apps" do
|
||||
it "shows the empty state" do
|
||||
visit apps_services_storage_path
|
||||
|
||||
expect(page).to have_content("No apps connected")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
|
||||
describe "#create" do
|
||||
after(:each) { clear_enqueued_jobs }
|
||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
||||
|
||||
let(:auth) do
|
||||
user.remote_storage_authorizations.create!(
|
||||
@@ -27,10 +26,9 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
end
|
||||
|
||||
it "stores a token in redis" do
|
||||
user_auth_keys = redis_rs.keys("authorizations:#{user.cn}:*")
|
||||
expect(user_auth_keys.length).to eq(1)
|
||||
auth
|
||||
|
||||
authorizations = redis_rs.smembers(user_auth_keys.first)
|
||||
authorizations = redis_rs.smembers("authorizations:#{user.cn}:#{auth.token}")
|
||||
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
|
||||
end
|
||||
|
||||
@@ -49,7 +47,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
|
||||
describe "#destroy" do
|
||||
after(:each) { clear_enqueued_jobs }
|
||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
||||
|
||||
it "removes the token from redis" do
|
||||
auth = user.remote_storage_authorizations.create!(
|
||||
@@ -59,7 +56,7 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
)
|
||||
auth.destroy!
|
||||
|
||||
expect(redis_rs.keys("authorizations:#{user.address}:*")).to be_empty
|
||||
expect(redis_rs.exists?("authorizations:#{user.cn}:#{auth.token}")).to be(false)
|
||||
end
|
||||
|
||||
context "with expiry set" do
|
||||
@@ -79,8 +76,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
|
||||
describe "#find_or_create_web_app" do
|
||||
context "with origin that looks hosted" do
|
||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
||||
|
||||
let(:auth) do
|
||||
user.remote_storage_authorizations.create!(
|
||||
permissions: %w(documents:rw),
|
||||
@@ -173,8 +168,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
end
|
||||
|
||||
describe "#launch_url" do
|
||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
||||
|
||||
context "without start URL" do
|
||||
before do
|
||||
AppCatalog::WebApp.create!(
|
||||
@@ -250,7 +243,6 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
after(:each) { clear_enqueued_jobs }
|
||||
after(:all) { redis_rs_delete_keys("authorizations:*") }
|
||||
|
||||
before { allow(user).to receive(:display_name).and_return("Jimmy") }
|
||||
|
||||
@@ -311,4 +303,73 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
describe "#expired?" do
|
||||
def auth_with_expiry(expire_at)
|
||||
RemoteStorageAuthorization.new(
|
||||
user: user, permissions: %w(documents:rw), client_id: "example.com",
|
||||
redirect_uri: "https://example.com", expire_at: expire_at
|
||||
)
|
||||
end
|
||||
|
||||
it "is false without an expiry" do
|
||||
expect(auth_with_expiry(nil)).not_to be_expired
|
||||
end
|
||||
|
||||
it "is false before the expiry" do
|
||||
expect(auth_with_expiry(1.day.from_now)).not_to be_expired
|
||||
end
|
||||
|
||||
it "is true after the expiry" do
|
||||
expect(auth_with_expiry(1.day.ago)).to be_expired
|
||||
end
|
||||
end
|
||||
|
||||
describe "#device_name" do
|
||||
def auth_with_user_agent(user_agent)
|
||||
RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
|
||||
end
|
||||
|
||||
it "returns nil without a user agent" do
|
||||
expect(auth_with_user_agent(nil).device_name).to be_nil
|
||||
end
|
||||
|
||||
it "recognizes Firefox on Linux" do
|
||||
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("Firefox on Linux")
|
||||
end
|
||||
|
||||
it "recognizes Chrome on macOS" do
|
||||
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
|
||||
"(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("Chrome on macOS")
|
||||
end
|
||||
|
||||
it "recognizes Mobile Safari on iOS" do
|
||||
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) " \
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("Mobile Safari on iOS")
|
||||
end
|
||||
|
||||
it "recognizes GNOME Web on Linux (Epiphany's Safari-like user agent)" do
|
||||
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
|
||||
"Version/16.0 Safari/605.1.15 Epiphany/45.0"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("GNOME Web on Linux")
|
||||
end
|
||||
|
||||
it "treats a WebKit Safari user agent on Linux as WebKit (WebKitGTK)" do
|
||||
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
|
||||
"Version/60.5 Safari/605.1.15"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("WebKit on Linux")
|
||||
end
|
||||
|
||||
it "keeps Safari on macOS as Safari" do
|
||||
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " \
|
||||
"(KHTML, like Gecko) Version/17.1 Safari/605.1.15"
|
||||
expect(auth_with_user_agent(ua).device_name).to eq("Safari on macOS")
|
||||
end
|
||||
|
||||
it "falls back to the browser when the OS is unknown" do
|
||||
expect(auth_with_user_agent("Firefox/120.0").device_name).to eq("Firefox")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RsAuthorizationCode, type: :model do
|
||||
let(:user) { create :user }
|
||||
|
||||
def build_code(**attrs)
|
||||
RsAuthorizationCode.create!(
|
||||
user: user,
|
||||
client_id: "example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
permissions: %w(documents:rw),
|
||||
code_challenge: "challenge",
|
||||
**attrs
|
||||
)
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
it "generates a raw code and stores only its digest" do
|
||||
code = build_code
|
||||
|
||||
expect(code.code).to be_present
|
||||
expect(code.code_digest).to eq(Digest::SHA256.hexdigest(code.code))
|
||||
end
|
||||
|
||||
it "sets a short expiry" do
|
||||
code = build_code
|
||||
|
||||
expect(code.expires_at).to be_within(5.seconds).of(60.seconds.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".find_by_code" do
|
||||
it "finds a code by its raw value" do
|
||||
code = build_code
|
||||
|
||||
expect(RsAuthorizationCode.find_by_code(code.code)).to eq(code)
|
||||
end
|
||||
|
||||
it "returns nil for an unknown code" do
|
||||
expect(RsAuthorizationCode.find_by_code("does-not-exist")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for a blank code" do
|
||||
expect(RsAuthorizationCode.find_by_code(nil)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "#expired?" do
|
||||
it "is false before the expiry" do
|
||||
expect(build_code).not_to be_expired
|
||||
end
|
||||
|
||||
it "is true after the expiry" do
|
||||
expect(build_code(expires_at: 1.minute.ago)).to be_expired
|
||||
end
|
||||
end
|
||||
|
||||
describe "#consume!" do
|
||||
it "destroys the record" do
|
||||
code = build_code
|
||||
|
||||
expect { code.consume! }.to change(RsAuthorizationCode, :count).by(-1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "remoteStorage OAuth token endpoint", type: :request do
|
||||
let(:user) { create :user }
|
||||
|
||||
# Example values from RFC 7636, Appendix B
|
||||
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
|
||||
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
||||
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
|
||||
end
|
||||
|
||||
after do
|
||||
user.remote_storage_authorizations.destroy_all
|
||||
RsAuthorizationCode.destroy_all
|
||||
end
|
||||
|
||||
def issue_code
|
||||
RsOauthManager::IssueCode.call(
|
||||
user: user,
|
||||
client_id: "example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
permissions: %w(documents:rw),
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256"
|
||||
)
|
||||
end
|
||||
|
||||
it "exchanges a code for an access token without a session" do
|
||||
code = issue_code
|
||||
|
||||
post "/rs/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: code.code,
|
||||
client_id: "https://example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq("application/json")
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["access_token"]).to be_present
|
||||
expect(json["token_type"]).to eq("bearer")
|
||||
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
|
||||
end
|
||||
|
||||
it "answers CORS preflight requests" do
|
||||
options "/rs/oauth/token"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.headers["Access-Control-Allow-Methods"]).to include("POST")
|
||||
end
|
||||
end
|
||||
@@ -133,6 +133,15 @@ RSpec.describe "WebFinger", type: :request do
|
||||
|
||||
oauth_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-4.2"]
|
||||
expect(oauth_url).to eql("http://localhost/rs/oauth/tony")
|
||||
|
||||
authorization_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.1"]
|
||||
expect(authorization_url).to eql("http://localhost/rs/oauth/tony")
|
||||
|
||||
token_url = rs_link["properties"]["http://tools.ietf.org/html/rfc6749#section-3.2"]
|
||||
expect(token_url).to eql("http://localhost/rs/oauth/token")
|
||||
|
||||
expect(rs_link["properties"]["http://tools.ietf.org/html/rfc7636"]).to eql("S256")
|
||||
expect(rs_link["properties"]["http://remotestorage.io/spec/version"]).to eql("draft-dejong-remotestorage-27")
|
||||
end
|
||||
|
||||
it "returns CORS headers" do
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
require 'rails_helper'
|
||||
require 'webmock/rspec'
|
||||
require 'digest'
|
||||
|
||||
RSpec.describe EjabberdManager::SetAvatar, type: :model do
|
||||
let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" }
|
||||
let(:fixture_path) { Rails.root.join("spec/fixtures/files/bender.png") }
|
||||
let(:img_data) do
|
||||
ImageProcessing::Vips
|
||||
.source(File.open(fixture_path))
|
||||
.resize_to_fill(256, 256)
|
||||
.convert("png")
|
||||
.call.read
|
||||
end
|
||||
let(:sha1_hash) { Digest::SHA1.hexdigest(img_data) }
|
||||
let(:base64_data) { Base64.strict_encode64(img_data) }
|
||||
|
||||
before do
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: File.open(fixture_path),
|
||||
filename: "bender.png",
|
||||
content_type: "image/png"
|
||||
).tap { |blob| user.avatar.attach(blob) }
|
||||
end
|
||||
|
||||
def stub_send_stanza_ok
|
||||
stub_request(:post, "http://xmpp.example.com/api/send_stanza")
|
||||
.to_return(status: 200, body: "", headers: {})
|
||||
end
|
||||
|
||||
context "when an avatar is already set and overwrite is false" do
|
||||
before do
|
||||
allow(EjabberdManager::GetAvatar).to receive(:call).with(user: user).and_return({ img_base64: "abc", content_type: "image/png" })
|
||||
stub_send_stanza_ok
|
||||
end
|
||||
|
||||
it "does not send any stanzas" do
|
||||
described_class.call(user: user)
|
||||
expect(WebMock).not_to have_requested(:post, "http://xmpp.example.com/api/send_stanza")
|
||||
end
|
||||
end
|
||||
|
||||
context "when no avatar is set yet" do
|
||||
let(:sent_stanzas) { [] }
|
||||
|
||||
before do
|
||||
allow(EjabberdManager::GetAvatar).to receive(:call).with(user: user).and_return(nil)
|
||||
stub_request(:post, "http://xmpp.example.com/api/send_stanza")
|
||||
.to_return(status: 200, body: "", headers: {})
|
||||
end
|
||||
|
||||
it "publishes the XEP-0084 data and metadata stanzas" do
|
||||
service = described_class.new(user: user)
|
||||
allow(service).to receive(:send_stanza) do |payload|
|
||||
sent_stanzas << payload[:stanza]
|
||||
double(status: 200)
|
||||
end
|
||||
|
||||
service.call
|
||||
|
||||
expect(sent_stanzas.length).to eq(2)
|
||||
|
||||
data_stanza = sent_stanzas.find { |s| s.include?("urn:xmpp:avatar:data") }
|
||||
meta_stanza = sent_stanzas.find { |s| s.include?("urn:xmpp:avatar:metadata") }
|
||||
|
||||
expect(data_stanza).to include("from='willherschel@kosmos.org'")
|
||||
expect(data_stanza).to include("<item id='#{sha1_hash}'>")
|
||||
expect(data_stanza).to include("<data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>")
|
||||
|
||||
expect(meta_stanza).to include("from='willherschel@kosmos.org'")
|
||||
expect(meta_stanza).to include("<item id='#{sha1_hash}'>")
|
||||
expect(meta_stanza).to include("bytes='#{img_data.size}'")
|
||||
expect(meta_stanza).to include("id='#{sha1_hash}'")
|
||||
expect(meta_stanza).to include("type='image/png'")
|
||||
expect(meta_stanza).to include("height='256'")
|
||||
expect(meta_stanza).to include("width='256'")
|
||||
end
|
||||
end
|
||||
|
||||
context "when overwrite is true" do
|
||||
before do
|
||||
allow(EjabberdManager::GetAvatar).to receive(:call).and_return({ img_base64: "abc", content_type: "image/png" })
|
||||
stub_send_stanza_ok
|
||||
end
|
||||
|
||||
it "does not check for an existing avatar and sends stanzas" do
|
||||
described_class.call(user: user, overwrite: true)
|
||||
expect(EjabberdManager::GetAvatar).not_to have_received(:call)
|
||||
expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/send_stanza").twice
|
||||
end
|
||||
end
|
||||
|
||||
context "when the ejabberd API returns a non-200 status" do
|
||||
before do
|
||||
allow(EjabberdManager::GetAvatar).to receive(:call).with(user: user).and_return(nil)
|
||||
stub_request(:post, "http://xmpp.example.com/api/send_stanza")
|
||||
.to_return(status: 500, body: "error", headers: {})
|
||||
end
|
||||
|
||||
it "raises" do
|
||||
expect { described_class.call(user: user) }.to raise_error(RuntimeError)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RsOauthManager::ExchangeCode do
|
||||
let(:user) { create :user }
|
||||
|
||||
# Example values from RFC 7636, Appendix B
|
||||
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
|
||||
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
||||
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
|
||||
end
|
||||
|
||||
def issue_code(**overrides)
|
||||
RsOauthManager::IssueCode.call(
|
||||
user: user,
|
||||
client_id: "example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
permissions: %w(documents:rw),
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
user_agent: "RSpec Device",
|
||||
**overrides
|
||||
)
|
||||
end
|
||||
|
||||
def exchange(code, **overrides)
|
||||
described_class.call(
|
||||
code: code.code,
|
||||
client_id: "https://example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
code_verifier: code_verifier,
|
||||
**overrides
|
||||
)
|
||||
end
|
||||
|
||||
it "creates an authorization and consumes the code" do
|
||||
code = issue_code
|
||||
auth = nil
|
||||
|
||||
expect { auth = exchange(code) }.to change(RemoteStorageAuthorization, :count).by(1)
|
||||
|
||||
expect(auth.permissions).to eq(%w(documents:rw))
|
||||
expect(auth.client_id).to eq("example.com")
|
||||
expect(auth.token).to be_present
|
||||
expect(auth.user_agent).to eq("RSpec Device")
|
||||
expect(RsAuthorizationCode.exists?(code.id)).to be(false)
|
||||
end
|
||||
|
||||
it "carries over the permission expiry" do
|
||||
expire_at = 1.month.from_now
|
||||
code = issue_code(expire_at: expire_at)
|
||||
|
||||
auth = exchange(code)
|
||||
|
||||
expect(auth.expire_at).to be_within(1.second).of(expire_at)
|
||||
end
|
||||
|
||||
it "rejects an unknown code" do
|
||||
expect do
|
||||
described_class.call(
|
||||
code: "unknown",
|
||||
client_id: "https://example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
code_verifier: code_verifier
|
||||
)
|
||||
end.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
|
||||
it "rejects a reused code" do
|
||||
code = issue_code
|
||||
exchange(code)
|
||||
|
||||
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
|
||||
it "rejects an expired code" do
|
||||
code = issue_code
|
||||
code.update_columns(expires_at: 1.minute.ago)
|
||||
|
||||
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
|
||||
it "rejects a mismatching redirect_uri" do
|
||||
code = issue_code
|
||||
|
||||
expect { exchange(code, redirect_uri: "https://example.com/other") }
|
||||
.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
|
||||
it "rejects a mismatching client_id host" do
|
||||
code = issue_code
|
||||
|
||||
expect { exchange(code, client_id: "https://evil.example.org") }
|
||||
.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
|
||||
it "rejects a bad code_verifier" do
|
||||
code = issue_code
|
||||
|
||||
expect { exchange(code, code_verifier: "wrong-verifier") }
|
||||
.to raise_error(RsOauthManagerService::InvalidGrant)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RsOauthManager::IssueCode do
|
||||
let(:user) { create :user }
|
||||
|
||||
subject do
|
||||
described_class.call(
|
||||
user: user,
|
||||
client_id: "example.com",
|
||||
redirect_uri: "https://example.com",
|
||||
permissions: %w(documents:rw),
|
||||
code_challenge: "challenge",
|
||||
code_challenge_method: "S256",
|
||||
expire_at: 1.month.from_now,
|
||||
user_agent: "RSpec Device"
|
||||
)
|
||||
end
|
||||
|
||||
it "creates a short-lived authorization code" do
|
||||
expect { subject }.to change(RsAuthorizationCode, :count).by(1)
|
||||
expect(subject.code).to be_present
|
||||
expect(subject.client_id).to eq("example.com")
|
||||
expect(subject.permissions).to eq(%w(documents:rw))
|
||||
expect(subject.code_challenge).to eq("challenge")
|
||||
expect(subject.user_agent).to eq("RSpec Device")
|
||||
end
|
||||
|
||||
it "does not create a remote storage authorization" do
|
||||
expect { subject }.not_to change(RemoteStorageAuthorization, :count)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RsOauthManager::VerifyPkce do
|
||||
# Example values from RFC 7636, Appendix B
|
||||
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
|
||||
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
|
||||
|
||||
subject do
|
||||
described_class.call(
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
code_verifier: code_verifier
|
||||
)
|
||||
end
|
||||
|
||||
context "with the S256 method" do
|
||||
let(:code_challenge_method) { "S256" }
|
||||
|
||||
it "accepts a matching verifier" do
|
||||
expect(subject).to be(true)
|
||||
end
|
||||
|
||||
it "rejects a non-matching verifier" do
|
||||
expect(
|
||||
described_class.call(
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
code_verifier: "wrong-verifier"
|
||||
)
|
||||
).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an unsupported method" do
|
||||
let(:code_challenge_method) { "plain" }
|
||||
|
||||
it "rejects the verifier" do
|
||||
expect(subject).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,11 +14,6 @@
|
||||
#
|
||||
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
||||
RSpec.configure do |config|
|
||||
# TODO Remove when Devise fixes https://github.com/heartcombo/devise/issues/5705
|
||||
config.before(:each, type: :controller) do
|
||||
Rails.application.reload_routes_unless_loaded
|
||||
end
|
||||
|
||||
# rspec-expectations config goes here. You can use an alternate
|
||||
# assertion/expectation library such as wrong or the stdlib/minitest
|
||||
# assertions if you prefer.
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
def redis_rs
|
||||
@redis_rs ||= Redis.new(url: Setting.rs_redis_url)
|
||||
end
|
||||
|
||||
def redis_rs_delete_keys(pattern)
|
||||
keys = redis_rs.keys(pattern)
|
||||
redis_rs.del(*keys)
|
||||
end
|
||||
Reference in new issue
Block a user