131 Commits
Author SHA1 Message Date
raucao 0c667795a7 0.11.0
CI / Test (push) Successful in 33s
2026-08-10 14:43:42 -06:00
greg 3517303d37 Merge pull request 'Fix after-sign-in redirect issues' (#243) from bugfix/after-signin_redirects into master
CI / Test (push) Successful in 32s
Reviewed-on: #243
Reviewed-by: Greg <greg@kosmos.org>
2026-08-10 17:43:39 +00:00
raucao 89184cd851 Redirect to root after password reset
CI / Test (pull_request) Successful in 32s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-10 11:22:03 -06:00
raucao 29a9dee1cb Consume user_return_to on sign-in 2026-08-10 11:21:59 -06:00
raucao a62d84e20a Validate rs/oauth redirect_uri before storing return URL 2026-08-10 11:21:33 -06:00
raucao 7a892fad4e Add AGENTS.md
CI / Test (push) Successful in 32s
2026-08-10 11:01:21 -06:00
raucao d60d211765 Merge pull request 'Admin users page: filter by username' (#242) from feature/admin_user_search into master
CI / Test (push) Successful in 34s
Reviewed-on: #242
2026-08-10 16:40:32 +00:00
raucao d58c995230 Admin users page: filter by username
CI / Test (pull_request) Successful in 33s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
Allow admins to filter the user list/index by username (partial matches
allowed)
2026-08-10 10:36:05 -06:00
raucao 9c143bc8d6 Merge pull request 'Switch from Yarn to Bun; use Ruby 3.3.12' (#241) from dev/yarn_to_bun into master
CI / Test (push) Successful in 33s
Reviewed-on: #241
2026-08-10 00:50:19 +00:00
raucao 0d476c8a8b Re-add Node.js for CI actions
CI / Test (pull_request) Successful in 34s
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2026-08-09 18:24:28 -06:00
raucao 31341fb4ce Upgrade Ruby to 3.3.12 everywhere
CI / Test (pull_request) Failing after 2s
2026-08-09 18:13:31 -06:00
raucao 39d9f9b312 Migrate everything from Yarn to Bun
No node.js or npm necessary anymore
2026-08-09 18:13:26 -06:00
raucao 8a6e733316 Merge pull request 'Show links for log-in, ToS, and Privacy Policy when logged out' (#240) from chore/login_links into master
CI / Test (push) Successful in 44s
Reviewed-on: #240
2026-08-09 20:13:39 +00:00
raucao 9f25b23e2e Show ToS and Privacy Policy links when logged out
CI / Test (pull_request) Successful in 41s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-09 14:10:25 -06:00
raucao 6a32dd5ee9 Show log-in link in header when logged out
Except on the login page itself
2026-08-09 14:10:00 -06:00
raucao b93ddd9dbd Merge pull request 'Add additional styles for rich text' (#239) from feature/rich-text into master
CI / Test (push) Successful in 41s
Reviewed-on: #239
2026-08-09 19:05:33 +00:00
raucao 7e7892df7a Fail CI if CSS wasn't built for any reason
CI / Test (pull_request) Successful in 40s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-09 13:03:02 -06:00
raucao aff9e534c4 Upgrade Tailwind CSS 2026-08-09 13:02:27 -06:00
raucao 5f6269204b Add auth token config for Release Drafter 2026-08-09 11:54:35 -06:00
raucao b44b689caf Additional styles for rich text
CI / Test (pull_request) Failing after 40s
Especially for editable content
2026-08-09 11:42:41 -06:00
greg 55df8a9c31 Merge pull request 'Migrate from Drone CI to Gitea Actions' (#238) from dev/gitea_actions into master
CI / Test (push) Successful in 39s
Reviewed-on: #238
Reviewed-by: Greg <greg@kosmos.org>
2026-08-08 19:52:03 +00:00
raucao da69cb4ad0 Cache gems in CI
CI / Test (pull_request) Successful in 41s
Release Drafter / Update release notes draft (pull_request) Failing after 2s
2026-08-08 08:36:39 -06:00
raucao 139d543e4f Remove bun from script
CI / Test (pull_request) Successful in 2m11s
2026-08-08 08:15:24 -06:00
raucao 744cc7d7dd Migrate from Drone CI to Gitea Actions
CI / Test (pull_request) Failing after 2m14s
2026-08-08 08:08:57 -06:00
raucao 8f1fadc74a Point tailwind command at module executable
continuous-integration/drone/push Build is failing
2026-08-07 12:38:09 -06:00
raucao e7527a1def Merge pull request 'Allow anyone to publish NIP-46 events' (#236) from feature/nostr_allow_public_bunker_events into master
continuous-integration/drone/push Build is passing
Reviewed-on: #236
2026-08-07 18:29:42 +00:00
raucao 0d248ead04 Allow anyone to publish NIP-46 events
Release Drafter / Update release notes draft (pull_request) Failing after 3s
This turns our relay into a public remote-sign-in relay
2026-08-07 11:03:31 -06:00
raucao 3bd07472b2 Fix pages views when signed out
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-06-12 16:09:42 +04:00
raucao 32b1c2748a Fix wrong variable
continuous-integration/drone/push Build is passing
2025-05-30 20:22:04 +04:00
raucao fc6cac8368 Remove superfluous link
continuous-integration/drone/push Build is passing
Already linked in the same paragraph
2025-05-30 16:53:05 +04:00
greg eefdc88a47 Merge pull request 'Editable content' (#229) from feature/186-content_editing into master
continuous-integration/drone/push Build is passing
Reviewed-on: #229
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-30 11:14:50 +00:00
raucao f2e8ca790c Add Privacy and ToS pages, footer menu
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2025-05-30 13:27:15 +04:00
raucao 32cd4d896d Fix link color for Devise links
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-29 17:26:18 +04:00
raucao 67c450860a Fix tab links
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-29 16:24:33 +04:00
raucao f1d9cf1e3d Remove special link class
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
This cleans up the code quite a bit, but also allows links in editable
content to be rendered with the default style.
2025-05-29 16:10:34 +04:00
raucao ab1490f472 Remove Kosmos name from wording
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
refs #222
2025-05-29 14:24:43 +04:00
raucao 6014134396 Finish MVP for content editing 2025-05-29 14:18:14 +04:00
raucao 6713665a61 WIP Rename "projects" page, make content editable
continuous-integration/drone/push Build is failing
2025-05-28 18:42:10 +04:00
raucao 315cf4dd9f Add editable content helpers 2025-05-28 18:41:53 +04:00
raucao 2f86b3c16f Add admin/editable_contents controller 2025-05-28 18:40:54 +04:00
raucao 55c63be9e2 Memoize instance variable 2025-05-28 18:39:48 +04:00
raucao 5c8ffc2630 Add editable contents table 2025-05-28 18:39:25 +04:00
raucao c7a21c7a69 Add top margin to h3 within content 2025-05-28 18:37:59 +04:00
raucao 252b0f1792 Revert "Add ActionText configs, update spec helpers/configs"
This reverts commit c9d23f829d.
2025-05-28 16:53:31 +04:00
raucao 57246ea76d Fix navbar current link 2025-05-28 15:35:57 +04:00
raucao c9d23f829d Add ActionText configs, update spec helpers/configs 2025-05-28 14:52:31 +04:00
raucao 55111f1b8b Allow using icons without custom class
continuous-integration/drone/push Build is passing
2025-05-28 14:50:59 +04:00
raucao 4c6e64095f Fix unused invitations count
continuous-integration/drone/push Build is failing
2025-05-28 14:28:59 +04:00
raucao 450ccff65b Add custom class to all remaining icons
continuous-integration/drone/push Build is failing
2025-05-28 13:57:01 +04:00
greg 0778f29a8e Merge pull request 'Refactor ejabberd API integration' (#226) from core/refactor_ejabberd_integration into master
continuous-integration/drone/push Build is passing
Reviewed-on: #226
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:22:39 +00:00
greg 3dbde86cdf Merge pull request 'Introduce membership statuses' (#227) from feature/contributor_status into master
continuous-integration/drone/push Build is passing
Reviewed-on: #227
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:16:02 +00:00
raucao 0dcfefd66c Merge pull request 'Improve admin pages for invitations' (#228) from feature/admin_invitations into feature/contributor_status
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
Reviewed-on: #228
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:00:11 +00:00
raucao c6a187b25a Limit invitees on admin user page, link to invitations for more
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2025-05-28 12:50:10 +04:00
raucao c99d8545c1 Add username filter to admin invitations index
continuous-integration/drone/push Build is passing
2025-05-28 12:34:52 +04:00
raucao e8f912360b Fix wrong stats number
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-28 12:11:26 +04:00
raucao c94a0e34d1 Add donations to user details, link to filtered list
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-27 19:04:35 +04:00
raucao 04094efbdb Add username filter with UI to admin donations page 2025-05-27 18:43:45 +04:00
raucao 71352d13d2 Add pending donations to admin donations index
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
And add more info to the details page
2025-05-27 18:08:22 +04:00
raucao fff7527694 Don't show njump link when no pubkey set 2025-05-27 17:35:48 +04:00
raucao 7a8ca0707a Add missing dash for no member status
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-27 17:18:47 +04:00
raucao b657a25d4d Wording 2025-05-27 17:16:26 +04:00
raucao e48132cf5f Set member status to sustainer upon payment
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Introduces a state machine for the payment status as well.

refs #213
2025-05-27 16:39:03 +04:00
raucao 463bf34cdf Add link for icon library to README
continuous-integration/drone/push Build is passing
2025-05-27 15:12:31 +04:00
raucao f313686b13 Add settings for member statuses
continuous-integration/drone/push Build is passing
2025-05-27 14:59:10 +04:00
raucao 0b4bc4ef5c Improve color shade of sidebar link icon
Was a bit bright
2025-05-27 14:58:45 +04:00
raucao 393f85e45c WIP Add member/contributor status to users
continuous-integration/drone/push Build is passing
2025-05-27 13:32:58 +04:00
raucao d737d9f6b8 Refactor ejabberd API integration
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-26 14:10:27 +04:00
raucao 4bf6985b87 Fix wrong matcher for custom LDAP attribute
continuous-integration/drone/push Build is passing
389ds doesn't like case-insensitive matches for 7-bit ASCII strings
2025-05-23 14:08:41 +04:00
greg 308cac5a39 Merge pull request 'Add Mastodon API client, service for syncing avatars and display names' (#225) from feature/mastodon_api into master
continuous-integration/drone/push Build is passing
Reviewed-on: #225
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-23 08:48:15 +00:00
raucao 7f766473ab Fix typo
continuous-integration/drone/push Build is passing
2025-05-22 13:21:37 +04:00
raucao c1bac2625c Only log exception to stdout
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-05-21 16:42:49 +04:00
raucao c5c6765d67 Log LDAP exceptions
continuous-integration/drone/push Build is passing
2025-05-21 16:29:52 +04:00
raucao 171524fb83 Use production link
continuous-integration/drone/push Build is passing
2025-05-18 14:58:55 +04:00
raucao 3538067da6 Use production link
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-18 14:58:34 +04:00
raucao c374bcd3bc Merge branch 'master' into feature/mastodon_api
continuous-integration/drone/push Build is running
2025-05-18 14:56:42 +04:00
raucao 655009ad7a Add example link for PGP pubkey
continuous-integration/drone/push Build is running
2025-05-18 14:56:29 +04:00
raucao 71c9bd29ab Merge branch 'master' into feature/mastodon_api 2025-05-18 14:46:28 +04:00
raucao e66d134550 Log missing l param for WKD requests, return 400
continuous-integration/drone/push Build is passing
2025-05-18 14:46:04 +04:00
raucao 11167e3e43 Merge branch 'master' into feature/mastodon_api 2025-05-18 14:37:47 +04:00
raucao ebbd87368c Handle l param missing for WKD request
continuous-integration/drone/push Build is passing
2025-05-18 14:37:22 +04:00
raucao 7b0ebb761f Allow display name to be removed
continuous-integration/drone/push Build is passing
When form field is empty
2025-05-18 14:26:09 +04:00
raucao fb03427d59 Allow syncing a single Mastodon profile
continuous-integration/drone/push Build is passing
2025-05-17 18:56:34 +04:00
raucao ad138f715c Update doc 2025-05-17 18:56:34 +04:00
raucao 6730aae2dc Only update other avatars in one place
Prevent future mistakes
2025-05-17 18:56:33 +04:00
raucao a71aa3fda2 Don't queue job when service isn't enabled 2025-05-17 18:56:33 +04:00
raucao 92e6b1395a Add avatar to admin user page 2025-05-17 18:56:33 +04:00
raucao 37c59b7b0c Sync Mastodon IDs/profiles to local accounts
Add a new service to import some data from Mastodon accounts:

* Find users by username, store Mastodon account ID in local db when
  found
* Import display name (don't overwrite existing)
* Import avatar (don't overwrite existing)
2025-05-17 18:56:30 +04:00
raucao c291765777 Add mastodon_id to users 2025-05-17 16:44:13 +04:00
raucao f0cfde560b Add Mastodon API service class, auth token config
Add a new REST API service class to keep things DRY
2025-05-17 14:18:16 +04:00
raucao c43e43d89c Open RS apps in new tab
continuous-integration/drone/push Build is passing
2025-05-16 17:30:11 +04:00
raucao dbbf116c52 Fix RS storage-first auth work in dev, remove token
continuous-integration/drone/push Build is passing
See https://github.com/remotestorage/remotestorage.js/issues/900
2025-05-16 15:59:40 +04:00
raucao 208b1f04ae Fix web app icon component
continuous-integration/drone/push Build is passing
2025-05-16 15:38:03 +04:00
raucao 8049f81b73 Merge pull request 'Set XMPP avatar when new avatar is uploaded' (#224) from feature/ejabberd_pep into master
continuous-integration/drone/push Build is running
Reviewed-on: #224
2025-05-16 11:37:29 +00:00
raucao 5f276ff349 Queue XmppSetAvatarJob when new avatar is uploaded
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Failing after 10m9s
And let job do nothing in development for now
2025-05-15 22:04:25 +04:00
raucao 5916969447 Add job for setting avatar via XMPP 2025-05-15 20:05:53 +04:00
raucao 382c5ad10e Return response for ejabberd API calls 2025-05-15 12:53:58 +04:00
raucao 8b3243af6b Sort API methods alphabetically
continuous-integration/drone/push Build is passing
2025-05-15 12:19:09 +04:00
raucao fc36fbf10c Add get_vcard2 to ejabberd client
continuous-integration/drone/push Build is passing
2025-05-15 12:16:53 +04:00
raucao 06d2705c4c Add private_get to ejabberd service
continuous-integration/drone/push Build is passing
2025-05-15 12:01:10 +04:00
raucao 03be2e09e6 Merge pull request 'User avatars' (#223) from feature/user_avatars into master
continuous-integration/drone/push Build is passing
Reviewed-on: #223
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-14 14:58:15 +00:00
raucao 582d339c0a Remove feature gate for avatar upload
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-14 18:55:26 +04:00
raucao a098ea43bb Add avatar URL to Webfinger when available
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-14 15:39:50 +04:00
raucao 417e346074 Do not use ActiveStorage variants, process original avatar
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Variants are currently broken. So we process the original file with the
most common avatar dimensions and stripping metadata, then hash and
upload only that version.
2025-05-14 14:42:03 +04:00
raucao 1884f082ee Add note about variants not working when not generated ad-hoc
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-12 18:07:10 +04:00
raucao 51a3652fc8 Fix S3 keys/paths for user avatars
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Also fixes the avatars controller to work with all back-ends
2025-05-12 16:39:53 +04:00
raucao 46b908839d Add avatar URL to Discourse Connect
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Discourse should download and set the avatar if the user doesn't have
one set yet.
2025-05-12 15:04:56 +04:00
raucao 512f0ccca1 Add controller for rendering avatars on simple URL 2025-05-12 15:04:01 +04:00
raucao 17ffbde03a WIP Store avatars as ActiveStorage attachments
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Also push to LDAP as jpegPhoto
2025-05-11 18:43:21 +04:00
raucao 9e2210c45b Store avatars as binary instead of base64
continuous-integration/drone/push Build is passing
2025-05-10 20:58:36 +04:00
raucao 6d7d722c5d Add inetOrgPerson objectclass to user entries
refs #174
2025-05-08 16:52:54 +04:00
greg ae5d63c613 Merge pull request 'Move remaining credentials from Rails credentials store to ENV' (#221) from chore/215-configs into master
continuous-integration/drone/push Build is passing
Reviewed-on: #221
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-06 17:16:32 +00:00
raucao 93aa26f430 Remove lockbox column
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-06 20:14:25 +04:00
raucao 50110c12b9 Remove lockbox gem
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-06 20:01:01 +04:00
raucao 95843aee6d Remove credentials files
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-06 19:50:27 +04:00
raucao 84ed4b2de2 Remove old ln columns from users table
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-06 19:47:58 +04:00
raucao 931624cf95 Add encryption credentials to test env
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-06 18:14:26 +04:00
raucao eae370b737 Migrate from lockbox to ActiveRecord encryption (1/2)
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-06 18:09:27 +04:00
raucao 15a9fdec3e Make RS auth work by default in dev with Docker Compose 2025-05-06 18:07:52 +04:00
raucao 3d8619532b Refactor LDAP config
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
* Move credentials to ENV vars in prod
* Use same configs in dev and prod
* Make UID attribute and admin DN configurable
2025-05-06 15:32:59 +04:00
raucao d56edb34f1 Remove SMTP credentials from Rails credentials
Already unused
2025-05-06 15:08:46 +04:00
raucao a97bbf61a8 Fix postgresql query for deleting auth expiry job
continuous-integration/drone/push Build is passing
Solid Queue uses a text column, instead of a jsonb, so we need to cast
it as jsonb on the fly.
2025-05-05 17:37:58 +04:00
raucao 5a523fd220 Merge pull request 'Refactor database configs' (#220) from chore/db_configs into master
continuous-integration/drone/push Build is passing
Reviewed-on: #220
2025-05-05 12:54:22 +00:00
raucaoandgreg 889c9ae824 Refactor database configs
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
* Move postgres credentials to ENV vars
* Allow postgres in development
* Allow SQlite in production
* Refactor optional lndhub db config

Co-authored-by: Greg Karékinian <greg@karekinian.com>
2025-05-05 15:25:25 +04:00
raucao e686cf42e8 Merge pull request 'Switch from Sidekiq to Solid Queue' (#219) from dev/sidekiq_to_solidqueue into master
continuous-integration/drone/push Build is passing
Reviewed-on: #219
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-05 11:24:56 +00:00
raucao 906468d156 Allow to immediately expire auth via job
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
When running the job before its schedule
2025-05-05 12:46:46 +04:00
raucao ee5c6d86d0 Port RS auth job removal to Solid Queue
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-05 11:07:30 +04:00
raucao d1eea85b04 Add Redis gem explicitly, remove sidekiq require
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-04 18:14:49 +04:00
raucao ecd814641a Remove Sidekiq initializer
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-04 17:44:37 +04:00
raucao b1dd5800b2 Update lockfile
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-04 17:42:31 +04:00
raucao 0cad4cdcfe WIP Switch from Sidekiq to Solid Queue
continuous-integration/drone/push Build is running
continuous-integration/drone/pr Build is failing
2025-05-04 17:40:33 +04:00
greg b61906059c Merge pull request 'Upgrade Rails to 8.0' (#216) from chore/upgrade_rails into master
continuous-integration/drone/push Build is passing
Reviewed-on: #216
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-04-30 08:36:16 +00:00
488 changed files with 3002 additions and 1920 deletions
-61
View File
@@ -1,61 +0,0 @@
---
kind: pipeline
type: docker
name: CI build
steps:
- name: restore-cache
image: drillster/drone-volume-cache
volumes:
- name: cache
path: /cache
settings:
restore: true
mount:
- ./vendor/cache
when:
branch:
- master
- name: rspec
image: gitea.kosmos.org/kosmos/akkounts-ci:0.9.1
environment:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
commands:
- bundle config unset deployment
- bundle config set cache_all 'true'
- bundle config set cache_path 'vendor/cache'
- bundle config set with 'development test'
- bundle install --jobs=3 --retry=3
- bundle exec rails db:create
- bundle exec rails db:migrate
- yarn install
- rake css:build
- bundle exec rspec
- name: rebuild-cache
image: drillster/drone-volume-cache
volumes:
- name: cache
path: /cache
settings:
rebuild: true
mount:
- ./vendor/cache
when:
branch:
- master
services:
- name: redis
image: redis
volumes:
- name: cache
host:
path: /var/lib/drone/tmp
---
kind: signature
hmac: f9a8cf97f6596625721365f6238f6f298aa5a7a4de10c3fb61c57202ae9d1ee1
...
+22 -1
View File
@@ -1,6 +1,23 @@
# PRIMARY_DOMAIN=kosmos.org
# AKKOUNTS_DOMAIN=accounts.example.com
# Generate this using `rails secret`
# SECRET_KEY_BASE=
# Generate these using `rails db:encryption:init`
# (Optional, needed for LndHub integration)
# ENCRYPTION_PRIMARY_KEY=
# ENCRYPTION_KEY_DERIVATION_SALT=
# The default backend is SQLite
# DB_ADAPTER=postgresql
# PG_HOST=localhost
# PG_PORT=5432
# PG_DATABASE=akkounts
# PG_DATABASE_QUEUE=akkounts_queue
# PG_USERNAME=akkounts
# PG_PASSWORD=
# SMTP_SERVER=smtp.example.com
# SMTP_PORT=587
# SMTP_LOGIN=accounts
@@ -20,8 +37,12 @@
# LDAP_HOST=localhost
# LDAP_PORT=389
# LDAP_USE_TLS=false
# LDAP_UID_ATTR=cn
# LDAP_BASE="ou=kosmos.org,cn=users,dc=kosmos,dc=org"
# LDAP_ADMIN_USER="cn=Directory Manager"
# LDAP_ADMIN_PASSWORD=passthebutter
# LDAP_SUFFIX='dc=kosmos,dc=org'
# LDAP_SUFFIX="dc=kosmos,dc=org"
# REDIS_URL='redis://localhost:6379/1'
+5 -1
View File
@@ -1,6 +1,9 @@
PRIMARY_DOMAIN=kosmos.org
AKKOUNTS_DOMAIN=accounts.kosmos.org
ENCRYPTION_PRIMARY_KEY=YhNLBgCFMAzw5dV3gISxnGrhNDMQwRdn
ENCRYPTION_KEY_DERIVATION_SALT=h28g16MRZ1sghF2jTCos1DiLZXUswinR
REDIS_URL='redis://localhost:6379/0'
BTCPAY_PUBLIC_URL='https://btcpay.example.com'
@@ -21,7 +24,8 @@ LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de55648
NOSTR_PRIVATE_KEY='7c3ef7e448505f0615137af38569d01807d3b05b5005d5ecf8aaafcd40323cea'
NOSTR_PUBLIC_KEY='bdd76ce2934b2f591f9fad2ebe9da18f20d2921de527494ba00eeaa0a0efadcf'
RS_STORAGE_URL='https://storage.kosmos.org'
RS_REDIS_URL='redis://localhost:6379/1'
RS_STORAGE_URL='https://storage.kosmos.org'
RS_AKKOUNTS_DOMAIN=localhost
WEBHOOKS_ALLOWED_IPS='10.1.1.23'
+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
test:
name: Test
runs-on: ubuntu-latest
container: gitea.kosmos.org/kosmos/akkounts-ci:0.11.1
services:
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
steps:
- uses: actions/checkout@v4
- name: Restore bundle cache
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-3.3.12-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-3.3.12-
- name: Install Ruby dependencies
run: |
bundle config unset deployment
bundle config set cache_all 'true'
bundle config set cache_path 'vendor/cache'
bundle config set path 'vendor/bundle'
bundle config set with 'development test'
bundle install --jobs=3 --retry=3
- name: Prepare database
run: |
bundle exec rails db:create
bundle exec rails db:migrate
- name: Install JS dependencies
run: bun install
- name: Build CSS
run: |
bundle exec rake css:build
test -s app/assets/builds/application.css
- name: Run tests
run: bundle exec rspec
+2
View File
@@ -9,3 +9,5 @@ jobs:
steps:
- name: Release Drafter
uses: https://github.com/raucao/gitea-release-drafter@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-4
View File
@@ -34,10 +34,6 @@
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
bun.lock
# Ignore local dotenv config file
.env
+1 -1
View File
@@ -1 +1 @@
3.3.0
3.3.12
+74
View File
@@ -0,0 +1,74 @@
# AGENTS.md
Akkounts is a Rails 8 monolith for managing Kosmos/LDAP user accounts.
It authenticates against an LDAP directory via Devise and integrates with
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).
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`.
## Common commands (prefix with `docker compose exec web`)
- `bin/rspec` — run the test suite (CI runs `bundle exec rspec`)
- `bin/rails db:prepare` — create/migrate DB (also `db:setup`, `db:migrate`)
- `bin/rails ldap:setup` — reset LDAP dir and seed dev entries
- `bin/rails css:build` — build Tailwind CSS (auto-watched in dev)
- `bin/rails lndhub:generate_wallets`, `invitations:generate_for_all_users[N]`
- `bin/jobs` — Solid Queue CLI
Tests use the test env explicitly: `docker compose exec -e RAILS_ENV=test web bin/rspec`.
There is no enforced linter — a `bin/rubocop` binstub exists but no `.rubocop.yml`
(rubocop is a transitive gem, not in the Gemfile); CI does not lint.
## Stack & conventions
- **Assets/views:** ERB + ViewComponent (`app/components/`), Tailwind built via Bun +
importmap (`config/importmap.rb`); pin JS deps with `bin/importmap pin <pkg> --download`.
- **Auth:** Devise + `devise_ldap_authenticatable`; `authentication_keys = [:cn]`,
custom SSHA512 password builder, admin bind. Admin-only mounts (`/jobs` MissionControl,
`/flipper` Flipper UI) are gated by `authenticate :user, ->(u) { u.is_admin? }`.
- **Services:** `app/services/` uses `ApplicationService.call(**args)` with namespaced
`*ManagerService` bases (e.g. `UserManagerService`) and verb-named operations
(`UserManager::CreateAccount`, `NostrManager::PublishEvent`, `BtcpayManager::*`).
- **Models:** `User` is central; `Setting` uses rails-settings-cached with one concern per
service in `app/models/concerns/settings/`. LNDHub models connect to a separate DB
(`LndhubBase` `establish_connection :lndhub`). LDAP is accessed via `LdapService`/
`LdapManagerService`, not ActiveRecord.
- **Jobs:** `app/jobs/` inherit `ApplicationJob < ActiveJob::Base`; backend is **Solid Queue**
(`config.active_job.queue_adapter = :solid_queue`, separate `queue` DB in prod).
`config/recurring.yml` has no active scheduled jobs. Ignore `config/sidekiq.yml` — it's
stale (sidekiq is not in the Gemfile; the README mention is outdated).
- **Config:** env-var driven (`.env.example`/`.env.development`/`.env.test`); `SERVICES`
constant is loaded from `config/services.yml` in `config/initializers/service_details.rb`.
Custom LDAP schema attributes are applied from `schemas/ldap/*.ldif` by `lib/tasks/ldap.rake`.
## Testing conventions (`spec/`)
`spec/rails_helper.rb` includes FactoryBot, Devise `ControllerHelpers` (controller specs),
Warden `Test::Helpers`, DatabaseCleaner (transactional fixtures **off**), ViewComponent
`TestHelpers` + Capybara matchers, and `ActiveJob::TestHelper` for `type: :job`.
- **Feature specs** (`spec/features/`, `type: :feature`): Capybara **rack-test** driver (no JS).
Log in with Warden: `login_as user, scope: :user`; stub admin with
`allow(Devise::LDAP::Adapter).to receive(:get_ldap_param).with(user.cn, :admin).and_return(["true"])`.
- **Services** (`spec/services/`) are tagged `type: :model`; exercise `#call`/`service.send(:private)`
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
- Generators (`config/application.rb`): ERB templates, RSpec, no stylesheets, factory_bot
with `_factory` suffix dir `spec/factories`.
- `gitno/` and `extras/strfry/` are scratch/infra (Deno Nostr relay policies) — not app code; ignore.
- Don't add code comments unless asked (matches repo style); `frozen_string_literal` is inconsistent.
- Default dev login: username `admin` / password `admin is admin`.
+6 -7
View File
@@ -1,22 +1,21 @@
# syntax=docker/dockerfile:1
FROM ruby:3.3.4
FROM ruby:3.3.12
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl unzip \
ldap-utils tini libvips
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get install -y nodejs
RUN curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.14
ENV PATH="/root/.bun/bin:$PATH"
WORKDIR /akkounts
COPY ["Gemfile", "Gemfile.lock", "package.json", "./"]
COPY ["Gemfile", "Gemfile.lock", "package.json", "bun.lock", "./"]
RUN bundle install
RUN gem install foreman
RUN npm install -g yarn
RUN yarn install
RUN bun install
ENTRYPOINT ["/usr/bin/tini", "--"]
EXPOSE 3000
+6 -8
View File
@@ -4,7 +4,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 8.0'
# Use Puma as the app server
gem 'puma', '~> 4.1'
gem 'puma', '~> 6.6'
# View components
gem "view_component"
# Asset bundler
@@ -19,17 +19,12 @@ gem "turbo-rails"
gem "stimulus-rails"
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.7'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use Active Model has_secure_password
gem 'bcrypt', '~> 3.1'
# Configuration
gem 'dotenv-rails'
# Security
gem 'lockbox'
# Authentication
gem 'warden'
gem 'devise', '~> 4.9.0'
@@ -37,6 +32,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap'
# Utilities
gem 'aasm'
gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3'
@@ -46,6 +42,7 @@ gem 'flipper-active_record'
gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1'
gem 'kramdown'
# HTTP requests
gem 'faraday'
@@ -53,8 +50,8 @@ gem 'down'
gem 'aws-sdk-s3', require: false
# Background/scheduled jobs
gem 'sidekiq', '< 7'
gem 'sidekiq-scheduler'
gem 'solid_queue'
gem "mission_control-jobs"
# Monitoring
gem "sentry-ruby"
@@ -65,6 +62,7 @@ gem 'discourse_api'
gem "lnurl"
gem 'manifique', '~> 1.1.0'
gem 'nostr', '~> 0.6.0'
gem "redis", "~> 5.4"
group :development, :test do
# Use sqlite3 as the database for Active Record
+30 -17
View File
@@ -1,6 +1,8 @@
GEM
remote: https://rubygems.org/
specs:
aasm (5.5.0)
concurrent-ruby (~> 1.0)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
@@ -244,7 +246,6 @@ GEM
rb-inotify (~> 0.9, >= 0.9.10)
lnurl (1.1.1)
bech32 (~> 1.1)
lockbox (2.0.1)
logger (1.7.0)
loofah (2.24.0)
crass (~> 1.0.2)
@@ -265,6 +266,16 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.5)
mission_control-jobs (1.0.2)
actioncable (>= 7.1)
actionpack (>= 7.1)
activejob (>= 7.1)
activerecord (>= 7.1)
importmap-rails (>= 1.2.1)
irb (~> 1.13)
railties (>= 7.1)
stimulus-rails
turbo-rails
multipart-post (2.4.1)
net-http (0.6.0)
uri
@@ -315,7 +326,7 @@ GEM
date
stringio
public_suffix (6.0.1)
puma (4.3.12)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
@@ -375,7 +386,10 @@ GEM
logger
rdoc (6.13.1)
psych (>= 4.0.0)
redis (4.8.1)
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)
io-console (~> 0.5)
@@ -424,8 +438,6 @@ GEM
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
sanitize (7.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
@@ -436,14 +448,6 @@ GEM
sentry-ruby (5.23.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
rack (~> 2.0)
redis (>= 4.5.0, < 5)
sidekiq-scheduler (5.0.6)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
solargraph (0.54.2)
backport (~> 1.2)
benchmark (~> 0.4)
@@ -463,6 +467,13 @@ GEM
tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24)
yard-solargraph (~> 0.1)
solid_queue (1.1.5)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.6.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.6.0-arm64-darwin)
@@ -517,6 +528,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
aasm
aws-sdk-s3
bcrypt (~> 3.1)
capybara
@@ -537,28 +549,29 @@ DEPENDENCIES
image_processing (~> 1.12.2)
importmap-rails
jbuilder (~> 2.7)
kramdown
letter_opener
letter_opener_web
listen (~> 3.2)
lnurl
lockbox
manifique (~> 1.1.0)
mission_control-jobs
net-ldap
nostr (~> 0.6.0)
pagy (~> 6.0, >= 6.0.2)
pg (~> 1.5)
propshaft
puma (~> 4.1)
puma (~> 6.6)
rails (~> 8.0)
rails-controller-testing
rails-settings-cached (~> 2.8.3)
redis (~> 5.4)
rqrcode (~> 2.0)
rspec-rails
sentry-rails
sentry-ruby
sidekiq (< 7)
sidekiq-scheduler
solargraph
solid_queue
sqlite3 (>= 2.1)
stimulus-rails
turbo-rails
+1 -1
View File
@@ -1,2 +1,2 @@
web: bin/rails server -b 0.0.0.0 -p 3000
css: yarn build:css --watch
css: bun run build:css:tailwind --watch
+4 -3
View File
@@ -1,4 +1,4 @@
[![Build Status](https://drone.kosmos.org/api/badges/kosmos/akkounts/status.svg)](https://drone.kosmos.org/kosmos/akkounts)
[![Build Status](https://gitea.kosmos.org/kosmos/akkounts/actions/workflows/ci.yml/badge.svg)](https://gitea.kosmos.org/kosmos/akkounts/actions)
# Akkounts
@@ -36,7 +36,7 @@ run web`._
Installing dependencies:
bundle install
yarn install
bun install
Migrating the local database (after schema changes):
@@ -57,7 +57,7 @@ Running the test suite:
Running the test suite with Docker Compose requires overriding the Rails
environment:
docker-compose run -e "RAILS_ENV=test" web rspec
docker-compose exec -e "RAILS_ENV=test" web rspec
### Docker Compose
@@ -128,6 +128,7 @@ command:
### Front-end
* [Icons](https://feathericons.com)
* [Tailwind CSS](https://tailwindcss.com/)
* [Sass](https://sass-lang.com/documentation)
* [Stimulus](https://stimulus.hotwired.dev/handbook/)
@@ -7,7 +7,7 @@
@import "components/buttons";
@import "components/dashboard_services";
@import "components/forms";
@import "components/links";
@import "components/notifications";
@import "components/pagination";
@import "components/rich_text";
@import "components/tables";
@@ -6,6 +6,7 @@
body {
@apply leading-none bg-cover bg-fixed;
background-image: linear-gradient(35deg, rgba(255,0,255,0.2) 0, rgba(13,79,153,0.8) 100%), url('/img/bg-1.jpg');
color: black;
}
body#admin {
@@ -32,6 +33,10 @@
@apply pt-8 sm:pt-12;
}
main section h3:not(:first-child) {
@apply mt-8;
}
main section:first-of-type {
@apply pt-0;
}
@@ -55,4 +60,11 @@
main ul li {
@apply leading-6;
}
main a:not(nav > *) {
@apply text-blue-600;
&:hover { @apply underline; }
&:visited { @apply text-indigo-600; }
&:active { @apply text-red-600; }
}
}
+16 -3
View File
@@ -1,5 +1,15 @@
@layer components {
.btn-text-dark { @apply text-black; }
.btn-text-dark:hover { @apply text-black no-underline; }
.btn-text-dark:visited { @apply text-black; }
.btn-text-dark:active { @apply text-black; }
.btn-text-light { @apply text-white; }
.btn-text-light:hover { @apply text-white no-underline; }
.btn-text-light:visited { @apply text-white; }
.btn-text-light:active { @apply text-white; }
.btn {
@apply btn-text-dark;
@apply inline-block font-semibold rounded-md leading-none cursor-pointer text-center
transition-colors duration-75 focus:outline-none focus:ring-4;
}
@@ -28,17 +38,20 @@
}
.btn-blue {
@apply bg-blue-500 hover:bg-blue-600 text-white
@apply btn-text-light;
@apply bg-blue-500 hover:bg-blue-600
focus:ring-blue-400 focus:ring-opacity-75;
}
.btn-emerald {
@apply bg-emerald-500 hover:bg-emerald-600 text-white
@apply btn-text-light;
@apply bg-emerald-500 hover:bg-emerald-600
focus:ring-emerald-400 focus:ring-opacity-75;
}
.btn-red {
@apply bg-red-600 hover:bg-red-700 text-white
@apply btn-text-light;
@apply bg-red-600 hover:bg-red-700
focus:ring-red-500 focus:ring-opacity-75;
}
@@ -1,8 +0,0 @@
@layer components {
.ks-text-link {
@apply text-blue-600;
&:hover { @apply underline; }
&:visited { @apply text-indigo-600; }
&:active { @apply text-red-600; }
}
}
@@ -34,7 +34,7 @@
.pagy-nav .page a, .page.gap {
@apply bg-white border-gray-300 text-gray-500 hover:bg-gray-100 relative
inline-flex items-center border px-4 py-2 text-sm font-medium
focus:z-20;
no-underline focus:z-20;
}
.pagy-nav .page.active {
@@ -0,0 +1,30 @@
@layer base {
main {
.rich-text {
ul {
@apply list-disc list-inside;
li {
@apply my-2;
}
}
ol {
@apply list-decimal list-inside;
li {
@apply my-2;
}
}
li > ul,
li > ol {
@apply ps-6 my-1;
}
hr {
@apply my-8;
}
}
}
}
@@ -2,6 +2,8 @@
module AppCatalog
class WebAppIconComponent < ViewComponent::Base
include ApplicationHelper
def initialize(web_app:)
if web_app&.icon&.attached?
@image_url = image_url_for(web_app.icon)
@@ -9,13 +11,5 @@ module AppCatalog
@image_url = image_url_for(web_app.apple_touch_icon)
end
end
def image_url_for(attachment)
if Setting.s3_enabled?
s3_image_url(attachment)
else
Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
end
end
end
end
@@ -1,4 +1,4 @@
<%= link_to @href, class: @class, data: {
<%= link_to @href, class: @class, target: @target, data: {
'dropdown-target': "menuItem",
'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
} do %>
+2 -1
View File
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class DropdownLinkComponent < ViewComponent::Base
def initialize(href:, separator: false, add_class: nil)
def initialize(href:, open_in_new_tab: false, separator: false, add_class: nil)
@href = href
@target = open_in_new_tab ? "_blank" : nil
@class = class_str(separator, add_class)
end
@@ -0,0 +1,30 @@
<div class="inline-block text-left" data-controller="modal" data-action="keydown.esc->modal#close">
<button class="btn-md btn-outline text-red-600" data-action="click->modal#open" title="Edit">
<%= content || "Edit" %>
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= form_with model: [:admin, @editable_content],
html: { autocomplete: "off" } do |form| %>
<%= form.hidden_field :redirect_to, value: @redirect_to %>
<p class="mb-2">
<%= form.label :content, @editable_content.key.capitalize, class: 'font-bold' %>
</p>
<% if @editable_content.rich_text %>
<p>
<%= form.textarea :content, class: "md:w-[56rem] md:h-[28rem]" %>
</p>
<p class="text-right">
<%= form.submit "Save", class: "ml-2 btn-md btn-blue" %>
</p>
<% else %>
<p class="">
<%= form.text_field :content, class: "w-80" %>
</p>
<p>
<%= form.submit "Save", class: "btn-md btn-blue w-full" %>
</p>
<% end %>
<% end %>
<% end %>
</div>
@@ -0,0 +1,6 @@
class EditContentButtonComponent < ViewComponent::Base
def initialize(context:, key:, rich_text: false, redirect_to: nil)
@editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
@redirect_to = redirect_to
end
end
@@ -0,0 +1,11 @@
<% if @editable_content.has_content? %>
<% if @editable_content.rich_text %>
<div class="rich-text">
<%= helpers.markdown_to_html @editable_content.content %>
</div>
<% else %>
<%= @editable_content.content %>
<% end %>
<% else %>
<%= @default %>
<% end %>
@@ -0,0 +1,6 @@
class EditableContentComponent < ViewComponent::Base
def initialize(context:, key:, rich_text: false, default: nil)
@editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
@default = default
end
end
@@ -1,4 +1,4 @@
<main class="w-full max-w-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<main class="w-full max-w-xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %>
</div>
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="md:min-h-[50vh] bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %>
</div>
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow">
<div class="md:min-h-[50vh] divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<aside class="py-6 sm:py-8 lg:col-span-3">
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="md:min-h-[50vh] bg-white rounded-lg shadow">
<div class="px-6 sm:px-12 pt-2 sm:pt-4">
<%= render partial: @tabnav_partial %>
+2 -1
View File
@@ -12,7 +12,8 @@
</div>
<%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new(
href: launch_app_services_storage_rs_auth_url(@auth)
href: launch_app_services_storage_rs_auth_url(@auth),
open_in_new_tab: true
) do %>
Launch app
<% end %>
+1 -1
View File
@@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base
def class_names_icon(path)
if @active
"text-teal-500 group-hover:text-teal-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
"text-teal-600 group-hover:text-teal-600 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
elsif @disabled
"text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
else
+14 -3
View File
@@ -4,11 +4,22 @@ class Admin::DonationsController < Admin::BaseController
# GET /donations
def index
@pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
@username = params[:username].presence
pending_scope = Donation.incomplete.joins(:user).order('paid_at desc')
completed_scope = Donation.completed.joins(:user).order('paid_at desc')
if @username
pending_scope = pending_scope.where(users: { cn: @username })
completed_scope = completed_scope.where(users: { cn: @username })
end
@pending_donations = pending_scope
@pagy, @donations = pagy(completed_scope)
@stats = {
overall_sats: @donations.sum("amount_sats"),
donor_count: Donation.completed.count(:user_id)
overall_sats: completed_scope.sum("amount_sats"),
donor_count: completed_scope.distinct.count(:user_id)
}
end
@@ -0,0 +1,45 @@
class Admin::EditableContentsController < Admin::BaseController
before_action :set_content, only: [:show, :edit, :update]
before_action :set_current_section, only: [:index, :show, :edit]
def index
@path = params[:path].presence
scope = EditableContent.order(path: :asc)
scope = scope.where(path: @path) if @path
@pagy, @contents = pagy(scope)
end
def show
end
def edit
end
def update
return_to = params[:editable_content][:redirect_to].presence
if @editable_content.update(content_params)
if return_to
redirect_to return_to
else
render status: :ok
end
else
render :edit, status: :unprocessable_entity
end
end
private
def set_content
@editable_content = EditableContent.find(params[:id])
end
def content_params
params.require(:editable_content).permit(:path, :key, :lang, :content, :rich_text)
end
def set_current_section
@current_section = :content
end
end
@@ -1,12 +1,28 @@
class Admin::InvitationsController < Admin::BaseController
before_action :set_current_section
def index
@current_section = :invitations
@pagy, @invitations_used = pagy(Invitation.used.order('used_at desc'))
@username = params[:username].presence
accepted_scope = Invitation.used.order('used_at desc')
unused_scope = Invitation.unused
if @username
accepted_scope = accepted_scope.joins(:user).where(users: { cn: @username })
unused_scope = unused_scope.joins(:user).where(users: { cn: @username })
end
@pagy, @invitations_used = pagy(accepted_scope)
@stats = {
available: Invitation.unused.count,
accepted: @invitations_used.length,
users_with_referrals: Invitation.used.distinct.count(:user_id)
available: unused_scope.count,
accepted: accepted_scope.count,
users_with_referrals: accepted_scope.distinct.count(:user_id)
}
end
private
def set_current_section
@current_section = :invitations
end
end
@@ -4,7 +4,7 @@ class Admin::LightningController < Admin::BaseController
def index
@current_section = :lightning
@users = User.pluck(:cn, :ou, :ln_account)
@users = User.pluck(:cn, :ou, :lndhub_username)
@accounts = LndhubAccount.with_balances.order(balance: :desc).to_a
@ln = {}
@@ -0,0 +1,23 @@
class Admin::Settings::MembershipController < Admin::SettingsController
def show
end
def update
update_settings
redirect_to admin_settings_membership_path, flash: {
success: "Settings saved"
}
end
private
def setting_params
params.require(:setting).permit([
:member_status_contributor,
:member_status_sustainer,
:user_index_show_contributors,
:user_index_show_sustainers
])
end
end
+22 -6
View File
@@ -4,25 +4,41 @@ class Admin::UsersController < Admin::BaseController
# GET /admin/users
def index
ldap = LdapService.new
@ou = Setting.primary_domain
@pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc))
ldap = LdapService.new
ou = Setting.primary_domain
@show_contributors = Setting.user_index_show_contributors
@show_sustainers = Setting.user_index_show_sustainers
@contributors = ldap.search_users(:memberStatus, :contributor, :cn) if @show_contributors
@sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) if @show_sustainers
@admins = ldap.search_users(:admin, true, :cn)
@username = params[:username].presence
users_scope = User.where(ou: ou).order(cn: :asc)
users_scope = users_scope.where("cn LIKE ?", "%#{User.sanitize_sql_like(@username.downcase)}%") if @username
@pagy, @users = pagy(users_scope)
@stats = {
users_confirmed: User.where(ou: @ou).confirmed.count,
users_pending: User.where(ou: @ou).pending.count
users_confirmed: User.where(ou: ou).confirmed.count,
users_pending: User.where(ou: ou).pending.count
}
@stats[:users_contributing] = @contributors.size if @show_contributors
@stats[:users_paying] = @sustainers.size if @show_sustainers
end
# GET /admin/users/:username
def show
@invitees = @user.invitees
@recent_invitees = @user.invitees.order(created_at: :desc).limit(5)
@more_invitees = (@invitees - @recent_invitees).count
if Setting.lndhub_admin_enabled?
@lndhub_user = @user.lndhub_user
end
@services_enabled = @user.services_enabled
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
@ldap_avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
end
# POST /admin/users/:username/invitations
+1 -1
View File
@@ -39,7 +39,7 @@ class ApplicationController < ActionController::Base
end
def after_sign_in_path_for(user)
session[:user_return_to] || root_path
session.delete(:user_return_to) || root_path
end
def lndhub_authenticate(options={})
+27
View File
@@ -0,0 +1,27 @@
class AvatarsController < ApplicationController
def show
if user = User.find_by(cn: params[:username])
http_status :not_found and return unless user.avatar.attached?
sha256_hash = params[:hash]
format = params[:format]&.to_sym || :png
# size = params[:size]&.to_sym || :original
unless user.avatar.filename.to_s == "#{sha256_hash}.#{format}"
http_status :not_found and return
end
# TODO See note for avatar_variant in user model
# blob = if size == :original
# user.avatar.blob
# else
# user.avatar_variant(size: size)&.blob
# end
data = user.avatar.blob.download
send_data data, type: "image/#{format}", disposition: "inline"
else
http_status :not_found
end
end
end
@@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController
def index
@current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc')
@donations_pending = current_user.donations.processing.order('created_at desc')
@donations_processing = current_user.donations.processing.order('created_at desc')
if Setting.lndhub_enabled?
begin
@@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController
case invoice["status"]
when "Settled"
@donation.paid_at = DateTime.now
@donation.payment_status = "settled"
@donation.save!
@donation.complete!
flash_message = { success: "Thank you!" }
when "Processing"
unless @donation.processing?
@donation.payment_status = "processing"
@donation.save!
@donation.start_processing!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end
@@ -0,0 +1,16 @@
class Contributions::OtherController < ApplicationController
before_action :authenticate_user!
before_action :set_content_editing
# GET /contributions/other
def index
@current_section = :contributions
end
private
def set_content_editing
return unless params[:edit] && current_user.is_admin?
@edit_content = true
end
end
@@ -1,8 +0,0 @@
class Contributions::ProjectsController < ApplicationController
before_action :authenticate_user!
# GET /contributions
def index
@current_section = :contributions
end
end
@@ -55,7 +55,12 @@ class Devise::PasswordsController < DeviseController
protected
def after_resetting_password_path_for(resource)
Devise.sign_in_after_reset_password ? after_sign_in_path_for(resource) : new_session_path(resource_name)
session.delete(:user_return_to)
if Devise.sign_in_after_reset_password
root_path
else
new_session_path(resource_name)
end
end
# The path used after sending reset password instructions
@@ -8,6 +8,9 @@ class Discourse::SsoController < ApplicationController
sso.email = current_user.email
sso.username = current_user.cn
sso.name = current_user.display_name
if current_user.avatar.attached?
sso.avatar_url = helpers.image_url_for(current_user.avatar)
end
sso.admin = current_user.is_admin?
sso.sso_secret = secret
+1 -1
View File
@@ -37,7 +37,7 @@ class LnurlpayController < ApplicationController
pubkey: Setting.lndhub_public_key,
customData: [{
customKey: "696969",
customValue: @user.ln_account
customValue: @user.lndhub_username
}]
}
end
+9
View File
@@ -0,0 +1,9 @@
class PagesController < ApplicationController
def privacy
@current_section = :privacy
end
def tos
@current_section = :tos
end
end
+5 -4
View File
@@ -1,4 +1,5 @@
class Rs::OauthController < ApplicationController
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
@@ -16,8 +17,6 @@ class Rs::OauthController < ApplicationController
["In 1 month", 1.month.from_now],
["In 1 day", 1.day.from_now]]
http_status :bad_request and return unless @redirect_uri.present?
unless current_user == @user
sign_out :user
@@ -64,8 +63,6 @@ class Rs::OauthController < ApplicationController
state = params[:state].presence
expire_at = params[:expire_at].presence
http_status :bad_request and return unless redirect_uri.present?
if permissions.empty?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return
@@ -97,6 +94,10 @@ class Rs::OauthController < ApplicationController
private
def assert_redirect_uri
http_status :bad_request unless params[:redirect_uri].present?
end
def require_signed_in_with_username
unless user_signed_in?
session[:user_return_to] = request.url
@@ -9,7 +9,7 @@ class Services::LightningController < ApplicationController
before_action :lndhub_fetch_balance
def index
@wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
@wallet_setup_url = "lndhub://#{current_user.lndhub_username}:#{current_user.lndhub_password}@#{ENV['LNDHUB_PUBLIC_URL']}"
end
def transactions
@@ -23,7 +23,11 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
launch_url = "#{@auth.launch_url}#remotestorage=#{current_user.address}&access_token=#{@auth.token}"
user_address = Rails.env.development? ?
"#{current_user.cn}@localhost:3000" :
current_user.address
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
redirect_to launch_url, allow_other_host: true
end
+34 -3
View File
@@ -25,7 +25,7 @@ class SettingsController < ApplicationController
def update
@user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar]
@user.avatar_new = user_params[:avatar_new]
@user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save
@@ -34,7 +34,12 @@ class SettingsController < ApplicationController
end
if @user.avatar_new.present?
LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new)
if store_user_avatar
UserManager::UpdateAvatar.call(user: @user)
else
@validation_errors = @user.errors
render :show, status: :unprocessable_entity and return
end
end
if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
@@ -162,7 +167,7 @@ class SettingsController < ApplicationController
def user_params
params.require(:user).permit(
:display_name, :avatar, :pgp_pubkey,
:display_name, :avatar_new, :pgp_pubkey,
preferences: UserPreferences.pref_keys
)
end
@@ -184,4 +189,30 @@ class SettingsController < ApplicationController
salt = BCrypt::Engine.generate_salt
BCrypt::Engine.hash_secret(password, salt)
end
def store_user_avatar
io = @user.avatar_new.tempfile
img_data = UserManager::ProcessAvatar.call(io: io)
if img_data.blank?
@user.errors.add(:avatar, "failed to process file")
false
end
tempfile = Tempfile.create
tempfile.binmode
tempfile.write(img_data)
tempfile.rewind
hash = Digest::SHA256.hexdigest(img_data)
ext = @user.avatar_new.content_type == "image/png" ? "png" : "jpg"
filename = "#{hash}.#{ext}"
if filename == @user.avatar.filename.to_s
@user.errors.add(:avatar, "must be a new file/picture")
false
else
key = "users/#{@user.cn}/avatars/#{filename}"
@user.avatar.attach io: tempfile, key: key, filename: filename
@user.save
end
end
end
@@ -1,8 +1,16 @@
class WebKeyDirectoryController < WellKnownController
before_action :allow_cross_origin_requests
# /.well-known/openpgpkey/hu/:hashed_username(.txt)
# /.well-known/openpgpkey/hu/:hashed_username(.txt)?l=username
def show
if params[:l].blank?
# TODO store hashed username in db if existing implementations trigger
# this a lot
msg = "WKD request with \"l\" param omitted for hu: #{params[:hashed_username]}"
Sentry.capture_message(msg) if Setting.sentry_enabled?
http_status :bad_request and return
end
@user = User.find_by(cn: params[:l].downcase)
if @user.nil? ||
+14
View File
@@ -33,6 +33,10 @@ class WebfingerController < WellKnownController
links: []
}
if @user.avatar.attached?
jrd[:links] += avatar_link
end
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
# https://docs.joinmastodon.org/spec/webfinger/
jrd[:aliases] += mastodon_aliases
@@ -47,6 +51,16 @@ class WebfingerController < WellKnownController
jrd
end
def avatar_link
[
{
rel: "http://webfinger.net/rel/avatar",
type: @user.avatar.content_type,
href: helpers.image_url_for(@user.avatar)
}
]
end
def mastodon_aliases
[
"#{Setting.mastodon_public_url}/@#{@user.cn}",
+1 -1
View File
@@ -5,7 +5,7 @@ class WebhooksController < ApplicationController
before_action :process_payload
def lndhub
@user = User.find_by!(ln_account: @payload[:user_login])
@user = User.find_by!(lndhub_username: @payload[:user_login])
if @zap = @user.zaps.find_by(payment_request: @payload[:payment_request])
settled_at = Time.parse(@payload[:settled_at])
+21
View File
@@ -14,4 +14,25 @@ module ApplicationHelper
def badge(text, color)
tag.span text, class: "inline-flex items-center rounded-full bg-#{color}-100 px-2.5 py-0.5 text-xs font-medium text-#{color}-800"
end
def markdown_to_html(string)
raw Kramdown::Document.new(string, {
input: "GFM"
}).to_html
end
def image_url_for(attachment)
return s3_image_url(attachment) if Setting.s3_enabled?
if attachment.record.is_a?(User) && attachment.name == "avatar"
hash, format = attachment.blob.filename.to_s.split(".", 2)
user_avatar_url(
username: attachment.record.cn,
hash: hash,
format: format
)
else
Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
end
end
end
+1 -3
View File
@@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob
case invoice["status"]
when "Settled"
donation.paid_at = DateTime.now
donation.payment_status = "settled"
donation.save!
donation.complete!
NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed
+1 -1
View File
@@ -4,7 +4,7 @@ class CreateLdapUserJob < ApplicationJob
def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
attr = {
objectclass: ["top", "account", "person", "extensibleObject"],
objectclass: ["top", "account", "person", "inetOrgPerson", "extensibleObject"],
cn: username,
sn: username,
uid: username,
+3 -3
View File
@@ -2,12 +2,12 @@ class CreateLndhubAccountJob < ApplicationJob
queue_as :default
def perform(user)
return if user.ln_account.present? && user.ln_password.present?
return if user.lndhub_username.present? && user.lndhub_password.present?
lndhub = LndhubV2.new
credentials = lndhub.create_account
user.update! ln_account: credentials["login"],
ln_password: credentials["password"]
user.update! lndhub_username: credentials["login"],
lndhub_password: credentials["password"]
end
end
@@ -3,8 +3,6 @@ class RemoteStorageExpireAuthorizationJob < ApplicationJob
def perform(rs_auth_id)
rs_auth = RemoteStorageAuthorization.find rs_auth_id
return unless rs_auth.expire_at.nil? || rs_auth.expire_at <= DateTime.now
rs_auth.destroy!
end
end
+1 -16
View File
@@ -2,21 +2,6 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
return unless inviter.service_enabled?(:ejabberd) &&
invitee.service_enabled?(:ejabberd) &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new
ejabberd.add_rosteritem({
"localuser": invitee.cn, "localhost": invitee.ou,
"user": inviter.cn, "host": inviter.ou,
"nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
ejabberd.add_rosteritem({
"localuser": inviter.cn, "localhost": inviter.ou,
"user": invitee.cn, "host": invitee.ou,
"nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
EjabberdManager::ExchangeContacts.call(inviter:, invitee:)
end
end
+1 -2
View File
@@ -2,7 +2,6 @@ class XmppSendMessageJob < ApplicationJob
queue_as :default
def perform(payload)
ejabberd = EjabberdApiClient.new
ejabberd.send_message payload
EjabberdManager::SendMessage.call(payload:)
end
end
+7
View File
@@ -0,0 +1,7 @@
class XmppSetAvatarJob < ApplicationJob
queue_as :default
def perform(user:, overwrite: false)
EjabberdManager::SetAvatar.call(user:, overwrite:)
end
end
+1 -20
View File
@@ -2,25 +2,6 @@ class XmppSetDefaultBookmarksJob < ApplicationJob
queue_as :default
def perform(user)
return unless Setting.xmpp_default_rooms.any?
@user = user
ejabberd = EjabberdApiClient.new
ejabberd.private_set user, storage_content
end
def storage_content
bookmarks = ""
Setting.xmpp_default_rooms.each do |r|
bookmarks << conference_element(
jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
autojoin: Setting.xmpp_autojoin_default_rooms
)
end
"<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
end
def conference_element(jid:, name:, autojoin: false, nick:)
"<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
EjabberdManager::SetDefaultBookmarks.call(user:)
end
end
@@ -11,6 +11,9 @@ module Settings
field :mastodon_address_domain, type: :string,
default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain
field :mastodon_auth_token, type: :string,
default: ENV["MASTODON_AUTH_TOKEN"].presence
end
end
end
@@ -0,0 +1,18 @@
module Settings
module MembershipSettings
extend ActiveSupport::Concern
included do
field :member_status_contributor, type: :string,
default: "Contributor"
field :member_status_sustainer, type: :string,
default: "Sustainer"
# Admin panel
field :user_index_show_contributors, type: :boolean,
default: false
field :user_index_show_sustainers, type: :boolean,
default: false
end
end
end
+38 -5
View File
@@ -1,22 +1,42 @@
class Donation < ApplicationRecord
# Relations
include AASM
belongs_to :user
# Validations
validates_presence_of :user
validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true,
inclusion: { in: %w[ processing settled ] }
inclusion: { in: %w[ pending processing settled ] }
validates_presence_of :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] }
#Scopes
scope :pending, -> { where(payment_status: "pending") }
scope :processing, -> { where(payment_status: "processing") }
scope :completed, -> { where(payment_status: "settled") }
scope :completed, -> { where(payment_status: "settled") }
scope :incomplete, -> { where.not(payment_status: "settled") }
aasm column: :payment_status do
state :pending, initial: true
state :processing
state :settled
event :start_processing do
transitions from: :pending, to: :processing
end
event :complete do
transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status]
transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status]
end
end
def pending?
payment_status == "pending"
end
def processing?
payment_status == "processing"
@@ -25,4 +45,17 @@ class Donation < ApplicationRecord
def completed?
payment_status == "settled"
end
private
def set_paid_at
update paid_at: DateTime.now if paid_at.nil?
end
def set_sustainer_status
user.add_member_status :sustainer
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error("Failed to set memberStatus: #{e.message}")
end
end
+12
View File
@@ -0,0 +1,12 @@
class EditableContent < ApplicationRecord
validates :key, presence: true,
uniqueness: { scope: :context }
def has_content?
content.present?
end
def is_empty?
content.blank?
end
end
+1 -1
View File
@@ -6,7 +6,7 @@ class LndhubUser < LndhubBase
foreign_key: "user_id"
belongs_to :user, class_name: "User",
primary_key: "ln_account",
primary_key: "lndhub_username",
foreign_key: "login"
def balance
+13 -5
View File
@@ -69,11 +69,19 @@ class RemoteStorageAuthorization < ApplicationRecord
end
def remove_token_expiry_job
queue = Sidekiq::Queue.new(RemoteStorageExpireAuthorizationJob.queue_name)
queue.each do |job|
next unless job.display_class == "RemoteStorageExpireAuthorizationJob"
job.delete if job.display_args == [id]
end
job_class = RemoteStorageExpireAuthorizationJob
job_args = [id]
query = SolidQueue::Job.where(class_name: job_class.to_s)
case ActiveRecord::Base.connection.adapter_name.downcase
when /sqlite/
query.where("json_extract(arguments, '$.arguments') = ?", job_args.to_json)
when /postgres/
query.where("CAST(arguments AS jsonb)->>'arguments' = ?", job_args.to_json)
else
raise "Unsupported database adapter"
end.destroy_all
end
def find_or_create_web_app
+1
View File
@@ -16,6 +16,7 @@ class Setting < RailsSettings::Base
include Settings::LightningNetworkSettings
include Settings::MastodonSettings
include Settings::MediaWikiSettings
include Settings::MembershipSettings
include Settings::NostrSettings
include Settings::OpenCollectiveSettings
include Settings::RemoteStorageSettings
+76 -23
View File
@@ -4,8 +4,8 @@ class User < ApplicationRecord
include EmailValidatable
attr_accessor :current_password
attr_accessor :avatar_new
attr_accessor :display_name
attr_accessor :avatar_new
attr_accessor :pgp_pubkey
serialize :preferences, coder: UserPreferences
@@ -23,10 +23,16 @@ class User < ApplicationRecord
has_many :zaps
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
primary_key: "ln_account", foreign_key: "login"
primary_key: "lndhub_username", foreign_key: "login"
has_many :accounts, through: :lndhub_user
#
# Attachments
#
has_one_attached :avatar
#
# Validations
#
@@ -50,6 +56,7 @@ class User < ApplicationRecord
validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
if: -> { defined?(@display_name) }
validate :acceptable_avatar
validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
@@ -66,7 +73,7 @@ class User < ApplicationRecord
# Encrypted database columns
#
has_encrypted :ln_login, :ln_password
encrypts :lndhub_password
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
@@ -77,6 +84,10 @@ class User < ApplicationRecord
:timeoutable,
:rememberable
#
# Methods
#
def ldap_before_save
self.email = Devise::LDAP::Adapter.get_ldap_param(self.cn, "mail").first
self.ou = dn.split(',')
@@ -120,11 +131,11 @@ class User < ApplicationRecord
end
def is_admin?
admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
!!admin.first
else
false
end
@admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
!!admin.first
else
false
end
end
def address
@@ -152,13 +163,41 @@ class User < ApplicationRecord
def ldap_entry(reload: false)
return @ldap_entry if defined?(@ldap_entry) && !reload
@ldap_entry = ldap.fetch_users(uid: self.cn, ou: self.ou).first
@ldap_entry = ldap.fetch_users(cn: self.cn).first
end
def add_to_ldap_array(attr_key, ldap_attr, value)
current_entries = ldap_entry[attr_key.to_sym] || []
new_entries = Array(value).map(&:to_s)
entries = (current_entries + new_entries).uniq.sort
ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
end
def remove_from_ldap_array(attr_key, ldap_attr, value)
current_entries = ldap_entry[attr_key.to_sym] || []
entries_to_remove = Array(value).map(&:to_s)
entries = (current_entries - entries_to_remove).uniq.sort
ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
end
def display_name
@display_name ||= ldap_entry[:display_name]
end
# TODO Variant keys are currently broken for some reason
# (They use the same key as the main blob, when it should be
# "/variants/#{key)"
# def avatar_variant(size: :medium)
# dimensions = case size
# when :large then [400, 400]
# when :medium then [256, 256]
# when :small then [64, 64]
# else [256, 256]
# end
# format = avatar.content_type == "image/png" ? :png : :jpeg
# avatar.variant(resize_to_fill: dimensions, format: format)
# end
def nostr_pubkey
@nostr_pubkey ||= ldap_entry[:nostr_key]
end
@@ -186,10 +225,6 @@ class User < ApplicationRecord
ZBase32.encode(Digest::SHA1.digest(cn))
end
def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
end
def services_enabled
ldap_entry[:services_enabled] || []
end
@@ -199,21 +234,39 @@ class User < ApplicationRecord
end
def enable_service(service)
current_services = services_enabled
new_services = Array(service).map(&:to_s)
services = (current_services + new_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
add_to_ldap_array :services_enabled, :serviceEnabled, service
ldap_entry(reload: true)[:services_enabled]
end
def disable_service(service)
current_services = services_enabled
disabled_services = Array(service).map(&:to_s)
services = (current_services - disabled_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
remove_from_ldap_array :services_enabled, :serviceEnabled, service
ldap_entry(reload: true)[:services_enabled]
end
def disable_all_services
ldap.delete_attribute(dn,:service)
ldap.delete_attribute(dn, :serviceEnabled)
end
def member_status
ldap_entry[:member_status] || []
end
def add_member_status(status)
add_to_ldap_array :member_status, :memberStatus, status
ldap_entry(reload: true)[:member_status]
end
def remove_member_status(status)
remove_from_ldap_array :member_status, :memberStatus, status
ldap_entry(reload: true)[:member_status]
end
def is_contributing_member?
member_status.map(&:to_sym).include?(:contributor)
end
def is_paying_member?
member_status.map(&:to_sym).include?(:sustainer)
end
private
@@ -227,7 +280,7 @@ class User < ApplicationRecord
return unless avatar_new.present?
if avatar_new.size > 1.megabyte
errors.add(:avatar, "file size is too large")
errors.add(:avatar, "must be less than 1MB file size")
end
acceptable_types = ["image/jpeg", "image/png"]
+14 -28
View File
@@ -1,36 +1,22 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcpayManagerService < ApplicationService
class BtcpayManagerService < RestApiService
private
def base_url
@base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
end
def base_url
@base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
end
def auth_token
@auth_token ||= Setting.btcpay_auth_token
end
def auth_token
@auth_token ||= Setting.btcpay_auth_token
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
}
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
JSON.parse(res.body)
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
JSON.parse(res.body)
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
}
end
end
-29
View File
@@ -1,29 +0,0 @@
class EjabberdApiClient
def initialize
@base_url = Setting.ejabberd_api_url
end
def post(endpoint, payload)
res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json,
"Content-Type" => "application/json")
if res.status != 200
Rails.logger.error "[ejabberd] API request failed:"
Rails.logger.error res.body
#TODO Send custom event to Sentry
end
end
def add_rosteritem(payload)
post "add_rosteritem", payload
end
def send_message(payload)
post "send_message", payload
end
def private_set(user, content)
payload = { user: user.cn, host: user.ou, element: content }
post "private_set", payload
end
end
@@ -0,0 +1,25 @@
module EjabberdManager
class ExchangeContacts < EjabberdManagerService
def initialize(inviter:, invitee:)
@inviter = inviter
@invitee = invitee
end
def call
return unless @inviter.service_enabled?(:ejabberd) &&
@invitee.service_enabled?(:ejabberd) &&
@inviter.preferences[:xmpp_exchange_contacts_with_invitees]
add_rosteritem({
"localuser": @invitee.cn, "localhost": @invitee.ou,
"user": @inviter.cn, "host": @inviter.ou,
"nick": @inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
add_rosteritem({
"localuser": @inviter.cn, "localhost": @inviter.ou,
"user": @invitee.cn, "host": @invitee.ou,
"nick": @invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
end
end
end
@@ -0,0 +1,25 @@
module EjabberdManager
class GetAvatar < EjabberdManagerService
def initialize(user:)
@user = user
end
def call
res = get_vcard2 @user, "PHOTO", "BINVAL"
if res.status == 200
# VCARD PHOTO/BINVAL prop exists
img_base64 = JSON.parse(res.body)["content"]
ct_res = get_vcard2 @user, "PHOTO", "TYPE"
content_type = JSON.parse(ct_res.body)["content"]
{ content_type:, img_base64: }
elsif res.status == 400
# VCARD or PHOTO/BINVAL prop does not exist
nil
else
# Unexpected error, let job fail
raise res.inspect
end
end
end
end
@@ -0,0 +1,11 @@
module EjabberdManager
class SendMessage < EjabberdManagerService
def initialize(payload:)
@payload = payload
end
def call
send_message @payload
end
end
end
@@ -0,0 +1,80 @@
require 'digest'
require "image_processing/vips"
module EjabberdManager
class SetAvatar < EjabberdManagerService
def initialize(user:, overwrite: false)
@user = user
@overwrite = overwrite
end
def call
unless @overwrite
current_avatar = EjabberdManager::GetAvatar.call(user: @user)
Rails.logger.info { "User #{@user.cn} already has an avatar set" }
return if current_avatar.present?
end
Rails.logger.debug { "Setting XMPP avatar for user #{@user.cn}" }
stanzas = build_xep0084_stanzas
stanzas.each do |stanza|
payload = { from: @user.address, to: @user.address, stanza: stanza }
res = send_stanza payload
raise res.inspect if res.status != 200
end
end
end
private
def process_avatar
@user.avatar.blob.open do |file|
processed = ImageProcessing::Vips
.source(file)
.resize_to_fill(256, 256)
.convert("png")
.call
processed.read
end
end
# See https://xmpp.org/extensions/xep-0084.html
def build_xep0084_stanzas
img_data = process_avatar
sha1_hash = Digest::SHA1.hexdigest(img_data)
base64_data = Base64.strict_encode64(img_data)
[
"""
<iq type='set' from='#{@user.address}' id='avatar-data-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:data'>
<item id='#{sha1_hash}'>
<data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>
</item>
</publish>
</pubsub>
</iq>
""".strip,
"""
<iq type='set' from='#{@user.address}' id='avatar-metadata-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:metadata'>
<item id='#{sha1_hash}'>
<metadata xmlns='urn:xmpp:avatar:metadata'>
<info bytes='#{img_data.size}'
id='#{sha1_hash}'
height='256'
type='image/png'
width='256'/>
</metadata>
</item>
</publish>
</pubsub>
</iq>
""".strip,
]
end
end
@@ -0,0 +1,31 @@
module EjabberdManager
class SetDefaultBookmarks < EjabberdManagerService
def initialize(user:)
@user = user
end
def call
return unless Setting.xmpp_default_rooms.any?
private_set @user, storage_content
end
private
def storage_content
bookmarks = ""
Setting.xmpp_default_rooms.each do |r|
bookmarks << conference_element(
jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
autojoin: Setting.xmpp_autojoin_default_rooms
)
end
"<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
end
def conference_element(jid:, name:, autojoin: false, nick:)
"<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
end
end
end
+55
View File
@@ -0,0 +1,55 @@
class EjabberdManagerService < RestApiService
private
def base_url
@base_url ||= Setting.ejabberd_api_url
end
def headers
{ "Content-Type" => "application/json" }
end
def parse_responses?
false
end
#
# API endpoints
#
def add_rosteritem(payload)
post "add_rosteritem", payload
end
def send_message(payload)
post "send_message", payload
end
def send_stanza(payload)
post "send_stanza", payload
end
def get_vcard2(user, name, subname)
payload = {
user: user.cn, host: user.ou,
name: name, subname: subname
}
post "get_vcard2", payload
end
def private_get(user, element_name, namespace)
payload = {
user: user.cn, host: user.ou,
element: element_name, ns: namespace
}
post "private_get", payload
end
def private_set(user, content)
payload = {
user: user.cn, host: user.ou,
element: content
}
post "private_set", payload
end
end
+3 -3
View File
@@ -5,12 +5,12 @@ module LdapManager
end
def call
treebase = ldap_config["base"]
treebase = ldap_config["base"]
attributes = %w{ jpegPhoto }
filter = Net::LDAP::Filter.eq("cn", @cn)
filter = Net::LDAP::Filter.eq("cn", @cn)
entry = client.search(base: treebase, filter: filter, attributes: attributes).first
entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
entry[:jpegPhoto].present? ? entry.jpegPhoto.first : nil
end
end
end
+28 -13
View File
@@ -2,26 +2,41 @@ require "image_processing/vips"
module LdapManager
class UpdateAvatar < LdapManagerService
def initialize(dn:, file:)
@dn = dn
@img_data = process(file)
def initialize(user:)
@user = user
@dn = user.dn
end
def call
replace_attribute @dn, :jpegPhoto, @img_data
unless @user.avatar.attached?
Rails.logger.error { "Cannot store empty jpegPhoto for user #{@user.cn}" }
return false
end
img_data = @user.avatar.blob.download
jpg_data = process_avatar
Rails.logger.debug { "Storing new jpegPhoto for user #{@user.cn} in LDAP" }
result = replace_attribute(@dn, :jpegPhoto, jpg_data)
result == 0
end
private
def process(file)
processed = ImageProcessing::Vips
.resize_to_fill(512, 512)
.source(file)
.convert("jpeg")
.saver(strip: true)
.call
Base64.strict_encode64 processed.read
def process_avatar
@user.avatar.blob.open do |file|
processed = ImageProcessing::Vips
.source(file)
.resize_to_fill(256, 256)
.convert("jpeg")
.saver(strip: true)
.call
processed.read
end
rescue Vips::Error => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error { "Image processing failed for LDAP avatar: #{e.message}" }
nil
end
end
end
@@ -6,7 +6,11 @@ module LdapManager
end
def call
replace_attribute @dn, :displayName, @display_name
if @display_name.present?
replace_attribute @dn, :displayName, @display_name
else
delete_attribute @dn, :displayName
end
end
end
end
+19 -10
View File
@@ -50,19 +50,17 @@ class LdapService < ApplicationService
end
def fetch_users(args={})
if args[:ou]
treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
else
treebase = ldap_config["base"]
end
attributes = %w[
dn cn uid mail displayName admin serviceEnabled
dn cn uid mail displayName admin serviceEnabled memberStatus
mailRoutingAddress mailpassword nostrKey pgpKey
]
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
filter = Net::LDAP::Filter.eq('objectClass', 'person') &
Net::LDAP::Filter.eq("cn", args[:cn] || "*")
entries = client.search(base: treebase, filter: filter, attributes: attributes)
entries = client.search(
base: ldap_config["base"], filter: filter,
attributes: attributes
)
entries.sort_by! { |e| e.cn[0] }
entries = entries.collect do |e|
{
@@ -71,6 +69,7 @@ class LdapService < ApplicationService
display_name: e.try(:displayName) ? e.displayName.first : nil,
admin: e.try(:admin) ? 'admin' : nil,
services_enabled: e.try(:serviceEnabled),
member_status: e.try(:memberStatus),
email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword),
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
@@ -79,10 +78,20 @@ class LdapService < ApplicationService
end
end
def search_users(search_attr, value, return_attr)
filter = Net::LDAP::Filter.eq('objectClass', 'person') &
Net::LDAP::Filter.eq(search_attr.to_s, value.to_s) &
Net::LDAP::Filter.present('cn')
entries = client.search(
base: ldap_config["base"], filter: filter,
attributes: [return_attr]
)
entries.map { |entry| entry[return_attr].first }.compact
end
def fetch_organizations
attributes = %w{dn ou description}
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
# filter = Net::LDAP::Filter.eq("objectClass", "*")
treebase = "cn=users,#{ldap_suffix}"
entries = client.search(base: treebase, filter: filter, attributes: attributes)
+4 -1
View File
@@ -33,7 +33,10 @@ class Lndhub < ApplicationService
end
def authenticate(user)
credentials = post "auth?type=auth", { login: user.ln_account, password: user.ln_password }
credentials = post "auth?type=auth", {
login: user.lndhub_username,
password: user.lndhub_password
}
self.auth_token = credentials["access_token"]
self.auth_token
end
@@ -0,0 +1,12 @@
module MastodonManager
class FetchUser < MastodonManagerService
def initialize(mastodon_id:)
@mastodon_id = mastodon_id
end
def call
user = get "v1/admin/accounts/#{@mastodon_id}"
user.with_indifferent_access
end
end
end
@@ -0,0 +1,14 @@
module MastodonManager
class FindUser < MastodonManagerService
def initialize(username:)
@username = username
end
def call
users = get "v2/admin/accounts?username=#{@username}&origin=local"
users = users.map { |u| u.with_indifferent_access }
# Results may contain partial matches
users.find { |u| u.dig(:username).downcase == @username.downcase }
end
end
end
@@ -0,0 +1,64 @@
module MastodonManager
class SyncAccountProfiles < MastodonManagerService
def initialize(direction: "down", overwrite: false, user: nil)
@direction = direction
@overwrite = overwrite
@user = user
if @direction != "down"
raise NotImplementedError
end
end
def call
if @user
Rails.logger.debug { "Syncing account profile for user #{@user.cn} (direction: #{@direction}, overwrite: #{@overwrite})"}
users = User.where(cn: @user.cn)
else
Rails.logger.debug { "Syncing account profiles (direction: #{@direction}, overwrite: #{@overwrite})"}
users = User
end
users.find_each do |user|
if user.mastodon_id.blank?
mastodon_user = MastodonManager::FindUser.call username: user.cn
if mastodon_user
Rails.logger.debug { "Setting mastodon_id for user #{user.cn}" }
user.update! mastodon_id: mastodon_user.dig(:account, :id).to_i
else
Rails.logger.debug { "No Mastodon user found for username #{user.cn}" }
next
end
end
next if user.avatar.attached? && user.display_name.present?
unless mastodon_user
Rails.logger.debug { "Fetching Mastodon account with ID #{user.mastodon_id} for #{user.cn}" }
mastodon_user = MastodonManager::FetchUser.call mastodon_id: user.mastodon_id
end
if user.display_name.blank?
if mastodon_display_name = mastodon_user.dig(:account, :display_name)
Rails.logger.debug { "Setting display name for user #{user.cn} from Mastodon" }
LdapManager::UpdateDisplayName.call(
dn: user.dn, display_name: mastodon_display_name
)
end
end
if !user.avatar.attached?
if avatar_url = mastodon_user.dig(:account, :avatar_static)
Rails.logger.debug { "Importing Mastodon avatar for user #{user.cn}" }
UserManager::ImportRemoteAvatar.call(
user: user, avatar_url: avatar_url
)
end
end
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error e
end
end
end
end
+22
View File
@@ -0,0 +1,22 @@
#
# API Docs: https://docs.joinmastodon.org/methods/
#
class MastodonManagerService < RestApiService
private
def base_url
@base_url ||= "#{Setting.mastodon_public_url}/api"
end
def auth_token
@auth_token ||= Setting.mastodon_auth_token
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "Bearer #{auth_token}"
}
end
end
+29
View File
@@ -0,0 +1,29 @@
class RestApiService < ApplicationService
private
def base_url
raise NotImplementedError
end
def headers
raise NotImplementedError
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def parse_responses?
true
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
parse_responses? ? JSON.parse(res.body) : res
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
parse_responses? ? JSON.parse(res.body) : res
end
end
@@ -0,0 +1,42 @@
module UserManager
class ImportRemoteAvatar < UserManagerService
def initialize(user:, avatar_url:)
@user = user
@avatar_url = avatar_url
end
def call
if import_remote_avatar
UserManager::UpdateAvatar.call(user: @user)
end
end
private
def import_remote_avatar
tempfile = Down.download(@avatar_url)
content_type = tempfile.content_type
unless %w[image/jpeg image/png].include?(content_type)
Rails.logger.warn { "Wrong content type of remote avatar for user #{user.cn}: '#{content_type}'" }
return false
end
img_data = UserManager::ProcessAvatar.call(io: tempfile)
tempfile = Tempfile.create
tempfile.binmode
tempfile.write(img_data)
tempfile.rewind
hash = Digest::SHA256.hexdigest(img_data)
ext = content_type == "image/png" ? "png" : "jpg"
filename = "#{hash}.#{ext}"
key = "users/#{@user.cn}/avatars/#{filename}"
@user.avatar.attach io: tempfile, key: key, filename: filename
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.warn "Importing remote avatar failed: \"#{e.message}\""
false
end
end
end
@@ -0,0 +1,21 @@
module UserManager
class ProcessAvatar < UserManagerService
def initialize(io:)
@io = io
end
def call
processed = ImageProcessing::Vips
.source(@io)
.resize_to_fill(400, 400)
.saver(strip: true)
.call
@io.rewind
processed.read
rescue Vips::Error => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.warn { "Image processing failed for avatar: #{e.message}" }
nil
end
end
end
@@ -0,0 +1,16 @@
module UserManager
class UpdateAvatar < UserManagerService
def initialize(user:)
@user = user
end
def call
LdapManager::UpdateAvatar.call(user: @user)
if Setting.ejabberd_enabled?
return if Rails.env.development?
XmppSetAvatarJob.perform_later(user: @user)
end
end
end
end
@@ -0,0 +1,11 @@
<%= form_with url: path, method: :get, local: true, class: "flex gap-1" do %>
<%= text_field_tag :username, @username, placeholder: 'Filter by username' %>
<%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %>
<%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
<% end %>
<% if @username %>
<%= link_to path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %>
<%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %>
<% end %>
<% end %>
<% end %>
@@ -38,8 +38,7 @@
<tr>
<td><%= web_app.name %></td>
<td><%= link_to web_app.url, web_app.url,
target: "_blank", rel: "nofollow noopener",
class: "ks-text-link" %></td>
target: "_blank", rel: "nofollow noopener" %></td>
<td class="hidden md:table-cell"><%= web_app.remote_storage_authorizations.count %></td>
<td class="hidden md:table-cell">
<span title="<%= web_app.created_at %>" class="cursor-help">
+34
View File
@@ -0,0 +1,34 @@
<table class="divided">
<thead>
<tr>
<th>User</th>
<th class="text-right">Sats</th>
<th class="text-right">Fiat Amount</th>
<th class="pl-2">Public name</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<% donations.each do |donation| %>
<tr>
<td><%= link_to donation.user.cn, admin_user_path(donation.user.cn) %></td>
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
<td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
<td class="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : donation.created_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
<td class="text-right">
<%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% if defined?(pagy) %>
<div class="mt-8">
<%== pagy_nav pagy %>
</div>
<% end %>
+21 -34
View File
@@ -5,7 +5,7 @@
<%= render QuickstatsContainerComponent.new do %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: 'Overall',
title: 'Received',
value: @stats[:overall_sats],
unit: 'sats'
) %>
@@ -19,41 +19,28 @@
</section>
<section>
<% if @donations.any? %>
<h3>Recent Donations</h3>
<table class="divided mb-8">
<thead>
<tr>
<th>User</th>
<th class="text-right">Sats</th>
<th class="text-right">Fiat Amount</th>
<th class="pl-2">Public name</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<% @donations.each do |donation| %>
<tr>
<td><%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %></td>
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
<td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
<td class="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
<td class="text-right">
<%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav @pagy %>
<%= render partial: "admin/username_search_form",
locals: { path: admin_donations_path } %>
</section>
<% if @pending_donations.present? %>
<section>
<h3>Pending</h3>
<%= render partial: "admin/donations/list", locals: {
donations: @pending_donations
} %>
</section>
<% end %>
<section>
<% if @donations.present? %>
<h3>Received</h3>
<%= render partial: "admin/donations/list", locals: {
donations: @donations, pagy: @pagy
} %>
<% else %>
<p>
No donations yet.
No donations received yet.
</p>
<% end %>
</section>
+10 -2
View File
@@ -6,7 +6,7 @@
<tbody>
<tr>
<th>User</th>
<td><%= link_to @donation.user.cn, admin_user_path(@donation.user.cn), class: 'ks-text-link' %></td>
<td><%= link_to @donation.user.cn, admin_user_path(@donation.user.cn) %></td>
</tr>
<tr>
<th>Donation Method</th>
@@ -25,7 +25,15 @@
<td><%= @donation.public_name %></td>
</tr>
<tr>
<th>Date</th>
<th>Payment status</th>
<td><%= @donation.payment_status %></td>
</tr>
<tr>
<th>Created at</th>
<td><%= @donation.created_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
</tr>
<tr>
<th>Paid at</th>
<td><%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
</tr>
</tbody>
+9 -3
View File
@@ -21,9 +21,15 @@
) %>
<% end %>
</section>
<section>
<%= render partial: "admin/username_search_form",
locals: { path: admin_invitations_path } %>
</section>
<% if @invitations_used.any? %>
<section>
<h3>Recently Accepted</h3>
<h3>Accepted</h3>
<table class="divided mb-8">
<thead>
<tr>
@@ -38,8 +44,8 @@
<tr>
<td class="overflow-ellipsis font-mono"><%= invitation.token %></td>
<td><%= invitation.used_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
<td><%= link_to invitation.user.cn, admin_user_path(invitation.user.cn), class: "ks-text-link" %></td>
<td><%= link_to invitation.invitee.cn, admin_user_path(invitation.invitee.cn), class: "ks-text-link" %></td>
<td><%= link_to invitation.user.cn, admin_user_path(invitation.user.cn) %></td>
<td><%= link_to invitation.invitee.cn, admin_user_path(invitation.invitee.cn) %></td>
</tr>
<% end %>
</tbody>
+1 -1
View File
@@ -36,7 +36,7 @@
</td>
<td>
<% if user = @users.find{ |u| u[2] == account.login } %>
<%= link_to user[0], admin_user_path(user[0]), class: "ks-text-link" %>
<%= link_to user[0], admin_user_path(user[0]) %>
<% end %>
</td>
<td><%= number_with_delimiter account.balance.to_i.to_s %></td>
@@ -0,0 +1,53 @@
<%= render HeaderComponent.new(title: "Settings") %>
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
<%= form_for(Setting.new, url: admin_settings_membership_path, method: :put) do |f| %>
<section>
<h3>Membership</h3>
<% if @errors && @errors.any? %>
<%= render partial: "admin/settings/errors", locals: { errors: @errors } %>
<% end %>
<ul role="list">
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :member_status_contributor,
title: "Status name for contributing users",
description: "A contributing member of your organization/group"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :member_status_sustainer,
title: "Status name for paying users",
description: "A paying/donating member or customer"
) %>
</ul>
</section>
<section>
<h3>Admin panel</h3>
<ul role="list">
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :user_index_show_contributors,
enabled: Setting.user_index_show_contributors?,
title: "Show #{Setting.member_status_contributor.downcase} status in user list",
description: "Can slow down page rendering with large user base"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :user_index_show_sustainers,
enabled: Setting.user_index_show_sustainers?,
title: "Show #{Setting.member_status_sustainer.downcase} status in user list",
description: "Can slow down page rendering with large user base"
) %>
</ul>
</section>
<section>
<p class="pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
</section>
<% end %>
<% end %>

Some files were not shown because too many files have changed in this diff Show More