There’s a whole lot more you can do in Fizzy. In the video below 37signals founder and CEO, Jason Fried, will walk you through the basics in just 8 minutes.
+
There’s a whole lot more you can do in Fizzy. In the video below, 37signals founder and CEO, Jason Fried, will walk you through the basics in just 17 minutes.
HTML
playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML
-
Open Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make an login for themselves in your account.
+
Open the Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.
HTML
@@ -43,7 +43,7 @@ class Account::Seeder
HTML
- playground.cards.create! creator: creator, title: "Now,check out all cards assigned to you", status: "published", description: <<~HTML
+ playground.cards.create! creator: creator, title: "Now, check out all cards assigned to you", status: "published", description: <<~HTML
Pull down the Fizzy menu at the top of the screen, and select “Assigned to me” or just hit “2” on your keyboard any time.
HTML
@@ -54,7 +54,7 @@ class Account::Seeder
HTML
playground.cards.create! creator: creator, title: "Next, assign this card to yourself", status: "published", description: <<~HTML
-
Click the little head with the + next to it, pick yourself.
+
Click the little head with the + next to it, then pick yourself.
HTML
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 45e9c2f21..4ed46f2c3 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -2,6 +2,4 @@ class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
configure_replica_connections
-
- attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
end
diff --git a/app/models/card/broadcastable.rb b/app/models/card/broadcastable.rb
index aa24b776c..9cf112200 100644
--- a/app/models/card/broadcastable.rb
+++ b/app/models/card/broadcastable.rb
@@ -3,5 +3,16 @@ module Card::Broadcastable
included do
broadcasts_refreshes
+
+ before_update :remember_if_preview_changed
end
+
+ private
+ def remember_if_preview_changed
+ @preview_changed ||= title_changed? || column_id_changed? || board_id_changed?
+ end
+
+ def preview_changed?
+ @preview_changed
+ end
end
diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb
index c52fb7a29..520fd95d4 100644
--- a/app/models/card/eventable.rb
+++ b/app/models/card/eventable.rb
@@ -19,7 +19,6 @@ module Card::Eventable
def touch_last_active_at
# Not using touch so that we can detect attribute change on callbacks
update!(last_active_at: Time.current)
- broadcast_activity
end
private
@@ -36,8 +35,4 @@ module Card::Eventable
def create_system_comment_for(event)
SystemCommenter.new(self, event).comment
end
-
- def broadcast_activity
- broadcast_render_later_to self, :activity, partial: "card/display/refresh_activity", locals: { card: self }
- end
end
diff --git a/app/models/card/pinnable.rb b/app/models/card/pinnable.rb
index ff3407ea0..9929a785d 100644
--- a/app/models/card/pinnable.rb
+++ b/app/models/card/pinnable.rb
@@ -3,6 +3,8 @@ module Card::Pinnable
included do
has_many :pins, dependent: :destroy
+
+ after_update_commit :broadcast_pin_updates, if: :preview_changed?
end
def pinned_by?(user)
@@ -20,4 +22,11 @@ module Card::Pinnable
def unpin_by(user)
pins.find_by(user: user).tap { it.destroy }
end
+
+ private
+ def broadcast_pin_updates
+ pins.find_each do |pin|
+ pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin"
+ end
+ end
end
diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb
index 903db2d71..9c272dd75 100644
--- a/app/models/card/readable.rb
+++ b/app/models/card/readable.rb
@@ -14,8 +14,9 @@ module Card::Readable
end
def remove_inaccessible_notifications
- User.find_each do |user|
- all_notifications_for(user).destroy_all unless accessible_to?(user)
+ accessible_user_ids = board.accesses.pluck(:user_id)
+ notification_sources.each do |sources|
+ inaccessible_notifications_from(sources, accessible_user_ids).in_batches.destroy_all
end
end
@@ -44,6 +45,14 @@ module Card::Readable
Event.where(eventable: comments)
end
+ def inaccessible_notifications_from(sources, accessible_user_ids)
+ Notification.where(source: sources).where.not(user_id: accessible_user_ids)
+ end
+
+ def notification_sources
+ [ events, comment_creation_events, mentions, comment_mentions ]
+ end
+
def mention_notification_sources
mentions.or(comment_mentions)
end
diff --git a/app/models/card/taggable.rb b/app/models/card/taggable.rb
index 5ad75a593..73c89d0b6 100644
--- a/app/models/card/taggable.rb
+++ b/app/models/card/taggable.rb
@@ -9,7 +9,7 @@ module Card::Taggable
end
def toggle_tag_with(title)
- tag = Tag.find_or_create_by!(title: title)
+ tag = account.tags.find_or_create_by!(title: title)
transaction do
if tagged_with?(tag)
diff --git a/app/models/eventable.rb b/app/models/concerns/eventable.rb
similarity index 100%
rename from app/models/eventable.rb
rename to app/models/concerns/eventable.rb
diff --git a/app/models/current.rb b/app/models/current.rb
index e40fc3156..47f2b6c21 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -12,11 +12,11 @@ class Current < ActiveSupport::CurrentAttributes
end
end
- def with_account(value, &block)
- with(account: value, &block)
+ def with_account(value, &)
+ with(account: value, &)
end
- def without_account(&block)
- with(account: nil, &block)
+ def without_account(&)
+ with(account: nil, &)
end
end
diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb
index 3521b43e3..655746f49 100644
--- a/app/models/filter/resources.rb
+++ b/app/models/filter/resources.rb
@@ -24,7 +24,7 @@ module Filter::Resources
def board_titles
if boards.none?
- Board.one? ? [ Board.first.name ] : [ "all boards" ]
+ creator.boards.one? ? [ creator.boards.first.name ] : [ "all boards" ]
else
boards.map(&:name)
end
diff --git a/app/models/identity.rb b/app/models/identity.rb
index 135fdaae7..ee4a323b2 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -10,6 +10,7 @@ class Identity < ApplicationRecord
before_destroy :deactivate_users
+ validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def send_magic_link(**attributes)
diff --git a/app/models/identity/joinable.rb b/app/models/identity/joinable.rb
index 4a6eabcd4..52afb339a 100644
--- a/app/models/identity/joinable.rb
+++ b/app/models/identity/joinable.rb
@@ -5,11 +5,9 @@ module Identity::Joinable
attributes[:name] ||= email_address
transaction do
- account.users.create!(**attributes, identity: self)
+ account.users.find_or_create_by!(identity: self) do |user|
+ user.assign_attributes(attributes)
+ end.previously_new_record?
end
end
-
- def member_of?(account)
- account.users.exists?(identity: self)
- end
end
diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb
index 6af1619d8..2bcc0c4e6 100644
--- a/app/models/magic_link/code.rb
+++ b/app/models/magic_link/code.rb
@@ -4,7 +4,7 @@ module MagicLink::Code
class << self
def generate(length)
- length.times.map { CODE_ALPHABET.sample }.join
+ Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
end
def sanitize(code)
diff --git a/app/models/search/record/sqlite.rb b/app/models/search/record/sqlite.rb
index c3da9131a..ae0d34281 100644
--- a/app/models/search/record/sqlite.rb
+++ b/app/models/search/record/sqlite.rb
@@ -2,8 +2,6 @@ module Search::Record::SQLite
extend ActiveSupport::Concern
included do
- # Override default UUID id attribute, as FTS5 uses rowid integer primary key
- attribute :id, :integer, default: nil
attribute :result_title, :string
attribute :result_content, :string
diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb
new file mode 100644
index 000000000..f3df2511b
--- /dev/null
+++ b/app/models/ssrf_protection.rb
@@ -0,0 +1,37 @@
+module SsrfProtection
+ extend self
+
+ DNS_RESOLUTION_TIMEOUT = 2
+
+ DISALLOWED_IP_RANGES = [
+ IPAddr.new("0.0.0.0/8") # Broadcasts
+ ].freeze
+
+ def resolve_public_ip(hostname)
+ ip_addresses = resolve_dns(hostname)
+ public_ips = ip_addresses.reject { |ip| private_address?(ip) }
+ public_ips.first&.to_s
+ end
+
+ def private_address?(ip)
+ ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
+ ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip)
+ end
+
+ private
+ def resolve_dns(hostname)
+ ip_addresses = []
+
+ Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
+ dns.each_address(hostname) do |ip_address|
+ ip_addresses << IPAddr.new(ip_address.to_s)
+ end
+ end
+
+ ip_addresses
+ end
+
+ def in_disallowed_range?(ip)
+ DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index d70a260ea..8c0ac42a5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,12 +1,8 @@
class User < ApplicationRecord
- include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable,
+ include Accessor, Assignee, Attachable, Avatar, Configurable, EmailAddressChangeable,
Mentionable, Named, Notifiable, Role, Searcher, Watcher
include Timelined # Depends on Accessor
- has_one_attached :avatar do |attachable|
- attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
- end
-
belongs_to :account
belongs_to :identity, optional: true
diff --git a/app/models/user/accessor.rb b/app/models/user/accessor.rb
index 352a9ecb2..a9cc5e459 100644
--- a/app/models/user/accessor.rb
+++ b/app/models/user/accessor.rb
@@ -4,6 +4,7 @@ module User::Accessor
included do
has_many :accesses, dependent: :destroy
has_many :boards, through: :accesses
+ has_many :accessible_columns, through: :boards, source: :columns
has_many :accessible_cards, through: :boards, source: :cards
has_many :accessible_comments, through: :accessible_cards, source: :comments
diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb
new file mode 100644
index 000000000..a4b2ae631
--- /dev/null
+++ b/app/models/user/avatar.rb
@@ -0,0 +1,24 @@
+module User::Avatar
+ extend ActiveSupport::Concern
+
+ ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
+
+ included do
+ has_one_attached :avatar do |attachable|
+ attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
+ end
+
+ validate :avatar_content_type_allowed
+ end
+
+ def avatar_thumbnail
+ avatar.variable? ? avatar.variant(:thumb) : avatar
+ end
+
+ private
+ def avatar_content_type_allowed
+ if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
+ errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
+ end
+ end
+end
diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb
index ce18d46d0..2c8de7217 100644
--- a/app/models/user/email_address_changeable.rb
+++ b/app/models/user/email_address_changeable.rb
@@ -7,14 +7,12 @@ module User::EmailAddressChangeable
def change_email_address_using_token(token)
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
- if parsed_token.nil?
- raise ArgumentError, "The token is invalid"
- elsif parsed_token.find != self
- raise ArgumentError, "The token was generated for a different user"
- elsif identity.email_address != parsed_token.params.fetch("old_email_address")
- raise ArgumentError, "The token was generated for a different email address"
+ old_email_address = parsed_token&.params&.fetch("old_email_address")
+ new_email_address = parsed_token&.params&.fetch("new_email_address")
+
+ if parsed_token.nil? || parsed_token.find != self || identity.email_address != old_email_address
+ false
else
- new_email_address = parsed_token.params.fetch("new_email_address")
change_email_address(new_email_address)
end
end
@@ -37,8 +35,6 @@ module User::EmailAddressChangeable
new_identity = Identity.find_or_create_by!(email_address: new_email_address)
update!(identity: new_identity)
end
-
- self
end
private
diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb
index ce69c01ad..b635baac5 100644
--- a/app/models/webhook/delivery.rb
+++ b/app/models/webhook/delivery.rb
@@ -2,11 +2,6 @@ class Webhook::Delivery < ApplicationRecord
STALE_TRESHOLD = 7.days
USER_AGENT = "fizzy/1.0.0 Webhook"
ENDPOINT_TIMEOUT = 7.seconds
- DNS_RESOLUTION_TIMEOUT = 2
- DISALLOWED_IP_RANGES = [
- # Broadcasts
- IPAddr.new("0.0.0.0/8")
- ].freeze
belongs_to :account, default: -> { webhook.account }
belongs_to :webhook
@@ -54,14 +49,14 @@ class Webhook::Delivery < ApplicationRecord
private
def perform_request
- if private_uri?
+ if resolved_ip.nil?
{ error: :private_uri }
else
response = http.request(
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
)
- { code: response.code.to_i }
+ { code: response.code.to_i }
end
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
{ error: :dns_lookup_failed }
@@ -73,18 +68,9 @@ class Webhook::Delivery < ApplicationRecord
{ error: :failed_tls }
end
- def private_uri?
- ip_addresses = []
-
- Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
- dns.each_address(uri.host) do |ip_address|
- ip_addresses << IPAddr.new(ip_address)
- end
- end
-
- ip_addresses.any? do |ip|
- ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
- end
+ def resolved_ip
+ return @resolved_ip if defined?(@resolved_ip)
+ @resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
end
def uri
@@ -92,7 +78,7 @@ class Webhook::Delivery < ApplicationRecord
end
def http
- Net::HTTP.new(uri.host, uri.port).tap do |http|
+ Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http|
http.use_ssl = (uri.scheme == "https")
http.open_timeout = ENDPOINT_TIMEOUT
http.read_timeout = ENDPOINT_TIMEOUT
diff --git a/app/views/action_text/attachables/_remote_image.html.erb b/app/views/action_text/attachables/_remote_image.html.erb
index 174a95c5e..f71f5dcaa 100644
--- a/app/views/action_text/attachables/_remote_image.html.erb
+++ b/app/views/action_text/attachables/_remote_image.html.erb
@@ -1,5 +1,5 @@
- <%= image_tag remote_image.url, width: remote_image.width, height: remote_image.height %>
+ <%= image_tag remote_image.url, skip_pipeline: true, width: remote_image.width, height: remote_image.height %>
<% if caption = remote_image.try(:caption) %>
<%= caption %>
diff --git a/app/views/boards/columns/_empty_placeholder.html.erb b/app/views/boards/columns/_empty_placeholder.html.erb
new file mode 100644
index 000000000..36507c1ff
--- /dev/null
+++ b/app/views/boards/columns/_empty_placeholder.html.erb
@@ -0,0 +1,6 @@
+