Author SHA1 Message Date
raucao 12a1570c7d Add Blossom server URL to global settings 2026-08-11 14:03:38 -06:00
raucao 4c20e8d7e0 Remove dead relay from default discovery relays 2026-08-11 13:29:39 -06:00
raucao d2152d0ca4 WIP Edit nostr profile 2026-08-11 13:26:38 -06:00
raucao cbde625a5d Reorder things in UI 2026-08-11 13:26:38 -06:00
raucao 2fd71d64aa Add timeout for fetching latest event 2026-08-11 13:26:38 -06:00
raucao 22d29b642c Conditional 2026-08-11 13:26:38 -06:00
raucao c11534ab81 Formatting 2026-08-11 13:26:38 -06:00
raucao 6577fd0251 Remove client-side nostr discovery 2026-08-11 13:26:37 -06:00
raucao 949d158b15 Use rails logger 2026-08-11 13:26:37 -06:00
raucao ea4767acc9 WIP Refactor stuff 2026-08-11 13:26:37 -06:00
raucao 0dcd07c6f7 WIP Render nostr profile and relay status with Ruby 2026-08-11 13:26:37 -06:00
raucao 4f22471aa0 WIP fetch relays and profile with ruby 2026-08-11 13:26:36 -06:00
raucao 3e6971b2d1 Fetch user relays, synchronously 2026-08-11 13:26:36 -06:00
raucao 7ebc4d3833 Add global setting and default for discovery relays 2026-08-11 13:26:36 -06:00
raucao 82385913b2 WIP Nostr onboarding 2026-08-11 13:26:36 -06:00
raucao 79e78623e0 Add nostrify lib 2026-08-11 13:26:32 -06:00
raucao 4536b6505c 0.11.1
CI / Test (push) Successful in 33s
2026-08-10 21:53:49 -06:00
raucao 8453c38f41 Merge pull request 'Fix avatars failing to be set on ejabberd accounts' (#247) from bugfix/246-xmpp_avatar_publishing into master
CI / Test (push) Successful in 31s
Reviewed-on: #247
2026-08-11 03:37:28 +00:00
raucao 1957df3bbd Fix avatars failing to be set on XMPP accounts
CI / Test (pull_request) Successful in 32s
Release Drafter / Update release notes draft (pull_request) Successful in 3s
There's a stray `end` that prematurely closes the class
2026-08-10 21:35:28 -06:00
raucao d196c161df Force-shutdown Puma if necessary
CI / Test (push) Successful in 31s
Do it before the systemd service's timeout is reached
2026-08-10 17:24:08 -06:00
raucao 44b5a0b36b Merge pull request 'Upgrade Rails to 8.1' (#245) from chore/upgrade_rails into master
CI / Test (push) Successful in 32s
Reviewed-on: #245
2026-08-10 23:11:28 +00:00
raucao 5ebf496096 Upgrade Rails to 8.1
CI / Test (pull_request) Successful in 1m4s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-10 17:02:37 -06:00
raucao 7a63c87b03 Fix deprecation warning 2026-08-10 17:02:20 -06:00
raucao 6cc35f07eb Remove more obsolete Devise code
The initializer was a no-op override after our Devise upgrade
2026-08-10 17:01:36 -06:00
raucao 89423ecf84 Merge pull request 'Upgrade Devise to 5.0, remove obsolete workarounds' (#244) from chore/upgrade_devise into master
CI / Test (push) Successful in 31s
Reviewed-on: #244
2026-08-10 22:40:47 +00:00
raucao c79c421b89 Fix Devise Turbo Stream responses for flash messages
CI / Test (pull_request) Successful in 33s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
Wire Users::DeviseController as Devise parent controller with the
Turbo-aware responder, so failed form submissions (login, password
reset) render correctly as HTML with 422 status instead of
unprocessable turbo-stream content that Turbo silently drops.

Also fixes a NameError in the responder (rendering_options ->
error_rendering_options) and removes the dead TurboController.
2026-08-10 16:37:01 -06:00
raucao 8a8d407bcc Add missing whitespace between Devise (sign-in) errors and forms
CI / Test (pull_request) Successful in 32s
2026-08-10 15:17:43 -06:00
raucao 045df94c29 Upgrade Devise to 5.0, remove workarounds
CI / Test (pull_request) Successful in 52s
2026-08-10 15:15:27 -06:00
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
480 changed files with 3494 additions and 2112 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
...
+1
View File
@@ -87,6 +87,7 @@
# NOSTR_PRIVATE_KEY='123456abcdef...' # NOSTR_PRIVATE_KEY='123456abcdef...'
# NOSTR_PUBLIC_KEY='123456abcdef...' # NOSTR_PUBLIC_KEY='123456abcdef...'
# NOSTR_RELAY_URL='wss://nostr.kosmos.org' # NOSTR_RELAY_URL='wss://nostr.kosmos.org'
# NOSTR_BLOSSOM_SERVER_URL='https://blossom.kosmos.org'
# RS_STORAGE_URL='https://storage.kosmos.org' # RS_STORAGE_URL='https://storage.kosmos.org'
# RS_REDIS_URL='redis://localhost:6379/2' # RS_REDIS_URL='redis://localhost:6379/2'
+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: steps:
- name: Release Drafter - name: Release Drafter
uses: https://github.com/raucao/gitea-release-drafter@dev 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
/public/packs-test /public/packs-test
/node_modules /node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
bun.lock
# Ignore local dotenv config file # Ignore local dotenv config file
.env .env
+1 -1
View File
@@ -1 +1 @@
3.3.0 3.3.12
+73
View File
@@ -0,0 +1,73 @@
# 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/`.
## 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 # syntax=docker/dockerfile:1
FROM ruby:3.3.4 FROM ruby:3.3.12
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \ RUN apt-get update -qq && apt-get install -y --no-install-recommends curl unzip \
ldap-utils tini libvips ldap-utils tini libvips
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - RUN curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.14
RUN apt-get update && apt-get install -y nodejs ENV PATH="/root/.bun/bin:$PATH"
WORKDIR /akkounts WORKDIR /akkounts
COPY ["Gemfile", "Gemfile.lock", "package.json", "./"] COPY ["Gemfile", "Gemfile.lock", "package.json", "bun.lock", "./"]
RUN bundle install RUN bundle install
RUN gem install foreman RUN gem install foreman
RUN npm install -g yarn RUN bun install
RUN yarn install
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
EXPOSE 3000 EXPOSE 3000
+7 -5
View File
@@ -2,9 +2,9 @@ source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" } git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 8.0' gem 'rails', '~> 8.1'
# Use Puma as the app server # Use Puma as the app server
gem 'puma', '~> 6.6' gem 'puma', '~> 8.0'
# View components # View components
gem "view_component" gem "view_component"
# Asset bundler # Asset bundler
@@ -18,7 +18,7 @@ gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails" gem "stimulus-rails"
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.7' gem 'jbuilder', '~> 2.15'
# Use Active Model has_secure_password # Use Active Model has_secure_password
gem 'bcrypt', '~> 3.1' gem 'bcrypt', '~> 3.1'
@@ -27,11 +27,12 @@ gem 'dotenv-rails'
# Authentication # Authentication
gem 'warden' gem 'warden'
gem 'devise', '~> 4.9.0' gem 'devise', '~> 5.0'
gem 'devise_ldap_authenticatable' gem 'devise_ldap_authenticatable'
gem 'net-ldap' gem 'net-ldap'
# Utilities # Utilities
gem 'aasm'
gem "image_processing", "~> 1.12.2" gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0" gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3' gem 'rails-settings-cached', '~> 2.8.3'
@@ -41,6 +42,7 @@ gem 'flipper-active_record'
gem 'flipper-ui' gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24' gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1' gem 'zbase32', '~> 0.1.1'
gem 'kramdown'
# HTTP requests # HTTP requests
gem 'faraday' gem 'faraday'
@@ -71,7 +73,7 @@ end
group :development do group :development do
# Access an interactive console on exception pages or by calling 'console' anywhere in the code. # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
gem 'web-console', '~> 4.2' gem 'web-console', '~> 4.3'
gem 'listen', '~> 3.2' gem 'listen', '~> 3.2'
gem 'letter_opener' gem 'letter_opener'
gem 'letter_opener_web' gem 'letter_opener_web'
+153 -140
View File
@@ -1,29 +1,33 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) aasm (5.5.0)
actionpack (= 8.0.2) concurrent-ruby (~> 1.0)
activesupport (= 8.0.2) action_text-trix (2.1.19)
railties
actioncable (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (8.1.3.1)
actionpack (= 8.0.2) actionpack (= 8.1.3.1)
activejob (= 8.0.2) activejob (= 8.1.3.1)
activerecord (= 8.0.2) activerecord (= 8.1.3.1)
activestorage (= 8.0.2) activestorage (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2) actionmailer (8.1.3.1)
actionpack (= 8.0.2) actionpack (= 8.1.3.1)
actionview (= 8.0.2) actionview (= 8.1.3.1)
activejob (= 8.0.2) activejob (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (8.1.3.1)
actionview (= 8.0.2) actionview (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -31,42 +35,43 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2) actiontext (8.1.3.1)
actionpack (= 8.0.2) action_text-trix (~> 2.1.15)
activerecord (= 8.0.2) actionpack (= 8.1.3.1)
activestorage (= 8.0.2) activerecord (= 8.1.3.1)
activesupport (= 8.0.2) activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2) activejob (8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
activerecord (8.0.2) activerecord (8.1.3.1)
activemodel (= 8.0.2) activemodel (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (8.1.3.1)
actionpack (= 8.0.2) actionpack (= 8.1.3.1)
activejob (= 8.0.2) activejob (= 8.1.3.1)
activerecord (= 8.0.2) activerecord (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (8.1.3.1)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
@@ -94,12 +99,12 @@ GEM
aws-sigv4 (1.11.0) aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
backport (1.2.0) backport (1.2.0)
base64 (0.2.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.22)
bech32 (1.5.0) bech32 (1.5.0)
thor (>= 1.1.0) thor (>= 1.1.0)
benchmark (0.4.0) benchmark (0.5.0)
bigdecimal (3.1.9) bigdecimal (4.1.2)
bindex (0.8.1) bindex (0.8.1)
bip-schnorr (0.7.0) bip-schnorr (0.7.0)
ecdsa_ext (~> 0.5.0) ecdsa_ext (~> 0.5.0)
@@ -116,12 +121,12 @@ GEM
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.8)
connection_pool (2.5.2) connection_pool (3.0.2)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.7)
cssbundling-rails (1.4.3) cssbundling-rails (1.4.3)
railties (>= 6.0.0) railties (>= 6.0.0)
database_cleaner (2.1.0) database_cleaner (2.1.0)
@@ -130,17 +135,17 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) date (3.5.1)
devise (4.9.4) devise (5.0.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 7.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise_ldap_authenticatable (0.8.7) devise_ldap_authenticatable (0.8.7)
devise (>= 3.4.1) devise (>= 3.4.1)
net-ldap (>= 0.16.0) net-ldap (>= 0.16.0)
diff-lcs (1.6.1) diff-lcs (1.6.2)
discourse_api (2.0.1) discourse_api (2.0.1)
faraday (~> 2.7) faraday (~> 2.7)
faraday-follow_redirects faraday-follow_redirects
@@ -152,12 +157,13 @@ GEM
railties (>= 6.1) railties (>= 6.1)
down (5.4.2) down (5.4.2)
addressable (~> 2.8) addressable (~> 2.8)
drb (2.2.1) drb (2.2.3)
ecdsa (1.2.0) ecdsa (1.2.0)
ecdsa_ext (0.5.1) ecdsa_ext (0.5.1)
ecdsa (~> 1.2.0) ecdsa (~> 1.2.0)
erb (6.0.7)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.4.1)
tzinfo tzinfo
event_emitter (0.2.6) event_emitter (0.2.6)
eventmachine (1.2.7) eventmachine (1.2.7)
@@ -194,34 +200,35 @@ GEM
rack-protection (>= 1.5.3, < 5.0.0) rack-protection (>= 1.5.3, < 5.0.0)
rack-session (>= 1.0.2, < 3.0.0) rack-session (>= 1.0.2, < 3.0.0)
sanitize (< 8) sanitize (< 8)
fugit (1.11.1) fugit (1.13.0)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
gpgme (2.0.24) gpgme (2.0.24)
mini_portile2 (~> 2.7) mini_portile2 (~> 2.7)
hashdiff (1.1.2) hashdiff (1.1.2)
i18n (1.14.7) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.1.0) importmap-rails (2.2.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.0) io-console (0.9.2)
irb (1.15.2) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jaro_winkler (1.6.0) jaro_winkler (1.6.0)
jbuilder (2.13.0) jbuilder (2.15.1)
actionview (>= 5.0.0) actionview (>= 7.0.0)
activesupport (>= 5.0.0) activesupport (>= 7.0.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.11.3) json (2.21.2)
kramdown (2.5.1) kramdown (2.5.1)
rexml (>= 3.3.9) rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
@@ -245,10 +252,11 @@ GEM
lnurl (1.1.1) lnurl (1.1.1)
bech32 (~> 1.1) bech32 (~> 1.1)
logger (1.7.0) logger (1.7.0)
loofah (2.24.0) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.1)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
@@ -257,14 +265,15 @@ GEM
faraday (~> 2.9.0) faraday (~> 2.9.0)
faraday-follow_redirects (= 0.3.0) faraday-follow_redirects (= 0.3.0)
nokogiri (~> 1.16.0) nokogiri (~> 1.16.0)
marcel (1.0.4) marcel (1.2.1)
matrix (0.4.2) matrix (0.4.2)
method_source (1.1.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (6.0.6)
mission_control-jobs (1.0.2) drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1) actioncable (>= 7.1)
actionpack (>= 7.1) actionpack (>= 7.1)
activejob (>= 7.1) activejob (>= 7.1)
@@ -277,7 +286,7 @@ GEM
multipart-post (2.4.1) multipart-post (2.4.1)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.5.7) net-imap (0.6.6)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@@ -287,7 +296,7 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.16.8) nokogiri (1.16.8)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
@@ -311,24 +320,23 @@ GEM
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.9) pg (1.5.9)
pp (0.6.2) pp (0.6.4)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.4.0) prism (1.9.0)
propshaft (1.1.0) propshaft (1.3.2)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0) psych (5.4.0)
psych (5.2.3)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.6.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.5.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.13) rack (2.2.23)
rack-protection (3.2.0) rack-protection (3.2.0)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
@@ -339,61 +347,64 @@ GEM
rackup (1.0.1) rackup (1.0.1)
rack (< 3) rack (< 3)
webrick webrick
rails (8.0.2) rails (8.1.3.1)
actioncable (= 8.0.2) actioncable (= 8.1.3.1)
actionmailbox (= 8.0.2) actionmailbox (= 8.1.3.1)
actionmailer (= 8.0.2) actionmailer (= 8.1.3.1)
actionpack (= 8.0.2) actionpack (= 8.1.3.1)
actiontext (= 8.0.2) actiontext (= 8.1.3.1)
actionview (= 8.0.2) actionview (= 8.1.3.1)
activejob (= 8.0.2) activejob (= 8.1.3.1)
activemodel (= 8.0.2) activemodel (= 8.1.3.1)
activerecord (= 8.0.2) activerecord (= 8.1.3.1)
activestorage (= 8.0.2) activestorage (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.1.3.1)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.1)
loofah (~> 2.21) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-settings-cached (2.8.3) rails-settings-cached (2.8.3)
activerecord (>= 5.0.0) activerecord (>= 5.0.0)
railties (>= 5.0.0) railties (>= 5.0.0)
railties (8.0.2) railties (8.1.3.1)
actionpack (= 8.0.2) actionpack (= 8.1.3.1)
activesupport (= 8.0.2) activesupport (= 8.1.3.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.4.2)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.11.1) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
rbs (3.9.2) rbs (3.9.2)
logger logger
rdoc (6.13.1) rdoc (7.2.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort
redis (5.4.0) redis (5.4.0)
redis-client (>= 0.22.0) redis-client (>= 0.22.0)
redis-client (0.24.0) redis-client (0.24.0)
connection_pool connection_pool
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.1) reline (0.7.0)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.1.1) responders (3.2.0)
actionpack (>= 5.2) actionpack (>= 7.0)
railties (>= 5.2) railties (>= 7.0)
reverse_markdown (3.0.0) reverse_markdown (3.0.0)
nokogiri nokogiri
rexml (3.4.1) rexml (3.4.1)
@@ -401,23 +412,23 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
rqrcode_core (1.2.0) rqrcode_core (1.2.0)
rspec-core (3.13.3) rspec-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.3) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.2) rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.1.1) rspec-rails (8.0.4)
actionpack (>= 7.0) actionpack (>= 7.2)
activesupport (>= 7.0) activesupport (>= 7.2)
railties (>= 7.0) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (~> 3.13) rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (~> 3.13) rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (~> 3.13) rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.2) rspec-support (3.13.7)
rubocop (1.75.3) rubocop (1.75.3)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
@@ -465,24 +476,25 @@ GEM
tilt (~> 2.0) tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24) yard (~> 0.9, >= 0.9.24)
yard-solargraph (~> 0.1) yard-solargraph (~> 0.1)
solid_queue (1.1.5) solid_queue (1.6.0)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (~> 1.3.1) thor (>= 1.3.1)
sqlite3 (2.6.0) sqlite3 (2.6.0)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (2.6.0-arm64-darwin) sqlite3 (2.6.0-arm64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu) sqlite3 (2.6.0-x86_64-linux-gnu)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) stringio (3.2.0)
thor (1.3.2) thor (1.5.0)
tilt (2.6.0) tilt (2.6.0)
timeout (0.4.3) timeout (0.6.1)
turbo-rails (2.0.13) tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -490,25 +502,24 @@ GEM
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uri (1.0.3) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
view_component (3.22.0) view_component (4.12.0)
activesupport (>= 5.2.0, < 8.1) actionview (>= 7.1.0)
concurrent-ruby (= 1.3.4) activesupport (>= 7.1.0)
method_source (~> 1.0) concurrent-ruby (~> 1)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
webmock (3.25.1) webmock (3.25.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) webrick (1.9.2)
websocket-driver (0.7.7) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@@ -518,7 +529,7 @@ GEM
yard-solargraph (0.1.0) yard-solargraph (0.1.0)
yard (~> 0.9) yard (~> 0.9)
zbase32 (0.1.1) zbase32 (0.1.1)
zeitwerk (2.7.2) zeitwerk (2.8.3)
PLATFORMS PLATFORMS
arm64-darwin-22 arm64-darwin-22
@@ -526,12 +537,13 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
aasm
aws-sdk-s3 aws-sdk-s3
bcrypt (~> 3.1) bcrypt (~> 3.1)
capybara capybara
cssbundling-rails cssbundling-rails
database_cleaner database_cleaner
devise (~> 4.9.0) devise (~> 5.0)
devise_ldap_authenticatable devise_ldap_authenticatable
discourse_api discourse_api
dotenv-rails dotenv-rails
@@ -545,7 +557,8 @@ DEPENDENCIES
gpgme (~> 2.0.24) gpgme (~> 2.0.24)
image_processing (~> 1.12.2) image_processing (~> 1.12.2)
importmap-rails importmap-rails
jbuilder (~> 2.7) jbuilder (~> 2.15)
kramdown
letter_opener letter_opener
letter_opener_web letter_opener_web
listen (~> 3.2) listen (~> 3.2)
@@ -557,8 +570,8 @@ DEPENDENCIES
pagy (~> 6.0, >= 6.0.2) pagy (~> 6.0, >= 6.0.2)
pg (~> 1.5) pg (~> 1.5)
propshaft propshaft
puma (~> 6.6) puma (~> 8.0)
rails (~> 8.0) rails (~> 8.1)
rails-controller-testing rails-controller-testing
rails-settings-cached (~> 2.8.3) rails-settings-cached (~> 2.8.3)
redis (~> 5.4) redis (~> 5.4)
@@ -574,7 +587,7 @@ DEPENDENCIES
tzinfo-data tzinfo-data
view_component view_component
warden warden
web-console (~> 4.2) web-console (~> 4.3)
webmock webmock
zbase32 (~> 0.1.1) zbase32 (~> 0.1.1)
+1 -1
View File
@@ -1,2 +1,2 @@
web: bin/rails server -b 0.0.0.0 -p 3000 web: bin/rails server -b 0.0.0.0 -p 3000
css: yarn build:css --watch css: bun run build:css:tailwind --watch
+3 -2
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 # Akkounts
@@ -36,7 +36,7 @@ run web`._
Installing dependencies: Installing dependencies:
bundle install bundle install
yarn install bun install
Migrating the local database (after schema changes): Migrating the local database (after schema changes):
@@ -128,6 +128,7 @@ command:
### Front-end ### Front-end
* [Icons](https://feathericons.com)
* [Tailwind CSS](https://tailwindcss.com/) * [Tailwind CSS](https://tailwindcss.com/)
* [Sass](https://sass-lang.com/documentation) * [Sass](https://sass-lang.com/documentation)
* [Stimulus](https://stimulus.hotwired.dev/handbook/) * [Stimulus](https://stimulus.hotwired.dev/handbook/)
@@ -7,7 +7,7 @@
@import "components/buttons"; @import "components/buttons";
@import "components/dashboard_services"; @import "components/dashboard_services";
@import "components/forms"; @import "components/forms";
@import "components/links";
@import "components/notifications"; @import "components/notifications";
@import "components/pagination"; @import "components/pagination";
@import "components/rich_text";
@import "components/tables"; @import "components/tables";
@@ -6,6 +6,7 @@
body { body {
@apply leading-none bg-cover bg-fixed; @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'); 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 { body#admin {
@@ -32,6 +33,10 @@
@apply pt-8 sm:pt-12; @apply pt-8 sm:pt-12;
} }
main section h3:not(:first-child) {
@apply mt-8;
}
main section:first-of-type { main section:first-of-type {
@apply pt-0; @apply pt-0;
} }
@@ -55,4 +60,11 @@
main ul li { main ul li {
@apply leading-6; @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 { @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 { .btn {
@apply btn-text-dark;
@apply inline-block font-semibold rounded-md leading-none cursor-pointer text-center @apply inline-block font-semibold rounded-md leading-none cursor-pointer text-center
transition-colors duration-75 focus:outline-none focus:ring-4; transition-colors duration-75 focus:outline-none focus:ring-4;
} }
@@ -28,17 +38,20 @@
} }
.btn-blue { .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; focus:ring-blue-400 focus:ring-opacity-75;
} }
.btn-emerald { .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; focus:ring-emerald-400 focus:ring-opacity-75;
} }
.btn-red { .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; 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 { .pagy-nav .page a, .page.gap {
@apply bg-white border-gray-300 text-gray-500 hover:bg-gray-100 relative @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 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 { .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 module AppCatalog
class WebAppIconComponent < ViewComponent::Base class WebAppIconComponent < ViewComponent::Base
include ApplicationHelper
def initialize(web_app:) def initialize(web_app:)
if web_app&.icon&.attached? if web_app&.icon&.attached?
@image_url = image_url_for(web_app.icon) @image_url = image_url_for(web_app.icon)
@@ -9,13 +11,5 @@ module AppCatalog
@image_url = image_url_for(web_app.apple_touch_icon) @image_url = image_url_for(web_app.apple_touch_icon)
end end
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
end end
@@ -1,4 +1,4 @@
<%= link_to @href, class: @class, data: { <%= link_to @href, class: @class, target: @target, data: {
'dropdown-target': "menuItem", 'dropdown-target': "menuItem",
'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent" 'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
} do %> } do %>
+2 -1
View File
@@ -1,8 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class DropdownLinkComponent < ViewComponent::Base 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 @href = href
@target = open_in_new_tab ? "_blank" : nil
@class = class_str(separator, add_class) @class = class_str(separator, add_class)
end 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"> <div class="bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %> <%= content %>
</div> </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"> <div class="md:min-h-[50vh] bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %> <%= content %>
</div> </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="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"> <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"> <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="md:min-h-[50vh] bg-white rounded-lg shadow">
<div class="px-6 sm:px-12 pt-2 sm:pt-4"> <div class="px-6 sm:px-12 pt-2 sm:pt-4">
<%= render partial: @tabnav_partial %> <%= render partial: @tabnav_partial %>
+2 -1
View File
@@ -12,7 +12,8 @@
</div> </div>
<%= render DropdownComponent.new do %> <%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new( <%= 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 %> ) do %>
Launch app Launch app
<% end %> <% end %>
@@ -0,0 +1,44 @@
<div class="w-[72vw] md:w-[500px]">
<header class="absolute z-10 h-36 sm:h-44 inset-x-1 top-1 rounded-t
bg-cover bg-center bg-gray-50"
style="background-image: url('<%= @profile["banner"]%>');">
<div class="inline-block z-20 size-28 sm:size-32 ml-4 mt-16 sm:mt-20">
<% if @profile["picture"].present? %>
<img src="<%= @profile["picture"] %>"
class="inline-block size:28 sm:size-32 rounded-full border-2 border-white" />
<% else %>
<span class="inline-block size:28 sm:size-32 overflow-hidden rounded-full border-2 border-white bg-gray-100">
<svg class="size-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
<% end %>
</div>
</header>
<main class="mt-44 sm:mt-52">
<%= form_for(@user, url: setting_path(:nostr), html: { :method => :put }) do |f| %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %>
<%= f.text_field :display_name, value: @display_name, class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:display_name].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:display_name].first %></p>
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Nostr address (NIP-05)") do %>
<%= f.text_field :nip05_address, value: @profile["nip05"], class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:nip05_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:nip05_address].first %></p>
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Ligtning address for Zaps") do %>
<%= f.text_field :lud16_address, value: @profile["lud16"], class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:lud16_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:lud16_address].first %></p>
<% end %>
<% end %>
<% end %>
</main>
<footer>
<%# <%= @profile.inspect %>
<%# <%= @profile_event.inspect %>
</footer>
</div>
@@ -0,0 +1,28 @@
# frozen_string_literal: true
module Settings
class NostrEditProfileComponent < ViewComponent::Base
def initialize(user:, profile_event:)
if profile_event.present?
@user = user
@profile_event = profile_event
@profile = JSON.parse(profile_event["content"])
@display_name = @profile["display_name"] || @profile["displayName"]
if @profile["nip05"].present? && @profile["nip05"] == @user.address
# "Your profile's Nostr address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet"
end
if @profile["lud16"].present? && @profile["lud16"] == @user.address
# "Your profile's Lightning address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet"
end
else
# "We could not find a profile for your public key"
end
end
end
end
@@ -0,0 +1,21 @@
<% @statuses.each do |status| %>
<%= render StatusTextComponent.new(
text: status[:text],
icon_name: status[:icon_name],
icon_color: status[:icon_color]
) %>
<% end %>
<% if @status == 1 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Edit my profile
</button>
</p>
<% elsif @status == 2 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Create my profile
</button>
</p>
<% end %>
@@ -0,0 +1,53 @@
# frozen_string_literal: true
module Settings
class NostrProfileStatusComponent < ViewComponent::Base
def initialize(profile_event:, user_address:)
@statuses = []
if profile_event.present?
profile = JSON.parse(profile_event["content"])
@statuses.push({
text: "You have a public Nostr profile",
icon_name: "check-circle",
icon_color: "emerald-500"
})
if profile["nip05"].present? && profile["nip05"] == user_address
@statuses.push({
text: "Your profile's Nostr address is set to <strong>#{ user_address }</strong>",
icon_name: "check-circle",
icon_color: "emerald-500"
})
else
@statuses.push({
text: "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
if profile["lud16"].present? && profile["lud16"] == user_address
@statuses.push({
text: "Your profile's Lightning address is set to <strong>#{ user_address }</strong>",
icon_name: "check-circle",
icon_color: "emerald-500"
})
else
@statuses.push({
text: "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
else
@statuses.push({
text: "We could not find a profile for your public key",
icon_name: "alert-octagon",
icon_color: "amber-500"
})
end
end
end
end
@@ -0,0 +1,18 @@
<%= render StatusTextComponent.new(
text: @text,
icon_name: @icon_name,
icon_color: @icon_color) %>
<% if @status == 1 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Add the relay to my list
</button>
</p>
<% elsif @status == 2 %>
<p class="mt-8">
<button class="btn-md btn-blue">
Set up default relays
</button>
</p>
<% end %>
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Settings
class NostrRelayStatusComponent < ViewComponent::Base
def initialize(nip65_event:)
if nip65_event.present?
if relay_urls(nip65_event).any? { |r| r.include?("wss://nostr.kosmos.org") }
@text = "You have a relay list, and the Kosmos relay is part of it"
@icon_name = "check-circle"
@icon_color = "emerald-500"
@status = 0
else
@text = "The Kosmos relay is missing from your relay list"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
@status = 1
end
else
@text = "We could not find a relay list for your public key"
@icon_name = "alert-octagon"
@icon_color = "amber-500"
@status = 2
end
end
private
def relay_urls(nip65_event)
nip65_event["tags"].select{ |t| t[0] == "r" }.map{ |t| t[1] }
# @inbox_relay_urls = relay_tags&.select{ |t| t[2] == "read" }&.map{ |t| t[1] }
# @outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
end
end
end
+1 -1
View File
@@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base
def class_names_icon(path) def class_names_icon(path)
if @active 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 elsif @disabled
"text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6" "text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
else else
@@ -0,0 +1,8 @@
<p class="flex gap-x-4 items-center">
<span class="inline-block h-6 w-6 grow-0 text-<%= @icon_color %>">
<%= render "icons/#{@icon_name}" %>
</span>
<span>
<%= raw @text %>
</span>
</p>
+7
View File
@@ -0,0 +1,7 @@
class StatusTextComponent < ViewComponent::Base
def initialize(text:, icon_name:, icon_color:)
@text = text
@icon_name = icon_name
@icon_color = icon_color
end
end
+14 -3
View File
@@ -4,11 +4,22 @@ class Admin::DonationsController < Admin::BaseController
# GET /donations # GET /donations
def index 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 = { @stats = {
overall_sats: @donations.sum("amount_sats"), overall_sats: completed_scope.sum("amount_sats"),
donor_count: Donation.completed.count(:user_id) donor_count: completed_scope.distinct.count(:user_id)
} }
end 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 class Admin::InvitationsController < Admin::BaseController
before_action :set_current_section
def index def index
@current_section = :invitations @username = params[:username].presence
@pagy, @invitations_used = pagy(Invitation.used.order('used_at desc')) 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 = { @stats = {
available: Invitation.unused.count, available: unused_scope.count,
accepted: @invitations_used.length, accepted: accepted_scope.count,
users_with_referrals: Invitation.used.distinct.count(:user_id) users_with_referrals: accepted_scope.distinct.count(:user_id)
} }
end end
private
def set_current_section
@current_section = :invitations
end
end end
@@ -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
+21 -5
View File
@@ -5,24 +5,40 @@ class Admin::UsersController < Admin::BaseController
# GET /admin/users # GET /admin/users
def index def index
ldap = LdapService.new ldap = LdapService.new
@ou = Setting.primary_domain ou = Setting.primary_domain
@pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc)) @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 = { @stats = {
users_confirmed: User.where(ou: @ou).confirmed.count, users_confirmed: User.where(ou: ou).confirmed.count,
users_pending: User.where(ou: @ou).pending.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 end
# GET /admin/users/:username # GET /admin/users/:username
def show 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? if Setting.lndhub_admin_enabled?
@lndhub_user = @user.lndhub_user @lndhub_user = @user.lndhub_user
end end
@services_enabled = @user.services_enabled @services_enabled = @user.services_enabled
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn) @ldap_avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
end end
# POST /admin/users/:username/invitations # POST /admin/users/:username/invitations
+1 -1
View File
@@ -39,7 +39,7 @@ class ApplicationController < ActionController::Base
end end
def after_sign_in_path_for(user) def after_sign_in_path_for(user)
session[:user_return_to] || root_path session.delete(:user_return_to) || root_path
end end
def lndhub_authenticate(options={}) 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 def index
@current_section = :contributions @current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc') @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? if Setting.lndhub_enabled?
begin begin
@@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController
case invoice["status"] case invoice["status"]
when "Settled" when "Settled"
@donation.paid_at = DateTime.now @donation.complete!
@donation.payment_status = "settled"
@donation.save!
flash_message = { success: "Thank you!" } flash_message = { success: "Thank you!" }
when "Processing" when "Processing"
unless @donation.processing? unless @donation.processing?
@donation.payment_status = "processing" @donation.start_processing!
@donation.save!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." } flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation) BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end 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 protected
def after_resetting_password_path_for(resource) 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 end
# The path used after sending reset password instructions # The path used after sending reset password instructions
@@ -8,6 +8,9 @@ class Discourse::SsoController < ApplicationController
sso.email = current_user.email sso.email = current_user.email
sso.username = current_user.cn sso.username = current_user.cn
sso.name = current_user.display_name 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.admin = current_user.is_admin?
sso.sso_secret = secret sso.sso_secret = secret
+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 class Rs::OauthController < ApplicationController
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create before_action :authenticate_user!, only: :create
@@ -16,8 +17,6 @@ class Rs::OauthController < ApplicationController
["In 1 month", 1.month.from_now], ["In 1 month", 1.month.from_now],
["In 1 day", 1.day.from_now]] ["In 1 day", 1.day.from_now]]
http_status :bad_request and return unless @redirect_uri.present?
unless current_user == @user unless current_user == @user
sign_out :user sign_out :user
@@ -64,8 +63,6 @@ class Rs::OauthController < ApplicationController
state = params[:state].presence state = params[:state].presence
expire_at = params[:expire_at].presence expire_at = params[:expire_at].presence
http_status :bad_request and return unless redirect_uri.present?
if permissions.empty? if permissions.empty?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state), redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return allow_other_host: true) and return
@@ -97,6 +94,10 @@ class Rs::OauthController < ApplicationController
private private
def assert_redirect_uri
http_status :bad_request unless params[:redirect_uri].present?
end
def require_signed_in_with_username def require_signed_in_with_username
unless user_signed_in? unless user_signed_in?
session[:user_return_to] = request.url session[:user_return_to] = request.url
@@ -23,7 +23,11 @@ class Services::RsAuthsController < Services::BaseController
end end
def launch_app 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 redirect_to launch_url, allow_other_host: true
end end
+63 -5
View File
@@ -4,8 +4,13 @@ require "bcrypt"
class SettingsController < ApplicationController class SettingsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_main_nav_section before_action :set_main_nav_section
before_action :set_settings_section, only: [:show, :update, :update_email, :reset_email_password] before_action :set_settings_section, only: [
before_action :set_user, only: [:show, :update, :update_email, :reset_email_password] :show, :update, :update_email, :reset_email_password
]
before_action :set_user, only: [
:show, :update, :update_email, :reset_email_password,
:fetch_nostr_user_metadata
]
def index def index
redirect_to setting_path(:profile) redirect_to setting_path(:profile)
@@ -25,7 +30,7 @@ class SettingsController < ApplicationController
def update def update
@user.preferences.merge!(user_params[:preferences] || {}) @user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name] @user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar] @user.avatar_new = user_params[:avatar_new]
@user.pgp_pubkey = user_params[:pgp_pubkey] @user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save if @user.save
@@ -34,7 +39,12 @@ class SettingsController < ApplicationController
end end
if @user.avatar_new.present? 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 end
if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key]) if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
@@ -138,6 +148,28 @@ class SettingsController < ApplicationController
} }
end end
def fetch_nostr_user_metadata
if @user.nostr_pubkey.present?
outbox_relay_urls = nil
# if @nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
# relay_tags = @nip65_event["tags"].select{ |t| t[0] == "r" }
# outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
# end
# @profile = NostrManager::DiscoverUserProfile.call(
# pubkey: @user.nostr_pubkey,
# relays: outbox_relay_urls
# )
@profile = {"content"=>"{\"name\":\"jimmy\",\"picture\":\"https://storage.kosmos.org/jimmy/public/shares/241028-1117-tony.jpg\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
# @profile = {"content"=>"{\"name\":\"jimmy\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
else
@relays, @profile = [nil, nil]
end
render partial: 'nostr_user_metadata'
end
private private
def set_main_nav_section def set_main_nav_section
@@ -162,7 +194,7 @@ class SettingsController < ApplicationController
def user_params def user_params
params.require(:user).permit( params.require(:user).permit(
:display_name, :avatar, :pgp_pubkey, :display_name, :avatar_new, :pgp_pubkey,
preferences: UserPreferences.pref_keys preferences: UserPreferences.pref_keys
) )
end end
@@ -184,4 +216,30 @@ class SettingsController < ApplicationController
salt = BCrypt::Engine.generate_salt salt = BCrypt::Engine.generate_salt
BCrypt::Engine.hash_secret(password, salt) BCrypt::Engine.hash_secret(password, salt)
end 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 end
-18
View File
@@ -1,18 +0,0 @@
class TurboController < ApplicationController
class Responder < ActionController::Responder
def to_turbo_stream
controller.render(options.merge(formats: :html))
rescue ActionView::MissingTemplate => error
if get?
raise error
elsif has_errors? && default_action
render rendering_options.merge(formats: :html, status: :unprocessable_entity)
else
redirect_to navigation_location
end
end
end
self.responder = Responder
respond_to :html, :turbo_stream
end
+1 -1
View File
@@ -6,7 +6,7 @@ class Users::DeviseController < ApplicationController
if get? if get?
raise error raise error
elsif has_errors? && default_action elsif has_errors? && default_action
render rendering_options.merge(formats: :html, status: :unprocessable_entity) render error_rendering_options.merge(formats: :html, status: :unprocessable_entity)
else else
redirect_to navigation_location redirect_to navigation_location
end end
@@ -1,8 +1,16 @@
class WebKeyDirectoryController < WellKnownController class WebKeyDirectoryController < WellKnownController
before_action :allow_cross_origin_requests before_action :allow_cross_origin_requests
# /.well-known/openpgpkey/hu/:hashed_username(.txt) # /.well-known/openpgpkey/hu/:hashed_username(.txt)?l=username
def show 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) @user = User.find_by(cn: params[:l].downcase)
if @user.nil? || if @user.nil? ||
+14
View File
@@ -33,6 +33,10 @@ class WebfingerController < WellKnownController
links: [] links: []
} }
if @user.avatar.attached?
jrd[:links] += avatar_link
end
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon) if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
# https://docs.joinmastodon.org/spec/webfinger/ # https://docs.joinmastodon.org/spec/webfinger/
jrd[:aliases] += mastodon_aliases jrd[:aliases] += mastodon_aliases
@@ -47,6 +51,16 @@ class WebfingerController < WellKnownController
jrd jrd
end 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 def mastodon_aliases
[ [
"#{Setting.mastodon_public_url}/@#{@user.cn}", "#{Setting.mastodon_public_url}/@#{@user.cn}",
+21
View File
@@ -14,4 +14,25 @@ module ApplicationHelper
def badge(text, color) 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" 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 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 end
+1 -3
View File
@@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob
case invoice["status"] case invoice["status"]
when "Settled" when "Settled"
donation.paid_at = DateTime.now donation.complete!
donation.payment_status = "settled"
donation.save!
NotificationMailer.with(user: donation.user) NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed .bitcoin_donation_confirmed
+1 -1
View File
@@ -4,7 +4,7 @@ class CreateLdapUserJob < ApplicationJob
def perform(username:, domain:, email:, hashed_pw:, confirmed: false) def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org" dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
attr = { attr = {
objectclass: ["top", "account", "person", "extensibleObject"], objectclass: ["top", "account", "person", "inetOrgPerson", "extensibleObject"],
cn: username, cn: username,
sn: username, sn: username,
uid: username, uid: username,
+1 -16
View File
@@ -2,21 +2,6 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default queue_as :default
def perform(inviter, invitee) def perform(inviter, invitee)
return unless inviter.service_enabled?(:ejabberd) && EjabberdManager::ExchangeContacts.call(inviter:, invitee:)
invitee.service_enabled?(:ejabberd) &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new
ejabberd.add_rosteritem({
"localuser": invitee.cn, "localhost": invitee.ou,
"user": inviter.cn, "host": inviter.ou,
"nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
ejabberd.add_rosteritem({
"localuser": inviter.cn, "localhost": inviter.ou,
"user": invitee.cn, "host": invitee.ou,
"nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
end end
end end
+1 -2
View File
@@ -2,7 +2,6 @@ class XmppSendMessageJob < ApplicationJob
queue_as :default queue_as :default
def perform(payload) def perform(payload)
ejabberd = EjabberdApiClient.new EjabberdManager::SendMessage.call(payload:)
ejabberd.send_message payload
end end
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 queue_as :default
def perform(user) def perform(user)
return unless Setting.xmpp_default_rooms.any? EjabberdManager::SetDefaultBookmarks.call(user:)
@user = user
ejabberd = EjabberdApiClient.new
ejabberd.private_set user, storage_content
end
def storage_content
bookmarks = ""
Setting.xmpp_default_rooms.each do |r|
bookmarks << conference_element(
jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
autojoin: Setting.xmpp_autojoin_default_rooms
)
end
"<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
end
def conference_element(jid:, name:, autojoin: false, nick:)
"<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
end end
end end
@@ -11,6 +11,9 @@ module Settings
field :mastodon_address_domain, type: :string, field :mastodon_address_domain, type: :string,
default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain
field :mastodon_auth_token, type: :string,
default: ENV["MASTODON_AUTH_TOKEN"].presence
end end
end 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
@@ -18,8 +18,23 @@ module Settings
field :nostr_relay_url, type: :string, field :nostr_relay_url, type: :string,
default: ENV["NOSTR_RELAY_URL"].presence default: ENV["NOSTR_RELAY_URL"].presence
field :nostr_blossom_server_url, type: :string,
default: ENV["NOSTR_BLOSSOM_SERVER_URL"].presence
field :nostr_zaps_relay_limit, type: :integer, field :nostr_zaps_relay_limit, type: :integer,
default: 12 default: 12
field :nostr_discovery_relays, type: :array, default: %w[
wss://nostr.kosmos.org
wss://relay.nostr.band
wss://njump.me
wss://relay.damus.io
]
def self.nostr_relay_url_http
self.nostr_relay_url.gsub(/^ws:/, "http:")
.gsub(/^wss:/, "https:")
end
end end
end end
end end
+37 -4
View File
@@ -1,22 +1,42 @@
class Donation < ApplicationRecord class Donation < ApplicationRecord
# Relations include AASM
belongs_to :user belongs_to :user
# Validations
validates_presence_of :user validates_presence_of :user
validates_presence_of :donation_method, validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] } inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true, 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 :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true, validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] } inclusion: { in: %w[ EUR USD ] }
#Scopes scope :pending, -> { where(payment_status: "pending") }
scope :processing, -> { where(payment_status: "processing") } 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? def processing?
payment_status == "processing" payment_status == "processing"
@@ -25,4 +45,17 @@ class Donation < ApplicationRecord
def completed? def completed?
payment_status == "settled" payment_status == "settled"
end 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 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
+4 -4
View File
@@ -10,18 +10,18 @@ class LndhubUser < LndhubBase
foreign_key: "login" foreign_key: "login"
def balance def balance
accounts.current.first.ledgers.sum("account_ledgers.amount").to_i.abs accounts.current.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
end end
def sum_outgoing def sum_outgoing
accounts.outgoing.first.ledgers.sum("account_ledgers.amount").to_i.abs accounts.outgoing.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
end end
def sum_incoming def sum_incoming
accounts.incoming.first.ledgers.sum("account_ledgers.amount").to_i.abs accounts.incoming.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
end end
def sum_fees def sum_fees
accounts.fees.first.ledgers.sum("account_ledgers.amount").to_i.abs accounts.fees.order(:id).first.ledgers.sum("account_ledgers.amount").to_i.abs
end end
end end
+1
View File
@@ -16,6 +16,7 @@ class Setting < RailsSettings::Base
include Settings::LightningNetworkSettings include Settings::LightningNetworkSettings
include Settings::MastodonSettings include Settings::MastodonSettings
include Settings::MediaWikiSettings include Settings::MediaWikiSettings
include Settings::MembershipSettings
include Settings::NostrSettings include Settings::NostrSettings
include Settings::OpenCollectiveSettings include Settings::OpenCollectiveSettings
include Settings::RemoteStorageSettings include Settings::RemoteStorageSettings
+70 -17
View File
@@ -4,8 +4,8 @@ class User < ApplicationRecord
include EmailValidatable include EmailValidatable
attr_accessor :current_password attr_accessor :current_password
attr_accessor :avatar_new
attr_accessor :display_name attr_accessor :display_name
attr_accessor :avatar_new
attr_accessor :pgp_pubkey attr_accessor :pgp_pubkey
serialize :preferences, coder: UserPreferences serialize :preferences, coder: UserPreferences
@@ -27,6 +27,12 @@ class User < ApplicationRecord
has_many :accounts, through: :lndhub_user has_many :accounts, through: :lndhub_user
#
# Attachments
#
has_one_attached :avatar
# #
# Validations # Validations
# #
@@ -50,6 +56,7 @@ class User < ApplicationRecord
validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true, validates_length_of :display_name, minimum: 3, maximum: 35, allow_blank: true,
if: -> { defined?(@display_name) } if: -> { defined?(@display_name) }
validate :acceptable_avatar validate :acceptable_avatar
validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? } validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
@@ -77,6 +84,10 @@ class User < ApplicationRecord
:timeoutable, :timeoutable,
:rememberable :rememberable
#
# Methods
#
def ldap_before_save def ldap_before_save
self.email = Devise::LDAP::Adapter.get_ldap_param(self.cn, "mail").first self.email = Devise::LDAP::Adapter.get_ldap_param(self.cn, "mail").first
self.ou = dn.split(',') self.ou = dn.split(',')
@@ -120,7 +131,7 @@ class User < ApplicationRecord
end end
def is_admin? def is_admin?
admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin) @admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
!!admin.first !!admin.first
else else
false false
@@ -152,13 +163,41 @@ class User < ApplicationRecord
def ldap_entry(reload: false) def ldap_entry(reload: false)
return @ldap_entry if defined?(@ldap_entry) && !reload 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 end
def display_name def display_name
@display_name ||= ldap_entry[:display_name] @display_name ||= ldap_entry[:display_name]
end 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 def nostr_pubkey
@nostr_pubkey ||= ldap_entry[:nostr_key] @nostr_pubkey ||= ldap_entry[:nostr_key]
end end
@@ -186,10 +225,6 @@ class User < ApplicationRecord
ZBase32.encode(Digest::SHA1.digest(cn)) ZBase32.encode(Digest::SHA1.digest(cn))
end end
def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
end
def services_enabled def services_enabled
ldap_entry[:services_enabled] || [] ldap_entry[:services_enabled] || []
end end
@@ -199,21 +234,39 @@ class User < ApplicationRecord
end end
def enable_service(service) def enable_service(service)
current_services = services_enabled add_to_ldap_array :services_enabled, :serviceEnabled, service
new_services = Array(service).map(&:to_s) ldap_entry(reload: true)[:services_enabled]
services = (current_services + new_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
end end
def disable_service(service) def disable_service(service)
current_services = services_enabled remove_from_ldap_array :services_enabled, :serviceEnabled, service
disabled_services = Array(service).map(&:to_s) ldap_entry(reload: true)[:services_enabled]
services = (current_services - disabled_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
end end
def disable_all_services def disable_all_services
ldap.delete_attribute(dn,:service) ldap.delete_attribute(dn, :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 end
private private
@@ -227,7 +280,7 @@ class User < ApplicationRecord
return unless avatar_new.present? return unless avatar_new.present?
if avatar_new.size > 1.megabyte 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 end
acceptable_types = ["image/jpeg", "image/png"] acceptable_types = ["image/jpeg", "image/png"]
+1 -15
View File
@@ -1,7 +1,7 @@
# #
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/ # API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
# #
class BtcpayManagerService < ApplicationService class BtcpayManagerService < RestApiService
private private
def base_url def base_url
@@ -19,18 +19,4 @@ class BtcpayManagerService < ApplicationService
"Authorization" => "token #{auth_token}" "Authorization" => "token #{auth_token}"
} }
end end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
JSON.parse(res.body)
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
JSON.parse(res.body)
end
end 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
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
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
+1 -1
View File
@@ -10,7 +10,7 @@ module LdapManager
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 = 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 end
end end
+23 -8
View File
@@ -2,26 +2,41 @@ require "image_processing/vips"
module LdapManager module LdapManager
class UpdateAvatar < LdapManagerService class UpdateAvatar < LdapManagerService
def initialize(dn:, file:) def initialize(user:)
@dn = dn @user = user
@img_data = process(file) @dn = user.dn
end end
def call 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 end
private private
def process(file) def process_avatar
@user.avatar.blob.open do |file|
processed = ImageProcessing::Vips processed = ImageProcessing::Vips
.resize_to_fill(512, 512)
.source(file) .source(file)
.resize_to_fill(256, 256)
.convert("jpeg") .convert("jpeg")
.saver(strip: true) .saver(strip: true)
.call .call
processed.read
Base64.strict_encode64 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 end
end end
@@ -6,7 +6,11 @@ module LdapManager
end end
def call def call
if @display_name.present?
replace_attribute @dn, :displayName, @display_name replace_attribute @dn, :displayName, @display_name
else
delete_attribute @dn, :displayName
end
end end
end end
end end
+19 -10
View File
@@ -50,19 +50,17 @@ class LdapService < ApplicationService
end end
def fetch_users(args={}) def fetch_users(args={})
if args[:ou]
treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
else
treebase = ldap_config["base"]
end
attributes = %w[ attributes = %w[
dn cn uid mail displayName admin serviceEnabled dn cn uid mail displayName admin serviceEnabled memberStatus
mailRoutingAddress mailpassword nostrKey pgpKey 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.sort_by! { |e| e.cn[0] }
entries = entries.collect do |e| entries = entries.collect do |e|
{ {
@@ -71,6 +69,7 @@ class LdapService < ApplicationService
display_name: e.try(:displayName) ? e.displayName.first : nil, display_name: e.try(:displayName) ? e.displayName.first : nil,
admin: e.try(:admin) ? 'admin' : nil, admin: e.try(:admin) ? 'admin' : nil,
services_enabled: e.try(:serviceEnabled), services_enabled: e.try(:serviceEnabled),
member_status: e.try(:memberStatus),
email_maildrop: e.try(:mailRoutingAddress), email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword), email_password: e.try(:mailpassword),
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil, nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
@@ -79,10 +78,20 @@ class LdapService < ApplicationService
end end
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 def fetch_organizations
attributes = %w{dn ou description} attributes = %w{dn ou description}
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit") filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
# filter = Net::LDAP::Filter.eq("objectClass", "*")
treebase = "cn=users,#{ldap_suffix}" treebase = "cn=users,#{ldap_suffix}"
entries = client.search(base: treebase, filter: filter, attributes: attributes) entries = client.search(base: treebase, filter: filter, attributes: attributes)
@@ -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
@@ -0,0 +1,21 @@
module NostrManager
class DiscoverUserProfile < NostrManagerService
def initialize(pubkey:, relays: nil)
@pubkey = pubkey
@relays = relays.present? ? relays : Setting.nostr_discovery_relays
end
def call
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [0],
limit: 1,
)
NostrManager::FetchLatestEvent.call(
relays: @relays,
filter: filter
)
end
end
end
@@ -0,0 +1,21 @@
module NostrManager
class DiscoverUserRelays < NostrManagerService
def initialize(pubkey:)
@pubkey = pubkey
@relays = Setting.nostr_discovery_relays
end
def call
filter = Nostr::Filter.new(
authors: [@pubkey],
kinds: [10002],
limit: 1,
)
NostrManager::FetchLatestEvent.call(
relays: @relays,
filter: filter
)
end
end
end
+59
View File
@@ -0,0 +1,59 @@
module NostrManager
class FetchEvent < NostrManagerService
TIMEOUT = 10
def initialize(filter:, relay_url:)
@filter = filter
@relay = new_relay(relay_url)
@client = Nostr::Client.new
end
def call
filter, client, relay = @filter, @client, @relay
event = nil
mutex = Mutex.new
received_event = ConditionVariable.new
log_prefix = "[nostr][#{@relay.name}]"
thread = Thread.new do
client.on :connect do
client.subscribe(filter: filter)
end
client.on :error do |e|
Rails.logger.info "#{log_prefix} Error: #{e}"
Thread.current.exit
end
client.on :message do |m|
msg = JSON.parse(m) rescue nil
if msg && msg[0] == "EVENT" && msg[2]
Rails.logger.debug "#{log_prefix} Event received: #{msg[2]["id"]}"
mutex.synchronize do
event = msg[2]
received_event.signal
end
elsif msg && msg[0] == "EOSE"
Thread.current.exit
end
end
client.connect relay
end
begin
Timeout.timeout(TIMEOUT) do
mutex.synchronize do
received_event.wait(mutex) if event.nil?
end
end
rescue Timeout::Error
Rails.logger.debug "#{log_prefix} Timeout: No event received within #{TIMEOUT} seconds"
ensure
thread.exit
end
event
end
end
end
@@ -0,0 +1,44 @@
module NostrManager
class FetchLatestEvent < NostrManagerService
TIMEOUT = 20
def initialize(relays:, filter:, max_events: 2)
@relays = relays
@filter = filter
@max_events = max_events
end
def call
received_events = 0
events = []
begin
Timeout.timeout(TIMEOUT) do
@relays.each do |url|
event = NostrManager::FetchEvent.call(filter: @filter, relay_url: url)
if event.present?
events << event if events.none? { |e| e["id"] == event["id"] }
received_events += 1
end
if received_events >= @max_events
Rails.logger.debug "Found #{@max_events} events, ending the search"
break
end
end
events.min_by { |e| e["created_at"] }
end
rescue Timeout::Error
if events.size == 1
Rails.logger.debug "[nostr] Timeout: only found 1 event within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
events.first
else
Rails.logger.debug "[nostr] Timeout: no events found within #{TIMEOUT} seconds for filter: #{@filter.inspect}"
nil
end
end
end
end
end
+7 -7
View File
@@ -19,28 +19,28 @@ module NostrManager
thread = Thread.new do thread = Thread.new do
client.on :connect do client.on :connect do
puts "#{log_prefix} Publishing #{event.id}..." Rails.logger.debug "#{log_prefix} Publishing #{event.id}..."
client.publish event client.publish event
end end
client.on :error do |e| client.on :error do |e|
puts "#{log_prefix} Error: #{e}" Rails.logger.debug "#{log_prefix} Error: #{e}"
puts "#{log_prefix} Closing thread..." Rails.logger.debug "#{log_prefix} Closing thread..."
thread.exit thread.exit
end end
client.on :message do |m| client.on :message do |m|
puts "#{log_prefix} Message: #{m}" Rails.logger.debug "#{log_prefix} Message: #{m}"
msg = JSON.parse(m) rescue [] msg = JSON.parse(m) rescue []
if msg[0] == "OK" && msg[1] == event.id && msg[2] if msg[0] == "OK" && msg[1] == event.id && msg[2]
puts "#{log_prefix} Event published. Closing thread..." Rails.logger.debug "#{log_prefix} Event published. Closing thread..."
else else
puts "#{log_prefix} Unexpected message from relay. Closing thread..." Rails.logger.debug "#{log_prefix} Unexpected message from relay. Closing thread..."
end end
thread.exit thread.exit
end end
puts "#{log_prefix} Connecting to #{relay.url}..." Rails.logger.debug "#{log_prefix} Connecting to #{relay.url}..."
client.connect relay client.connect relay
end end
+5
View File
@@ -3,6 +3,7 @@ require "nostr"
class NostrManagerService < ApplicationService class NostrManagerService < ApplicationService
def parse_tags(tags) def parse_tags(tags)
out = {} out = {}
# TODO support more than 1 item for each tag type
tags.each do |tag| tags.each do |tag|
out[tag[0].to_sym] = tag[1, tag.length] out[tag[0].to_sym] = tag[1, tag.length]
end end
@@ -19,4 +20,8 @@ class NostrManagerService < ApplicationService
def site_user def site_user
Nostr::User.new(keypair: site_keypair) Nostr::User.new(keypair: site_keypair)
end end
def new_relay(url)
Nostr::Relay.new(url: url, name: URI.parse(url).host)
end
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

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