mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 08:26:48 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ffeeea78 | ||
|
|
77bcad65b5 | ||
|
|
238f73c26f | ||
|
|
1feb2d94b9 | ||
|
|
49c0ce496c | ||
|
|
88f3f942f7 | ||
|
|
6f256f5f2d | ||
|
|
089a8b35c0 | ||
|
|
b52c318518 | ||
|
|
de5493d8a9 | ||
|
|
74346342df | ||
|
|
e4a49d52b2 | ||
|
|
b8919161a8 | ||
|
|
80585a9585 | ||
|
|
b3d97ecb0e | ||
|
|
94692026d3 | ||
|
|
0672673916 | ||
|
|
5667262d1c |
No files matched your search
@@ -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],
|
[disabled] &[type=submit],
|
||||||
&[type=submit]:disabled {
|
&[type=submit]:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ class AccountsController < ApplicationController
|
|||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
|
||||||
def edit
|
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
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController
|
|||||||
user = FirstRun.create!(user_params)
|
user = FirstRun.create!(user_params)
|
||||||
start_new_session_for user
|
start_new_session_for user
|
||||||
|
|
||||||
|
redirect_to root_url
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
redirect_to root_url
|
redirect_to root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
class Rooms::InvolvementsController < ApplicationController
|
class Rooms::InvolvementsController < ApplicationController
|
||||||
include RoomScoped
|
include RoomScoped
|
||||||
|
|
||||||
before_action :ensure_can_administer, only: %i[ update ]
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@involvement = @membership.involvement
|
@involvement = @membership.involvement
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -100,7 +100,13 @@
|
|||||||
|
|
||||||
<menu class="flex flex-column gap margin-none pad">
|
<menu class="flex flex-column gap margin-none pad">
|
||||||
<turbo-frame id="account_users">
|
<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? %>
|
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||||
</turbo-frame>
|
</turbo-frame>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -9,23 +9,26 @@
|
|||||||
|
|
||||||
<hr class="separator" aria-hidden="true">
|
<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? %>
|
<% unless user.bot? %>
|
||||||
<%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %>
|
<%= 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) %>">
|
<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>
|
<span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span>
|
||||||
<%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %>
|
<%= 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>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
|
<% unless user == Current.user %>
|
||||||
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
|
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
|
||||||
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
|
||||||
<span class="for-screen-reader">Delete <%= user.name %></span>
|
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
|
||||||
|
<span class="for-screen-reader">Delete <%= user.name %></span>
|
||||||
|
<% end %>
|
||||||
<% 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 %>
|
<%= 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" } %>
|
<%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %>
|
||||||
<span class="for-screen-reader">My settings</span>
|
<span class="for-screen-reader">My settings</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<%= button_to user_ban_path(user), method: :delete,
|
<%= button_to user_ban_path(user), method: :delete,
|
||||||
class: "btn btn--negative full-width",
|
class: "btn btn--negative full-width",
|
||||||
data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %>
|
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>
|
<span>Remove ban</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
+11
@@ -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
|
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)
|
def announcing(message)
|
||||||
puts "\n\e[1;36m#{message}\e[0m"
|
puts "\n\e[1;36m#{message}\e[0m"
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -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
+3
-1
@@ -10,14 +10,16 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.2].define(version: 2025_11_26_130131) do
|
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
|
||||||
create_table "accounts", force: :cascade do |t|
|
create_table "accounts", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.text "custom_styles"
|
t.text "custom_styles"
|
||||||
t.string "join_code", null: false
|
t.string "join_code", null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.json "settings"
|
t.json "settings"
|
||||||
|
t.integer "singleton_guard", default: 0, null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "action_text_rich_texts", force: :cascade do |t|
|
create_table "action_text_rich_texts", force: :cascade do |t|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ActiveSupport.on_load(:action_text_content) do
|
|||||||
|
|
||||||
def attachable_from_possibly_expired_sgid(sgid)
|
def attachable_from_possibly_expired_sgid(sgid)
|
||||||
if message = sgid&.split("--")&.first
|
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")
|
decoded_gid = if data = encoded_message.dig("_rails", "data")
|
||||||
data
|
data
|
||||||
@@ -20,7 +20,7 @@ ActiveSupport.on_load(:action_text_content) do
|
|||||||
# Rails 7 used an older format of GID that serialized the payload using Marshall
|
# 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
|
# 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
|
# To work around this, we manually extract the GID from the marshaled data
|
||||||
Base64.urlsafe_decode64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
|
decode_base64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
|
||||||
else
|
else
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -32,6 +32,12 @@ ActiveSupport.on_load(:action_text_content) do
|
|||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def decode_base64(message)
|
||||||
|
Base64.strict_decode64(message)
|
||||||
|
rescue => _e
|
||||||
|
Base64.urlsafe_decode64(message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -16,7 +16,7 @@ module RestrictedHTTP
|
|||||||
|
|
||||||
def private_ip?(ip)
|
def private_ip?(ip)
|
||||||
IPAddr.new(ip).then do |ipaddr|
|
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
|
end
|
||||||
rescue IPAddr::InvalidAddressError
|
rescue IPAddr::InvalidAddressError
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :ok
|
assert_response :ok
|
||||||
end
|
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
|
test "update" do
|
||||||
assert users(:david).administrator?
|
assert users(:david).administrator?
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert parsed_cookies.signed[:session_token]
|
assert parsed_cookies.signed[:session_token]
|
||||||
end
|
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
|
end
|
||||||
@@ -43,4 +43,13 @@ class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
@@ -21,6 +21,28 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
|
|||||||
assert_equal [ "is not public" ], location.errors[:url]
|
assert_equal [ "is not public" ], location.errors[:url]
|
||||||
end
|
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
|
test "avoid reading file urls when expecting HTML" do
|
||||||
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")
|
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user