All Files ( 95.94% covered at 7.29 hits/line )
188 files in total.
3101 relevant lines,
2975 lines covered and
126 lines missed.
(
95.94%
)
-
1
module ApplicationCable
-
1
class Channel < ActionCable::Channel::Base
-
end
-
end
-
1
module ApplicationCable
-
1
class Connection < ActionCable::Connection::Base
-
1
include Authentication::SessionLookup
-
-
1
identified_by :current_user
-
-
1
def connect
-
3
self.current_user = find_verified_user
-
end
-
-
1
private
-
1
def find_verified_user
-
3
if verified_session = find_session_by_cookie
-
1
verified_session.user
-
else
-
2
reject_unauthorized_connection
-
end
-
end
-
end
-
end
-
1
class PresenceChannel < RoomChannel
-
1
on_subscribe :present, unless: :subscription_rejected?
-
1
on_unsubscribe :absent, unless: :subscription_rejected?
-
-
1
def present
-
3
membership.present
-
-
3
broadcast_read_room
-
end
-
-
1
def absent
-
1
membership.disconnected
-
end
-
-
1
def refresh
-
membership.refresh_connection
-
end
-
-
1
private
-
1
def membership
-
7
@room.memberships.find_by(user: current_user)
-
end
-
-
1
def broadcast_read_room
-
3
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
-
end
-
end
-
1
class RoomChannel < ApplicationCable::Channel
-
1
def subscribed
-
6
if @room = find_room
-
3
stream_for @room
-
else
-
3
reject
-
end
-
end
-
-
1
private
-
1
def find_room
-
6
current_user.rooms.find_by(id: params[:room_id])
-
end
-
end
-
1
class Accounts::Bots::KeysController < ApplicationController
-
1
before_action :ensure_can_administer
-
-
1
def update
-
1
User.active_bots.find(params[:bot_id]).reset_bot_key
-
1
redirect_to account_bots_url
-
end
-
end
-
1
class Accounts::BotsController < ApplicationController
-
1
before_action :ensure_can_administer
-
1
before_action :set_bot, only: %i[ edit update destroy ]
-
-
1
def index
-
1
@bots = User.active_bots.ordered
-
end
-
-
1
def new
-
1
@bot = User.active_bots.new
-
end
-
-
1
def create
-
1
User.create_bot! bot_params
-
1
redirect_to account_bots_url
-
end
-
-
1
def edit
-
end
-
-
1
def update
-
2
@bot.update_bot! bot_params
-
2
redirect_to account_bots_url
-
end
-
-
1
def destroy
-
1
@bot.deactivate
-
1
redirect_to account_bots_url
-
end
-
-
1
private
-
1
def set_bot
-
4
@bot = User.active_bots.find(params[:id])
-
end
-
-
1
def bot_params
-
3
params.require(:user).permit(:name, :avatar, :webhook_url)
-
end
-
end
-
1
class Accounts::CustomStylesController < ApplicationController
-
1
before_action :ensure_can_administer, :set_account
-
-
1
def edit
-
end
-
-
1
def update
-
1
@account.update!(account_params)
-
1
redirect_to edit_account_custom_styles_url, notice: "✓"
-
end
-
-
1
private
-
1
def set_account
-
2
@account = Current.account
-
end
-
-
1
def account_params
-
1
params.require(:account).permit(:custom_styles)
-
end
-
end
-
1
class Accounts::JoinCodesController < ApplicationController
-
1
before_action :ensure_can_administer
-
-
1
def create
-
1
Current.account.reset_join_code
-
1
redirect_to edit_account_url
-
end
-
end
-
1
class Accounts::LogosController < ApplicationController
-
1
include ActiveStorage::Streaming, ActionView::Helpers::AssetUrlHelper
-
-
1
allow_unauthenticated_access only: :show
-
1
before_action :ensure_can_administer, only: :destroy
-
-
1
def show
-
4
if stale?(etag: Current.account)
-
4
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
-
-
4
if Current.account&.logo&.attached?
-
2
logo = Current.account.logo.variant(logo_variant).processed
-
2
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
-
else
-
2
send_stock_icon
-
end
-
end
-
end
-
-
1
def destroy
-
1
Current.account.logo.destroy
-
1
redirect_to edit_account_url
-
end
-
-
1
private
-
1
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
-
1
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
-
-
1
def send_png_file(path)
-
4
send_file path, content_type: "image/png", disposition: :inline
-
end
-
-
1
def send_stock_icon
-
2
if small_logo?
-
1
send_png_file logo_path("app-icon-192.png")
-
else
-
1
send_png_file logo_path("app-icon.png")
-
end
-
end
-
-
1
def logo_variant
-
2
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
-
end
-
-
1
def small_logo?
-
4
params[:size] == "small"
-
end
-
-
1
def logo_path(filename)
-
2
Rails.root.join("app/assets/images/logos/#{filename}")
-
end
-
end
-
1
class Accounts::UsersController < ApplicationController
-
1
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
-
-
1
def index
-
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
-
end
-
-
1
def update
-
1
@user.update(role_params)
-
1
redirect_to edit_account_url
-
end
-
-
1
def destroy
-
1
@user.deactivate
-
1
redirect_to edit_account_url
-
end
-
-
1
private
-
1
def set_user
-
2
@user = User.active.find(params[:user_id] || params[:id])
-
end
-
-
1
def role_params
-
1
{ role: params.require(:user)[:role].presence_in(%w[ member administrator ]) || "member" }
-
end
-
end
-
1
class AccountsController < ApplicationController
-
1
before_action :ensure_can_administer, only: :update
-
1
before_action :set_account
-
-
1
def edit
-
1
set_page_and_extract_portion_from User.active.ordered, per_page: 500
-
end
-
-
1
def update
-
1
@account.update!(account_params)
-
1
redirect_to edit_account_url, notice: "✓"
-
end
-
-
1
private
-
1
def set_account
-
2
@account = Current.account
-
end
-
-
1
def account_params
-
1
params.require(:account).permit(:name, :logo)
-
end
-
end
-
1
class ApplicationController < ActionController::Base
-
1
include AllowBrowser, Authentication, Authorization, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
-
1
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
-
end
-
1
class Autocompletable::UsersController < ApplicationController
-
1
def index
-
4
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
-
end
-
-
1
private
-
1
def find_autocompletable_users
-
4
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
-
end
-
-
1
def users_scope
-
4
params[:room_id].present? ? Current.user.rooms.find(params[:room_id]).users : User.all
-
end
-
end
-
1
module AllowBrowser
-
1
extend ActiveSupport::Concern
-
-
1
VERSIONS = { safari: 17.2, chrome: 120, firefox: 121, opera: 104, ie: false }
-
-
1
included do
-
2
allow_browser versions: VERSIONS, block: -> { render template: "sessions/incompatible_browser" }
-
end
-
end
-
1
module Authentication
-
1
extend ActiveSupport::Concern
-
1
include SessionLookup
-
-
1
included do
-
1
before_action :require_authentication
-
1
before_action :deny_bots
-
1
helper_method :signed_in?
-
-
232
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
-
end
-
-
1
class_methods do
-
1
def allow_unauthenticated_access(**options)
-
5
skip_before_action :require_authentication, **options
-
end
-
-
1
def allow_bot_access(**options)
-
1
skip_before_action :deny_bots, **options
-
end
-
-
1
def require_unauthenticated_access(**options)
-
1
skip_before_action :require_authentication, **options
-
1
before_action :restore_authentication, :redirect_signed_in_user_to_root, **options
-
end
-
end
-
-
1
private
-
1
def signed_in?
-
5
Current.user.present?
-
end
-
-
1
def require_authentication
-
104
restore_authentication || bot_authentication || request_authentication
-
end
-
-
1
def restore_authentication
-
109
if session = find_session_by_cookie
-
99
resume_session session
-
end
-
end
-
-
1
def bot_authentication
-
6
if params[:bot_key].present? && bot = User.authenticate_bot(params[:bot_key].strip)
-
6
Current.user = bot
-
6
set_authenticated_by(:bot_key)
-
end
-
end
-
-
1
def request_authentication
-
session[:return_to_after_authenticating] = request.url
-
redirect_to new_session_url
-
end
-
-
1
def redirect_signed_in_user_to_root
-
5
redirect_to root_url if signed_in?
-
end
-
-
1
def start_new_session_for(user)
-
111
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
-
111
authenticated_as session
-
end
-
end
-
-
1
def resume_session(session)
-
99
session.resume user_agent: request.user_agent, ip_address: request.remote_ip
-
99
authenticated_as session
-
end
-
-
1
def authenticated_as(session)
-
210
Current.user = session.user
-
210
set_authenticated_by(:session)
-
210
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
-
end
-
-
1
def post_authenticating_url
-
109
session.delete(:return_to_after_authenticating) || root_url
-
end
-
-
1
def reset_authentication
-
2
cookies.delete(:session_token)
-
end
-
-
1
def deny_bots
-
227
head :forbidden if authenticated_by.bot_key?
-
end
-
-
1
def set_authenticated_by(method)
-
216
@authenticated_by = method.to_s.inquiry
-
end
-
-
1
def authenticated_by
-
458
@authenticated_by ||= "".inquiry
-
end
-
end
-
1
module Authentication::SessionLookup
-
1
def find_session_by_cookie
-
112
if token = cookies.signed[:session_token]
-
101
Session.find_by(token: token)
-
end
-
end
-
end
-
1
module Authorization
-
1
private
-
1
def ensure_can_administer
-
20
head :forbidden unless Current.user.can_administer?
-
end
-
end
-
1
module RoomScoped
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
3
before_action :set_room
-
end
-
-
1
private
-
1
def set_room
-
25
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
-
25
@room = @membership.room
-
end
-
end
-
1
module SetCurrentRequest
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
before_action do
-
232
Current.request = request
-
end
-
end
-
-
1
def default_url_options
-
1104
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
-
end
-
end
-
1
module SetPlatform
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
helper_method :platform
-
end
-
-
1
private
-
1
def platform
-
63
@platform ||= ApplicationPlatform.new(request.user_agent)
-
end
-
end
-
1
module TrackedRoomVisit
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
helper_method :last_room_visited
-
end
-
-
1
def remember_last_room_visited
-
4
cookies.permanent[:last_room] = @room.id
-
end
-
-
1
def last_room_visited
-
8
Current.user.rooms.find_by(id: cookies[:last_room]) || default_room
-
end
-
-
1
private
-
1
def default_room
-
7
Current.user.rooms.original
-
end
-
end
-
1
module VersionHeaders
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
before_action :set_version_headers
-
end
-
-
1
private
-
1
def set_version_headers
-
232
response.headers["X-Version"] = Rails.application.config.app_version
-
232
response.headers["X-Rev"] = Rails.application.config.git_revision
-
end
-
end
-
1
class FirstRunsController < ApplicationController
-
1
allow_unauthenticated_access
-
-
1
before_action :prevent_repeats
-
-
1
def show
-
1
@user = User.new
-
end
-
-
1
def create
-
1
user = FirstRun.create!(user_params)
-
1
start_new_session_for user
-
-
1
redirect_to root_url
-
end
-
-
1
private
-
1
def prevent_repeats
-
3
redirect_to root_url if Account.any?
-
end
-
-
1
def user_params
-
1
params.require(:user).permit(:name, :avatar, :email_address, :password)
-
end
-
end
-
1
class Messages::BoostsController < ApplicationController
-
1
before_action :set_message
-
-
1
def index
-
end
-
-
1
def new
-
end
-
-
1
def create
-
1
@boost = @message.boosts.create!(boost_params)
-
-
1
broadcast_create
-
1
redirect_to message_boosts_url(@message)
-
end
-
-
1
def destroy
-
1
@boost = Current.user.boosts.find(params[:id])
-
1
@boost.destroy!
-
-
1
broadcast_remove
-
end
-
-
1
private
-
1
def set_message
-
2
@message = Current.user.reachable_messages.find(params[:message_id])
-
end
-
-
1
def boost_params
-
1
params.require(:boost).permit(:content)
-
end
-
-
1
def broadcast_create
-
1
@boost.broadcast_append_to @boost.message.room, :messages,
-
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
-
end
-
-
1
def broadcast_remove
-
1
@boost.broadcast_remove_to @boost.message.room, :messages
-
end
-
end
-
1
class Messages::ByBotsController < MessagesController
-
1
allow_bot_access only: :create
-
-
1
def create
-
5
super
-
5
head :created, location: message_url(@message)
-
end
-
-
1
private
-
1
def message_params
-
5
if params[:attachment]
-
1
params.permit(:attachment)
-
else
-
8
reading(request.body) { |body| { body: body } }
-
end
-
end
-
-
1
def reading(io)
-
4
io.rewind
-
4
yield io.read.force_encoding("UTF-8")
-
ensure
-
4
io.rewind
-
end
-
end
-
1
class MessagesController < ApplicationController
-
1
include ActiveStorage::SetCurrent, RoomScoped
-
-
1
before_action :set_room, except: :create
-
1
before_action :set_message, only: %i[ show edit update destroy ]
-
1
before_action :ensure_can_administer, only: %i[ edit update destroy ]
-
-
1
layout false, only: :index
-
-
1
def index
-
4
@messages = find_paged_messages
-
-
4
if @messages.any?
-
3
fresh_when @messages
-
else
-
1
head :no_content
-
end
-
end
-
-
1
def create
-
8
set_room
-
8
@message = @room.messages.create_with_attachment!(message_params)
-
-
8
@message.broadcast_create
-
8
deliver_webhooks_to_bots
-
rescue ActiveRecord::RecordNotFound
-
render action: :room_not_found
-
end
-
-
1
def show
-
end
-
-
1
def edit
-
end
-
-
1
def update
-
2
@message.update!(message_params)
-
-
2
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
-
2
redirect_to room_message_url(@room, @message)
-
end
-
-
1
def destroy
-
2
@message.destroy
-
2
@message.broadcast_remove_to @room, :messages
-
end
-
-
1
private
-
1
def set_message
-
7
@message = @room.messages.find(params[:id])
-
end
-
-
1
def ensure_can_administer
-
6
head :forbidden unless Current.user.can_administer?(@message)
-
end
-
-
-
1
def find_paged_messages
-
case
-
4
when params[:before].present?
-
1
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
-
when params[:after].present?
-
1
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
-
else
-
2
@room.messages.with_creator.last_page
-
end
-
end
-
-
-
1
def message_params
-
5
params.require(:message).permit(:body, :attachment, :client_message_id)
-
end
-
-
-
1
def deliver_webhooks_to_bots
-
9
bots_eligible_for_webhook.excluding(@message.creator).each { |bot| bot.deliver_webhook_later(@message) }
-
end
-
-
1
def bots_eligible_for_webhook
-
8
@room.direct? ? @room.users.active_bots : @message.mentionees.active_bots
-
end
-
end
-
1
class QrCodeController < ApplicationController
-
1
allow_unauthenticated_access
-
-
1
def show
-
1
url = Base64.urlsafe_decode64(params[:id])
-
1
qr_code = RQRCode::QRCode.new(url).as_svg(viewbox: true, fill: :white, color: :black)
-
-
1
expires_in 1.year, public: true
-
1
render plain: qr_code, content_type: "image/svg+xml"
-
end
-
end
-
1
class Rooms::ClosedsController < RoomsController
-
1
before_action :force_room_type, only: %i[ edit update ]
-
-
1
DEFAULT_ROOM_NAME = "New room"
-
-
1
def show
-
redirect_to room_url(@room)
-
end
-
-
1
def new
-
1
@room = Rooms::Closed.new(name: DEFAULT_ROOM_NAME)
-
1
@users = User.active.ordered
-
end
-
-
1
def create
-
1
room = Rooms::Closed.create_for(room_params, users: grantees)
-
-
1
broadcast_create_room(room)
-
1
redirect_to room_url(room)
-
end
-
-
1
def edit
-
selected_user_ids = @room.users.pluck(:id)
-
@selected_users, @unselected_users = User.active.ordered.partition { |user| selected_user_ids.include?(user.id) }
-
end
-
-
1
def update
-
3
@room.update! room_params
-
3
@room.memberships.revise(granted: grantees, revoked: revokees)
-
-
3
broadcast_update_room
-
3
redirect_to room_url(@room)
-
end
-
-
1
private
-
# Allows us to edit an open room and turn it into a closed one on saving.
-
1
def force_room_type
-
3
@room = @room.becomes!(Rooms::Closed)
-
end
-
-
1
def grantees
-
4
User.where(id: grantee_ids)
-
end
-
-
1
def revokees
-
3
@room.users.where.not(id: grantee_ids)
-
end
-
-
1
def grantee_ids
-
7
params.fetch(:user_ids, [])
-
end
-
-
1
def broadcast_create_room(room)
-
1
each_user_and_html_for(room) do |user, html|
-
3
broadcast_prepend_to user, :rooms, target: :shared_rooms, html: html
-
end
-
end
-
-
1
def broadcast_update_room
-
3
each_user_and_html_for(@room) do |user, html|
-
7
broadcast_replace_to user, :rooms, target: [ @room, :list ], html: html
-
end
-
end
-
-
1
def each_user_and_html_for(room)
-
# Optimization to avoid rendering the same partial for every user
-
4
html = render_to_string(partial: "users/sidebars/rooms/shared", locals: { room: room })
-
-
14
room.users.each { |user| yield user, html }
-
end
-
end
-
1
class Rooms::DirectsController < RoomsController
-
1
def new
-
@room = Rooms::Direct.new
-
end
-
-
1
def create
-
3
room = Rooms::Direct.find_or_create_for(selected_users)
-
-
3
broadcast_create_room(room)
-
3
redirect_to room_url(room)
-
end
-
-
1
def edit
-
end
-
-
1
private
-
1
def selected_users
-
3
User.where(id: selected_users_ids.including(Current.user.id))
-
end
-
-
1
def selected_users_ids
-
3
params.fetch(:user_ids, [])
-
end
-
-
1
def broadcast_create_room(room)
-
3
room.memberships.each do |membership|
-
6
membership.broadcast_prepend_to membership.user, :rooms, target: :direct_rooms, partial: "users/sidebars/rooms/direct"
-
end
-
end
-
-
# All users in a direct room can administer it
-
1
def ensure_can_administer
-
1
true
-
end
-
end
-
1
class Rooms::InvolvementsController < ApplicationController
-
1
include RoomScoped
-
-
1
def show
-
1
@involvement = @membership.involvement
-
end
-
-
1
def update
-
4
@membership.update! involvement: params[:involvement]
-
-
4
broadcast_visibility_changes
-
4
redirect_to room_involvement_url(@room)
-
end
-
-
1
private
-
1
def broadcast_visibility_changes
-
case
-
4
when @room.direct?
-
# Do nothing
-
when @membership.involved_in_invisible?
-
1
broadcast_remove_to @membership.user, :rooms, target: [ @room, :list ]
-
when @membership.involvement_previously_was.inquiry.invisible?
-
1
broadcast_prepend_to @membership.user, :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: @room }
-
end
-
end
-
end
-
1
class Rooms::OpensController < RoomsController
-
1
before_action :force_room_type, only: %i[ edit update ]
-
-
1
DEFAULT_ROOM_NAME = "New room"
-
-
1
def show
-
2
redirect_to room_url(@room)
-
end
-
-
1
def new
-
1
@room = Rooms::Open.new(name: DEFAULT_ROOM_NAME)
-
1
@users = User.active.ordered
-
end
-
-
1
def create
-
1
room = Rooms::Open.create_for(room_params, users: Current.user)
-
-
1
broadcast_create_room(room)
-
1
redirect_to room_url(room)
-
end
-
-
1
def edit
-
@users = User.active.ordered
-
end
-
-
1
def update
-
2
@room.update! room_params
-
-
2
broadcast_update_room
-
2
redirect_to room_url(@room)
-
end
-
-
1
private
-
# Allows us to edit a closed room and turn it into an open one on saving.
-
1
def force_room_type
-
2
@room = @room.becomes!(Rooms::Open)
-
end
-
-
1
def broadcast_create_room(room)
-
1
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
-
end
-
-
1
def broadcast_update_room
-
2
broadcast_replace_to :rooms, target: [ @room, :list ], partial: "users/sidebars/rooms/shared", locals: { room: @room }
-
end
-
end
-
1
class Rooms::RefreshesController < ApplicationController
-
1
include RoomScoped
-
-
1
before_action :set_last_updated_at
-
-
1
def show
-
1
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
-
1
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
-
end
-
-
1
private
-
1
def set_last_updated_at
-
1
@last_updated_at = Time.at(0, params[:since].to_i, :millisecond)
-
end
-
end
-
1
class RoomsController < ApplicationController
-
1
before_action :set_room, only: %i[ edit update show destroy ]
-
1
before_action :ensure_can_administer, only: %i[ update destroy ]
-
1
before_action :remember_last_room_visited, only: :show
-
-
1
def index
-
1
redirect_to room_url(Current.user.rooms.last)
-
end
-
-
1
def show
-
2
@messages = find_messages
-
end
-
-
1
def destroy
-
3
@room.destroy
-
-
3
broadcast_remove_room
-
3
redirect_to root_url
-
end
-
-
1
private
-
1
def set_room
-
16
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
-
15
@room = room
-
else
-
1
redirect_to root_url, alert: "Room not found or inaccessible"
-
end
-
end
-
-
1
def ensure_can_administer
-
10
head :forbidden unless Current.user.can_administer?(@room)
-
end
-
-
1
def find_messages
-
2
messages = @room.messages.with_creator
-
-
2
if show_first_message = messages.find_by(id: params[:message_id])
-
@messages = messages.page_around(show_first_message)
-
else
-
2
@messages = messages.last_page
-
end
-
end
-
-
1
def room_params
-
7
params.require(:room).permit(:name)
-
end
-
-
1
def broadcast_remove_room
-
3
broadcast_remove_to :rooms, target: [ @room, :list ]
-
end
-
end
-
1
class SearchesController < ApplicationController
-
1
before_action :set_messages
-
-
1
def index
-
3
@query = query if query.present?
-
3
@recent_searches = Current.user.searches.ordered
-
3
@return_to_room = last_room_visited
-
end
-
-
1
def create
-
1
Current.user.searches.record(query)
-
1
redirect_to searches_url(q: query)
-
end
-
-
1
def clear
-
1
Current.user.searches.destroy_all
-
1
redirect_to searches_url
-
end
-
-
1
private
-
1
def set_messages
-
5
if query.present?
-
3
@messages = Current.user.reachable_messages.search(query).last(100)
-
else
-
2
@messages = Message.none
-
end
-
end
-
-
1
def query
-
15
params[:q]&.gsub(/[^[:word:]]/, " ")
-
end
-
end
-
1
class Sessions::TransfersController < ApplicationController
-
1
allow_unauthenticated_access
-
-
1
def show
-
end
-
-
1
def update
-
1
if user = User.active.find_by_transfer_id(params[:id])
-
1
start_new_session_for user
-
1
redirect_to post_authenticating_url
-
else
-
head :bad_request
-
end
-
end
-
end
-
1
class SessionsController < ApplicationController
-
1
allow_unauthenticated_access only: %i[ new create ]
-
1
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
-
-
1
before_action :ensure_user_exists, only: :new
-
-
1
def new
-
end
-
-
1
def create
-
109
if user = User.active.authenticate_by(email_address: params[:email_address], password: params[:password])
-
108
start_new_session_for user
-
108
redirect_to post_authenticating_url
-
else
-
1
render_rejection :unauthorized
-
end
-
end
-
-
1
def destroy
-
2
remove_push_subscription
-
2
reset_authentication
-
2
redirect_to root_url
-
end
-
-
1
private
-
1
def ensure_user_exists
-
3
redirect_to first_run_url if User.none?
-
end
-
-
1
def render_rejection(status)
-
1
flash.now[:alert] = "Too many requests or unauthorized."
-
1
render :new, status: status
-
end
-
-
1
def remove_push_subscription
-
2
if endpoint = params[:push_subscription_endpoint]
-
1
Push::Subscription.destroy_by(endpoint: endpoint, user_id: Current.user.id)
-
end
-
end
-
end
-
1
class UnfurlLinksController < ApplicationController
-
1
def create
-
5
opengraph = Opengraph::Metadata.from_url(url_param)
-
-
4
if opengraph.valid?
-
3
render json: opengraph
-
else
-
1
head :no_content
-
end
-
end
-
-
1
private
-
1
def url_param
-
5
params.require(:url)
-
end
-
end
-
1
class Users::AvatarsController < ApplicationController
-
1
include ActiveStorage::Streaming
-
-
2
rescue_from(ActiveSupport::MessageVerifier::InvalidSignature) { head :not_found }
-
-
1
def show
-
3
@user = User.from_avatar_token(params[:user_id])
-
-
2
if stale?(etag: @user)
-
2
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
-
-
2
if @user.avatar.attached?
-
1
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
-
1
send_webp_blob_file avatar_variant.key
-
1
elsif @user.bot?
-
render_default_bot
-
else
-
1
render_initials
-
end
-
end
-
end
-
-
1
def destroy
-
Current.user.avatar.destroy
-
redirect_to user_profile_url
-
end
-
-
1
private
-
1
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
-
-
1
def send_webp_blob_file(key)
-
1
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
-
end
-
-
1
def render_default_bot
-
send_file Rails.root.join("app/assets/images/default-bot-avatar.svg"), content_type: "image/svg+xml", disposition: :inline
-
end
-
-
1
def render_initials
-
1
render formats: :svg
-
end
-
end
-
1
class Users::ProfilesController < ApplicationController
-
1
before_action :set_user
-
-
1
def show
-
@direct_memberships, @shared_memberships =
-
7
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
-
end
-
-
1
def update
-
2
@user.update user_params
-
2
redirect_to user_profile_url, notice: update_notice
-
end
-
-
1
private
-
1
def set_user
-
3
@user = Current.user
-
end
-
-
1
def user_params
-
2
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
-
end
-
-
1
def update_notice
-
2
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
-
end
-
end
-
1
class Users::PushSubscriptionsController < ApplicationController
-
1
before_action :set_push_subscriptions
-
-
1
def index
-
end
-
-
1
def create
-
2
if subscription = @push_subscriptions.find_by(push_subscription_params)
-
1
subscription.touch
-
else
-
1
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
-
end
-
-
2
head :ok
-
end
-
-
1
def destroy
-
1
@push_subscriptions.destroy_by(id: params[:id])
-
1
redirect_to user_push_subscriptions_url
-
end
-
-
1
private
-
1
def set_push_subscriptions
-
3
@push_subscriptions = Current.user.push_subscriptions
-
end
-
-
1
def push_subscription_params
-
3
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
-
end
-
end
-
1
class Users::SidebarsController < ApplicationController
-
1
DIRECT_PLACEHOLDERS = 20
-
-
1
def show
-
3
all_memberships = Current.user.memberships.visible.with_ordered_room
-
3
@direct_memberships = extract_direct_memberships(all_memberships)
-
3
@other_memberships = all_memberships.without(@direct_memberships)
-
-
3
@direct_placeholder_users = find_direct_placeholder_users
-
end
-
-
1
private
-
1
def extract_direct_memberships(all_memberships)
-
27
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
-
end
-
-
1
def find_direct_placeholder_users
-
3
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
-
3
User.active.where.not(id: exclude_user_ids).order(:created_at).limit(DIRECT_PLACEHOLDERS - exclude_user_ids.count)
-
end
-
-
1
def user_ids_already_in_direct_rooms_with_current_user
-
3
Membership.where(room_id: Current.user.rooms.directs.pluck(:id)).pluck(:user_id).uniq
-
end
-
end
-
1
class UsersController < ApplicationController
-
1
require_unauthenticated_access only: %i[ new create ]
-
-
1
before_action :set_user, only: :show
-
1
before_action :verify_join_code, only: %i[ new create ]
-
-
1
def new
-
1
@user = User.new
-
end
-
-
1
def create
-
2
@user = User.create!(user_params)
-
1
start_new_session_for @user
-
1
redirect_to root_url
-
rescue ActiveRecord::RecordNotUnique
-
1
redirect_to new_session_url(email_address: user_params[:email_address])
-
end
-
-
1
def show
-
end
-
-
1
private
-
1
def set_user
-
1
@user = User.find(params[:id])
-
end
-
-
1
def verify_join_code
-
4
head :not_found if Current.account.join_code != params[:join_code]
-
end
-
-
1
def user_params
-
3
params.require(:user).permit(:name, :avatar, :email_address, :password)
-
end
-
end
-
1
class WelcomeController < ApplicationController
-
1
def show
-
2
if Current.user.rooms.any?
-
2
redirect_to room_url(last_room_visited)
-
else
-
render
-
end
-
end
-
end
-
1
module AccountsHelper
-
1
def account_logo_tag(style: nil)
-
4
tag.figure image_tag(fresh_account_logo_path, alt: "Account logo", size: 300), class: "account-logo avatar #{style}"
-
end
-
end
-
1
module ApplicationHelper
-
1
def page_title_tag
-
26
tag.title @page_title || "Campfire"
-
end
-
-
1
def current_user_meta_tags
-
26
unless Current.user.nil?
-
19
safe_join [
-
tag(:meta, name: "current-user-id", content: Current.user.id),
-
tag(:meta, name: "current-user-name", content: Current.user.name)
-
]
-
end
-
end
-
-
1
def custom_styles_tag
-
26
if custom_styles = Current.account&.custom_styles
-
tag.style(custom_styles.to_s.html_safe, data: { turbo_track: "reload" })
-
end
-
end
-
-
1
def body_classes
-
26
[ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ")
-
end
-
-
1
def link_back
-
2
link_back_to request.referrer || root_path
-
end
-
-
1
def link_back_to(destination)
-
9
link_to destination, class: "btn" do
-
9
image_tag("arrow-left.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span("Go Back", class: "for-screen-reader")
-
end
-
end
-
-
1
private
-
1
def admin_body_class
-
26
"admin" if Current.user&.can_administer?
-
end
-
-
1
def account_logo_body_class
-
26
"account-has-logo" if Current.account&.logo&.attached?
-
end
-
end
-
1
module BroadcastsHelper
-
1
def broadcast_image_tag(image, options)
-
2
image_tag(broadcast_image_path(image), options)
-
end
-
-
1
def broadcast_image_path(image)
-
2
if image.is_a?(Symbol) || image.is_a?(String)
-
image_path(image)
-
else
-
2
polymorphic_url(image, only_path: true)
-
end
-
end
-
end
-
1
module ClipboardHelper
-
1
def button_to_copy_to_clipboard(url, &)
-
5
tag.button class: "btn", data: {
-
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
-
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url
-
}, &
-
end
-
end
-
1
module ContentFilters
-
1
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
-
end
-
1
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
-
1
def applicable?
-
41
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
-
end
-
-
1
def apply
-
9
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
-
end
-
-
1
private
-
1
TWITTER_DOMAINS = %w[ x.com twitter.com ]
-
1
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
-
-
1
def solo_unfurled_url
-
41
unfurled_links.first["href"] if unfurled_links.size == 1
-
end
-
-
1
def unfurled_links
-
48
fragment.find_all("action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']")
-
end
-
-
1
def normalize_tweet_url(url)
-
82
return url unless twitter_url?(url)
-
-
4
uri = URI.parse(url)
-
-
4
uri.dup.tap do |u|
-
4
u.host = TWITTER_DOMAIN_MAPPING[uri.host&.downcase] || uri.host
-
4
u.query = nil
-
end.to_s
-
rescue URI::InvalidURIError
-
url
-
end
-
-
1
def twitter_url?(url)
-
170
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
-
end
-
end
-
1
class ContentFilters::SanitizeTags < ActionText::Content::Filter
-
1
def applicable?
-
41
true
-
end
-
-
1
def apply
-
42
fragment.replace(not_allowed_tags_css_selector) { nil }
-
end
-
-
1
private
-
1
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
-
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
-
-
1
def not_allowed_tags_css_selector
-
1845
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
-
end
-
end
-
1
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
-
1
def applicable?
-
43
unfurled_twitter_avatars.present?
-
end
-
-
1
def apply
-
1
fragment.update do |source|
-
1
div = source.at_css("div")
-
1
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
-
end
-
end
-
-
1
private
-
1
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
-
1
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
-
-
1
def unfurled_twitter_avatars
-
43
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
-
end
-
-
1
def opengraph_css_selector
-
43
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
-
end
-
end
-
1
module DropTargetHelper
-
1
def drop_target_actions
-
4
"dragenter->drop-target#dragenter dragover->drop-target#dragover drop->drop-target#drop"
-
end
-
end
-
1
module EmojiHelper
-
REACTIONS = {
-
1
"👍" => "Thumbs up",
-
"👏" => "Clapping",
-
"👋" => "Waving hand",
-
"💪" => "Muscle",
-
"❤️" => "Red heart",
-
"😂" => "Face with tears of joy",
-
"🎉" => "Party popper",
-
"🔥" => "Fire"
-
}
-
end
-
1
module FormsHelper
-
1
def auto_submit_form_with(**attributes, &)
-
1
data = attributes.delete(:data) || {}
-
1
data[:controller] = "auto-submit #{data[:controller]}".strip
-
-
1
form_with **attributes, data: data, &
-
end
-
end
-
1
class Messages::AttachmentPresentation
-
1
def initialize(message, context:)
-
2
@message, @context = message, context
-
end
-
-
1
def render
-
2
if message.attachment.attached?
-
2
if message.attachment.previewable? || message.attachment.variable?
-
2
render_preview
-
else
-
render_link
-
end
-
end
-
end
-
-
1
private
-
1
attr_reader :message, :context
-
1
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
-
-
1
def render_preview
-
2
if message.attachment.video?
-
video_preview_tag
-
else
-
2
lightboxed_image_preview_tag
-
end
-
end
-
-
1
def video_preview_tag
-
width, height = preview_dimensions
-
-
inline_media_dimension_constraints(width, height) do
-
tag.video \
-
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
-
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
-
end
-
end
-
-
1
def lightboxed_image_preview_tag
-
2
width, height = preview_dimensions
-
-
2
inline_media_dimension_constraints(width, height) do
-
2
lightbox_link do
-
2
broadcast_image_tag message.attachment.representation(:thumb), width: width, height: height, class: "message__attachment", loading: "lazy"
-
end
-
end
-
end
-
-
1
def inline_media_dimension_constraints(width, height, &)
-
2
if width && height
-
2
aspect_ratio = (width / height.to_f)
-
-
2
tag.div class: "max-inline-size center flex overflow-clip", style: "width: #{width / 2}px; aspect-ratio: #{aspect_ratio};", &
-
else
-
tag.div class: "max-inline-size center overflow-clip", &
-
end
-
end
-
-
1
def preview_dimensions
-
2
width = message.attachment.metadata[:width]
-
2
height = message.attachment.metadata[:height]
-
-
case
-
2
when width.nil? || height.nil?
-
[ nil, nil ]
-
when width <= Message::THUMBNAIL_MAX_WIDTH && height <= Message::THUMBNAIL_MAX_HEIGHT
-
[ width, height ]
-
else
-
2
width_factor = Message::THUMBNAIL_MAX_WIDTH.to_f / width
-
2
height_factor = Message::THUMBNAIL_MAX_HEIGHT.to_f / height
-
2
scale_factor = [ width_factor, height_factor ].min
-
-
2
[ width * scale_factor, height * scale_factor ]
-
end
-
end
-
-
1
def render_link
-
tag.div class: "flex-inline align-center gap-half" do
-
broadcast_image_tag("common-file-text.svg", size: 22, class: "colorize--black", aria: { hidden: "true" }) +
-
tag.span(filename) + download_link + share_button
-
end
-
end
-
-
1
def lightbox_link(&)
-
2
link_to rails_blob_path(message.attachment), class: "flex", data: {
-
lightbox_target: "image", action: "lightbox#open", lightbox_url_value: download_url }, &
-
end
-
-
1
def download_link
-
link_to download_url, class: "btn message__action-btn hide-in-ios-pwa", style: "--width: auto;" do
-
broadcast_image_tag("download.svg", aria: { hidden: "true" }, size: 20) + tag.span("Download #{ filename }", class: "for-screen-reader")
-
end
-
end
-
-
1
def share_button
-
tag.button class: "btn message__action-btn", style: "--width: auto;", data: { controller: "web-share", action: "web-share#share", web_share_files_value: download_url } do
-
broadcast_image_tag("share.svg", aria: { hidden: "true" }, size: 20) + tag.span("Share #{ filename }", class: "for-screen-reader")
-
end
-
end
-
-
1
def filename
-
message.attachment.filename.to_s
-
end
-
-
1
def download_url
-
2
rails_blob_path message.attachment, disposition: "attachment", only_path: true
-
end
-
end
-
1
module MessagesHelper
-
1
def message_area_tag(room, &)
-
2
tag.div id: "message-area", class: "message-area", contents: true, data: {
-
controller: "messages presence drop-target",
-
action: [ messages_actions, drop_target_actions, presence_actions ].join(" "),
-
messages_first_of_day_class: "message--first-of-day",
-
messages_formatted_class: "message--formatted",
-
messages_me_class: "message--me",
-
messages_mentioned_class: "message--mentioned",
-
messages_threaded_class: "message--threaded",
-
messages_page_url_value: room_messages_url(room)
-
}, &
-
end
-
-
1
def messages_tag(room, &)
-
2
tag.div id: dom_id(room, :messages), class: "messages", data: {
-
controller: "maintain-scroll refresh-room",
-
action: [ maintain_scroll_actions, refresh_room_actions ].join(" "),
-
messages_target: "messages",
-
refresh_room_loaded_at_value: room.updated_at.to_fs(:epoch),
-
refresh_room_url_value: room_refresh_url(room)
-
}, &
-
end
-
-
1
def message_tag(message, &)
-
37
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
-
-
37
tag.div id: dom_id(message),
-
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
-
data: {
-
controller: "reply",
-
user_id: message.creator_id,
-
message_id: message.id,
-
message_timestamp: message_timestamp_milliseconds,
-
message_updated_at: message.updated_at.to_fs(:epoch),
-
sort_value: message_timestamp_milliseconds,
-
messages_target: "message",
-
search_results_target: "message",
-
refresh_room_target: "message",
-
reply_composer_outlet: "#composer"
-
}, &
-
rescue Exception => e
-
Sentry.capture_exception(e, extra: { message: message })
-
Rails.logger.error "Exception while rendering message #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
-
-
render "messages/unrenderable"
-
end
-
-
1
def message_timestamp(message, **attributes)
-
37
local_datetime_tag message.created_at, **attributes
-
end
-
-
1
def message_presentation(message)
-
37
case message.content_type
-
when "attachment"
-
2
message_attachment_presentation(message)
-
when "sound"
-
message_sound_presentation(message)
-
else
-
35
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
-
end
-
rescue Exception => e
-
Sentry.capture_exception(e, extra: { message: message })
-
Rails.logger.error "Exception while generating message representation for #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
-
-
""
-
end
-
-
1
private
-
1
def messages_actions
-
2
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
-
end
-
-
1
def maintain_scroll_actions
-
2
"turbo:before-stream-render@document->maintain-scroll#beforeStreamRender"
-
end
-
-
1
def refresh_room_actions
-
2
"visibilitychange@document->refresh-room#visibilityChanged online@window->refresh-room#online"
-
end
-
-
1
def presence_actions
-
2
"visibilitychange@document->presence#visibilityChanged"
-
end
-
-
1
def message_attachment_presentation(message)
-
2
Messages::AttachmentPresentation.new(message, context: self).render
-
end
-
-
1
def message_sound_presentation(message)
-
sound = message.sound
-
-
tag.div class: "sound", data: { controller: "sound", action: "messages:play->sound#play", sound_url_value: asset_path(sound.asset_path) } do
-
play_button + (sound.image ? sound_image_tag(sound.image) : sound.text)
-
end
-
end
-
-
1
def play_button
-
tag.button "🔊", class: "btn btn--plain", data: { action: "sound#play" }
-
end
-
-
1
def sound_image_tag(image)
-
image_tag image.asset_path, width: image.width, height: image.height, class: "align--middle"
-
end
-
-
1
def message_author_title(author)
-
[ author.name, author.bio ].compact_blank.join(" – ")
-
end
-
end
-
1
module QrCodeHelper
-
1
def link_to_zoom_qr_code(url, &)
-
3
id = Base64.urlsafe_encode64(url)
-
-
3
link_to qr_code_path(id), class: "btn", data: {
-
lightbox_target: "image", action: "lightbox#open", lightbox_url_value: qr_code_path(id) }, &
-
end
-
end
-
1
module RichTextHelper
-
1
def rich_text_data_actions
-
default_actions =
-
2
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
-
-
autocomplete_actions =
-
2
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
-
-
2
[ default_actions, autocomplete_actions ].join(" ")
-
end
-
end
-
1
module Rooms::InvolvementsHelper
-
1
def turbo_frame_for_involvement_tag(room, &)
-
1
turbo_frame_tag dom_id(room, :involvement), data: {
-
controller: "turbo-frame", action: "notifications:ready@window->turbo-frame#load", turbo_frame_url_param: room_involvement_path(room)
-
}, &
-
end
-
-
1
def button_to_change_involvement(room, involvement)
-
7
button_to room_involvement_path(room, involvement: next_involvement_for(room, involvement: involvement)),
-
method: :put,
-
role: "checkbox", aria: { checked: true, labelledby: dom_id(room, :involvement_label) }, tabindex: 0,
-
class: "btn #{involvement}" do
-
7
image_tag("notification-bell-#{involvement}.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span(HUMANIZE_INVOLVEMENT[involvement], class: "for-screen-reader", id: dom_id(room, :involvement_label))
-
end
-
end
-
-
1
private
-
HUMANIZE_INVOLVEMENT = {
-
1
"mentions" => "Notifying about @ mentions",
-
"everything" => "Notifying about all messages",
-
"nothing" => "Notifications are off",
-
"invisible" => "Notifications are off and room invisible in sidebar"
-
}
-
-
1
SHARED_INVOLVEMENT_ORDER = %w[ mentions everything nothing invisible ]
-
1
DIRECT_INVOLVEMENT_ORDER = %w[ everything nothing ]
-
-
1
def next_involvement_for(room, involvement:)
-
7
if room.direct?
-
2
DIRECT_INVOLVEMENT_ORDER[DIRECT_INVOLVEMENT_ORDER.index(involvement) + 1] || DIRECT_INVOLVEMENT_ORDER.first
-
else
-
5
SHARED_INVOLVEMENT_ORDER[SHARED_INVOLVEMENT_ORDER.index(involvement) + 1] || SHARED_INVOLVEMENT_ORDER.first
-
end
-
end
-
end
-
1
module RoomsHelper
-
1
def link_to_room(room, **attributes, &)
-
32
link_to room_path(room), **attributes, data: {
-
rooms_list_target: "room", room_id: room.id, badge_dot_target: "unread", sorted_list_target: "item"
-
}.merge(attributes.delete(:data) || {}), &
-
end
-
-
1
def link_to_edit_room(room, &)
-
2
link_to \
-
[ :edit, @room ],
-
class: "btn",
-
style: "view-transition-name: edit-room-#{@room.id}",
-
data: { room_id: @room.id },
-
&
-
end
-
-
1
def link_back_to_last_room_visited
-
3
if last_room = last_room_visited
-
3
link_back_to room_path(last_room)
-
else
-
link_back_to root_path
-
end
-
end
-
-
1
def button_to_delete_room(room, url: nil)
-
button_to url || room_url(room), method: :delete, class: "btn btn--negative max-width", aria: { label: "Delete #{room.name}" },
-
data: { turbo_confirm: "Are you sure you want to delete this room and all messages in it? This can’t be undone." } do
-
image_tag("trash.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span(room_display_name(room), class: "overflow-ellipsis")
-
end
-
end
-
-
1
def button_to_jump_to_newest_message
-
2
tag.button \
-
class: "message-area__return-to-latest btn",
-
data: { action: "messages#returnToLatest", messages_target: "latest" },
-
hidden: true do
-
2
image_tag("arrow-down.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span("Jump to newest message", class: "for-screen-reader")
-
end
-
end
-
-
1
def submit_room_button_tag
-
2
button_tag class: "btn btn--reversed txt-large center", type: "submit" do
-
2
image_tag("check.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span("Save", class: "for-screen-reader")
-
end
-
end
-
-
1
def composer_form_tag(room, &)
-
2
form_with model: Message.new, url: room_messages_path(room),
-
id: "composer", class: "margin-block flex-item-grow contain", data: composer_data_options(room), &
-
end
-
-
1
def room_display_name(room, for_user: Current.user)
-
48
if room.direct?
-
7
room.users.without(for_user).pluck(:name).to_sentence.presence || for_user&.name
-
else
-
41
room.name
-
end
-
end
-
-
1
private
-
1
def composer_data_options(room)
-
{
-
2
controller: "composer drop-target",
-
action: composer_data_actions,
-
composer_messages_outlet: "#message-area",
-
composer_toolbar_class: "composer--rich-text", composer_room_id_value: room.id
-
}
-
end
-
-
1
def composer_data_actions
-
2
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
-
-
trix_attachment_actions =
-
2
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
-
-
remaining_actions =
-
2
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
-
-
2
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
-
end
-
end
-
1
module SearchesHelper
-
1
def search_results_tag(&)
-
3
tag.div id: "search-results", class: "messages searches__results", data: {
-
controller: "search-results",
-
search_results_target: "messages",
-
search_results_me_class: "message--me",
-
search_results_threaded_class: "message--threaded",
-
search_results_mentioned_class: "message--mentioned",
-
search_results_formatted_class: "message--formatted"
-
}, &
-
end
-
end
-
1
module TimeHelper
-
1
def local_datetime_tag(datetime, style: :time, **attributes)
-
74
tag.time **attributes, datetime: datetime.iso8601, data: { local_time_target: style }
-
end
-
end
-
1
module TranslationsHelper
-
TRANSLATIONS = {
-
1
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email" },
-
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha" },
-
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha" },
-
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome" },
-
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta" },
-
room_name: { "🇺🇸": "Name the room", "🇪🇸": "Nombrar la sala", "🇫🇷": "Nommez la salle", "🇮🇳": "कमरे का नाम दें", "🇩🇪": "Geben Sie dem Raum einen Namen", "🇧🇷": "Dê um nome a essa sala" },
-
invite_message: { "🇺🇸": "Welcome to Campfire. To invite some people to chat with you, share the join link below.", "🇪🇸": "Bienvenido a Campfire. Para invitar a algunas personas a chatear contigo, comparte el enlace de unión que se encuentra a continuación.", "🇫🇷": "Bienvenue sur Campfire. Pour inviter des personnes à discuter avec vous, partagez le lien pour rejoindre ci-dessous.", "🇮🇳": "Campfire में आपका स्वागत है। अधिक लोगों को चैट के लिए आमंत्रित करने के लिए, नीचे जुड़ने का लिंक साझा करें।", "🇩🇪": "Willkommen bei Campfire. Um einige Personen zum Chatten einzuladen, teilen Sie den unten stehenden Beitrittslink.", "🇧🇷": "Boas vindas ao Campfire. Para convidar pessoas para conversarem com você, compartilhe o link de convite abaixo." },
-
incompatible_browser_messsage: { "🇺🇸": "Upgrade to a supported web browser. Campfire requires a modern web browser. Please use one of the browsers listed below and make sure auto-updates are enabled.", "🇪🇸": "Actualiza a un navegador web compatible. Campfire requiere un navegador web moderno. Utiliza uno de los navegadores listados a continuación y asegúrate de que las actualizaciones automáticas estén habilitadas.", "🇫🇷": "Mettez à jour vers un navigateur web pris en charge. Campfire nécessite un navigateur web moderne. Veuillez utiliser l'un des navigateurs répertoriés ci-dessous et assurez-vous que les mises à jour automatiques sont activées.", "🇮🇳": "समर्थित वेब ब्राउज़र में अपग्रेड करें। Campfire को एक आधुनिक वेब ब्राउज़र की आवश्यकता है। कृपया नीचे सूचीबद्ध ब्राउज़रों में से कोई एक का उपयोग करें और सुनिश्चित करें कि स्वचालित अपडेट्स सक्षम हैं।", "🇩🇪": "Aktualisieren Sie auf einen unterstützten Webbrowser. Campfire erfordert einen modernen Webbrowser. Verwenden Sie bitte einen der unten aufgeführten Browser und stellen Sie sicher, dass automatische Updates aktiviert sind.", "🇧🇷": "Atualize para um navegador compatível. O Campfire requer um navegador moderno. Por favor, use um dos navegadores listados abaixo e certifique-se de que as atualizações automáticas estão ativadas." },
-
bio: { "🇺🇸": "Enter a few words about yourself.", "🇪🇸": "Ingresa algunas palabras sobre ti mismo.", "🇫🇷": "Saisissez quelques mots à propos de vous-même.", "🇮🇳": "अपने बारे में कुछ शब्द लिखें.", "🇩🇪": "Geben Sie ein paar Worte über sich selbst ein.", "🇧🇷": "Insira alguma palavras sobre você." },
-
webhook_url: { "🇺🇸": "Webhook URL", "🇪🇸": "URL del Webhook", "🇫🇷": "URL du webhook", "🇮🇳": "वेबहुक URL", "🇩🇪": "Webhook-URL", "🇧🇷": "URL do Webhook" },
-
chat_bots: { "🇺🇸": "Chat bots. With Chat bots, other sites and services can post updates directly to Campfire.", "🇪🇸": "Bots de chat. Con los bots de chat, otros sitios y servicios pueden publicar actualizaciones directamente en Campfire.", "🇫🇷": "Bots de discussion. Avec les bots de discussion, d'autres sites et services peuvent publier des mises à jour directement sur Campfire.", "🇮🇳": "चैट बॉट। चैट बॉट के साथ, अन्य साइटों और सेवाएं सीधे कैम्पफायर में अपडेट पोस्ट कर सकती हैं।", "🇩🇪": "Chat-Bots. Mit Chat-Bots können andere Websites und Dienste Updates direkt in Campfire veröffentlichen.", "🇧🇷": "Chat bots. Com Chat bots, outros sites e serviços podem postar atualizações diretamente no Campfire." },
-
bot_name: { "🇺🇸": "Name the bot", "🇪🇸": "Nombrar al bot", "🇫🇷": "Nommer le bot", "🇮🇳": "बॉट का नाम दें", "🇩🇪": "Benenne den Bot", "🇧🇷": "Dê um nome ao bot" },
-
custom_styles: { "🇺🇸": "Add custom CSS styles. Use Caution: you could break things.", "🇪🇸": "Agrega estilos CSS personalizados. Usa precaución: podrías romper cosas.", "🇫🇷": "Ajoutez des styles CSS personnalisés. Utilisez avec précaution : vous pourriez casser des choses.", "🇮🇳": "कस्टम CSS स्टाइल जोड़ें। सावधानी बरतें: आप चीज़ों को तोड़ सकते हैं।", "🇩🇪": "Fügen Sie benutzerdefinierte CSS-Stile hinzu. Vorsicht: Sie könnten Dinge kaputt machen.", "🇧🇷": "Adicione estilos CSS personalizados. Use com cuidado: você pode quebrar coisas." }
-
}
-
-
1
def translations_for(translation_key)
-
26
tag.dl(class: "language-list") do
-
26
TRANSLATIONS[translation_key].map do |language, translation|
-
156
concat tag.dt(language)
-
156
concat tag.dd(translation, class: "margin-none")
-
end
-
end
-
end
-
-
1
def translation_button(translation_key)
-
26
tag.details(class: "position-relative", data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" }) do
-
26
tag.summary(class: "btn", tabindex: -1) do
-
26
concat image_tag("globe.svg", size: 20, aria: { hidden: "true" }, class: "color-icon")
-
26
concat tag.span("Translate", class: "for-screen-reader")
-
end +
-
tag.div(class: "lanuage-list-menu shadow", data: { popup_target: "menu" }) do
-
26
translations_for(translation_key)
-
end
-
end
-
end
-
end
-
1
require "zlib"
-
-
1
module Users::AvatarsHelper
-
AVATAR_COLORS = %w[
-
1
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
-
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
-
]
-
-
1
def avatar_background_color(user)
-
1
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
-
end
-
-
1
def avatar_tag(user, **options)
-
69
link_to user_path(user), title: user.title, class: "btn avatar", data: { turbo_frame: "_top" } do
-
69
image_tag fresh_user_avatar_path(user), aria: { hidden: "true" }, size: 48, **options
-
end
-
end
-
end
-
1
module Users::FilterHelper
-
1
def user_filter_menu_tag(&)
-
2
tag.menu class: "flex flex-column gap margin-none pad overflow-y constrain-height",
-
data: { controller: "filter", filter_active_class: "filter--active", filter_selected_class: "selected" }, &
-
end
-
-
1
def user_filter_search_tag
-
tag.input type: "search", id: "search", autocorrect: "off", autocomplete: "off", "data-1p-ignore": "true", class: "input input--transparent full-width", placeholder: "Filter…", data: { action: "input->filter#filter" }
-
end
-
end
-
1
module Users::ProfilesHelper
-
1
def profile_form_with(model, **params, &)
-
3
form_with \
-
model: @user, url: user_profile_path, method: :patch,
-
data: { controller: "form" },
-
**params,
-
&
-
end
-
-
1
def profile_form_submit_button
-
3
tag.button class: "btn btn--reversed center txt-large", type: "submit" do
-
3
image_tag("check.svg", aria: { hidden: "true" }, size: 20) +
-
tag.span("Save changes", class: "for-screen-reader")
-
end
-
end
-
-
1
def web_share_session_button(url, title, text, &)
-
3
tag.button class: "btn", hidden: true, data: {
-
controller: "web-share", action: "web-share#share",
-
web_share_url_value: url,
-
web_share_text_value: text,
-
web_share_title_value: title
-
}, &
-
end
-
end
-
1
module Users::SidebarHelper
-
1
def sidebar_turbo_frame_tag(src: nil, &)
-
5
turbo_frame_tag :user_sidebar, src: src, target: "_top", data: {
-
turbo_permanent: true,
-
controller: "rooms-list read-rooms turbo-frame",
-
rooms_list_unread_class: "unread",
-
action: "presence:present@window->rooms-list#read read-rooms:read->rooms-list#read turbo:frame-load->rooms-list#loaded refresh-room:visible@window->turbo-frame#reload".html_safe # otherwise -> is escaped
-
}, &
-
end
-
end
-
1
module UsersHelper
-
1
def button_to_direct_room_with(user)
-
button_to rooms_directs_path(user_ids: [ user.id ]), class: "btn btn--primary full-width txt--large" do
-
image_tag("messages.svg")
-
end
-
end
-
end
-
1
module VersionHelper
-
1
def version_badge
-
5
tag.span(Rails.application.config.app_version, class: "version-badge")
-
end
-
end
-
1
class ApplicationJob < ActiveJob::Base
-
# Automatically retry jobs that encountered a deadlock
-
# retry_on ActiveRecord::Deadlocked
-
-
# Most jobs are safe to ignore if the underlying records are no longer available
-
# discard_on ActiveJob::DeserializationError
-
end
-
1
class Bot::WebhookJob < ApplicationJob
-
1
def perform(bot, message)
-
1
bot.deliver_webhook(message)
-
end
-
end
-
1
class Room::PushMessageJob < ApplicationJob
-
1
def perform(room, message)
-
6
Room::MessagePusher.new(room:, message:).push
-
end
-
end
-
1
class Account < ApplicationRecord
-
1
include Joinable
-
-
1
has_one_attached :logo
-
end
-
1
module Account::Joinable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
7
before_create { self.join_code = generate_join_code }
-
end
-
-
1
def reset_join_code
-
2
update! join_code: generate_join_code
-
end
-
-
1
private
-
1
def generate_join_code
-
8
SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
-
end
-
end
-
1
class ApplicationPlatform < PlatformAgent
-
1
def ios?
-
match? /iPhone|iPad/
-
end
-
-
1
def android?
-
match? /Android/
-
end
-
-
1
def mac?
-
match? /Macintosh/
-
end
-
-
1
def chrome?
-
21
user_agent.browser.match? /Chrome/
-
end
-
-
1
def firefox?
-
14
user_agent.browser.match? /Firefox|FxiOS/
-
end
-
-
1
def safari?
-
14
user_agent.browser.match? /Safari/
-
end
-
-
1
def edge?
-
7
user_agent.browser.match? /Edg/
-
end
-
-
1
def apple_messages?
-
# Apple Messages pretends to be Facebook and Twitter bots via spoofed user agent.
-
# We want to avoid showing "Unsupported browser" message when a Campfire link
-
# is shared via Messages.
-
1
match?(/facebookexternalhit/i) && match?(/Twitterbot/i)
-
end
-
-
1
def mobile?
-
ios? || android?
-
end
-
-
1
def desktop?
-
!mobile?
-
end
-
-
1
def windows?
-
operating_system == "Windows"
-
end
-
-
1
def operating_system
-
2
case user_agent.platform
-
when /Android/ then "Android"
-
when /iPad/ then "iPad"
-
when /iPhone/ then "iPhone"
-
when /Macintosh/ then "macOS"
-
when /Windows/ then "Windows"
-
when /CrOS/ then "ChromeOS"
-
else
-
2
os =~ /Linux/ ? "Linux" : os
-
end
-
end
-
end
-
1
class ApplicationRecord < ActiveRecord::Base
-
1
primary_abstract_class
-
end
-
1
class Boost < ApplicationRecord
-
1
belongs_to :message, touch: true
-
2
belongs_to :booster, class_name: "User", default: -> { Current.user }
-
-
38
scope :ordered, -> { order(:created_at) }
-
end
-
1
class Current < ActiveSupport::CurrentAttributes
-
1
attribute :user, :request
-
-
1
delegate :host, :protocol, to: :request, prefix: true, allow_nil: true
-
-
1
def account
-
136
Account.first
-
end
-
end
-
1
class FirstRun
-
1
ACCOUNT_NAME = "Campfire"
-
1
FIRST_ROOM_NAME = "All Talk"
-
-
1
def self.create!(user_params)
-
4
account = Account.create!(name: ACCOUNT_NAME)
-
4
room = Rooms::Open.new(name: FIRST_ROOM_NAME)
-
-
4
administrator = room.creator = User.new(user_params.merge(role: :administrator))
-
4
room.save!
-
-
4
room.memberships.grant_to administrator
-
-
4
administrator
-
end
-
end
-
1
class Membership < ApplicationRecord
-
1
include Connectable
-
-
1
belongs_to :room
-
1
belongs_to :user
-
-
8
after_destroy_commit { user.reset_remote_connections }
-
-
1
enum involvement: %w[ invisible nothing mentions everything ].index_by(&:itself), _prefix: :involved_in
-
-
5
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
-
6
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
-
-
60
scope :visible, -> { where.not(involvement: :invisible) }
-
15
scope :unread, -> { where.not(unread_at: nil) }
-
-
1
def read
-
update!(unread_at: nil)
-
end
-
-
1
def unread?
-
30
unread_at.present?
-
end
-
end
-
1
module Membership::Connectable
-
1
extend ActiveSupport::Concern
-
-
1
CONNECTION_TTL = 60.seconds
-
-
1
included do
-
4
scope :connected, -> { where(connected_at: CONNECTION_TTL.ago..) }
-
60
scope :disconnected, -> { where(connected_at: [ nil, ...CONNECTION_TTL.ago ]) }
-
end
-
-
1
class_methods do
-
1
def disconnect_all
-
connected.update_all connected_at: nil, connections: 0, updated_at: Time.current
-
end
-
-
1
def connect(membership, connections)
-
3
where(id: membership.id).update_all(connections: connections, connected_at: Time.current, unread_at: nil)
-
end
-
end
-
-
1
def connected?
-
35
connected_at? && connected_at >= CONNECTION_TTL.ago
-
end
-
-
1
def present
-
3
self.class.connect(self, connected? ? connections + 1 : 1)
-
end
-
-
1
def connected
-
14
increment_connections
-
14
touch :connected_at
-
end
-
-
1
def disconnected
-
6
decrement_connections
-
6
update! connected_at: nil if connections < 1
-
end
-
-
1
def refresh_connection
-
1
increment_connections unless connected?
-
1
touch :connected_at
-
end
-
-
1
def increment_connections
-
15
connected? ? increment!(:connections, touch: true) : update!(connections: 1)
-
end
-
-
1
def decrement_connections
-
6
connected? ? decrement!(:connections, touch: true) : update!(connections: 0)
-
end
-
end
-
1
class Message < ApplicationRecord
-
1
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
-
-
1
belongs_to :room, touch: true
-
9
belongs_to :creator, class_name: "User", default: -> { Current.user }
-
-
1
has_many :boosts, dependent: :destroy
-
-
1
has_rich_text :body
-
-
45
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
-
45
after_create_commit -> { room.receive(self) }
-
-
32
scope :ordered, -> { order(:created_at) }
-
9
scope :with_creator, -> { includes(:creator) }
-
-
1
def plain_text_body
-
193
body.to_plain_text.presence || attachment&.filename&.to_s || ""
-
end
-
-
1
def to_key
-
610
[ client_message_id ]
-
end
-
-
1
def content_type
-
case
-
78
when attachment? then "attachment"
-
when sound.present? then "sound"
-
70
else "text"
-
end.inquiry
-
end
-
-
1
def sound
-
70
plain_text_body.match(/\A\/play (?<name>\w+)\z/) do |match|
-
Sound.find_by_name match[:name]
-
end
-
end
-
end
-
1
module Message::Attachment
-
1
extend ActiveSupport::Concern
-
-
1
THUMBNAIL_MAX_WIDTH = 1200
-
1
THUMBNAIL_MAX_HEIGHT = 800
-
-
1
included do
-
1
has_one_attached :attachment do |attachable|
-
1
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
-
end
-
end
-
-
1
module ClassMethods
-
1
def create_with_attachment!(attributes)
-
12
create!(attributes).tap(&:process_attachment)
-
end
-
end
-
-
1
def attachment?
-
74
attachment.attached?
-
end
-
-
1
def process_attachment
-
12
ensure_attachment_analyzed
-
12
process_attachment_thumbnail
-
end
-
-
1
private
-
1
def ensure_attachment_analyzed
-
12
attachment&.analyze
-
end
-
-
1
def process_attachment_thumbnail
-
case
-
12
when attachment.video?
-
1
attachment.preview(format: :webp).processed
-
when attachment.representable?
-
4
attachment.representation(:thumb).processed
-
end
-
end
-
end
-
1
module Message::Broadcasts
-
1
def broadcast_create
-
11
broadcast_append_to room, :messages, target: [ room, :messages ]
-
11
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
-
end
-
end
-
1
module Message::Mentionee
-
1
extend ActiveSupport::Concern
-
-
1
def mentionees
-
16
room.users.where(id: mentioned_users.map(&:id))
-
end
-
-
1
private
-
1
def mentioned_users
-
16
if body.body
-
15
body.body.attachables.grep(User).uniq
-
else
-
1
[]
-
end
-
end
-
end
-
1
module Message::Pagination
-
1
extend ActiveSupport::Concern
-
-
1
PAGE_SIZE = 40
-
-
1
included do
-
7
scope :last_page, -> { ordered.last(PAGE_SIZE) }
-
3
scope :first_page, -> { ordered.first(PAGE_SIZE) }
-
-
2
scope :before, ->(message) { where("created_at < ?", message.created_at) }
-
2
scope :after, ->(message) { where("created_at > ?", message.created_at) }
-
-
2
scope :page_before, ->(message) { before(message).last_page }
-
2
scope :page_after, ->(message) { after(message).first_page }
-
-
2
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
-
2
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
-
end
-
-
1
class_methods do
-
1
def page_around(message)
-
page_before(message) + [ message ] + page_after(message)
-
end
-
-
1
def paged?
-
count > PAGE_SIZE
-
end
-
end
-
end
-
1
module Message::Searchable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
after_create_commit :create_in_index
-
1
after_update_commit :update_in_index
-
1
after_destroy_commit :remove_from_index
-
-
10
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
-
end
-
-
1
private
-
1
def create_in_index
-
44
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
-
end
-
-
1
def update_in_index
-
20
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
-
end
-
-
1
def remove_from_index
-
110
execute_sql_with_binds "delete from message_search_index where rowid = ?", id
-
end
-
-
1
def execute_sql_with_binds(*statement)
-
174
self.class.connection.execute self.class.sanitize_sql(statement)
-
end
-
end
-
1
require "nokogiri"
-
-
1
class Opengraph::Document
-
1
attr_accessor :html
-
-
1
def initialize(html)
-
20
@html = Nokogiri::HTML(html)
-
end
-
-
1
def opengraph_attributes
-
20
@opengraph_attributes ||= extract_opengraph_attributes
-
end
-
-
1
private
-
1
def extract_opengraph_attributes
-
20
opengraph_tags = html.xpath("//*/meta[starts-with(@property, \"og:\") or starts-with(@name, \"og:\")]").map do |tag|
-
61
key = tag.key?("property") ? "property" : "name"
-
61
[ tag[key].gsub("og:", "").to_sym, sanitize_content(tag["content"]) ] if tag["content"].present?
-
end
-
-
20
Hash[opengraph_tags.compact].slice(*Opengraph::Metadata::ATTRIBUTES)
-
end
-
-
1
def sanitize_content(content)
-
61
html.meta_encoding ? content : content.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "")
-
end
-
end
-
1
require "net/http"
-
1
require "restricted_http/private_network_guard"
-
-
1
class Opengraph::Fetch
-
1
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
-
1
MAX_BODY_SIZE = 5.megabytes
-
1
MAX_REDIRECTS = 10
-
-
1
class TooManyRedirectsError < StandardError; end
-
1
class RedirectDeniedError < StandardError; end
-
-
1
def fetch_document(url, ip: RestrictedHTTP::PrivateNetworkGuard.resolve(url.host))
-
29
request(url, Net::HTTP::Get, ip: ip) do |response|
-
25
return body_if_acceptable(response)
-
end
-
end
-
-
1
def fetch_content_type(url, ip: RestrictedHTTP::PrivateNetworkGuard.resolve(url.host))
-
10
request(url, Net::HTTP::Head, ip: ip) do |response|
-
10
return response["Content-Type"]
-
end
-
end
-
-
1
private
-
1
def request(url, request_class, ip:)
-
39
MAX_REDIRECTS.times do
-
50
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
-
50
http.request request_class.new(url) do |response|
-
48
if response.is_a?(Net::HTTPRedirection)
-
13
url, ip = resolve_redirect(response["location"])
-
else
-
35
yield response
-
end
-
end
-
end
-
end
-
-
1
raise TooManyRedirectsError
-
end
-
-
1
def resolve_redirect(location)
-
13
url = URI.parse(location)
-
13
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
-
13
[ url, RestrictedHTTP::PrivateNetworkGuard.resolve(url.host) ]
-
end
-
-
1
def body_if_acceptable(response)
-
25
size_restricted_body(response) if response_valid?(response)
-
end
-
-
1
def size_restricted_body(response)
-
# We've already checked the Content-Length header, to try to avoid reading
-
# the body of any large responses. But that header could be wrong or
-
# missing. To be on the safe side, we'll read the body in chunks, and bail
-
# if it runs over our size limit.
-
19
"".tap do |body|
-
19
response.read_body do |chunk|
-
19
return nil if body.bytesize + chunk.bytesize > MAX_BODY_SIZE
-
17
body << chunk
-
end
-
end
-
end
-
-
1
def response_valid?(response)
-
25
status_valid?(response) && content_type_valid?(response) && content_length_valid?(response)
-
end
-
-
1
def status_valid?(response)
-
25
response.is_a?(Net::HTTPOK)
-
end
-
-
1
def content_type_valid?(response)
-
24
response.content_type == ALLOWED_DOCUMENT_CONTENT_TYPE
-
end
-
-
1
def content_length_valid?(response)
-
21
response.content_length.to_i <= MAX_BODY_SIZE
-
end
-
end
-
1
require "restricted_http/private_network_guard"
-
-
1
class Opengraph::Location
-
1
include ActiveModel::Validations
-
-
1
attr_accessor :url, :parsed_url
-
-
1
validate :validate_url, :validate_url_is_public
-
-
1
def initialize(url)
-
72
@url = url
-
end
-
-
1
def read_html
-
25
fetch_html if valid? && !url.match(FILES_AND_MEDIA_URL_REGEX)
-
end
-
-
1
def fetch_content_type
-
13
Opengraph::Fetch.new.fetch_content_type(parsed_url, ip: resolved_ip) if valid?
-
rescue => e
-
Rails.logger.warn "Failed to fetch #{parsed_url} at #{resolved_ip} (#{e})"
-
nil
-
end
-
-
1
def resolved_ip
-
99
return @resolved_ip if defined? @resolved_ip
-
71
@resolved_ip = RestrictedHTTP::PrivateNetworkGuard.resolve(parsed_url.host) rescue nil
-
end
-
-
1
private
-
1
FILES_AND_MEDIA_URL_REGEX = /\bhttps?:\/\/\S+\.(?:zip|tar|tar\.gz|tar\.bz2|tar\.xz|gz|bz2|rar|7z|dmg|exe|msi|pkg|deb|iso|jpg|jpeg|png|gif|bmp|mp4|mov|avi|mkv|wmv|flv|heic|heif|mp3|wav|ogg|aac|wma|webm|ogv|mpg|mpeg)\b/
-
-
1
def validate_url
-
71
errors.add :url, "is invalid" unless parsed_url.is_a?(URI::HTTP)
-
end
-
-
1
def validate_url_is_public
-
71
errors.add :url, "is not public" unless resolved_ip
-
end
-
-
1
def parsed_url
-
170
return @parsed_url if defined? @parsed_url
-
71
@parsed_url = URI.parse(url) rescue nil
-
end
-
-
1
def fetch_html
-
19
Opengraph::Fetch.new.fetch_document(parsed_url, ip: resolved_ip)
-
rescue => e
-
Rails.logger.warn "Failed to fetch #{parsed_url} at #{resolved_ip} (#{e})"
-
nil
-
end
-
end
-
1
class Opengraph::Metadata
-
1
include ActiveModel::Model
-
1
include ActiveModel::Validations::Callbacks
-
1
include ActionView::Helpers::SanitizeHelper
-
-
1
include Fetching
-
-
1
ATTRIBUTES = %i[ title url image description ]
-
1
attr_accessor *ATTRIBUTES
-
-
1
before_validation :sanitize_fields
-
-
1
validates_presence_of :title, :url, :description
-
1
validate :ensure_valid_image_url
-
-
1
private
-
1
def sanitize_fields
-
17
self.title = sanitize(strip_tags(title))
-
17
self.description = sanitize(strip_tags(description))
-
end
-
-
1
def ensure_valid_image_url
-
17
if image.present?
-
8
errors.add :image, "url is invalid" unless Opengraph::Location.new(image).valid?
-
end
-
end
-
end
-
1
module Opengraph::Metadata::Fetching
-
1
extend ActiveSupport::Concern
-
-
1
module ClassMethods
-
1
def from_url(url)
-
17
document = fetch_document(url)
-
17
attributes = document.opengraph_attributes
-
17
new attributes.merge(url: valid_canonical_url(attributes[:url], url), image: valid_image_content_type(attributes[:image]))
-
end
-
-
1
private
-
1
TWITTER_HOSTS = %w[ twitter.com www.twitter.com x.com www.x.com ]
-
1
FX_TWITTER_HOST = "fxtwitter.com"
-
1
ALLOWED_IMAGE_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ]
-
-
1
def fetch_document(untrusted_url)
-
17
tweet_url?(untrusted_url) ? fetch_fxtwitter_document(untrusted_url) : fetch_non_fxtwitter_document(untrusted_url)
-
end
-
-
1
def fetch_fxtwitter_document(untrusted_url)
-
2
fxtwitter_url = replace_twitter_domain_for_opengraph_support(untrusted_url)
-
-
2
Opengraph::Location.new(fxtwitter_url).then do |location|
-
# fxtwitter.com HTML response does not include character encoding, resulting in emojis and quotes not
-
# being encoded properly.
-
2
Opengraph::Document.new(location.read_html.force_encoding("UTF-8"))
-
end
-
end
-
-
1
def fetch_non_fxtwitter_document(untrusted_url)
-
15
Opengraph::Location.new(untrusted_url).then do |location|
-
15
Opengraph::Document.new(location.read_html)
-
end
-
end
-
-
1
def valid_canonical_url(url, fallback)
-
17
Opengraph::Location.new(url).valid? ? url : fallback
-
end
-
-
1
def valid_image_content_type(image)
-
17
return unless image.present?
-
-
13
content_type = Opengraph::Location.new(URI.parse(image)).fetch_content_type&.downcase
-
13
content_type.in?(ALLOWED_IMAGE_CONTENT_TYPES) ? image : nil
-
rescue => e
-
Rails.logger.warn "Failed to fetch image content tpye: #{image} (#{e})"
-
nil
-
end
-
-
# Twitter.com and X.com do not support Opengraph at the moment.
-
# Piggybacking on fxtwitter.com allows us to have twitter unfurling
-
# without relying on fxtwitter.com's future availability.
-
1
def replace_twitter_domain_for_opengraph_support(url)
-
2
uri = URI.parse(url)
-
2
uri.host = FX_TWITTER_HOST if uri.host.in?(TWITTER_HOSTS)
-
2
uri.to_s
-
rescue URI::InvalidURIError
-
nil
-
end
-
-
1
def tweet_url?(url)
-
17
uri = URI.parse(url)
-
17
uri.host.in?(TWITTER_HOSTS) && uri.path.present? && uri.path != "/"
-
rescue URI::InvalidURIError
-
nil
-
end
-
end
-
end
-
1
module Push
-
1
def self.table_name_prefix
-
1
"push_"
-
end
-
end
-
1
class Push::Subscription < ApplicationRecord
-
1
belongs_to :user
-
-
1
def notification(**params)
-
14
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
-
end
-
end
-
1
class Room < ApplicationRecord
-
1
has_many :memberships, dependent: :delete_all do
-
1
def grant_to(users)
-
23
room = proxy_association.owner
-
72
Membership.insert_all(Array(users).collect { |user| { room_id: room.id, user_id: user.id, involvement: room.default_involvement } })
-
end
-
-
1
def revoke_from(users)
-
4
destroy_by user: users
-
end
-
-
1
def revise(granted: [], revoked: [])
-
4
transaction do
-
4
grant_to(granted) if granted.present?
-
4
revoke_from(revoked) if revoked.present?
-
end
-
end
-
end
-
-
1
has_many :users, through: :memberships
-
1
has_many :messages, dependent: :destroy
-
-
5
belongs_to :creator, class_name: "User", default: -> { Current.user }
-
-
4
scope :opens, -> { where(type: "Rooms::Open") }
-
3
scope :closeds, -> { where(type: "Rooms::Closed") }
-
4
scope :directs, -> { where(type: "Rooms::Direct") }
-
2
scope :without_directs, -> { where.not(type: "Rooms::Direct") }
-
-
2
scope :ordered, -> { order("LOWER(name)") }
-
-
1
class << self
-
1
def create_for(attributes, users:)
-
6
transaction do
-
6
create!(attributes).tap do |room|
-
6
room.memberships.grant_to users
-
end
-
end
-
end
-
-
1
def original
-
10
order(:created_at).first
-
end
-
end
-
-
1
def receive(message)
-
44
unread_memberships(message)
-
44
push_later(message)
-
end
-
-
1
def open?
-
2
is_a?(Rooms::Open)
-
end
-
-
1
def closed?
-
1
is_a?(Rooms::Closed)
-
end
-
-
1
def direct?
-
117
is_a?(Rooms::Direct)
-
end
-
-
1
def default_involvement
-
45
"mentions"
-
end
-
-
1
private
-
1
def unread_memberships(message)
-
44
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
-
end
-
-
1
def push_later(message)
-
44
Room::PushMessageJob.perform_later(self, message)
-
end
-
end
-
1
class Room::MessagePusher
-
1
attr_reader :room, :message
-
-
1
def initialize(room:, message:)
-
6
@room, @message = room, message
-
end
-
-
1
def push
-
6
build_payload.tap do |payload|
-
6
push_to_users_involved_in_everything(payload)
-
6
push_to_users_involved_in_mentions(payload)
-
end
-
end
-
-
1
private
-
1
def build_payload
-
6
if room.direct?
-
build_direct_payload
-
else
-
6
build_shared_payload
-
end
-
end
-
-
1
def build_direct_payload
-
{
-
title: message.creator.name,
-
body: message.plain_text_body,
-
path: Rails.application.routes.url_helpers.room_path(room)
-
}
-
end
-
-
1
def build_shared_payload
-
{
-
6
title: room.name,
-
body: "#{message.creator.name}: #{message.plain_text_body}",
-
path: Rails.application.routes.url_helpers.room_path(room)
-
}
-
end
-
-
1
def push_to_users_involved_in_everything(payload)
-
6
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
-
end
-
-
1
def push_to_users_involved_in_mentions(payload)
-
6
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
-
end
-
-
1
def push_subscriptions_for_users_involved_in_everything
-
6
relevant_subscriptions.merge(Membership.involved_in_everything)
-
end
-
-
1
def push_subscriptions_for_mentionable_users(mentionees)
-
6
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
-
end
-
-
1
def relevant_subscriptions
-
12
Push::Subscription
-
.joins(user: :memberships)
-
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
-
end
-
-
1
def enqueue_payload_for_delivery(payload, subscriptions)
-
12
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
-
end
-
end
-
# Rooms where only a subset of all users on the account have been explicited granted membership.
-
1
class Rooms::Closed < Room
-
end
-
# Rooms for direct message chats between users. These act as a singleton, so a single set of users will
-
# always refer to the same direct room.
-
1
class Rooms::Direct < Room
-
1
class << self
-
1
def find_or_create_for(users)
-
7
find_for(users) || create_for({}, users: users)
-
end
-
-
1
private
-
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
-
# which could be to store the membership id list as a hash on the room, and use that for lookup.
-
1
def find_for(users)
-
7
all.joins(:users).detect do |room|
-
39
Set.new(room.user_ids) == Set.new(users.pluck(:id))
-
end
-
end
-
end
-
-
1
def default_involvement
-
4
"everything"
-
end
-
end
-
# Rooms open to all users on the account. When a new user is added to the account, they're automatically granted membership.
-
1
class Rooms::Open < Room
-
1
after_save_commit :grant_access_to_all_users
-
-
1
private
-
1
def grant_access_to_all_users
-
28
memberships.grant_to(User.active) if type_previously_changed?(to: "Rooms::Open")
-
end
-
end
-
1
class Search < ApplicationRecord
-
1
belongs_to :user
-
-
1
after_create :trim_recent_searches
-
-
5
scope :ordered, -> { order(updated_at: :desc) }
-
-
1
class << self
-
1
def record(query)
-
1
find_or_create_by(query: query).touch
-
end
-
end
-
-
1
private
-
1
def trim_recent_searches
-
1
user.searches.excluding(user.searches.ordered.limit(10)).destroy_all
-
end
-
end
-
1
class Session < ApplicationRecord
-
1
ACTIVITY_REFRESH_RATE = 1.hour
-
-
1
has_secure_token
-
-
1
belongs_to :user
-
-
112
before_create { self.last_active_at ||= Time.now }
-
-
1
def self.start!(user_agent:, ip_address:)
-
111
create! user_agent: user_agent, ip_address: ip_address
-
end
-
-
1
def resume(user_agent:, ip_address:)
-
99
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
-
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
-
end
-
end
-
end
-
1
class User < ApplicationRecord
-
1
include Avatar, Bot, Mentionable, Role, Transferable
-
-
1
has_many :memberships, dependent: :delete_all
-
1
has_many :rooms, through: :memberships
-
-
1
has_many :reachable_messages, through: :rooms, source: :messages
-
1
has_many :messages, dependent: :destroy, foreign_key: :creator_id
-
-
1
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
-
-
1
has_many :boosts, dependent: :destroy, foreign_key: :booster_id
-
1
has_many :searches, dependent: :delete_all
-
-
1
has_many :sessions, dependent: :destroy
-
-
157
scope :active, -> { where(active: true) }
-
-
1
has_secure_password validations: false
-
-
1
after_create_commit :grant_membership_to_open_rooms
-
-
8
scope :ordered, -> { order("LOWER(name)") }
-
4
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
-
-
1
def initials
-
2
name.scan(/\b\w/).join
-
end
-
-
1
def title
-
106
[ name, bio ].compact_blank.join(" – ")
-
end
-
-
1
def deactivate
-
4
transaction do
-
4
close_remote_connections
-
-
4
memberships.without_direct_rooms.delete_all
-
4
push_subscriptions.delete_all
-
4
searches.delete_all
-
4
sessions.delete_all
-
-
4
update! active: false, email_address: deactived_email_address
-
end
-
end
-
-
1
def deactivated?
-
1
!active?
-
end
-
-
1
def reset_remote_connections
-
6
close_remote_connections reconnect: true
-
end
-
-
1
private
-
1
def grant_membership_to_open_rooms
-
29
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
-
end
-
-
1
def deactived_email_address
-
4
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
-
end
-
-
1
def close_remote_connections(reconnect: false)
-
10
ActionCable.server.remote_connections.where(current_user: self).disconnect reconnect: reconnect
-
end
-
end
-
1
module User::Avatar
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
has_one_attached :avatar
-
end
-
-
1
class_methods do
-
1
def from_avatar_token(sid)
-
3
find_signed!(sid, purpose: :avatar)
-
end
-
end
-
-
1
def avatar_token
-
97
signed_id(purpose: :avatar)
-
end
-
end
-
1
module User::Bot
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
18
scope :active_bots, -> { active.where(role: :bot) }
-
1
scope :without_bots, -> { where.not(role: :bot) }
-
1
has_one :webhook, dependent: :delete
-
end
-
-
1
module ClassMethods
-
1
def create_bot!(attributes)
-
4
bot_token = generate_bot_token
-
4
webhook_url = attributes.delete(:webhook_url)
-
-
4
User.create!(**attributes, bot_token: bot_token, role: :bot).tap do |user|
-
4
user.create_webhook!(url: webhook_url) if webhook_url
-
end
-
end
-
-
1
def authenticate_bot(bot_key)
-
7
bot_id, bot_token = bot_key.split("-")
-
7
active.find_by(id: bot_id, bot_token: bot_token)
-
end
-
-
1
def generate_bot_token
-
8
SecureRandom.alphanumeric(12)
-
end
-
end
-
-
1
def update_bot!(attributes)
-
2
transaction do
-
2
update_webhook_url!(attributes.delete(:webhook_url))
-
2
update!(attributes)
-
end
-
end
-
-
-
1
def bot_key
-
20
"#{id}-#{bot_token}"
-
end
-
-
1
def reset_bot_key
-
2
update! bot_token: self.class.generate_bot_token
-
end
-
-
-
1
def webhook_url
-
2
webhook&.url
-
end
-
-
1
def deliver_webhook_later(message)
-
2
Bot::WebhookJob.perform_later(self, message) if webhook
-
end
-
-
1
def deliver_webhook(message)
-
1
webhook.deliver(message)
-
end
-
-
-
1
private
-
1
def update_webhook_url!(url)
-
2
if url.present?
-
webhook&.update!(url: url) || create_webhook!(url: url)
-
else
-
2
webhook&.destroy
-
end
-
end
-
end
-
1
module User::Mentionable
-
1
include ActionText::Attachable
-
-
1
def to_attachable_partial_path
-
3
"users/mention"
-
end
-
-
1
def to_trix_content_attachment_partial_path
-
"users/mention"
-
end
-
-
1
def attachable_plain_text_representation(caption)
-
25
"@#{name}"
-
end
-
end
-
1
module User::Role
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
enum role: %i[ member administrator bot ]
-
end
-
-
1
def can_administer?(record = nil)
-
86
administrator? || self == record&.creator || record&.new_record?
-
end
-
end
-
1
module User::Transferable
-
1
extend ActiveSupport::Concern
-
-
1
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
-
-
1
class_methods do
-
1
def find_by_transfer_id(id)
-
1
find_signed(id, purpose: :transfer)
-
end
-
end
-
-
1
def transfer_id
-
3
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
-
end
-
end
-
1
require "net/http"
-
1
require "uri"
-
-
1
class Webhook < ApplicationRecord
-
1
ENDPOINT_TIMEOUT = 7.seconds
-
-
1
belongs_to :user
-
-
1
def deliver(message)
-
7
post(payload(message)).tap do |response|
-
6
if text = extract_text_from(response)
-
1
receive_text_reply_to(message.room, text: text)
-
5
elsif attachment = extract_attachment_from(response)
-
1
receive_attachment_reply_to(message.room, attachment: attachment)
-
end
-
end
-
rescue Net::OpenTimeout, Net::ReadTimeout
-
1
receive_text_reply_to message.room, text: "Failed to respond within #{ENDPOINT_TIMEOUT} seconds"
-
end
-
-
1
private
-
1
def post(payload)
-
6
http.request \
-
6
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
-
end
-
-
1
def http
-
6
Net::HTTP.new(uri.host, uri.port).tap do |http|
-
6
http.use_ssl = (uri.scheme == "https")
-
6
http.open_timeout = ENDPOINT_TIMEOUT
-
6
http.read_timeout = ENDPOINT_TIMEOUT
-
end
-
end
-
-
1
def uri
-
24
@uri ||= URI(url)
-
end
-
-
1
def payload(message)
-
{
-
7
user: { id: message.creator.id, name: message.creator.name },
-
room: { id: message.room.id, name: message.room.name, path: room_bot_messages_path(message) },
-
message: { id: message.id, body: { html: message.body.body, plain: without_recipient_mentions(message.plain_text_body) }, path: message_path(message) }
-
}.to_json
-
end
-
-
1
def message_path(message)
-
7
Rails.application.routes.url_helpers.room_at_message_path(message.room, message)
-
end
-
-
1
def room_bot_messages_path(message)
-
7
Rails.application.routes.url_helpers.room_bot_messages_path(message.room, user.bot_key)
-
end
-
-
1
def extract_text_from(response)
-
6
response.body.force_encoding("UTF-8") if response.code == "200" && response.content_type.in?(%w[ text/html text/plain ])
-
end
-
-
1
def receive_text_reply_to(room, text:)
-
2
room.messages.create!(body: text, creator: user).broadcast_create
-
end
-
-
1
def extract_attachment_from(response)
-
5
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
-
1
ActiveStorage::Blob.create_and_upload! \
-
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
-
end
-
end
-
-
1
def receive_attachment_reply_to(room, attachment:)
-
1
room.messages.create_with_attachment!(attachment: attachment, creator: user).broadcast_create
-
end
-
-
1
def without_recipient_mentions(body)
-
7
body \
-
.gsub(user.attachable_plain_text_representation(nil), "") # Remove mentions of the recipient user
-
.gsub(/\A\p{Space}+|\p{Space}+\z/, "") # Remove leading and trailing whitespace uncluding unicode spaces
-
end
-
end
-
# Load the Rails application.
-
1
require_relative "application"
-
-
# Initialize the Rails application.
-
1
Rails.application.initialize!
-
1
require "active_support/core_ext/integer/time"
-
-
# The test environment is used exclusively to run your application's
-
# test suite. You never need to work with it otherwise. Remember that
-
# your test database is "scratch space" for the test suite and is wiped
-
# and recreated between test runs. Don't rely on the data there!
-
-
1
Rails.application.configure do
-
# Settings specified here will take precedence over those in config/application.rb.
-
-
# Turn false under Spring and add config.action_view.cache_template_loading = true.
-
1
config.cache_classes = true
-
-
# Eager loading loads your whole application. When running a single test locally,
-
# this probably isn't necessary. It's a good idea to do in a continuous integration
-
# system, or in some way before deploying your code.
-
1
config.eager_load = ENV["CI"].present?
-
-
# Configure public file server for tests with Cache-Control for performance.
-
1
config.public_file_server.enabled = true
-
1
config.public_file_server.headers = {
-
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
-
}
-
-
# Show full error reports and disable caching.
-
1
config.consider_all_requests_local = true
-
1
config.action_controller.perform_caching = false
-
1
config.cache_store = :null_store
-
-
# Raise exceptions instead of rendering exception templates.
-
1
config.action_dispatch.show_exceptions = :none
-
-
# Disable request forgery protection in test environment.
-
1
config.action_controller.allow_forgery_protection = false
-
-
# Store uploaded files on the local file system in a temporary directory.
-
1
config.active_storage.service = :test
-
-
# Print deprecation notices to the stderr.
-
1
config.active_support.deprecation = :stderr
-
-
# Raise exceptions for disallowed deprecations.
-
1
config.active_support.disallowed_deprecation = :raise
-
-
# Tell Active Support which deprecation messages to disallow.
-
1
config.active_support.disallowed_deprecation_warnings = []
-
-
# Raises error for missing translations.
-
# config.i18n.raise_on_missing_translations = true
-
-
# Annotate rendered view with file names.
-
# config.action_view.annotate_rendered_view_with_filenames = true
-
-
# Load test helpers
-
1
config.autoload_paths += %w[ test/test_helpers ]
-
end
-
1
ActiveSupport.on_load(:active_storage_blob) do
-
1
ActiveStorage::DiskController.after_action only: :show do
-
response.set_header("Cache-Control", "max-age=3600, public")
-
end
-
end
-
# Be sure to restart your server when you modify this file.
-
-
# Version of your assets, change this if you want to expire all your assets.
-
1
Rails.application.config.assets.version = "1.0"
-
# Be sure to restart your server when you modify this file.
-
-
# Define an application-wide content security policy.
-
# See the Securing Rails Applications Guide for more information:
-
# https://guides.rubyonrails.org/security.html#content-security-policy-header
-
-
# Rails.application.configure do
-
# config.content_security_policy do |policy|
-
# policy.default_src :self, :https
-
# policy.font_src :self, :https, :data
-
# policy.img_src :self, :https, :data
-
# policy.object_src :none
-
# policy.script_src :self, :https
-
# policy.style_src :self, :https
-
# # Specify URI for violation reports
-
# # policy.report_uri "/csp-violation-report-endpoint"
-
# end
-
#
-
# # Generate session nonces for permitted importmap and inline scripts
-
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
-
# config.content_security_policy_nonce_directives = %w(script-src)
-
#
-
# # Report violations without enforcing the policy.
-
# # config.content_security_policy_report_only = true
-
# end
-
1
%w[ rails_ext ].each do |extensions_dir|
-
6
Dir["#{Rails.root}/lib/#{extensions_dir}/*"].each { |path| require "#{extensions_dir}/#{File.basename(path)}" }
-
end
-
# Be sure to restart your server when you modify this file.
-
-
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
-
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
-
# notations and behaviors.
-
1
Rails.application.config.filter_parameters += [
-
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :endpoint, "message.body"
-
]
-
# Be sure to restart your server when you modify this file.
-
-
# Add new inflection rules using the following format. Inflections
-
# are locale specific, and you may define rules for as many different
-
# locales as you wish. All of these examples are active by default:
-
# ActiveSupport::Inflector.inflections(:en) do |inflect|
-
# inflect.plural /^(ox)$/i, "\\1en"
-
# inflect.singular /^(ox)en/i, "\\1"
-
# inflect.irregular "person", "people"
-
# inflect.uncountable %w( fish sheep )
-
# end
-
-
# These inflection rules are supported but not enabled by default:
-
# ActiveSupport::Inflector.inflections(:en) do |inflect|
-
# inflect.acronym "RESTful"
-
# end
-
# Define an application-wide HTTP permissions policy. For further
-
# information see https://developers.google.com/web/updates/2018/06/feature-policy
-
#
-
# Rails.application.config.permissions_policy do |f|
-
# f.camera :none
-
# f.gyroscope :none
-
# f.microphone :none
-
# f.usb :none
-
# f.fullscreen :self
-
# f.payment :self, "https://secure.example.com"
-
# end
-
1
if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
-
Sentry.init do |config|
-
config.dsn = "https://975a8bf631edee43b6a8cf4823998d92@o33603.ingest.sentry.io/4506587182530560"
-
config.breadcrumbs_logger = [ :active_support_logger, :http_logger ]
-
config.send_default_pii = false
-
config.release = ENV["GIT_REVISION"]
-
end
-
end
-
1
Rails.application.config.session_store :cookie_store,
-
key: "_campfire_session",
-
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
-
expire_after: 20.years
-
1
module SQLite3Configuration
-
1
private
-
1
def configure_connection
-
2
super
-
-
2
if @config[:retries]
-
2
retries = self.class.type_cast_config_to_integer(@config[:retries])
-
2
raw_connection.busy_handler do |count|
-
(count <= retries).tap { |result| sleep count * 0.001 if result }
-
end
-
end
-
end
-
end
-
-
1
module SQLite3DumpConfiguration
-
1
def structure_dump(filename, extra_flags)
-
args = []
-
args.concat(Array(extra_flags)) if extra_flags
-
args << db_config.database
-
-
ignore_tables = ActiveRecord::SchemaDumper.ignore_tables
-
if ignore_tables.any?
-
ignore_tables = connection.data_sources.select { |table| ignore_tables.any? { |pattern| pattern === table } }
-
condition = ignore_tables.map { |table| connection.quote(table) }.join(", ")
-
args << "SELECT sql || ';' FROM sqlite_master WHERE tbl_name NOT IN (#{condition}) ORDER BY tbl_name, type DESC, name"
-
else
-
args << ".schema --nosys"
-
end
-
run_cmd("sqlite3", args, filename)
-
end
-
end
-
-
1
ActiveSupport.on_load :active_record do
-
1
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend SQLite3Configuration
-
1
ActiveRecord::Tasks::SQLiteDatabaseTasks.prepend SQLite3DumpConfiguration
-
end
-
1
Rails.application.config.after_initialize do
-
1
%w[ db files ].each do |dir|
-
2
Rails.root.join("storage", dir).mkpath
-
end
-
end
-
# Used to match JavaScripts (new Date).getTime() for sorting
-
90
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
-
1
Rails.application.configure do
-
1
config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key))
-
1
config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key))
-
end
-
1
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
-
1
Rails.application.config.git_revision = ENV["GIT_REVISION"]
-
1
require "web-push"
-
1
require "web_push/pool"
-
1
require "web_push/notification"
-
-
1
Rails.application.configure do
-
1
config.x.web_push_pool = WebPush::Pool.new(
-
invalid_subscription_handler: ->(subscription_id) do
-
2
Rails.application.executor.wrap do
-
2
Rails.logger.info "Destroying push subscription: #{subscription_id}"
-
2
Push::Subscription.find_by(id: subscription_id)&.destroy
-
end
-
end
-
)
-
-
1
at_exit { config.x.web_push_pool.shutdown }
-
end
-
-
1
module WebPush::PersistentRequest
-
1
def perform
-
if @options[:connection]
-
http = @options[:connection]
-
else
-
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
-
http.use_ssl = true
-
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
-
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
-
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
-
end
-
-
req = Net::HTTP::Post.new(uri.request_uri, headers)
-
req.body = body
-
-
if http.is_a?(Net::HTTP::Persistent)
-
response = http.request uri, req
-
else
-
resp = http.request(req)
-
verify_response(resp)
-
end
-
-
resp
-
end
-
end
-
-
1
WebPush::Request.prepend WebPush::PersistentRequest
-
1
Rails.application.routes.draw do
-
1
root "welcome#show"
-
-
1
resource :first_run
-
-
1
resource :session do
-
1
scope module: "sessions" do
-
1
resources :transfers, only: %i[ show update ]
-
end
-
end
-
-
1
resource :account do
-
1
scope module: "accounts" do
-
1
resources :users
-
-
1
resources :bots do
-
1
scope module: "bots" do
-
1
resource :key, only: :update
-
end
-
end
-
-
1
resource :join_code, only: :create
-
1
resource :logo, only: %i[ show destroy ]
-
1
resource :custom_styles, only: %i[ edit update ]
-
end
-
end
-
-
1
direct :fresh_account_logo do |options|
-
57
route_for :account_logo, v: Current.account&.updated_at&.to_fs(:number), size: options[:size]
-
end
-
-
1
get "join/:join_code", to: "users#new", as: :join
-
1
post "join/:join_code", to: "users#create"
-
-
1
resources :qr_code, only: :show
-
-
1
resources :users, only: :show do
-
1
scope module: "users" do
-
1
resource :avatar, only: %i[ show destroy ]
-
-
1
scope defaults: { user_id: "me" } do
-
1
resource :sidebar, only: :show
-
1
resource :profile
-
1
resources :push_subscriptions do
-
1
scope module: "push_subscriptions" do
-
1
resources :test_notifications, only: :create
-
end
-
end
-
end
-
end
-
end
-
-
1
namespace :autocompletable do
-
1
resources :users, only: :index
-
end
-
-
1
direct :fresh_user_avatar do |user, options|
-
95
route_for :user_avatar, user.avatar_token, v: user.updated_at.to_fs(:number)
-
end
-
-
1
resources :rooms do
-
1
resources :messages
-
-
1
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages
-
-
1
scope module: "rooms" do
-
1
resource :refresh, only: :show
-
1
resource :settings, only: :show
-
1
resource :involvement, only: %i[ show update ]
-
end
-
-
1
get "@:message_id", to: "rooms#show", as: :at_message
-
end
-
-
1
namespace :rooms do
-
1
resources :opens
-
1
resources :closeds
-
1
resources :directs
-
end
-
-
1
resources :messages do
-
1
scope module: "messages" do
-
1
resources :boosts
-
end
-
end
-
-
1
resources :searches, only: %i[ index create ] do
-
1
delete :clear, on: :collection
-
end
-
-
1
resource :unfurl_link, only: :create
-
-
1
get "webmanifest" => "pwa#manifest"
-
1
get "service-worker" => "pwa#service_worker"
-
-
1
get "up" => "rails/health#show", as: :rails_health_check
-
end
-
1
ActiveSupport.on_load(:action_text_content) do
-
1
class ActionText::Attachment
-
1
class << self
-
1
def from_node(node, attachable = nil)
-
39
new(node, attachable || ActionText::Attachment::OpengraphEmbed.from_node(node) || attachable_from_possibly_expired_sgid(node["sgid"]) || ActionText::Attachable.from_node(node))
-
end
-
-
1
private
-
# Our @mentions use ActionText attachments, which are signed. If someone rotates SECRET_KEY_BASE, the existing attachments become invalid.
-
# This allows ignoring invalid signatures for User attachments in ActionText.
-
1
ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES = %w[ User ]
-
-
1
def attachable_from_possibly_expired_sgid(sgid)
-
24
if message = sgid&.split("--")&.first
-
23
encoded_message = JSON.parse Base64.strict_decode64(message)
-
23
decoded_gid = Marshal.load Base64.urlsafe_decode64(encoded_message.dig("_rails", "message"))
-
23
model = GlobalID.find(decoded_gid)
-
-
23
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
-
end
-
rescue ActiveRecord::RecordNotFound
-
nil
-
end
-
end
-
end
-
end
-
1
class ActionText::Attachment::OpengraphEmbed
-
1
include ActiveModel::Model
-
-
1
OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
-
-
1
class << self
-
1
def from_node(node)
-
36
if node["content-type"]
-
33
if matches = node["content-type"].match(OPENGRAPH_EMBED_CONTENT_TYPE)
-
12
attachment = new(attributes_from_node(node))
-
12
attachment if attachment.valid?
-
end
-
end
-
end
-
-
1
private
-
1
def attributes_from_node(node)
-
{
-
12
href: node["href"],
-
url: node["url"],
-
filename: node["filename"],
-
description: node["caption"]
-
}
-
end
-
end
-
-
1
attr_accessor :href, :url, :filename, :description
-
-
1
def attachable_content_type
-
OPENGRAPH_EMBED_CONTENT_TYPE
-
end
-
-
1
def attachable_plain_text_representation(caption)
-
12
""
-
end
-
-
1
def to_partial_path
-
"action_text/attachables/opengraph_embed"
-
end
-
-
1
def to_trix_content_attachment_partial_path
-
"action_text/attachables/opengraph_embed"
-
end
-
end
-
1
class ActionText::Content::Filter
-
1
class << self
-
1
def apply(content)
-
125
filter = new(content)
-
125
filter.applicable? ? ActionText::Content.new(filter.apply, canonicalize: false) : content
-
end
-
end
-
-
1
def initialize(content)
-
125
@content = content
-
end
-
-
1
def applicable?
-
raise NotImplementedError
-
end
-
-
1
def apply
-
raise NotImplementedError
-
end
-
-
1
private
-
1
attr_reader :content
-
1
delegate :fragment, to: :content
-
end
-
1
class ActionText::Content::Filters
-
1
def initialize(*filters)
-
1
@filters = filters
-
end
-
-
1
def apply(content)
-
164
filters.reduce(content) { |content, filter| filter.apply(content) }
-
end
-
-
1
private
-
1
attr_reader :filters
-
end
-
1
class String
-
1
def all_emoji?
-
44
self.match? /\A(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+\z/u
-
end
-
end
-
1
require "resolv"
-
-
1
module RestrictedHTTP
-
1
class Violation < StandardError; end
-
-
1
module PrivateNetworkGuard
-
1
extend self
-
-
1
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
-
-
1
def resolve(hostname)
-
85
Resolv.getaddress(hostname).tap do |ip|
-
77
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip)
-
end
-
end
-
-
1
def private_ip?(ip)
-
77
IPAddr.new(ip).then do |ipaddr|
-
77
ipaddr.private? || ipaddr.loopback? || LOCAL_IP.include?(ipaddr)
-
end
-
rescue IPAddr::InvalidAddressError
-
true
-
end
-
end
-
end
-
1
class WebPush::Notification
-
1
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
-
14
@title, @body, @path, @badge = title, body, path, badge
-
14
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
-
end
-
-
1
def deliver(connection: nil)
-
14
WebPush.payload_send \
-
message: encoded_message,
-
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
-
vapid: vapid_identification,
-
connection: connection,
-
urgency: "high"
-
end
-
-
1
private
-
1
def vapid_identification
-
14
{ subject: "mailto:support@37signals.com" }.merge \
-
Rails.configuration.x.vapid.symbolize_keys
-
end
-
-
1
def encoded_message
-
14
JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
-
end
-
-
1
def icon_path
-
14
Rails.application.routes.url_helpers.account_logo_path
-
end
-
end
-
# This is in lib so we can use it in a thread pool without the Rails executor
-
1
class WebPush::Pool
-
1
attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
-
-
1
def initialize(invalid_subscription_handler:)
-
224
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)
-
224
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
-
224
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
-
224
@invalid_subscription_handler = invalid_subscription_handler
-
end
-
-
1
def queue(payload, subscriptions)
-
12
subscriptions.find_each do |subscription|
-
14
deliver_later(payload, subscription)
-
end
-
end
-
-
1
def shutdown
-
223
connection.shutdown
-
223
shutdown_pool(delivery_pool)
-
223
shutdown_pool(invalidation_pool)
-
end
-
-
1
private
-
1
def deliver_later(payload, subscription)
-
# Ensure any AR operations happen before we post to the thread pool
-
14
notification = subscription.notification(**payload)
-
14
subscription_id = subscription.id
-
-
14
delivery_pool.post do
-
14
deliver(notification, subscription_id)
-
rescue Exception => e
-
Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}"
-
end
-
rescue Concurrent::RejectedExecutionError
-
end
-
-
1
def deliver(notification, id)
-
14
notification.deliver(connection: connection)
-
rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex
-
2
invalidate_subscription_later(id) if invalid_subscription_handler
-
end
-
-
1
def invalidate_subscription_later(id)
-
2
invalidation_pool.post do
-
2
invalid_subscription_handler.call(id)
-
rescue Exception => e
-
Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}"
-
end
-
end
-
-
1
def shutdown_pool(pool)
-
446
pool.shutdown
-
446
pool.kill unless pool.wait_for_termination(1)
-
end
-
end
-
1
require "test_helper"
-
-
1
class PresenceChannelTest < ActionCable::Channel::TestCase
-
1
setup do
-
6
stub_connection(current_user: users(:david))
-
end
-
-
1
test "subscribes" do
-
1
room = users(:david).rooms.first
-
-
1
subscribe room_id: room.id
-
-
1
assert subscription.confirmed?
-
1
assert_has_stream_for room
-
end
-
-
1
test "rejects subscription to a room that the user is not a member of" do
-
1
subscribe room_id: Rooms::Closed.create!(name: "New Room", creator: users(:david)).id
-
-
1
assert subscription.rejected?
-
end
-
-
1
test "rejects subscription to non-existent room" do
-
1
subscribe room_id: -1
-
-
1
assert subscription.rejected?
-
end
-
-
1
test "rejects subscription without a room" do
-
1
subscribe room_id: -1
-
-
1
assert subscription.rejected?
-
end
-
-
1
test "subscribing marks the membership as connected" do
-
1
membership = users(:david).memberships.first
-
-
3
assert_changes -> { membership.reload.connected? }, from: false, to: true do
-
1
subscribe room_id: membership.room_id
-
end
-
end
-
-
1
test "unsubscribing marks the membership as disconnected" do
-
1
membership = users(:david).memberships.first
-
1
subscribe room_id: membership.room_id
-
-
3
assert_changes -> { membership.reload.connected? }, from: true, to: false do
-
1
unsubscribe
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Accounts::Bots::KeysControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
1
sign_in :david
-
end
-
-
1
test "update" do
-
3
assert_changes -> { users(:bender).reload.bot_token } do
-
1
put account_bot_key_url(users(:bender))
-
1
assert_redirected_to account_bots_url
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Accounts::BotsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
5
sign_in :david
-
end
-
-
1
test "index" do
-
1
get account_bots_url
-
1
assert_response :ok
-
end
-
-
1
test "create" do
-
1
get new_account_bot_url
-
1
assert_response :ok
-
-
1
post account_bots_url, params: { user: { name: "Bender's Friend" } }
-
1
assert_redirected_to account_bots_url
-
1
assert_equal "Bender's Friend", User.bot.last.name
-
end
-
-
1
test "update" do
-
1
get edit_account_bot_url(users(:bender))
-
1
assert_response :ok
-
-
1
put account_bot_url(users(:bender)), params: { user: { name: "Bender's New Friend" } }
-
1
assert_redirected_to account_bots_url
-
1
assert_equal "Bender's New Friend", users(:bender).reload.name
-
end
-
-
1
test "destroy" do
-
3
assert_difference -> { User.active_bots.count }, -1 do
-
1
delete account_bot_url(users(:bender))
-
end
-
-
1
assert users(:bender).reload.deactivated?
-
end
-
-
1
test "remove webhook" do
-
3
assert_difference -> { Webhook.count }, -1 do
-
1
put account_bot_url(users(:bender)), params: { user: { name: "Bender's New Friend", webook_url: "" } }
-
1
assert_redirected_to account_bots_url
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Accounts::CustomStylesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "edit" do
-
1
get edit_account_custom_styles_url
-
1
assert_response :ok
-
end
-
-
1
test "update" do
-
1
assert users(:david).administrator?
-
-
1
put account_custom_styles_url, params: { account: { custom_styles: ":root { --color-text: red; }" } }
-
-
1
assert_redirected_to edit_account_custom_styles_url
-
1
assert_equal accounts(:signal).custom_styles, ":root { --color-text: red; }"
-
end
-
-
1
test "non-admins cannot update" do
-
1
sign_in :kevin
-
1
assert users(:kevin).member?
-
-
1
put account_custom_styles_url, params: { account: { custom_styles: ":root { --color-text: red; }" } }
-
1
assert_response :forbidden
-
end
-
end
-
1
require "test_helper"
-
-
1
class Accounts::JoinCodesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
2
sign_in :david
-
end
-
-
1
test "create new join code" do
-
3
assert_changes -> { accounts(:signal).reload.join_code } do
-
1
post account_join_code_url
-
1
assert_redirected_to edit_account_url
-
end
-
end
-
-
1
test "only administrators can create new join codes" do
-
1
sign_in :jz
-
1
post account_join_code_url
-
1
assert_response :forbidden
-
end
-
end
-
1
require "test_helper"
-
1
require "vips"
-
-
1
class Accounts::LogosControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
5
sign_in :david
-
end
-
-
1
test "show stock" do
-
1
get account_logo_url
-
1
assert_valid_png_response size: 512
-
end
-
-
1
test "show stock small size" do
-
1
get account_logo_url(size: :small)
-
1
assert_valid_png_response size: 192
-
end
-
-
1
test "show custom" do
-
1
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
-
-
1
get account_logo_url
-
1
assert_valid_png_response size: 512
-
end
-
-
1
test "show custom small size" do
-
1
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
-
-
1
get account_logo_url(size: :small)
-
1
assert_valid_png_response size: 192
-
end
-
-
1
test "destroy" do
-
1
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
-
-
1
delete account_logo_url
-
1
assert_redirected_to edit_account_url
-
1
assert_not accounts(:signal).reload.logo.attached?
-
end
-
-
1
private
-
1
def assert_valid_png_response(size:)
-
4
assert_equal @response.headers["content-type"], "image/png"
-
-
4
image = ::Vips::Image.new_from_buffer(@response.body, "")
-
4
assert_equal size, image.width
-
4
assert_equal size, image.height
-
end
-
end
-
1
require "test_helper"
-
-
1
class Accounts::UsersControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "update" do
-
1
assert users(:david).administrator?
-
-
1
put account_user_url(users(:david)), params: { user: { role: "administrator" } }
-
-
1
assert_redirected_to edit_account_url
-
1
assert users(:david).reload.administrator?
-
end
-
-
1
test "destroy" do
-
3
assert_difference -> { User.active.count }, -1 do
-
1
delete account_user_url(users(:david))
-
end
-
-
1
assert_redirected_to edit_account_url
-
1
assert_nil User.active.find_by(id: users(:david).id)
-
end
-
-
1
test "non-admins cannot perform actions" do
-
1
sign_in :kevin
-
-
1
put account_user_url(users(:david)), params: { user: { role: "administrator" } }
-
1
assert_response :forbidden
-
-
1
delete account_user_url(users(:david))
-
1
assert_response :forbidden
-
end
-
end
-
1
require "test_helper"
-
-
1
class AccountsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "edit" do
-
1
get edit_account_url
-
1
assert_response :ok
-
end
-
-
1
test "update" do
-
1
assert users(:david).administrator?
-
-
1
put account_url, params: { account: { name: "Different" } }
-
-
1
assert_redirected_to edit_account_url
-
1
assert_equal accounts(:signal).name, "Different"
-
end
-
-
1
test "non-admins cannot update" do
-
1
sign_in :kevin
-
1
assert users(:kevin).member?
-
-
1
put account_url, params: { account: { name: "Different" } }
-
1
assert_response :forbidden
-
end
-
end
-
1
require "test_helper"
-
-
1
class Autocompletable::UsersControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
4
sign_in :david
-
end
-
-
1
test "search returns matching users" do
-
1
get autocompletable_users_url(format: :json), params: { query: "da" }
-
-
1
assert_response :success
-
1
assert_equal "David", response.parsed_body.first["name"]
-
end
-
-
1
test "search results escape HTML in names" do
-
1
users(:david).update!(name: "David <script>alert(123)</script>")
-
-
1
get autocompletable_users_url(format: :json), params: { query: "da" }
-
-
1
assert_response :success
-
1
assert_equal "David <script>alert(123)</script>", response.parsed_body.first["name"]
-
end
-
-
1
test "room search returns matching users" do
-
1
get autocompletable_users_url(room_id: rooms(:hq).id, format: :json), params: { query: "da" }
-
-
1
assert_response :success
-
1
assert_equal "David", response.parsed_body.first["name"]
-
end
-
-
1
test "room search is scoped by membership" do
-
1
sign_in :kevin
-
-
1
assert_not_includes users(:kevin).rooms, rooms(:watercooler)
-
-
1
assert_raises ActiveRecord::RecordNotFound do
-
1
get autocompletable_users_url(room_id: rooms(:watercooler).id, format: :json), params: { query: "da" }
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class FirstRunsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
Account.destroy_all
-
3
User.destroy_all
-
3
Room.destroy_all
-
end
-
-
1
test "new is permitted when no other users exit" do
-
1
get first_run_url
-
1
assert_response :success
-
end
-
-
1
test "new is not permitted when account exist" do
-
1
Account.create!(name: "Chat")
-
-
1
get first_run_url
-
1
assert_redirected_to root_url
-
end
-
-
1
test "create" do
-
3
assert_difference -> { Room.count }, 1 do
-
3
assert_difference -> { User.count }, 1 do
-
1
post first_run_url, params: { account: { name: "37signals" }, user: { name: "New Person", email_address: "new@37signals.com", password: "secret123456" } }
-
end
-
end
-
-
1
assert_redirected_to root_url
-
-
1
assert parsed_cookies.signed[:session_token]
-
end
-
end
-
1
require "test_helper"
-
-
1
class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
2
sign_in :david
-
2
@message = messages(:first)
-
end
-
-
1
test "create" do
-
1
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
-
3
assert_difference -> { @message.boosts.count }, 1 do
-
1
post message_boosts_url(@message, format: :turbo_stream), params: { boost: { content: "Morning!" } }
-
1
assert_redirected_to message_boosts_url(@message)
-
end
-
end
-
end
-
-
1
test "destroy" do
-
1
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
-
3
assert_difference -> { @message.boosts.count }, -1 do
-
1
delete message_boost_url(@message, boosts(:first), format: :turbo_stream)
-
1
assert_response :success
-
end
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Messages::ByBotsControlleTest < ActionDispatch::IntegrationTest
-
1
setup do
-
6
@room = rooms(:watercooler)
-
end
-
-
1
test "create" do
-
3
assert_difference -> { Message.count }, +1 do
-
1
post room_bot_messages_url(@room, users(:bender).bot_key), params: "Hello Bot World!"
-
1
assert_equal "Hello Bot World!", Message.last.plain_text_body
-
end
-
end
-
-
1
test "create with UTF-8 content" do
-
3
assert_difference -> { Message.count }, +1 do
-
1
post room_bot_messages_url(@room, users(:bender).bot_key), params: "Hello 👋!"
-
1
assert_equal "Hello 👋!", Message.last.plain_text_body
-
end
-
end
-
-
1
test "create file" do
-
3
assert_difference -> { Message.count }, +1 do
-
1
post room_bot_messages_url(@room, users(:bender).bot_key), params: { attachment: fixture_file_upload("moon.jpg", "image/jpeg") }
-
1
assert Message.last.attachment.present?
-
end
-
end
-
-
1
test "create does not trigger a webhook to the sending bot if it mentions itself" do
-
1
body = "<div>Hey #{mention_attachment_for(:bender)}</div>"
-
-
1
assert_no_enqueued_jobs only: Bot::WebhookJob do
-
1
post room_bot_messages_url(@room, users(:bender).bot_key), params: body
-
end
-
end
-
-
1
test "create does not trigger a webhook to the sending bot in a direct room" do
-
1
assert_no_enqueued_jobs only: Bot::WebhookJob do
-
1
post room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key), params: "Talking to myself again!"
-
end
-
end
-
-
1
test "denied index" do
-
1
get room_messages_url(@room, bot_key: users(:bender).bot_key, format: :json)
-
1
assert_response :forbidden
-
end
-
end
-
1
require "test_helper"
-
-
1
class MessagesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
14
host! "once.campfire.test"
-
-
14
sign_in :david
-
14
@room = rooms(:watercooler)
-
14
@messages = @room.messages.ordered.to_a
-
end
-
-
1
test "index returns the last page by default" do
-
1
get room_messages_url(@room)
-
-
1
assert_response :success
-
1
ensure_messages_present @messages.last
-
end
-
-
1
test "index returns a page before the specified message" do
-
1
get room_messages_url(@room, before: @messages.third)
-
-
1
assert_response :success
-
1
ensure_messages_present @messages.first, @messages.second
-
1
ensure_messages_not_present @messages.third, @messages.fourth, @messages.fifth
-
end
-
-
1
test "index returns a page after the specified message" do
-
1
get room_messages_url(@room, after: @messages.third)
-
-
1
assert_response :success
-
1
ensure_messages_present @messages.fourth, @messages.fifth
-
1
ensure_messages_not_present @messages.first, @messages.second, @messages.third
-
end
-
-
1
test "index returns no_content when there are no messages" do
-
1
@room.messages.destroy_all
-
-
1
get room_messages_url(@room)
-
-
1
assert_response :no_content
-
end
-
-
1
test "get renders a single message belonging to the user" do
-
1
message = @room.messages.where(creator: users(:david)).first
-
-
1
get room_message_url(@room, message)
-
-
1
assert_response :success
-
end
-
-
1
test "creating a message broadcasts the message to the room" do
-
1
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
-
-
1
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
-
1
assert_select ".message__body", text: /New one/
-
1
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
-
end
-
end
-
-
1
test "creating a message broadcasts unread room" do
-
1
assert_broadcasts "unread_rooms", 1 do
-
1
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
-
end
-
end
-
-
1
test "update updates a message belonging to the user" do
-
1
message = @room.messages.where(creator: users(:david)).first
-
-
1
Turbo::StreamsChannel.expects(:broadcast_replace_to).once
-
1
put room_message_url(@room, message), params: { message: { body: "Updated body" } }
-
-
1
assert_redirected_to room_message_url(@room, message)
-
1
assert_equal "Updated body", message.reload.plain_text_body
-
end
-
-
1
test "admin updates a message belonging to another user" do
-
1
message = @room.messages.where(creator: users(:jason)).first
-
-
1
Turbo::StreamsChannel.expects(:broadcast_replace_to).once
-
1
put room_message_url(@room, message), params: { message: { body: "Updated body" } }
-
-
1
assert_redirected_to room_message_url(@room, message)
-
1
assert_equal "Updated body", message.reload.plain_text_body
-
end
-
-
1
test "destroy destroys a message belonging to the user" do
-
1
message = @room.messages.where(creator: users(:david)).first
-
-
3
assert_difference -> { Message.count }, -1 do
-
1
Turbo::StreamsChannel.expects(:broadcast_remove_to).once
-
1
delete room_message_url(@room, message, format: :turbo_stream)
-
1
assert_response :success
-
end
-
end
-
-
1
test "admin destroy destroys a message belonging to another user" do
-
1
assert users(:david).administrator?
-
1
message = @room.messages.where(creator: users(:jason)).first
-
-
3
assert_difference -> { Message.count }, -1 do
-
1
Turbo::StreamsChannel.expects(:broadcast_remove_to).once
-
1
delete room_message_url(@room, message, format: :turbo_stream)
-
1
assert_response :success
-
end
-
end
-
-
1
test "ensure non-admin can't update a message belonging to another user" do
-
1
sign_in :jz
-
1
assert_not users(:jz).administrator?
-
-
1
room = rooms(:designers)
-
1
message = room.messages.where(creator: users(:jason)).first
-
-
1
put room_message_url(room, message), params: { message: { body: "Updated body" } }
-
1
assert_response :forbidden
-
end
-
-
1
test "ensure non-admin can't destroy a message belonging to another user" do
-
1
sign_in :jz
-
1
assert_not users(:jz).administrator?
-
-
1
room = rooms(:designers)
-
1
message = room.messages.where(creator: users(:jason)).first
-
-
1
delete room_message_url(room, message, format: :turbo_stream)
-
1
assert_response :forbidden
-
end
-
-
1
test "mentioning a bot triggers a webhook" do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200)
-
-
1
assert_enqueued_jobs 1, only: Bot::WebhookJob do
-
1
post room_messages_url(@room, format: :turbo_stream), params: { message: {
-
body: "<div>Hey #{mention_attachment_for(:bender)}</div>", client_message_id: 999 } }
-
end
-
end
-
-
1
private
-
1
def ensure_messages_present(*messages, count: 1)
-
5
messages.each do |message|
-
11
assert_select "#" + dom_id(message), count:
-
end
-
end
-
-
1
def ensure_messages_not_present(*messages)
-
2
ensure_messages_present *messages, count: 0
-
end
-
-
1
def assert_copy_link_button(url)
-
1
assert_select ".btn[title='Copy link'][data-copy-to-clipboard-content-value='#{url}']"
-
end
-
end
-
1
require "test_helper"
-
-
1
class QrCodeControllerTest < ActionDispatch::IntegrationTest
-
1
test "show renders a QR code as a cacheable SVG image" do
-
1
id = Base64.urlsafe_encode64("http://example.com")
-
-
1
get qr_code_path(id)
-
-
1
assert_response :success
-
1
assert_includes response.content_type, "image/svg+xml"
-
-
1
assert_equal 1.year, response.cache_control[:max_age].to_i
-
1
assert response.cache_control[:public]
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
7
sign_in :david
-
end
-
-
1
test "show redirects to get general show" do
-
1
get rooms_open_url(users(:david).rooms.closeds.last)
-
1
assert_redirected_to room_url(users(:david).rooms.closeds.last)
-
end
-
-
1
test "new" do
-
1
get new_rooms_closed_url
-
1
assert_response :success
-
end
-
-
1
test "create" do
-
1
assert_turbo_stream_broadcasts [ users(:david), :rooms ], count: 1 do
-
1
assert_turbo_stream_broadcasts [ users(:kevin), :rooms ], count: 1 do
-
1
assert_turbo_stream_broadcasts [ users(:jason), :rooms ], count: 1 do
-
1
post rooms_closeds_url, params: { room: { name: "My New Room" }, user_ids: [ users(:david).id, users(:kevin).id, users(:jason).id ] }
-
end
-
end
-
end
-
-
1
new_room = Room.last
-
1
assert_equal new_room.memberships.count, 3
-
1
assert_redirected_to room_url(Room.last)
-
end
-
-
1
test "update with membership revisions" do
-
3
assert_difference -> { rooms(:designers).reload.users.count }, -1 do
-
1
put rooms_closed_url(rooms(:designers)), params: {
-
room: { name: "New Name" }, user_ids: rooms(:designers).users.without(users(:jason)).collect(&:id)
-
}
-
end
-
-
1
assert_redirected_to room_url(rooms(:designers))
-
1
assert rooms(:designers).reload.name, "New Name"
-
end
-
-
1
test "update an open room to be closed" do
-
1
put rooms_closed_url(rooms(:pets)), params: { room: { name: "Doesn't matter" }, user_ids: [ users(:david).id, users(:jason).id ] }
-
1
assert_equal rooms(:pets).memberships.count, 2
-
end
-
-
1
test "only admins or creators can update" do
-
1
sign_in :jz
-
-
1
assert_turbo_stream_broadcasts :rooms, count: 0 do
-
1
put rooms_closed_url(rooms(:designers)), params: { room: { name: "New Name" } }
-
end
-
-
1
assert_response :forbidden
-
1
assert rooms(:designers).reload.name, "Designers"
-
end
-
-
1
test "remove yourself" do
-
3
assert_difference -> { users(:david).rooms.count }, -1 do
-
1
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
-
-
1
assert_redirected_to room_url(rooms(:designers))
-
1
follow_redirect!
-
1
assert_redirected_to root_url
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "create" do
-
1
post rooms_directs_url, params: { user_ids: [ users(:jz).id ] }
-
-
1
room = Room.last
-
1
assert_redirected_to room_url(room)
-
1
assert room.users.include?(users(:david))
-
1
assert room.users.include?(users(:jz))
-
end
-
-
1
test "create only once per user set" do
-
3
assert_difference -> { Room.all.count }, +1 do
-
1
post rooms_directs_url, params: { user_ids: [ users(:jz).id ] }
-
1
post rooms_directs_url, params: { user_ids: [ users(:jz).id ] }
-
end
-
end
-
-
1
test "destroy only allowed for all room users" do
-
1
sign_in :kevin
-
-
3
assert_difference -> { Room.count }, -1 do
-
1
delete rooms_direct_url(rooms(:david_and_kevin))
-
1
assert_redirected_to root_url
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
4
sign_in :david
-
end
-
-
1
test "show" do
-
1
get room_involvement_url(rooms(:designers))
-
1
assert_response :success
-
end
-
-
1
test "update involvement sends turbo update when becoming visible and when going invisible" do
-
1
assert_turbo_stream_broadcasts [ users(:david), :rooms ], count: 1 do
-
3
assert_changes -> { memberships(:david_watercooler).reload.involvement }, from: "everything", to: "invisible" do
-
1
put room_involvement_url(rooms(:watercooler)), params: { involvement: "invisible" }
-
1
assert_redirected_to room_involvement_url(rooms(:watercooler))
-
end
-
end
-
-
1
assert_turbo_stream_broadcasts [ users(:david), :rooms ], count: 2 do
-
3
assert_changes -> { memberships(:david_watercooler).reload.involvement }, from: "invisible", to: "everything" do
-
1
put room_involvement_url(rooms(:watercooler)), params: { involvement: "everything" }
-
1
assert_redirected_to room_involvement_url(rooms(:watercooler))
-
end
-
end
-
end
-
-
1
test "updating involvement does not send turbo update changing visible states" do
-
1
assert_no_turbo_stream_broadcasts [ users(:david), :rooms ] do
-
3
assert_changes -> { memberships(:david_watercooler).reload.involvement }, from: "everything", to: "mentions" do
-
1
put room_involvement_url(rooms(:watercooler)), params: { involvement: "mentions" }
-
1
assert_redirected_to room_involvement_url(rooms(:watercooler))
-
end
-
end
-
end
-
-
1
test "updating involvement does not send turbo update for direct rooms" do
-
1
assert_no_turbo_stream_broadcasts [ users(:david), :rooms ] do
-
3
assert_changes -> { memberships(:david_david_and_jason).reload.involvement }, from: "everything", to: "nothing" do
-
1
put room_involvement_url(rooms(:david_and_jason)), params: { involvement: "nothing" }
-
1
assert_redirected_to room_involvement_url(rooms(:david_and_jason))
-
end
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
6
sign_in :david
-
end
-
-
1
test "show redirects to get general show" do
-
1
get rooms_open_url(users(:david).rooms.opens.last)
-
1
assert_redirected_to room_url(users(:david).rooms.opens.last)
-
end
-
-
1
test "new" do
-
1
get new_rooms_open_url
-
1
assert_response :success
-
end
-
-
1
test "create" do
-
1
assert_turbo_stream_broadcasts :rooms, count: 1 do
-
1
post rooms_opens_url, params: { room: { name: "My New Room" } }
-
end
-
-
1
assert_equal Room.last.memberships.count, User.count
-
1
assert_redirected_to room_url(Room.last)
-
end
-
-
1
test "only admins or creators can update" do
-
1
sign_in :jz
-
-
1
assert_turbo_stream_broadcasts :rooms, count: 0 do
-
1
put rooms_open_url(rooms(:hq)), params: { room: { name: "New Name" } }
-
end
-
-
1
assert_response :forbidden
-
1
assert rooms(:hq).reload.name, "HQ"
-
end
-
-
1
test "update" do
-
1
assert_turbo_stream_broadcasts :rooms, count: 1 do
-
1
put rooms_open_url(rooms(:pets)), params: { room: { name: "New Name" } }
-
end
-
-
1
assert_redirected_to room_url(rooms(:pets))
-
1
assert rooms(:pets).reload.name, "New Name"
-
end
-
-
1
test "update a closed room to be open" do
-
1
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
-
1
assert_equal rooms(:designers).memberships.count, User.count
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::RefreshesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
1
sign_in :david
-
end
-
-
1
test "refresh includes new messages since the last known" do
-
1
travel_to 1.day.ago do
-
1
@old_message = rooms(:hq).messages.create!(creator: users(:jason), body: "Old message", client_message_id: "old")
-
end
-
-
1
travel_to 1.minute.ago do
-
1
@new_message = rooms(:hq).messages.create!(creator: users(:jason), body: "New message", client_message_id: "new")
-
1
@old_message.touch
-
end
-
-
1
get room_refresh_url(rooms(:hq), format: :turbo_stream), params: { since: 10.minutes.ago.to_fs(:epoch) }
-
-
1
assert_response :success
-
-
1
assert_select "turbo-stream[action='append']" do
-
1
assert_select "#" + dom_id(@new_message)
-
1
assert_select "template", count: 1
-
end
-
-
1
assert_select "turbo-stream[action='replace']" do
-
1
assert_select "#" + dom_id(@old_message)
-
1
assert_select "template", count: 1
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class RoomsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
5
sign_in :david
-
end
-
-
1
test "index redirects to the user's last room" do
-
1
get rooms_url
-
1
assert_redirected_to room_url(users(:david).rooms.last)
-
end
-
-
1
test "show" do
-
1
get room_url(users(:david).rooms.last)
-
1
assert_response :success
-
end
-
-
1
test "shows records the last room visited in a cookie" do
-
1
get room_url(users(:david).rooms.last)
-
1
assert response.cookies[:last_room] = users(:david).rooms.last.id
-
end
-
-
1
test "destroy" do
-
1
assert_turbo_stream_broadcasts :rooms, count: 1 do
-
3
assert_difference -> { Room.count }, -1 do
-
1
delete room_url(rooms(:designers))
-
end
-
end
-
end
-
-
1
test "destroy only allowed for creators or those who can administer" do
-
1
sign_in :jz
-
-
3
assert_no_difference -> { Room.count } do
-
1
delete room_url(rooms(:designers))
-
1
assert_response :forbidden
-
end
-
-
1
rooms(:designers).update! creator: users(:jz)
-
-
3
assert_difference -> { Room.count }, -1 do
-
1
delete room_url(rooms(:designers))
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class SearchesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
5
sign_in :david
-
5
@message = rooms(:designers).messages.create! body: "Hello world!", client_message_id: "search", creator: users(:david)
-
end
-
-
1
test "index initial view" do
-
1
get searches_url
-
-
1
assert_response :success
-
1
assert_select ".message", count: 0
-
end
-
-
1
test "finding reachable messages" do
-
1
get searches_url, params: { q: "hello" }
-
-
1
assert_response :success
-
1
assert_select ".message", text: /Hello world!/
-
end
-
-
1
test "unreachable messages are not found" do
-
1
memberships(:david_designers).destroy!
-
-
1
get searches_url, params: { q: "hello" }
-
-
1
assert_response :success
-
1
assert_select ".message", count: 0
-
end
-
-
1
test "create saves the search term" do
-
3
assert_difference -> { users(:david).searches.count }, +1 do
-
1
post searches_url, params: { q: "hello" }
-
end
-
-
1
assert_redirected_to searches_url(q: "hello")
-
1
assert users(:david).searches.exists?(query: "hello")
-
end
-
-
1
test "clear search history" do
-
1
assert users(:david).searches.any?
-
-
1
delete clear_searches_url
-
-
1
assert users(:david).searches.none?
-
end
-
end
-
1
require "test_helper"
-
-
1
class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
-
1
test "show renders when not signed in" do
-
1
get session_transfer_url("some-token")
-
-
1
assert_response :success
-
end
-
-
1
test "update establishes a session when the code is valid" do
-
1
user = users(:david)
-
-
1
put session_transfer_url(user.transfer_id)
-
-
1
assert_redirected_to root_url
-
1
assert parsed_cookies.signed[:session_token]
-
end
-
end
-
1
require "test_helper"
-
-
1
class SessionsControllerTest < ActionDispatch::IntegrationTest
-
1
ALLOWED_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
-
1
DISALLOWED_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0"
-
-
1
test "new" do
-
1
get new_session_url
-
1
assert_response :success
-
end
-
-
1
test "new redirects to first run when no users exist" do
-
1
User.destroy_all
-
-
1
get new_session_url
-
-
1
assert_redirected_to first_run_url
-
end
-
-
1
test "new denied with incompatible browser" do
-
1
get new_session_url, env: { "HTTP_USER_AGENT" => DISALLOWED_BROWSER }
-
1
assert_select "h1", /Upgrade to a supported web browser/
-
end
-
-
1
test "new allowed with compatible browser" do
-
1
get new_session_url, env: { "HTTP_USER_AGENT" => ALLOWED_BROWSER }
-
1
assert_select "h1", text: /Upgrade to a supported web browser/, count: 0
-
end
-
-
1
test "create with valid credentials" do
-
1
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
-
-
1
assert_redirected_to root_url
-
1
assert parsed_cookies.signed[:session_token]
-
end
-
-
1
test "create with invalid credentials" do
-
1
post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
-
-
1
assert_response :unauthorized
-
1
assert_nil parsed_cookies.signed[:session_token]
-
end
-
-
1
test "destroy" do
-
1
sign_in :david
-
-
1
delete session_url
-
-
1
assert_redirected_to root_url
-
1
assert_not cookies[:session_token].present?
-
end
-
-
1
test "destroy removes the push subscription for the device" do
-
1
sign_in :david
-
-
3
assert_difference -> { users(:david).push_subscriptions.count }, -1 do
-
1
delete session_url, params: { push_subscription_endpoint: push_subscriptions(:david_chrome).endpoint }
-
end
-
-
1
assert_redirected_to root_url
-
1
assert_not cookies[:session_token].present?
-
end
-
end
-
1
require "test_helper"
-
-
1
class UnfurlLinksControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
5
sign_in :david
-
end
-
-
1
test "create" do
-
1
stub_successful_request
-
-
1
post unfurl_link_url, params: { url: "https://www.example.com" }
-
1
assert_response :success
-
-
1
json_response = JSON.parse(response.body)
-
1
assert_equal "Hey!", json_response["title"]
-
1
assert_equal "https://example.com", json_response["url"]
-
1
assert_equal "https://example.com/image.png", json_response["image"]
-
1
assert_equal "desc..", json_response["description"]
-
end
-
-
1
test "create with missing opengraph meta tags" do
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: "<html><head></head></html>", headers: {})
-
-
1
post unfurl_link_url, params: { url: "https://www.example.com" }
-
1
assert_response :no_content
-
end
-
-
1
test "create with a missing URL" do
-
1
assert_raise ActionController::ParameterMissing do
-
1
post unfurl_link_url, params: { url: "" }
-
assert_response :bad_request
-
end
-
end
-
-
1
test "create for twitter.com" do
-
1
stub_successful_request url: "https://fxtwitter.com/dhh/status/834146806594433025"
-
-
1
post unfurl_link_url, params: { url: "https://twitter.com/dhh/status/834146806594433025" }
-
1
assert_response :success
-
1
assert_equal "Hey!", JSON.parse(response.body)["title"]
-
end
-
-
1
test "create for x.com" do
-
1
stub_successful_request url: "https://fxtwitter.com/dhh/status/834146806594433025"
-
-
1
post unfurl_link_url, params: { url: "https://x.com/dhh/status/834146806594433025" }
-
1
assert_response :success
-
1
assert_equal "Hey!", JSON.parse(response.body)["title"]
-
end
-
-
1
private
-
1
def stub_successful_request(url: "https://www.example.com/")
-
3
WebMock.stub_request(:get, url).to_return(
-
status: 200,
-
body: "<html><head><meta property=\"og:url\" content=\"https://example.com\"><meta property=\"og:title\" content=\"Hey!\"><meta property=\"og:description\" content=\"desc..\"><meta property=\"og:image\" content=\"https://example.com/image.png\"></head></html>",
-
headers: { content_type: "text/html" }
-
)
-
-
3
WebMock.stub_request(:head, "https://example.com/image.png").to_return(
-
status: 200,
-
headers: { content_type: "image/png" }
-
)
-
end
-
end
-
1
require "test_helper"
-
-
1
class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "show initials" do
-
1
get user_avatar_url(users(:kevin).avatar_token)
-
1
assert_select "text", text: "K"
-
end
-
-
1
test "show image" do
-
1
users(:kevin).update! avatar: fixture_file_upload("moon.jpg", "image/jpeg")
-
1
get user_avatar_url(users(:kevin).avatar_token)
-
-
1
assert_response :success
-
1
assert_equal "image/webp", @response.content_type
-
end
-
-
1
test "show image with invalid token responds 404" do
-
1
get user_avatar_url("not-a-valid-token")
-
-
1
assert_response :not_found
-
end
-
end
-
1
require "test_helper"
-
-
1
class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "show" do
-
1
get user_profile_url
-
-
1
assert_response :success
-
end
-
-
1
test "update" do
-
1
put user_profile_url, params: { user: { name: "John Doe", bio: "Acrobat" } }
-
-
1
assert_redirected_to user_profile_url
-
1
assert_equal "John Doe", users(:david).reload.name
-
1
assert_equal "Acrobat", users(:david).bio
-
1
assert_equal "david@37signals.com", users(:david).email_address
-
end
-
-
1
test "updates are limited to the current user" do
-
1
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
-
-
1
assert_equal "Jason", users(:jason).reload.name
-
end
-
end
-
1
require "test_helper"
-
-
1
class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "create new push subscription" do
-
1
subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" }
-
-
1
post user_push_subscriptions_url,
-
params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }
-
-
1
assert_response :ok
-
-
1
assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key")
-
1
assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent
-
end
-
-
1
test "touch existing subscription" do
-
3
assert_no_difference -> { users(:david).push_subscriptions.count } do
-
3
assert_changes -> { push_subscriptions(:david_chrome).reload.updated_at } do
-
1
post user_push_subscriptions_url(params: {
-
push_subscription: push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
-
})
-
end
-
end
-
-
1
assert_response :ok
-
end
-
-
1
test "destroy a push subscription via dev mode" do
-
3
assert_difference -> { Push::Subscription.count }, -1 do
-
1
delete user_push_subscription_url(push_subscriptions(:david_chrome))
-
1
assert_redirected_to user_push_subscriptions_url
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
3
sign_in :david
-
end
-
-
1
test "show" do
-
1
get user_sidebar_url
-
-
1
users(:david).rooms.opens.each do |room|
-
2
assert_match /#{room.name}/, @response.body
-
end
-
end
-
-
1
test "unread directs" do
-
1
rooms(:david_and_jason).messages.create! client_message_id: 999, body: "Hello", creator: users(:jason)
-
-
1
get user_sidebar_url
-
7
assert_select ".unread", count: users(:david).memberships.select { |m| m.room.direct? && m.unread? }.count
-
end
-
-
-
1
test "unread other" do
-
1
rooms(:watercooler).messages.create! client_message_id: 999, body: "Hello", creator: users(:jason)
-
-
1
get user_sidebar_url
-
7
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
-
end
-
end
-
1
require "test_helper"
-
-
1
class UsersControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
6
@join_code = accounts(:signal).join_code
-
end
-
-
1
test "show" do
-
1
sign_in :david
-
1
get user_url(users(:david))
-
1
assert_response :ok
-
end
-
-
1
test "new" do
-
1
get join_url(@join_code)
-
1
assert_response :success
-
end
-
-
1
test "new does not allow a signed in user" do
-
1
sign_in :david
-
-
1
get join_url(@join_code)
-
1
assert_redirected_to root_url
-
end
-
-
1
test "new requires a join code" do
-
1
get join_url("not")
-
1
assert_response :not_found
-
end
-
-
1
test "create" do
-
3
assert_difference -> { User.count }, 1 do
-
1
post join_url(@join_code), params: { user: { name: "New Person", email_address: "new@37signals.com", password: "secret123456" } }
-
end
-
-
1
assert_redirected_to root_url
-
-
1
user = User.last
-
1
assert_equal user.id, Session.find_by(token: parsed_cookies.signed[:session_token]).user.id
-
1
assert_equal Rooms::Open.all, user.rooms
-
end
-
-
1
test "creating a new user with an existing email address will redirect to login screen" do
-
3
assert_no_difference -> { User.count } do
-
1
post join_url(@join_code), params: { user: { name: "Another David", email_address: users(:david).email_address, password: "secret123456" } }
-
end
-
-
1
assert_redirected_to new_session_url(email_address: users(:david).email_address)
-
end
-
end
-
1
require "test_helper"
-
-
1
class WelcomeControllerTest < ActionDispatch::IntegrationTest
-
1
setup do
-
2
sign_in :david
-
end
-
-
1
test "redirects to the first created visible room the user has access to" do
-
1
get root_url
-
-
1
assert_redirected_to room_url(users(:david).rooms.original)
-
end
-
-
1
test "redirects to the last room visited, if we have one" do
-
1
cookies[:last_room] = rooms(:watercooler).id
-
-
1
get root_url
-
-
1
assert_redirected_to room_url(rooms(:watercooler))
-
end
-
end
-
1
require "test_helper"
-
-
1
class ContentFiltersTest < ActionView::TestCase
-
1
test "entire message contains an unfurled URL" do
-
1
text = "https://basecamp.com/"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
assert_not_equal message.body.body.to_html, filtered.to_html
-
1
assert_match /<div><action-text-attachment/, filtered.to_html
-
end
-
-
1
test "message includes additional text besides an unfurled URL" do
-
1
text = "Hello https://basecamp.com/"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
assert_equal message.body.body.to_html, filtered.to_html
-
1
assert_match %r{<div>Hello https://basecamp\.com/<action-text-attachment}, filtered.to_html
-
end
-
-
1
test "unfurled tweet without any image" do
-
1
text = "<div>https://twitter.com/37signals/status/1750290547908952568<action-text-attachment content-type=\"application/vnd.actiontext.opengraph-embed\" url=\"https://pbs.twimg.com/profile_images/1671940407633010689/9P5gi6LF_200x200.jpg\" href=\"https://twitter.com/37signals/status/1750290547908952568\" filename=\"37signals (@37signals)\" caption=\"We're back up on all apps, everyone. Really sorry for the disruption to your day.\" content=\"<actiontext-opengraph-embed>\n <div class="og-embed">\n <div class="og-embed__content">\n <div class="og-embed__title">37signals (@37signals)</div>\n <div class="og-embed__description">We're back up on all apps, everyone. Really sorry for the disruption to your day.</div>\n </div>\n <div class="og-embed__image">\n <img src="https://pbs.twimg.com/profile_images/1671940407633010689/9P5gi6LF_200x200.jpg" class="image" alt="" />\n </div>\n </div>\n </actiontext-opengraph-embed>\"></action-text-attachment></div>"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::StyleUnfurledTwitterAvatars.apply(message.body.body)
-
1
assert_match %r{<div class="cf-twitter-avatar">}, filtered.to_html
-
end
-
-
1
test "unfurled tweet containing an image" do
-
1
text = "<div>https://twitter.com/dhh/status/1748445489648050505<action-text-attachment content-type=\"application/vnd.actiontext.opengraph-embed\" url=\"https://pbs.twimg.com/media/GEO5l04bsAA9f6H.jpg\" href=\"https://twitter.com/dhh/status/1748445489648050505\" filename=\"DHH (@dhh)\" caption=\"We pay homage to the glorious MIT License with the ONCE license. May all our future legalese be as succinct!\" content=\"<actiontext-opengraph-embed>\n <div class="og-embed">\n <div class="og-embed__content">\n <div class="og-embed__title">DHH (@dhh)</div>\n <div class="og-embed__description">We pay homage to the glorious MIT License with the ONCE license. May all our future legalese be as succinct!</div>\n </div>\n <div class="og-embed__image">\n <img src="https://pbs.twimg.com/media/GEO5l04bsAA9f6H.jpg" class="image" alt="" />\n </div>\n </div>\n </actiontext-opengraph-embed>\"></action-text-attachment></div>"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::StyleUnfurledTwitterAvatars.apply(message.body.body)
-
1
assert_no_match %r{<div class="cf-twitter-avatar">}, filtered.to_html
-
end
-
-
1
test "entire message contains an unfurled URL from x.com but unfurls to twitter.com" do
-
1
text = "https://x.com/dhh/status/1752476663303323939"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_twitter(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
assert_not_equal message.body.body.to_html, filtered.to_html
-
1
assert_match /<div><action-text-attachment/, filtered.to_html
-
end
-
-
1
test "entire message contains an unfurled URL from x.com with query params" do
-
1
text = "https://x.com/dhh/status/1752476663303323939?s=20"
-
1
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_twitter(text), client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
assert_not_equal message.body.body.to_html, filtered.to_html
-
1
assert_match /<div><action-text-attachment/, filtered.to_html
-
end
-
-
1
test "message contains a forbidden tag" do
-
1
exploit_image_tag = 'Hello <img src="https://ssecurityrise.com/tests/billionlaughs-cache.svg">World'
-
1
message = Message.create! room: rooms(:pets), body: exploit_image_tag, client_message_id: "0015", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
assert_equal "Hello World", filtered.to_html
-
end
-
-
1
test "message with a mention attachment" do
-
1
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
-
-
1
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
-
1
expected = /<action-text-attachment sgid="#{users(:david).attachable_sgid}" content-type="application\/vnd\.campfire\.mention" content="(.*?)"><\/action-text-attachment>/m
-
-
1
assert_match expected, filtered.to_html
-
end
-
-
1
private
-
1
def unfurled_message_body_for_basecamp(text)
-
4
"<div>#{text}#{unfurled_link_trix_attachment_for_basecamp}</div>"
-
end
-
-
1
def unfurled_link_trix_attachment_for_basecamp
-
4
<<~BASECAMP
-
<action-text-attachment content-type=\"application/vnd.actiontext.opengraph-embed\" url=\"https://basecamp.com/assets/general/opengraph.png\" href=\"https://basecamp.com/\" filename=\"Project management software, online collaboration\" caption=\"Trusted by millions, Basecamp puts everything you need to get work done in one place. It’s the calm, organized way to manage projects, work with clients, and communicate company-wide.\" content=\"<actiontext-opengraph-embed>\n <div class="og-embed">\n <div class="og-embed__content">\n <div class="og-embed__title">Project management software, online collaboration</div>\n <div class="og-embed__description">Trusted by millions, Basecamp puts everything you need to get work done in one place. It’s the calm, organized way to manage projects, work with clients, and communicate company-wide.</div>\n </div>\n <div class="og-embed__image">\n <img src="https://basecamp.com/assets/general/opengraph.png" class="image" alt="" />\n </div>\n </div>\n </actiontext-opengraph-embed>\"></action-text-attachment>
-
BASECAMP
-
end
-
-
1
def unfurled_message_body_for_twitter(text)
-
2
"<div>#{text}#{unfurled_link_trix_attachment_for_twitter}</div>"
-
end
-
-
1
def unfurled_link_trix_attachment_for_twitter
-
2
<<~TWEET
-
<action-text-attachment content-type=\"application/vnd.actiontext.opengraph-embed\" url=\"https://pbs.twimg.com/ext_tw_video_thumb/1752476502791503873/pu/img/WEAqUgarUxWjPNHD.jpg\" href=\"https://twitter.com/dhh/status/1752476663303323939\" filename=\"DHH (@dhh)\" caption=\"We're playing with adding easy extension points to ONCE/Campfire. Here's one experiment for allowing any type of CSS to be easily added.\" content=\"<actiontext-opengraph-embed>\n <div class="og-embed">\n <div class="og-embed__content">\n <div class="og-embed__title">DHH (@dhh)</div>\n <div class="og-embed__description">We're playing with adding easy extension points to ONCE/Campfire. Here's one experiment for allowing any type of CSS to be easily added.</div>\n </div>\n <div class="og-embed__image">\n <img src="https://pbs.twimg.com/ext_tw_video_thumb/1752476502791503873/pu/img/WEAqUgarUxWjPNHD.jpg" class="image" alt="" />\n </div>\n </div>\n </actiontext-opengraph-embed>\"><figure class=\"attachment attachment--content attachment--og\">\n \n <div class=\"og-embed gap\">\n <div class=\"og-embed__content\">\n <div class=\"og-embed__title\">\n <a href=\"https://twitter.com/dhh/status/1752476663303323939\">DHH (@dhh)</a>\n </div>\n <div class=\"og-embed__description\">We're playing with adding easy extension points to ONCE/Campfire. Here's one experiment for allowing any type of CSS to be easily added.</div>\n </div>\n <div class=\"og-embed__image\">\n <img src=\"https://pbs.twimg.com/ext_tw_video_thumb/1752476502791503873/pu/img/WEAqUgarUxWjPNHD.jpg\" class=\"image center\" alt=\"\">\n </div>\n </div>\n \n</figure></action-text-attachment>
-
TWEET
-
end
-
end
-
1
require "test_helper"
-
-
1
class Account::JoinableTest < ActiveSupport::TestCase
-
1
test "new accounts get a joinable code" do
-
1
Account.destroy_all
-
1
account = Account.create!(name: "Chat")
-
1
assert_match /\w{4}-\w{4}-\w{4}/, account.join_code
-
end
-
-
1
test "accounts can reset join code" do
-
3
assert_changes -> { accounts(:signal).reload.join_code } do
-
1
accounts(:signal).reset_join_code
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class AccountTest < ActiveSupport::TestCase
-
end
-
1
require "test_helper"
-
-
1
class ActionTextAttachmentTest < ActiveSupport::TestCase
-
1
setup do
-
3
@user = users(:david)
-
end
-
-
1
test "lookup user attachable with invalid sgid" do
-
1
message, signature = @user.attachable_sgid.split("--")
-
-
1
html = %Q(<action-text-attachment sgid="#{message}--invalid"></action-text-attachment>)
-
1
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
-
-
1
attachment = ActionText::Attachment.from_node(node)
-
1
assert_equal @user, attachment.attachable
-
end
-
-
1
test "lookup attachable with nil sgid" do
-
1
html = %Q(<action-text-attachment></action-text-attachment>)
-
1
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
-
-
1
attachment = ActionText::Attachment.from_node(node)
-
1
assert_kind_of ActionText::Attachables::MissingAttachable, attachment.attachable
-
end
-
-
1
test "lookup invalid sgid for an attachable requiring a valid sgid" do
-
# Make room instance attachable for testing purposes
-
2
room = rooms(:pets).tap { |r| r.extend ActionText::Attachable }
-
-
1
message, signature = rooms(:pets).attachable_sgid.split("--")
-
-
1
html = %Q(<action-text-attachment sgid="#{message}--invalid"></action-text-attachment>)
-
1
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
-
-
1
attachment = ActionText::Attachment.from_node(node)
-
1
assert_kind_of ActionText::Attachables::MissingAttachable, attachment.attachable
-
end
-
end
-
1
require "test_helper"
-
-
1
class FirstRunTest < ActiveSupport::TestCase
-
1
setup do
-
3
Account.destroy_all
-
3
Room.destroy_all
-
3
User.destroy_all
-
end
-
-
1
test "creating makes first user an administrator" do
-
1
user = create_first_run_user
-
1
assert user.administrator?
-
end
-
-
1
test "first user has access to first room" do
-
1
user = create_first_run_user
-
1
assert user.rooms.one?
-
end
-
-
1
test "first room is an open room" do
-
1
create_first_run_user
-
1
assert Room.first.open?
-
end
-
-
1
private
-
1
def create_first_run_user
-
3
FirstRun.create!({ name: "User", email_address: "user@example.com", password: "secret123456" })
-
end
-
end
-
1
require "test_helper"
-
-
1
class MembershipTest < ActiveSupport::TestCase
-
1
setup do
-
9
@membership = memberships(:david_watercooler)
-
end
-
-
1
test "connected scope" do
-
1
@membership.connected
-
1
assert Membership.connected.exists?(@membership.id)
-
-
1
@membership.disconnected
-
1
assert_not Membership.connected.exists?(@membership.id)
-
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
assert_not Membership.connected.exists?(@membership.id)
-
end
-
-
1
test "disconnected scope" do
-
1
@membership.disconnected
-
1
assert Membership.disconnected.exists?(@membership.id)
-
-
1
@membership.connected
-
1
assert_not Membership.disconnected.exists?(@membership.id)
-
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
assert Membership.disconnected.exists?(@membership.id)
-
end
-
-
1
test "connected? is false when connection is stale" do
-
1
@membership.connected
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
assert_not @membership.connected?
-
end
-
-
1
test "connecting" do
-
1
@membership.connected
-
1
assert @membership.connected?
-
1
assert_equal 1, @membership.connections
-
-
1
@membership.connected
-
1
assert_equal 2, @membership.connections
-
end
-
-
1
test "connecting resets stale connection count" do
-
3
2.times { @membership.connected }
-
1
assert_equal 2, @membership.connections
-
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
@membership.connected
-
1
assert_equal 1, @membership.connections
-
end
-
-
1
test "disconnecting" do
-
3
2.times { @membership.connected }
-
-
1
@membership.disconnected
-
1
assert @membership.connected?
-
1
assert_equal 1, @membership.connections
-
-
1
@membership.disconnected
-
1
assert_not @membership.connected?
-
1
assert_equal 0, @membership.connections
-
end
-
-
1
test "disconnecting resets stale connection count" do
-
3
2.times { @membership.connected }
-
1
assert_equal 2, @membership.connections
-
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
@membership.disconnected
-
1
assert_equal 0, @membership.connections
-
end
-
-
1
test "refreshing the connection" do
-
1
@membership.connected
-
-
1
travel_to Membership::Connectable::CONNECTION_TTL.from_now + 1
-
1
assert_not @membership.connected?
-
-
1
@membership.refresh_connection
-
1
assert @membership.connected?
-
end
-
-
1
test "removing a membership resets the user's connections" do
-
1
@membership.user.expects :reset_remote_connections
-
-
1
@membership.destroy
-
end
-
end
-
1
require "test_helper"
-
-
1
class Message::AttachmentTest < ActiveSupport::TestCase
-
1
include ActiveJob::TestHelper
-
1
include ActionDispatch::TestProcess
-
-
1
test "creating a message creates image thumbnail" do
-
1
message = create_attachment_message("moon.jpg", "image/jpeg")
-
1
assert message.attachment.representation(:thumb).image.present?
-
end
-
-
1
test "creating a message creates video preview" do
-
1
message = create_attachment_message("alpha-centuri.mov", "video/quicktime")
-
1
assert message.reload.attachment.preview(format: :webp).image.attached?
-
end
-
-
1
test "creating a blank message with attachment will use filename as plain text body" do
-
1
message = create_attachment_message("moon.jpg", "image/jpeg")
-
1
assert_equal message.plain_text_body, "moon.jpg"
-
end
-
-
-
1
private
-
1
def create_attachment_message(file, content_type)
-
3
rooms(:hq).messages.create_with_attachment! \
-
creator: users(:david),
-
client_message_id: "message",
-
attachment: fixture_file_upload(file, content_type)
-
end
-
end
-
1
require "test_helper"
-
-
1
class Message::SearchableTest < ActiveSupport::TestCase
-
1
test "message body is indexed and searchable" do
-
1
message = rooms(:designers).messages.create! body: "My hovercraft is full of eels", client_message_id: "earth", creator: users(:david)
-
1
assert_equal [ message ], rooms(:designers).messages.search("eel")
-
-
1
message.update! body: "My hovercraft is full of sharks"
-
1
assert_equal [ message ], rooms(:designers).messages.search("sharks")
-
-
1
message.destroy!
-
1
assert_equal [], rooms(:designers).messages.search("sharks")
-
end
-
-
1
test "search results are returned in message order" do
-
1
messages = [ "first cat", "second cat", "third cat", "cat cat cat" ].map do |body|
-
4
rooms(:designers).messages.create! body: body, client_message_id: body, creator: users(:david)
-
end
-
-
1
assert_equal messages, rooms(:designers).messages.search("cat")
-
end
-
-
1
test "rich text body is converted to plain text for indexing" do
-
1
message = rooms(:designers).messages.create! body: "<span>My hovercraft is full of eels</span>", client_message_id: "earth", creator: users(:david)
-
-
1
assert_equal [], rooms(:designers).messages.search("span")
-
1
assert_equal [ message ], rooms(:designers).messages.search("eel")
-
end
-
end
-
1
require "test_helper"
-
-
1
class MessageTest < ActiveSupport::TestCase
-
1
include ActionCable::TestHelper, ActiveJob::TestHelper
-
-
1
test "creating a message enqueues to push later" do
-
1
assert_enqueued_jobs 1, only: [ Room::PushMessageJob ] do
-
1
create_new_message_in rooms(:designers)
-
end
-
end
-
-
1
test "all emoji" do
-
1
assert Message.new(body: "😄🤘").plain_text_body.all_emoji?
-
1
assert_not Message.new(body: "Haha! 😄🤘").plain_text_body.all_emoji?
-
1
assert_not Message.new(body: "🔥\nmultiple lines\n💯").plain_text_body.all_emoji?
-
1
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
-
end
-
-
1
test "mentionees" do
-
1
message = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
-
1
assert_equal [ users(:david) ], message.mentionees
-
-
1
message_with_duplicate_mentions = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)} #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
-
1
assert_equal [ users(:david) ], message.mentionees
-
-
1
message_mentioning_a_non_member = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:kevin)}</div>", creator: users(:jason), client_message_id: "earth"
-
1
assert_equal [], message_mentioning_a_non_member.mentionees
-
end
-
-
1
private
-
1
def create_new_message_in(room)
-
1
room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123")
-
end
-
end
-
1
require "test_helper"
-
-
1
class Opengraph::DocumentTest < ActiveSupport::TestCase
-
1
test "extract opengraph tags using property attribute" do
-
1
document = Opengraph::Document.new("<html><head><meta property=\"og:url\" content=\"https://example.com\"><meta property=\"og:title\" content=\"Hey!\"><meta property=\"og:description\" content=\"desc..\"><meta property=\"og:image\" content=\"https://example.com/image.png\"></head></html>")
-
1
attributes = document.opengraph_attributes
-
-
1
assert_equal "https://example.com", attributes[:url]
-
1
assert_equal "Hey!", attributes[:title]
-
1
assert_equal "desc..", attributes[:description]
-
1
assert_equal "https://example.com/image.png", attributes[:image]
-
end
-
-
1
test "extract opengraph tags using name attribute" do
-
1
document = Opengraph::Document.new("<html><head><meta name=\"og:url\" content=\"https://example.com\"><meta name=\"og:title\" content=\"Hey!\"><meta name=\"og:description\" content=\"desc..\"><meta name=\"og:image\" content=\"https://example.com/image.png\"></head></html>")
-
1
attributes = document.opengraph_attributes
-
-
1
assert_equal "https://example.com", attributes[:url]
-
1
assert_equal "Hey!", attributes[:title]
-
1
assert_equal "desc..", attributes[:description]
-
1
assert_equal "https://example.com/image.png", attributes[:image]
-
end
-
-
1
test "document containing missing meta encoding tag and non-UTF8 characters" do
-
1
document = Opengraph::Document.new("<html><head><meta name=\"og:url\" content=\"https://example.com\"><meta name=\"og:title\" content=\"Hey!\"><meta name=\"og:description\" content=\"Hello â\u0080\u0099World\"><meta name=\"og:image\" content=\"https://example.com/image.png\"></head></html>")
-
1
attributes = document.opengraph_attributes
-
-
1
assert_equal "https://example.com", attributes[:url]
-
1
assert_equal "Hey!", attributes[:title]
-
1
assert_equal "Hello World", attributes[:description]
-
1
assert_equal "https://example.com/image.png", attributes[:image]
-
end
-
end
-
1
require "test_helper"
-
1
require "restricted_http/private_network_guard"
-
-
1
class Opengraph::FetchTest < ActiveSupport::TestCase
-
1
setup do
-
11
@fetch = Opengraph::Fetch.new
-
11
@url = URI.parse("https://www.example.com")
-
end
-
-
1
test "#fetch_document fetches valid HTML" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
-
-
1
assert_equal "<body>ok<body>", @fetch.fetch_document(@url)
-
end
-
-
1
test "#fetch_document discards other content types" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: "I'm not HTML!", headers: { content_type: "text/plain" })
-
-
1
assert_nil @fetch.fetch_document(@url)
-
end
-
-
1
test "#fetch_document follows redirects" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 302, headers: { location: "https://www.other.com/" })
-
-
1
WebMock.stub_request(:get, "https://www.other.com/")
-
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
-
-
1
assert_equal "<body>ok<body>", @fetch.fetch_document(@url)
-
end
-
-
1
test "#fetch_document does not follow redirects to private networks" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 302, headers: { location: "https://www.other.com/" })
-
-
1
WebMock.stub_request(:get, "https://www.other.com/")
-
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
-
1
Resolv.stubs(:getaddress).with("www.other.com").returns("127.0.0.1")
-
-
1
assert_raises RestrictedHTTP::Violation do
-
1
@fetch.fetch_document(@url, ip: "1.2.3.4")
-
end
-
end
-
-
1
test "#fetch_document resolves hostnames once to avoid DNS rebinding" do
-
# Allow but interrupt a real connection to demonstrate that we connect
-
# to a resolved IP, not a hostname to re-resolve.
-
1
WebMock.disable_net_connect! allow: [ @url.host ]
-
1
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
-
1
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
-
1
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
-
-
1
assert_throws :dns_not_rebound do
-
1
@fetch.fetch_document(@url)
-
end
-
end
-
-
1
test "#fetch_document resolves redirect location hostnames once to avoid DNS rebinding" do
-
# Stub the initial URL to redirect to a DNS-rebound location
-
1
WebMock.stub_request(:get, "https://www.other.com/")
-
.to_return(status: 302, headers: { location: @url.to_s })
-
-
# Allow but interrupt a real connection to demonstrate that we connect
-
# to a resolved IP, not a hostname to re-resolve.
-
1
WebMock.disable_net_connect! allow: [ @url.host ]
-
1
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
-
1
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never
-
1
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
-
-
1
assert_throws :dns_not_rebound do
-
1
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
-
end
-
end
-
-
1
test "#fetch_document is empty following redirects that never finish" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 302, headers: { location: "https://www.example.com/" })
-
-
1
assert_raises Opengraph::Fetch::TooManyRedirectsError do
-
1
@fetch.fetch_document(@url)
-
end
-
end
-
-
1
test "#fetch_document ignores large responses" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: "too large", headers: { content_length: 1.gigabyte, content_type: "text/html" })
-
-
1
assert_nil @fetch.fetch_document(@url)
-
end
-
-
1
test "#fetch_document ignores large responses that were missing their content length" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: large_body_content, headers: { content_type: "text/html" })
-
-
1
assert_nil @fetch.fetch_document(@url)
-
end
-
-
1
test "#fetch_document ignores large responses that were lying about their content length" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: large_body_content, headers: { content_length: 1.megabyte, content_type: "text/html" })
-
-
1
assert_nil @fetch.fetch_document(@url)
-
end
-
-
1
test "fetch content type" do
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
url = URI.parse("https://example.com/image.png")
-
1
assert_equal "image/png", @fetch.fetch_content_type(url)
-
end
-
-
1
private
-
1
def large_body_content
-
2
"x" * (Opengraph::Fetch::MAX_BODY_SIZE + 1)
-
end
-
end
-
1
require "test_helper"
-
1
require "restricted_http/private_network_guard"
-
-
1
class Opengraph::LocationTest < ActiveSupport::TestCase
-
1
test "url validations" do
-
1
assert Opengraph::Location.new("https://www.example.com").valid?
-
1
assert Opengraph::Location.new("http://www.example.com").valid?
-
-
1
assert_not Opengraph::Location.new("~/etc/password").valid?
-
1
assert_not Opengraph::Location.new("ftp://speedtest.tele2.net").valid?
-
1
assert_not Opengraph::Location.new("httpfake").valid?
-
1
assert_not Opengraph::Location.new(" foo").valid?
-
1
assert_not Opengraph::Location.new("https/incorrect").valid?
-
end
-
-
1
test "private network urls" do
-
1
Resolv.stubs(:getaddress).with("www.example.com").returns("172.16.0.0")
-
-
1
location = Opengraph::Location.new("https://www.example.com")
-
1
assert_not location.valid?
-
1
assert_equal [ "is not public" ], location.errors[:url]
-
end
-
-
1
test "avoid reading file urls when expecting HTML" do
-
1
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")
-
-
1
assert_nil Opengraph::Location.new("http://www.example.com/video.mp4").read_html
-
1
assert_nil Opengraph::Location.new("http://www.example.com/archive.tar").read_html
-
1
assert_nil Opengraph::Location.new("https://www.example.com/large.heic").read_html
-
1
assert_nil Opengraph::Location.new("https://www.example.com/image.jpeg").read_html
-
1
assert_nil Opengraph::Location.new("https://www.example.com/malware.exe").read_html
-
1
assert_nil Opengraph::Location.new("https://www.example.com/massiveOS.iso").read_html
-
end
-
-
1
test "read valid HTML" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
-
-
1
location = Opengraph::Location.new("https://www.example.com")
-
1
assert_equal "<body>ok<body>", location.read_html
-
end
-
-
1
test "read ignores invalid responses" do
-
1
WebMock.stub_request(:get, "https://www.example.com/")
-
.to_return(status: 200, body: "too large", headers: { content_length: 1.gigabyte, content_type: "text/html" })
-
-
1
location = Opengraph::Location.new("https://www.example.com")
-
1
assert_nil location.read_html
-
end
-
end
-
1
require "test_helper"
-
-
1
class Opengraph::MetadataTest < ActiveSupport::TestCase
-
1
test "successful fetch" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:url" content="https://example.com">
-
<meta property="og:title" content="Hey!">
-
<meta property="og:description" content="Hello">
-
<meta property="og:image" content="https://example.com/image.png">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com")
-
1
assert metadata.valid?
-
-
1
assert_equal "https://example.com", metadata.url
-
1
assert_equal "Hey!", metadata.title
-
1
assert_equal "Hello", metadata.description
-
1
assert_equal "https://example.com/image.png", metadata.image
-
end
-
-
1
test "missing opengraph meta tags" do
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: "<html><head></head></html>", headers: { content_type: "text/html" })
-
1
opengraph = Opengraph::Metadata.from_url("https://www.example.com")
-
-
1
assert_not opengraph.valid?
-
1
assert_equal [ "Title can't be blank", "Description can't be blank" ], opengraph.errors.full_messages
-
end
-
-
1
test "URL uses the provided value if the returned value is missing" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:title" content="Hey!">
-
<meta property="og:description" content="Hello">
-
<meta property="og:image" content="https://example.com/image.png">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com")
-
-
1
assert metadata.valid?
-
1
assert_equal "https://www.example.com", metadata.url
-
end
-
-
1
test "URL uses the provided value if the returned value is invalid" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:url" content="/foo">
-
<meta property="og:title" content="Hey!">
-
<meta property="og:description" content="Hello">
-
<meta property="og:image" content="https://example.com/image.png">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/foo").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com/foo")
-
-
1
assert metadata.valid?
-
1
assert_equal "https://www.example.com/foo", metadata.url
-
end
-
-
1
test "missing response body" do
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 403, body: "", headers: { content_type: "text/html" })
-
1
assert_not Opengraph::Metadata.from_url("https://www.example.com").valid?
-
end
-
-
1
test "non html response" do
-
1
WebMock.stub_request(:get, "https://www.example.com/image").to_return(status: 200, body: "[blob]", headers: { content_type: "image/jpeg" })
-
1
assert_not Opengraph::Metadata.from_url("https://www.example.com/image").valid?
-
end
-
-
1
test "relative and invalid image URLs are ignored" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:url" content="https://example.com">
-
<meta property="og:title" content="Hey!">
-
<meta property="og:description" content="Hello">
-
<meta property="og:image" content="%s">
-
</head>
-
</html>
-
HTML
-
-
1
[ "/image.png", "foo", "https/incorrect", "~/etc/password" ].each do |invalid_image_url|
-
4
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body % invalid_image_url, headers: { content_type: "text/html" })
-
4
opengraph = Opengraph::Metadata.from_url("https://www.example.com")
-
-
4
assert opengraph.valid?
-
4
assert_nil opengraph.image
-
end
-
end
-
-
1
test "sanitize title and description" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:title" content="Hey!<script>alert('hi')</script>">
-
<meta property="og:description" content="Hello<script>alert('hi')</script>">
-
<meta property="og:image" content="https://example.com/image.png">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com")
-
-
1
assert metadata.valid?
-
1
assert_equal "Hey!", metadata.title
-
1
assert_equal "Hello", metadata.description
-
end
-
-
1
test "remove encoded tags from title and description" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:title" content="Hey!</script><img src=a onerror=prompt(1)>">
-
<meta property="og:description" content="Hello</script><img src=a onerror=prompt(2)></script>">
-
<meta property="og:image" content="https://example.com/image.png">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.png").to_return(status: 200, headers: { content_type: "image/png" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com")
-
-
1
assert metadata.valid?
-
1
assert_equal "Hey!", metadata.title
-
1
assert_equal "Hello", metadata.description
-
end
-
-
1
test "does not allow SVG content type for preview image" do
-
1
body = <<~HTML
-
<html>
-
<head>
-
<meta property="og:url" content="https://example.com">
-
<meta property="og:title" content="Hey!">
-
<meta property="og:description" content="Hello">
-
<meta property="og:image" content="https://example.com/image.svg">
-
</head>
-
</html>
-
HTML
-
-
1
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: body, headers: { content_type: "text/html" })
-
1
WebMock.stub_request(:head, "https://example.com/image.svg").to_return(status: 200, headers: { content_type: "image/svg+xml" })
-
-
1
metadata = Opengraph::Metadata.from_url("https://www.example.com")
-
1
assert metadata.valid?
-
-
1
assert_nil metadata.image
-
end
-
end
-
1
require "test_helper"
-
-
1
class Room::PushTest < ActiveSupport::TestCase
-
1
include ActiveJob::TestHelper
-
-
1
test "deliver new message to other room users with push subscriptions" do
-
1
task_count = Push::Subscription.count - users(:david).push_subscriptions.count
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(task_count)
-
1
rooms(:hq).messages.create! body: "This is from earth", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(task_count)
-
end
-
-
1
test "notifies subscribed users" do
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(2)
-
1
rooms(:designers).messages.create! body: "This is from earth", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(2)
-
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(3)
-
1
rooms(:designers).messages.create! body: "Hey #{mention_attachment_for(:kevin)}", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(5)
-
end
-
-
1
test "does not notify for connected rooms" do
-
1
memberships(:kevin_designers).connected
-
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(2)
-
1
rooms(:designers).messages.create! body: "Hey @kevin", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(2)
-
end
-
-
1
test "does not notify for invisible rooms" do
-
1
memberships(:kevin_designers).update! involvement: "invisible"
-
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(2)
-
1
rooms(:designers).messages.create! body: "Hey @kevin", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(2)
-
end
-
-
1
test "destroys invalid subscriptions" do
-
1
memberships(:kevin_designers).update! involvement: "invisible"
-
-
3
assert_difference -> { Push::Subscription.count }, -2 do
-
1
perform_enqueued_jobs only: Room::PushMessageJob do
-
1
WebPush.expects(:payload_send).times(2).raises(WebPush::ExpiredSubscription.new(Struct.new(:body).new, "example.com"))
-
1
rooms(:designers).messages.create! body: "Hey @kevin", client_message_id: "earth", creator: users(:david)
-
end
-
1
wait_for_web_push_delivery_pool_tasks(2)
-
1
wait_for_invalidation_pool_tasks(2)
-
end
-
end
-
-
1
private
-
1
def wait_for_web_push_delivery_pool_tasks(count)
-
6
wait_for_pool_tasks(Rails.configuration.x.web_push_pool.delivery_pool, count)
-
end
-
-
1
def wait_for_invalidation_pool_tasks(count)
-
1
wait_for_pool_tasks(Rails.configuration.x.web_push_pool.invalidation_pool, count)
-
end
-
-
1
def wait_for_pool_tasks(pool, count)
-
7
start = Time.now
-
7
timeout = 0.2
-
7
while pool.completed_task_count < count
-
1
raise "Timeout waiting for pool tasks to complete" if Time.now - start > timeout
-
1
sleep timeout / 10.0
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class RoomTest < ActiveSupport::TestCase
-
1
test "grant membership to user" do
-
1
rooms(:watercooler).memberships.grant_to(users(:kevin))
-
1
assert rooms(:watercooler).users.include?(users(:kevin))
-
end
-
-
1
test "revoke membership from user" do
-
1
rooms(:watercooler).memberships.revoke_from(users(:david))
-
1
assert_not rooms(:watercooler).users.include?(users(:david))
-
end
-
-
1
test "revise memberships" do
-
1
rooms(:watercooler).memberships.revise(granted: users(:kevin), revoked: users(:david))
-
1
assert rooms(:watercooler).users.include?(users(:kevin))
-
1
assert_not rooms(:watercooler).users.include?(users(:david))
-
end
-
-
1
test "create for users by giving them immediate membership" do
-
1
room = Rooms::Closed.create_for({ name: "Hello!", creator: users(:david) }, users: [ users(:kevin), users(:david) ])
-
1
assert room.users.include?(users(:kevin))
-
1
assert room.users.include?(users(:david))
-
end
-
-
1
test "type" do
-
1
assert Rooms::Open.new.open?
-
1
assert_not Rooms::Open.new.direct?
-
1
assert Rooms::Direct.new.direct?
-
1
assert Rooms::Closed.new.closed?
-
end
-
-
1
test "default involvement for new users" do
-
1
room = Rooms::Closed.create_for({ name: "Hello!", creator: users(:david) }, users: [ users(:kevin), users(:david) ])
-
3
assert room.memberships.all? { |m| m.involved_in_mentions? }
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::DirectTest < ActiveSupport::TestCase
-
1
test "create room for same users" do
-
1
room = Rooms::Direct.find_or_create_for([ users(:david), users(:kevin) ])
-
1
assert room.users.include?(users(:david))
-
1
assert room.users.include?(users(:kevin))
-
1
assert_not room.users.include?(users(:jason))
-
end
-
-
1
test "only one room will exist for the same users" do
-
1
room1 = Rooms::Direct.find_or_create_for([ users(:david), users(:kevin) ])
-
1
room2 = Rooms::Direct.find_or_create_for([ users(:kevin), users(:david) ])
-
1
assert_equal room1, room2
-
end
-
-
1
test "default involvement for new users" do
-
1
room = Rooms::Direct.find_or_create_for([ users(:david), users(:kevin) ])
-
3
assert room.memberships.all? { |m| m.involved_in_everything? }
-
end
-
end
-
1
require "test_helper"
-
-
1
class Rooms::OpenTest < ActiveSupport::TestCase
-
1
test "grants access to all users after creation" do
-
1
room = Rooms::Open.create!(name: "My open room with everyone!", creator: users(:david))
-
1
assert_equal User.count, room.users.count
-
end
-
-
1
test "grants access to all users after becoming open" do
-
1
room = rooms(:watercooler).becomes!(Rooms::Open)
-
1
room.save!
-
1
assert_equal User.count, room.users.count
-
end
-
end
-
1
require "test_helper"
-
-
1
class User::BotTest < ActiveSupport::TestCase
-
1
test "create bot" do
-
1
token = "5M0aLYwQyBXOXa5Wsz6NZb11EE4tW2"
-
1
SecureRandom.stubs(:alphanumeric).returns(token)
-
-
1
uuid = "3574925f-479d-44f8-82b7-fc039af5367c"
-
1
Random.stubs(:uuid).returns(uuid)
-
-
1
bot = User.create_bot!(name: "Bender")
-
1
assert_equal "#{bot.id}-#{token}", bot.bot_key
-
end
-
-
1
test "reset bot key" do
-
1
first_token = "5M0aLYwQyBXOXa5Wsz6NZb11EE4tW2"
-
1
SecureRandom.stubs(:alphanumeric).returns(first_token)
-
-
1
bot = User.create_bot!(name: "Bender")
-
1
assert_equal "#{bot.id}-#{first_token}", bot.bot_key
-
-
1
second_token = "R4kme9anwWRuz3sSoBXiB8Li8ioZPP"
-
1
SecureRandom.stubs(:alphanumeric).returns(second_token)
-
-
1
bot.reset_bot_key
-
1
assert_equal "#{bot.id}-#{second_token}", bot.bot_key
-
end
-
-
1
test "authenticate" do
-
1
bot = User.create_bot!(name: "Bender")
-
1
assert User.authenticate_bot(bot.bot_key)
-
end
-
-
1
test "deliver message by webhook" do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200)
-
-
1
perform_enqueued_jobs only: Bot::WebhookJob do
-
1
users(:bender).deliver_webhook_later(messages(:first))
-
end
-
end
-
end
-
1
require "test_helper"
-
-
1
class User::RoleTest < ActiveSupport::TestCase
-
1
test "creating subsequent users makes them members" do
-
1
assert User.create!(name: "User", email_address: "user@example.com", password: "secret123456").member?
-
end
-
-
1
test "can_administer?" do
-
1
assert User.new(role: :administrator).can_administer?
-
-
1
assert_not User.new(role: :member).can_administer?
-
1
assert_not User.new.can_administer?
-
end
-
-
1
test "can administer a record" do
-
1
member = User.new(role: :member)
-
1
assert member.can_administer?(Room.new(creator: member))
-
-
1
another_member = User.new(role: :member)
-
1
assert another_member.can_administer?(Room.new(creator: member))
-
1
assert_not another_member.can_administer?(rooms(:designers))
-
end
-
end
-
1
require "test_helper"
-
-
1
class UserTest < ActiveSupport::TestCase
-
1
test "user does not prevent very long passwords" do
-
1
users(:david).update(password: "secret" * 50)
-
1
assert users(:david).valid?
-
end
-
-
1
test "creating users grants membership to the open rooms" do
-
3
assert_difference -> { Membership.count }, +Rooms::Open.count do
-
1
create_new_user
-
end
-
end
-
-
1
test "deactivating a user deletes push subscriptions, searches, memberships for non-direct rooms, and changes their email address" do
-
3
assert_difference -> { Membership.count }, -users(:david).memberships.without_direct_rooms.count do
-
3
assert_difference -> { Push::Subscription.count }, -users(:david).push_subscriptions.count do
-
3
assert_difference -> { Search.count }, -users(:david).searches.count do
-
1
SecureRandom.stubs(:uuid).returns("2e7de450-cf04-4fa8-9b02-ff5ab2d733e7")
-
1
users(:david).deactivate
-
1
assert_equal "david-deactivated-2e7de450-cf04-4fa8-9b02-ff5ab2d733e7@37signals.com", users(:david).reload.email_address
-
end
-
end
-
end
-
end
-
-
1
test "deactivating a user deletes their sessions" do
-
3
assert_changes -> { users(:david).sessions.count }, from: 1, to: 0 do
-
1
users(:david).deactivate
-
end
-
end
-
-
1
private
-
1
def create_new_user
-
1
User.create!(name: "User", email_address: "user@example.com", password: "secret123456")
-
end
-
end
-
1
require "test_helper"
-
-
1
class WebhookTest < ActiveSupport::TestCase
-
1
test "payload" do
-
1
message = messages(:first)
-
1
message_path = Rails.application.routes.url_helpers.room_at_message_path(message.room, message)
-
1
bot_messages_path = Rails.application.routes.url_helpers.room_bot_messages_path(message.room, users(:bender).bot_key)
-
-
1
WebMock.stub_request(:post, webhooks(:bender).url).
-
with(body: hash_including(
-
user: { id: message.creator.id, name: message.creator.name },
-
room: { id: message.room.id, name: message.room.name, path: bot_messages_path },
-
message: { id: message.id, body: { html: "First post!", plain: "First post!" }, path: message_path },
-
))
-
-
1
response = webhooks(:bender).deliver(messages(:first))
-
1
assert_equal 200, response.code.to_i
-
end
-
-
1
test "delivery" do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200, body: "", headers: {})
-
1
response = webhooks(:bender).deliver(messages(:first))
-
1
assert_equal 200, response.code.to_i
-
end
-
-
1
test "delivery with OK text reply" do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200, body: "Hello back!", headers: { "Content-Type" => "text/plain" })
-
1
response = webhooks(:bender).deliver(messages(:first))
-
-
1
reply_message = Message.last
-
1
assert_equal "Hello back!", reply_message.body.to_plain_text
-
end
-
-
1
test "delivery with OK attachment reply" do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200, body: file_fixture("moon.jpg"), headers: { "Content-Type" => "image/jpeg" })
-
1
response = webhooks(:bender).deliver(messages(:first))
-
-
1
reply_message = Message.last
-
1
assert reply_message.attachment.present?
-
end
-
-
1
test "delivery with error reply" do
-
3
assert_no_difference -> { Message.count } do
-
1
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 500, body: "Internal Error!", headers: {})
-
1
response = webhooks(:bender).deliver(messages(:first))
-
end
-
end
-
-
1
test "delivery that times out" do
-
1
Webhook.any_instance.stubs(:post).raises(Net::OpenTimeout)
-
1
response = webhooks(:bender).deliver(messages(:first))
-
-
1
reply_message = Message.last
-
1
assert_equal "Failed to respond within 7 seconds", reply_message.body.to_plain_text
-
end
-
end
-
1
module MentionTestHelper
-
1
def mention_attachment_for(name)
-
8
user = users(name)
-
8
attachment_body = ApplicationController.render partial: "users/mention", locals: { user: user }
-
8
"<action-text-attachment sgid=\"#{user.attachable_sgid}\" content-type=\"application/vnd.campfire.mention\" content=\"#{attachment_body.gsub('"', '"')}\"></action-text-attachment>"
-
end
-
end
-
1
module SessionTestHelper
-
1
def parsed_cookies
-
5
ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
-
end
-
-
1
def sign_in(user)
-
107
user = users(user) unless user.is_a? User
-
107
post session_url, params: { email_address: user.email_address, password: "secret123456" }
-
107
assert cookies[:session_token].present?
-
end
-
end
-
1
module TurboTestHelper
-
1
def assert_rendered_turbo_stream_broadcast(*streambles, action:, target:, &block)
-
1
streams = find_broadcasts_for(*streambles)
-
1
target = ActionView::RecordIdentifier.dom_id(*target)
-
1
assert_select Nokogiri::HTML.fragment(streams), %(turbo-stream[action="#{action}"][target="#{target}"]), &block
-
end
-
-
1
private
-
1
def find_broadcasts_for(*streambles)
-
1
broadcasting = streambles.collect do |streamble|
-
2
streamble.try(:to_gid_param) || streamble
-
end.join(":")
-
-
1
broadcasts = ActionCable.server.pubsub.broadcasts(broadcasting)
-
2
broadcasts.collect { |b| JSON.parse(b) }.join("\n\n")
-
end
-
end