Compare commits

..
53 Commits
Author SHA1 Message Date
Stanko Krtalić 71ffeeea78 Merge pull request #147 from basecamp/fix-sgid-decoding
Try to decodde SGIDs in multiple ways
2025-12-15 17:05:01 +01:00
Stanko K.R. 77bcad65b5 Try to decode SGIDs in multiple ways
This should avoid message decoding failures between different versions
of sgids
2025-12-15 17:00:50 +01:00
Mike Dalessio 238f73c26f Merge pull request #146 from basecamp/flavorjones/fix-account-creation-race
Address race condition during "first run" account creation
2025-12-12 11:14:52 -05:00
Mike Dalessio 1feb2d94b9 Address race condition during "first run" account creation 2025-12-12 10:51:28 -05:00
Jason Zimdars 49c0ce496c Merge pull request #144 from basecamp/user-self
User self
2025-12-08 23:03:07 -06:00
Jason Zimdars 88f3f942f7 Ensure edit (not trash) is displayed
Fix conditional
2025-12-08 22:57:38 -06:00
Jason Zimdars 6f256f5f2d Show admin toggle, but disable for current user
You shouldn't be able to demote yourself but displaying the disabled
toggle helps explain the group of admins at the top of the list. Builds
upon #140
2025-12-08 22:53:11 -06:00
Jason Zimdars 089a8b35c0 Merge pull request #140 from ashwin47/admin-ordering
Sort users with administrators appearing first in ordered scope
2025-12-08 22:33:49 -06:00
Ashwin M b52c318518 Group administrators separately from members with visual divider 2025-12-09 08:42:00 +05:30
Stanko Krtalić de5493d8a9 Merge pull request #124 from ashwin47/ban_typo
Fix button label to 'UnBan' for clarity
2025-12-06 11:42:50 +01:00
Ashwin M 74346342df Rewrite label 2025-12-06 16:09:13 +05:30
Stanko Krtalić e4a49d52b2 Merge pull request #125 from mphalliday/involvement-fix
Allow non-admins to update their room involvements
2025-12-04 21:43:31 +01:00
Michael Halliday b8919161a8 Allow non-admins to update their room involvements 2025-12-03 09:56:15 -05:00
Ashwin M 80585a9585 Fix button label to 'UnBan' for clarity 2025-12-03 17:50:16 +05:30
Stanko K.R. b3d97ecb0e Add safety checks to release script 2025-12-03 08:24:04 +01:00
Stanko Krtalić 94692026d3 Merge pull request #122 from basecamp/unfurl-blind-ssrf
Security: disallow blind SSRF to link-local IPs via URL unfurling
2025-12-03 08:12:24 +01:00
Stanko K.R. 0672673916 Disallow SSRF via IPv6 addresses mapped to IPv4 addresses 2025-12-03 08:08:34 +01:00
Jeremy Daer 5667262d1c Security: disallow blind SSRF to link-local IPs via URL unfurling 2025-12-02 21:33:44 -08:00
Stanko Krtalić 1babf3f9ed Merge pull request #121 from basecamp/fix-crash-on-mentions-when-upgrading-from-rails-7-to-8
Parse Rails 7 GIDs
2025-12-02 11:37:51 +01:00
Stanko K.R. 4d04f9beee Use urlsafe base64 decode 2025-12-02 11:34:12 +01:00
Stanko K.R. bebe518c74 Parse Rails 7 GIDs 2025-12-02 11:06:23 +01:00
Stanko K.R. 13897eac59 Set title on new releases to be the version number 2025-12-02 08:36:15 +01:00
Stanko Krtalić 6bb0ee2436 Merge pull request #120 from basecamp/allow-restricting-new-room-creation-to-admins
Add new has_json to add Account#settings to restrict room creation to only administrators
2025-12-02 08:27:29 +01:00
Stanko K.R. 550d4c75bd Invert the icon color in dark mode 2025-12-02 08:22:32 +01:00
Jason Zimdars 7b7b3f8a67 Rework toggle as switch 2025-12-01 23:13:54 -06:00
Stanko K.R. aec8747710 Fix failing system tests
Something broke when the dependencies were updated so I copied over the Gemfile.lock file from main - which is known to work
2025-12-01 16:34:51 +01:00
Stanko K.R. 71b5edae01 Run migrations 2025-12-01 15:31:53 +01:00
Stanko K.R. b1325ccee7 Bump Redis 2025-12-01 15:31:07 +01:00
David Heinemeier Hansson 5266ffc049 Always just go through the settings object 2025-12-01 15:26:06 +01:00
David Heinemeier Hansson bd3b0c5988 Not needed 2025-12-01 15:26:06 +01:00
David Heinemeier Hansson e8626f9d5d Use rails edge that now includes the feature 2025-12-01 15:26:06 +01:00
David Heinemeier Hansson 796195c2cc Give up on the auto delegation to get a cleaner API 2025-12-01 15:26:06 +01:00
David Heinemeier Hansson 559629537b We don't need to specify the default any more 2025-12-01 15:26:06 +01:00
David Heinemeier Hansson 42c411b660 Use upstream version of has_json 2025-12-01 15:25:39 +01:00
David Heinemeier Hansson 6c59b8c82b Use public_send instead of send 2025-12-01 15:25:39 +01:00
David Heinemeier Hansson 20ba1cf2ae Ensure mutable string is used to prevent warning 2025-12-01 15:25:06 +01:00
David Heinemeier Hansson d323c3cfc0 Now required to be explicitly included
Not sure why
2025-12-01 15:23:57 +01:00
David Heinemeier Hansson 53671b48e0 Update to latest Rails 8.2.0 alpha 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 66b4e41281 Rename 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 6476bab4cc Use consistent yield naming 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson f7c3aaa2a9 Allow for default values 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 593f8dd04c No need for self 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson d3b6507ce2 Layer on top a more pleasant API for the default case 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 32be03a240 Rely on method missing 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 8e94a4aa1e Better wording 2025-12-01 15:23:23 +01:00
David Heinemeier Hansson 15db4033bc Enforce restriction to create new rooms 2025-12-01 15:22:37 +01:00
David Heinemeier Hansson f56e33e323 Fix fetching missing values 2025-12-01 15:22:36 +01:00
David Heinemeier Hansson bea2c89c2b Add new has_json to add Account#settings to restrict room creation to only administrators 2025-12-01 15:22:36 +01:00
Stanko Krtalić b7c7d99dcd Merge pull request #119 from basecamp/upgrade-to-rails-8.2.alpha
Upgrade to Rails 8.2.0.alpha
2025-12-01 13:59:28 +01:00
Stanko K.R. 6197f20a31 Upgrade to Rails 8.2.0.alpha 2025-12-01 13:50:53 +01:00
Stanko Krtalić 02023b0f0c Merge pull request #118 from basecamp/modernize-scripts
Create release first
2025-12-01 12:22:04 +01:00
Stanko K.R. af81a4c1ac Create release first 2025-12-01 12:21:08 +01:00
Stanko Krtalić d4a132865a Merge pull request #117 from basecamp/modernize-scripts
Modernize scripts
2025-12-01 12:01:13 +01:00
31 changed files with 371 additions and 118 deletions

No files matched your search

+3 -1
View File
@@ -4,9 +4,11 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Rails
gem "rails", github: "rails/rails", branch: "main"
gem "ostruct"
gem "benchmark"
# Drivers
gem "sqlite3", "~> 2.7"
gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
+85 -80
View File
@@ -26,32 +26,32 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 02660cd9e84aa57c6cf3bf6e08cc18e18292e2fa
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
branch: main
specs:
actioncable (8.1.0.alpha)
actionpack (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.0.alpha)
actionpack (= 8.1.0.alpha)
activejob (= 8.1.0.alpha)
activerecord (= 8.1.0.alpha)
activestorage (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actionmailbox (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
mail (>= 2.8.0)
actionmailer (8.1.0.alpha)
actionpack (= 8.1.0.alpha)
actionview (= 8.1.0.alpha)
activejob (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actionmailer (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
actionview (= 8.2.0.alpha)
activejob (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.0.alpha)
actionview (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actionpack (8.2.0.alpha)
actionview (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -59,65 +59,65 @@ GIT
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.0.alpha)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.0.alpha)
activerecord (= 8.1.0.alpha)
activestorage (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.0.alpha)
activesupport (= 8.1.0.alpha)
actionview (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.0.alpha)
activesupport (= 8.1.0.alpha)
activejob (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
globalid (>= 0.3.6)
activemodel (8.1.0.alpha)
activesupport (= 8.1.0.alpha)
activerecord (8.1.0.alpha)
activemodel (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
activemodel (8.2.0.alpha)
activesupport (= 8.2.0.alpha)
activerecord (8.2.0.alpha)
activemodel (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
timeout (>= 0.4.0)
activestorage (8.1.0.alpha)
actionpack (= 8.1.0.alpha)
activejob (= 8.1.0.alpha)
activerecord (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
activestorage (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
activejob (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
marcel (~> 1.0)
activesupport (8.1.0.alpha)
activesupport (8.2.0.alpha)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
rails (8.1.0.alpha)
actioncable (= 8.1.0.alpha)
actionmailbox (= 8.1.0.alpha)
actionmailer (= 8.1.0.alpha)
actionpack (= 8.1.0.alpha)
actiontext (= 8.1.0.alpha)
actionview (= 8.1.0.alpha)
activejob (= 8.1.0.alpha)
activemodel (= 8.1.0.alpha)
activerecord (= 8.1.0.alpha)
activestorage (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
rails (8.2.0.alpha)
actioncable (= 8.2.0.alpha)
actionmailbox (= 8.2.0.alpha)
actionmailer (= 8.2.0.alpha)
actionpack (= 8.2.0.alpha)
actiontext (= 8.2.0.alpha)
actionview (= 8.2.0.alpha)
activejob (= 8.2.0.alpha)
activemodel (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
bundler (>= 1.15.0)
railties (= 8.1.0.alpha)
railties (8.1.0.alpha)
actionpack (= 8.1.0.alpha)
activesupport (= 8.1.0.alpha)
railties (= 8.2.0.alpha)
railties (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
activesupport (= 8.2.0.alpha)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -135,8 +135,8 @@ GEM
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (7.1.1)
racc
builder (3.3.0)
@@ -151,17 +151,17 @@ GEM
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
date (3.4.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (5.0.2)
erb (6.0.0)
erubi (1.13.1)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
@@ -172,7 +172,7 @@ GEM
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
i18n (1.14.7)
@@ -181,7 +181,7 @@ GEM
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
irb (1.15.3)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@@ -201,17 +201,18 @@ GEM
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.25.5)
minitest (5.26.2)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
@@ -220,7 +221,7 @@ GEM
ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.5.9)
net-imap (0.5.12)
date
net-protocol
net-pop (0.1.2)
@@ -229,16 +230,17 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
openssl (3.3.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
@@ -246,7 +248,7 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.2)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.4.0)
@@ -257,7 +259,7 @@ GEM
puma (6.6.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.4)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -281,10 +283,11 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.25.2)
@@ -292,7 +295,7 @@ GEM
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
@@ -367,21 +370,21 @@ GEM
sqlite3 (2.7.3-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
tilt (2.6.1)
timeout (0.4.3)
timeout (0.4.4)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
uri (1.1.1)
useragent (0.16.11)
web-push (3.0.2)
jwt (~> 3.0)
@@ -407,6 +410,7 @@ PLATFORMS
DEPENDENCIES
bcrypt
benchmark
brakeman
capybara
debug
@@ -418,6 +422,7 @@ DEPENDENCIES
kredis
mocha
net-http-persistent
ostruct
platform_agent
propshaft!
puma (~> 6.6)
@@ -431,7 +436,7 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
sqlite3 (~> 2.7)
sqlite3
stimulus-rails
thruster
turbo-rails!
+7
View File
@@ -127,6 +127,13 @@
}
}
&:has(input[type=checkbox][disabled]) {
cursor: not-allowed;
filter: grayscale(100%) contrast(0.5);
opacity: 0.5;
pointer-events: none;
}
[disabled] &[type=submit],
&[type=submit]:disabled {
cursor: not-allowed;
+4 -2
View File
@@ -3,7 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account
def edit
set_page_and_extract_portion_from account_users.ordered, per_page: 500
users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
end
def update
@@ -17,7 +19,7 @@ class AccountsController < ApplicationController
end
def account_params
params.require(:account).permit(:name, :logo)
params.require(:account).permit(:name, :logo, settings: {})
end
def account_users
+2
View File
@@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController
user = FirstRun.create!(user_params)
start_new_session_for user
redirect_to root_url
rescue ActiveRecord::RecordNotUnique
redirect_to root_url
end
@@ -3,6 +3,7 @@ class Rooms::ClosedsController < RoomsController
before_action :ensure_can_administer, only: %i[ update ]
before_action :remember_last_room_visited, only: :show
before_action :force_room_type, only: %i[ edit update ]
before_action :ensure_permission_to_create_rooms, only: %i[ new create ]
DEFAULT_ROOM_NAME = "New room"
@@ -1,8 +1,6 @@
class Rooms::InvolvementsController < ApplicationController
include RoomScoped
before_action :ensure_can_administer, only: %i[ update ]
def show
@involvement = @membership.involvement
end
@@ -3,6 +3,7 @@ class Rooms::OpensController < RoomsController
before_action :ensure_can_administer, only: %i[ update ]
before_action :remember_last_room_visited, only: :show
before_action :force_room_type, only: %i[ edit update ]
before_action :ensure_permission_to_create_rooms, only: %i[ new create ]
DEFAULT_ROOM_NAME = "New room"
+6
View File
@@ -31,6 +31,12 @@ class RoomsController < ApplicationController
head :forbidden unless Current.user.can_administer?(@room)
end
def ensure_permission_to_create_rooms
if Current.account.settings.restrict_room_creation_to_administrators? && !Current.user.administrator?
head :forbidden
end
end
def find_messages
messages = @room.messages.with_creator.with_attachment_details.with_boosts
+1
View File
@@ -2,4 +2,5 @@ class Account < ApplicationRecord
include Joinable
has_one_attached :logo
has_json :settings, restrict_room_creation_to_administrators: false
end
+1 -1
View File
@@ -53,7 +53,7 @@ class Webhook < ApplicationRecord
end
def extract_text_from(response)
response.body.dup.force_encoding("UTF-8") if response.code == "200" && response.content_type.in?(%w[ text/html text/plain ])
String.new(response.body).force_encoding("UTF-8") if response.code == "200" && response.content_type.in?(%w[ text/html text/plain ])
end
def receive_text_reply_to(room, text:)
+31 -1
View File
@@ -64,6 +64,30 @@
<% end %>
</div>
<% end %>
<div class="margin-block-start pad-block pad-inline-double fill-shade border-radius">
<%= form_with model: @account, method: :put, data: { controller: "form" }, class: "flex align-center gap center" do |form| %>
<div class="flex-item-grow flex align-center gap txt-align-start">
<%= image_tag "crown.svg", class: "colorize--black", aria: { hidden: "true" }, size: 18 %> Must be admin to create new rooms
</div>
<%= form.fields_for :settings, @account.settings do |settings_form| %>
<%= settings_form.hidden_field :restrict_room_creation_to_administrators,
value: !Current.account.settings.restrict_room_creation_to_administrators? %>
<% end %>
<label class="switch">
<input type="checkbox"
class="switch__input"
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
data-action="change->form#submit">
<span class="switch__btn round"></span>
<span class="for-screen-reader">
Must be admin to create new rooms
</span>
</label>
<% end %>
</div>
<% else %>
<%= account_logo_tag style: "txt-xx-large center" %>
<h1 class="flex-item-grow txt-x-large"><%= @account.name %></h1>
@@ -76,7 +100,13 @@
<menu class="flex flex-column gap margin-none pad">
<turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @members.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame>
</menu>
+10 -7
View File
@@ -9,23 +9,26 @@
<hr class="separator" aria-hidden="true">
<% if Current.user.can_administer? && user != Current.user && user.active? %>
<% if Current.user.can_administer? && user.active? %>
<% unless user.bot? %>
<%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %>
<label class="btn txt-small flex-item-no-shrink" for="<%= dom_id(user, :role) %>">
<span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span>
<%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %>
<%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role) }, "administrator", "member" %>
<%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role), disabled: user == Current.user }, "administrator", "member" %>
</label>
<% end %>
<% end %>
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete <%= user.name %></span>
<% unless user == Current.user %>
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete <%= user.name %></span>
<% end %>
<% end %>
<% elsif user == Current.user %>
<% end %>
<% if user == Current.user %>
<%= link_to user_profile_path, class: "btn txt-small flex-item-no-shrink", target: "_top" do %>
<%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">My settings</span>
+1 -1
View File
@@ -9,7 +9,7 @@
<%= button_to user_ban_path(user), method: :delete,
class: "btn btn--negative full-width",
data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %>
<%= image_tag "cancel.svg", aria: { hidden: "true", label: "Ban #{@user.name}" } %>
<%= image_tag "cancel.svg", aria: { hidden: "true", label: "Remove Ban #{@user.name}" } %>
<span>Remove ban</span>
<% end %>
<% end %>
+4 -2
View File
@@ -36,8 +36,10 @@
<% end %>
</div>
<%= link_to new_rooms_open_path, class: "rooms__new-btn btn room align-center gap txt-reversed", aria: { label: "New Chat Room" } do %>
<%= image_tag "add.svg", size: 20, aria: { hidden: "true" }, style: "view-transition-name: new-room" %>
<% if Current.user.administrator? || !Current.account.settings.restrict_room_creation_to_administrators? %>
<%= link_to new_rooms_open_path, class: "rooms__new-btn btn room align-center gap txt-reversed", aria: { label: "New Chat Room" } do %>
<%= image_tag "add.svg", size: 20, aria: { hidden: "true" }, style: "view-transition-name: new-room" %>
<% end %>
<% end %>
</div>
+22 -14
View File
@@ -30,6 +30,17 @@ EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
print "\e[1;33mWarning: You are on branch '#{current_branch}', not 'main'. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
unless `git status --porcelain`.strip.empty?
print "\e[1;33mWarning: You have uncommitted changes. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
def announcing(message)
puts "\n\e[1;36m#{message}\e[0m"
yield
@@ -45,23 +56,20 @@ on(:local) do
end
end
announcing "Tagging as #{VERSION}" do
on(:local) do
execute :git, :fetch, :origin, "--tags"
announcing "Creating GitHub release v#{VERSION}..." do
notes_file = "tmp/release_notes.md"
unless system "git tag -a v#{VERSION} -m 'Version #{VERSION}\n\n- list important changes here' -e"
abort "Failed to create tag; check that the version doesn't already exist"
on(:local) do
execute :mkdir, "-p", "tmp"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
unless test :git, :push, "--tags"
abort "Failed to push tag; check that the version doesn't already exist"
end
end
end
announcing "Creating GitHub release..." do
on(:local) do
execute :gh, :release, :create, "v#{VERSION}", "--notes-from-tag"
execute :rm, notes_file
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ Bundler.require(*Rails.groups)
module Campfire
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
config.load_defaults 8.2
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
@@ -0,0 +1,5 @@
class AddAccountSettings < ActiveRecord::Migration[7.2]
def change
add_column :accounts, :settings, :json
end
end
@@ -0,0 +1,6 @@
class AddSingletonConstraintToAccounts < ActiveRecord::Migration[8.2]
def change
add_column :accounts, :singleton_guard, :integer, default: 0, null: false
add_index :accounts, :singleton_guard, unique: true
end
end
Generated
+4 -1
View File
@@ -10,13 +10,16 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_26_115722) do
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
t.string "join_code", null: false
t.string "name", null: false
t.json "settings"
t.integer "singleton_guard", default: 0, null: false
t.datetime "updated_at", null: false
t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true
end
create_table "action_text_rich_texts", force: :cascade do |t|
+15 -4
View File
@@ -12,21 +12,32 @@ ActiveSupport.on_load(:action_text_content) do
def attachable_from_possibly_expired_sgid(sgid)
if message = sgid&.split("--")&.first
encoded_message = JSON.parse Base64.strict_decode64(message)
encoded_message = JSON.parse(decode_base64(message))
decoded_gid = if data = encoded_message.dig("_rails", "data")
data
elsif data = encoded_message.dig("_rails", "message")
# Rails 7 used an older format of GID that serialized the payload using Marshall
# Since we intentionally skip signature verification, we can't safely unmarshal the data
# To work around this, we manually extract the GID from the marshaled data
decode_base64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
else
nil
end
model = GlobalID.find(decoded_gid)
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
if model = GlobalID.find(decoded_gid)
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
end
end
rescue ActiveRecord::RecordNotFound
nil
end
def decode_base64(message)
Base64.strict_decode64(message)
rescue => _e
Base64.urlsafe_decode64(message)
end
end
end
end
+1 -1
View File
@@ -16,7 +16,7 @@ module RestrictedHTTP
def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr|
ipaddr.private? || ipaddr.loopback? || LOCAL_IP.include?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || LOCAL_IP.include?(ipaddr)
end
rescue IPAddr::InvalidAddressError
true
@@ -10,6 +10,38 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :ok
end
test "edit groups administrators separately from members with a divider" do
get edit_account_url
assert_response :ok
# Verify the divider exists between administrator and member sections
assert_select "turbo-frame#account_users hr.separator.full-width"
# Verify administrators appear before the divider and members appear after
# by checking the order of user names in the response body
administrators = users(:david, :jason).map(&:name)
members = users(:jz, :kevin).map(&:name)
response_body = response.body
# Find positions of divider and user names
divider_position = response_body.index('hr class="separator full-width"')
assert divider_position, "Divider should exist in the response"
administrators.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Administrator #{name} should appear in the response"
assert name_position < divider_position, "Administrator #{name} should appear before the divider"
end
members.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Member #{name} should appear in the response"
assert name_position > divider_position, "Member #{name} should appear after the divider"
end
end
test "update" do
assert users(:david).administrator?
@@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token]
end
test "create is not vulnerable to race conditions" do
num_attackers = 5
url = first_run_url
barrier = Concurrent::CyclicBarrier.new(num_attackers)
num_attackers.times.map do |i|
Thread.new do
session = ActionDispatch::Integration::Session.new(Rails.application)
barrier.wait # All threads wait here, then fire simultaneously
session.post url, params: {
user: {
name: "Attacker#{i}",
email_address: "attacker#{i}@example.com",
password: "password123"
}
}
end
end.each(&:join)
assert_equal 1, Account.count, "Race condition allowed #{Account.count} accounts to be created!"
assert_equal 1, User.where(role: :administrator).count, "Race condition allowed multiple admin users!"
end
end
@@ -29,6 +29,16 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to room_url(Room.last)
end
test "create forbidden by non-admin when account restricts creation to admins" do
accounts(:signal).settings.restrict_room_creation_to_administrators = true
accounts(:signal).save!
sign_in :jz
post rooms_closeds_url, params: { room: { name: "My New Room" }, user_ids: [ users(:david).id, users(:kevin).id, users(:jason).id ] }
assert_response :forbidden
end
test "update with membership revisions" do
assert_difference -> { rooms(:designers).reload.users.count }, -1 do
put rooms_closed_url(rooms(:designers)), params: {
@@ -43,4 +43,13 @@ class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
end
end
end
test "a non-admin can update their room involvement" do
sign_in :jz
assert_changes -> { memberships(:jz_designers).reload.involvement }, from: "everything", to: "mentions" do
put room_involvement_url(rooms(:designers)), params: { involvement: "mentions" }
assert_redirected_to room_involvement_url(rooms(:designers))
end
end
end
@@ -24,6 +24,15 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to room_url(Room.last)
end
test "create forbidden by non-admin when account restricts creation to admins" do
accounts(:signal).settings.restrict_room_creation_to_administrators = true
accounts(:signal).save!
sign_in :jz
post rooms_opens_url, params: { room: { name: "My New Room" } }
assert_response :forbidden
end
test "only admins or creators can update" do
sign_in :jz
@@ -0,0 +1,39 @@
require "test_helper"
class ActionText::AttachmentTest < ActiveSupport::TestCase
setup do
@user = users(:david)
end
test "from_node" do
html = %Q(<action-text-attachment sgid="#{@user.attachable_sgid}"></action-text-attachment>)
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
attachment = ActionText::Attachment.from_node(node)
assert_equal @user, attachment.attachable
end
test "from_node with a Rails 7 SGID" do
gid = @user.to_gid.to_s
marshaled_gid = Base64.urlsafe_encode64(Marshal.dump(gid))
rails7_payload = { "_rails" => { "message" => marshaled_gid, "exp" => nil, "pur" => "attachable" } }
rails7_message = Base64.strict_encode64(JSON.generate(rails7_payload))
rails7_sgid = "#{rails7_message}--invalidsignature"
html = %Q(<action-text-attachment sgid="#{rails7_sgid}"></action-text-attachment>)
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
attachment = ActionText::Attachment.from_node(node)
assert_equal @user, attachment.attachable
end
test "from_node with an invalid SGID" do
room = rooms(:pets).tap { |r| r.extend ActionText::Attachable }
html = %Q(<action-text-attachment sgid="#{room.attachable_sgid}invalid"></action-text-attachment>)
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
attachment = ActionText::Attachment.from_node(node)
assert_kind_of ActionText::Attachables::MissingAttachable, attachment.attachable
end
end
+14
View File
@@ -1,4 +1,18 @@
require "test_helper"
class AccountTest < ActiveSupport::TestCase
test "settings" do
accounts(:signal).settings.restrict_room_creation_to_administrators = true
assert accounts(:signal).settings.restrict_room_creation_to_administrators?
assert_equal({ "restrict_room_creation_to_administrators" => true }, accounts(:signal)[:settings])
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "true" })
assert accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
accounts(:signal).settings.restrict_room_creation_to_administrators = false
assert_not accounts(:signal).settings.restrict_room_creation_to_administrators?
assert_equal({ "restrict_room_creation_to_administrators" => false }, accounts(:signal)[:settings])
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
end
end
+22
View File
@@ -21,6 +21,28 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
assert_equal [ "is not public" ], location.errors[:url]
end
test "link-local addresses are blocked" do
Resolv.stubs(:getaddress).with("metadata.internal").returns("169.254.169.254")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
end
test "IPv6 addresses mapped to IPv4 addresses are blocked" do
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:192.168.1.1")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:c0a8:0101")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
end
test "avoid reading file urls when expecting HTML" do
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")
+1
View File
@@ -5,6 +5,7 @@ require "rails/test_help"
require "minitest/unit"
require "mocha/minitest"
require "webmock/minitest"
require "turbo/broadcastable/test_helper"
WebMock.enable!