0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-12 02:52:01 +08:00
discourse/plugins/discourse-calendar/app/models/discourse_post_event/event.rb
Kris 0cf23c2854
FEATURE: Add a livestream_allowed_hosts site setting for livestreams (#42374)
Currently the hosts that can be used for a livestream event are
hardcoded in the composer's JavaScript, and the server accepts any https
location, so the list isn't really enforced... and there's no way to use
hosts that would otherwise work, including custom or self-hosted
sources.

This adds a `livestream_allowed_hosts` site setting (defaulting to the
existing hardcoded list) and validates against it both in the composer
and on the server. Whether a livestream embeds a player still depends on
the site's onebox settings.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
2026-08-06 14:49:38 -04:00

713 lines
23 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscoursePostEvent
class Event < ActiveRecord::Base
PUBLIC_GROUP = "trust_level_0"
# Keep in sync with the constants of the same name in the
# DiscoursePostEventEvent JS model.
EARLY_ACCESS_MINUTES = 30
GRACE_PERIOD_MINUTES = 10
MIN_NAME_LENGTH = 5
MAX_NAME_LENGTH = 255
MAX_DESCRIPTION_LENGTH = 1000
DEFAULT_TIMEZONE = "UTC"
self.table_name = "discourse_post_event_events"
self.ignored_columns = %w[starts_at ends_at]
has_many :event_dates, dependent: :destroy
# this is a cross plugin dependency, only called if chat is enabled
belongs_to :chat_channel, class_name: "Chat::Channel"
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
belongs_to :post, foreign_key: :id
belongs_to :image_upload, class_name: "Upload", optional: true
has_many :upload_references, as: :target, dependent: :destroy
scope :visible, -> { where(deleted_at: nil) }
scope :open, -> { where(closed: false) }
before_validation :reset_invalid_livestream
before_save :chat_channel_sync
# prepend so it runs before `dependent: :delete_all` wipes the invitees
before_destroy :reset_invitees_topic_tracking, prepend: true
after_commit :create_livestream_chat_channel, on: %i[create update]
after_commit :enqueue_warm_livestream_onebox, on: %i[create update]
after_commit :destroy_topic_custom_field, on: %i[destroy]
after_commit :create_or_update_event_date, on: %i[create update]
after_save do
if saved_change_to_image_upload_id?
UploadReference.ensure_exist!(upload_ids: [image_upload_id], target: self)
end
end
validate :raw_invitees_are_groups
validates :original_starts_at, presence: true
validates :name,
length: {
in: MIN_NAME_LENGTH..MAX_NAME_LENGTH,
},
unless: ->(event) { event.name.blank? }
validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }
validates :max_attendees, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validate :raw_invitees_length
validate :ends_before_start
validate :allowed_custom_fields
def self.attributes_protected_by_default
super - %w[id]
end
def reset_invalid_livestream
return unless livestream?
self.livestream = false unless allowed_livestream_url? && post&.is_first_post?
end
def allowed_livestream_url?
DiscourseCalendar::Livestream::AllowedHosts.allows_url?(livestream_url)
end
def create_livestream_chat_channel
return unless livestream? && SiteSetting.chat_enabled
return unless post&.is_first_post?
return if post.topic.blank?
DiscourseCalendar::Livestream.handle_topic_chat_channel_creation(post.topic)
end
def has_cached_livestream_onebox?
livestream? && livestream_url.present? && Oneboxer.cached_onebox(livestream_url).present?
end
# Happens after the event record is saved to prepare the onebox
def enqueue_warm_livestream_onebox
return if !livestream? || livestream_url.blank?
return if !saved_change_to_livestream? && !saved_change_to_location? && !saved_change_to_url?
return if has_cached_livestream_onebox?
Jobs.enqueue(:warm_livestream_onebox, event_id: id, url: livestream_url)
end
# Fetches the onebox and tells clients to re-render the post with it. Only
# ever called from the background job, never during a request.
def warm_livestream_onebox!
return if has_cached_livestream_onebox?
Oneboxer.onebox(livestream_url)
post&.publish_change_to_clients!(:revised)
end
def destroy_topic_custom_field
if post && post.is_first_post?
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_STARTS_AT).delete_all
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_ENDS_AT).delete_all
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_ALL_DAY).delete_all
end
end
def create_or_update_event_date
starts_at_changed = saved_change_to_original_starts_at
ends_at_changed = saved_change_to_original_ends_at
return if !starts_at_changed && !ends_at_changed
set_next_date
end
def set_next_date
return if closed
next_date_result = calculate_next_date
return event_dates.update_all(finished_at: Time.current) if next_date_result.nil?
starts_at, ends_at = next_date_result
finish_previous_event_dates(starts_at) if dates_changed?
upsert_event_date(starts_at, ends_at)
reset_invitee_notifications
notify_if_new_event
publish_update!
end
def set_topic_bump
return if closed
date = nil
return if reminders.blank? || starts_at.nil?
reminders
.split(",")
.each do |reminder|
type, value, unit = reminder.split(".")
next if type != "bumpTopic" || !validate_reminder_unit(unit)
date = starts_at - value.to_i.public_send(unit)
break
end
return if date.blank?
Jobs.enqueue(:discourse_post_event_bump_topic, topic_id: post.topic_id, date: date.iso8601)
end
def validate_reminder_unit(input)
ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input }
end
def expired?
if recurring?
return false if recurrence_until.nil?
return Time.current > recurrence_until
end
return true if starts_at.nil?
(ends_at || starts_at.end_of_day) <= Time.now
end
# Mirrors `currentlyWithinEventTimeframe` in the DiscoursePostEventEvent JS
# model: joining opens EARLY_ACCESS_MINUTES before the start and, when the
# event has an end time, closes GRACE_PERIOD_MINUTES after it.
def currently_within_event_timeframe?
return false if starts_at.nil?
now = Time.zone.now
if all_day
opens_at = starts_at.beginning_of_day
closes_at = starts_at.end_of_day
return opens_at <= now && now <= closes_at
end
return false if now < starts_at - EARLY_ACCESS_MINUTES.minutes
ends_at.nil? || now < ends_at + GRACE_PERIOD_MINUTES.minutes
end
def starts_at
return nil if recurring? && recurrence_until.present? && recurrence_until < Time.current
current_event_date&.starts_at || original_starts_at
end
def ends_at
return nil if recurring? && recurrence_until.present? && recurrence_until < Time.current
current_event_date&.ends_at || original_ends_at
end
def current_event_date
if association(:event_dates).loaded?
pending = event_dates.select { |d| d.finished_at.nil? }
pending.max_by(&:starts_at) || event_dates.max_by { |d| [d.updated_at, d.id] }
else
event_dates.current_first.first
end
end
def on_going_event_invitees
return [] if starts_at.nil? # Can't determine ongoing status without start time
if !ends_at && starts_at < Time.now && (!all_day || starts_at.end_of_day <= Time.now)
return []
end
if ends_at
extended_ends_at =
ends_at + SiteSetting.discourse_post_event_edit_notifications_time_extension.minutes
return [] if !(starts_at..extended_ends_at).cover?(Time.now)
end
invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going])
end
def raw_invitees_length
if raw_invitees && raw_invitees.length > 10
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.raw_invitees_length", count: 10),
)
end
end
def raw_invitees_are_groups
return if raw_invitees.blank?
non_group_invitees = raw_invitees - Group.where(name: raw_invitees).pluck(:name)
return if non_group_invitees.blank?
if User.where(username: non_group_invitees).exists?
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.raw_invitees.only_group"),
)
end
end
def ends_before_start
if original_starts_at && original_ends_at && original_starts_at >= original_ends_at
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at"),
)
end
end
def allowed_custom_fields
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|")
custom_fields.each do |key, value|
if !allowed_custom_fields.include?(key)
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.custom_field_is_invalid", field: key),
)
end
end
end
def create_invitees(attrs)
timestamp = Time.now
attrs.map! do |attr|
{ post_id: id, created_at: timestamp, updated_at: timestamp }.merge(attr)
end
result = invitees.insert_all!(attrs)
# batch event does not call callback
ChatChannelSync.sync(self) if chat_enabled?
result
end
def notify_invitees!(predefined_attendance: false)
invitees
.where(notified: false)
.find_each do |invitee|
create_notification!(invitee.user, post, predefined_attendance: predefined_attendance)
invitee.update!(notified: true)
end
end
def notify_missing_invitees!
missing_users.each { |user| create_notification!(user, post) } if private?
end
def create_notification!(user, post, predefined_attendance: false)
return if post.event.starts_at.nil? || post.event.starts_at < Time.current
return if !Guardian.new(user).can_see?(post)
message =
if predefined_attendance
"discourse_post_event.notifications.invite_user_predefined_attendance_notification"
else
"discourse_post_event.notifications.invite_user_notification"
end
attrs = {
notification_type: Notification.types[:event_invitation] || Notification.types[:custom],
topic_id: post.topic_id,
post_number: post.post_number,
data: {
user_id: user.id,
topic_title: name || post.topic.title,
display_username: post.user.username,
message: message,
event_name: name,
}.to_json,
}
user.notifications.consolidate_or_create!(attrs)
end
def ongoing?
return false if closed || expired? || starts_at.nil?
finishes_at = ends_at || starts_at.end_of_day
(starts_at..finishes_at).cover?(Time.now)
end
def going_count
invitees.where(status: Invitee.statuses[:going]).count
end
def at_capacity?
return false if max_attendees.blank?
going_count >= max_attendees
end
def self.statuses
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
end
def public?
status == Event.statuses[:public]
end
def standalone?
status == Event.statuses[:standalone]
end
def private?
status == Event.statuses[:private]
end
def recurring?
recurrence.present?
end
def most_likely_going(limit = SiteSetting.displayed_invitees_limit)
going = invitees.order(%i[status created_at user_id]).limit(limit)
if private? && going.count < limit
# invitees are only created when an attendance is set
# so we create a dummy invitee object with only what's needed for serializer
going =
going +
missing_users(going.pluck(:user_id))
.limit(limit - going.count)
.map { |user| Invitee.new(user: user, post_id: id) }
end
going
end
def publish_update!
post.publish_message!("/discourse-post-event/#{post.topic_id}", id: id)
end
def fetch_users
@fetched_users ||= Invitee.extract_uniq_usernames(raw_invitees)
end
def enforce_private_invitees!
pruned = invitees.where.not(user_id: fetch_users.select(:id))
pruned_user_ids = pruned.pluck(:user_id)
pruned.delete_all
Invitee.reset_topic_tracking!(user_ids: pruned_user_ids, topic_id: post.topic_id)
unfollow_livestream_chat(pruned_user_ids)
end
# Unfollow users from the livestream chat channel once they are no longer
# attending (e.g. pruned when a private event's invited groups change). Chat
# following tracks attendance, so removed attendees should not keep the
# channel in their chat list.
def unfollow_livestream_chat(user_ids)
return if user_ids.blank?
channel = post.topic.topic_chat_channel&.chat_channel
return if channel.nil?
manager = Chat::ChannelMembershipManager.new(channel)
User
.where(id: user_ids)
.find_each do |user|
membership = manager.unfollow(user)
next if membership.nil?
DiscourseCalendar::Livestream.publish_livestream_chat_status(membership, user:)
end
end
def user_in_invited_group?(user, group_names: nil)
return false if user.blank? || raw_invitees.blank?
return (Array(raw_invitees) & Array(group_names)).any? unless group_names.nil?
GroupUser.where(
user_id: user.id,
group_id: Group.where(name: raw_invitees).select(:id),
).exists?
end
# Unlike can_user_update_attendance?, this stays true after the event
# closes or expires so attendees keep access to the livestream chat.
#
# Callers serializing many events at once (e.g. the chat channel list) can
# pass preloaded group_names to avoid a per-event query.
def can_access_livestream_chat?(user, group_names: nil)
return true if !private?
return false if user.blank?
return true if user.admin?
user_in_invited_group?(user, group_names:)
end
def can_user_update_attendance?(user, group_names: nil)
return false if user.blank? || closed || expired?
return true if public?
private? && user_in_invited_group?(user, group_names:)
end
def sync_image_to_post_and_topic(generate_thumbnails: false)
return unless image_upload_id
post.update_column(:image_upload_id, image_upload_id)
if post.is_first_post?
post.topic.update_column(:image_upload_id, image_upload_id)
if generate_thumbnails
extra_sizes =
ThemeModifierHelper.new(
theme_ids: Theme.user_selectable.pluck(:id),
).topic_thumbnail_sizes
post.topic.generate_thumbnails!(extra_sizes: extra_sizes)
end
end
end
def self.handle_post_event_webhooks(post, event_before)
had_event_before = event_before.present?
if post.event && had_event_before
WebHook.enqueue_calendar_event_hooks(:calendar_event_updated, post.event)
elsif post.event && !had_event_before
WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
elsif !post.event && had_event_before
payload = WebHook.build_calendar_event_payload(event_before)
WebHook.enqueue_calendar_event_hooks(:calendar_event_destroyed, event_before, payload)
end
end
def missing_users(excluded_ids = invitees.select(:user_id))
users = User.real.activated.not_silenced.not_suspended.not_staged
if raw_invitees.present?
user_ids =
users
.joins(:groups)
.where("groups.name" => raw_invitees)
.where.not(id: excluded_ids)
.select(:id)
User.where(id: user_ids)
elsif private?
User.none
else
users.where.not(id: excluded_ids)
end
end
SUGGESTED_USERS_LIMIT = 10
# Users that could be invited to this event, ranked by how closely their
# username matches +filter+ (exact match first). Already-invited users are
# excluded, optionally narrowed to a given attendance +type+.
def suggested_users(filter, type: nil)
excluded = type ? invitees.with_status(type) : invitees
missing_users(excluded.select(:user_id))
.where(
"LOWER(username) LIKE :filter",
filter: "%#{User.sanitize_sql_like(filter.downcase)}%",
)
.order(
DB.sql_fragment(
"CASE WHEN LOWER(username) = ? THEN 0 ELSE 1 END ASC, LOWER(username) ASC",
filter.downcase,
),
)
.limit(SUGGESTED_USERS_LIMIT)
end
def update_with_params!(params)
case params[:status] ? params[:status].to_i : status
when Event.statuses[:private]
if params.key?(:raw_invitees)
params = params.merge(raw_invitees: Array(params[:raw_invitees]) - [PUBLIC_GROUP])
else
params = params.merge(raw_invitees: Array(raw_invitees) - [PUBLIC_GROUP])
end
update!(params)
enforce_private_invitees!
when Event.statuses[:public]
update!(params.merge(raw_invitees: [PUBLIC_GROUP]))
when Event.statuses[:standalone]
update!(params.merge(raw_invitees: []))
invitees.destroy_all
end
publish_update!
end
def chat_channel_sync
if chat_enabled && chat_channel_id.blank? && post.last_editor_id.present?
DiscoursePostEvent::ChatChannelSync.sync(
self,
guardian: Guardian.new(User.find_by(id: post.last_editor_id)),
)
end
end
def calculate_next_date
if recurrence.blank? || original_starts_at > Time.current
return original_starts_at, original_ends_at
end
next_starts_at = calculate_next_recurring_date
return nil unless next_starts_at
next_ends_at =
if original_ends_at
next_starts_at + (original_ends_at - original_starts_at)
elsif all_day
next_starts_at.end_of_day
else
next_starts_at + 3600
end
[next_starts_at, next_ends_at]
end
def calculate_next_occurrence_from(from_time)
return nil if recurrence.blank?
if original_starts_at > from_time
return { starts_at: original_starts_at, ends_at: original_ends_at }
end
next_starts_at = calculate_next_recurring_date_from(from_time)
return nil unless next_starts_at
next_ends_at =
if original_ends_at
next_starts_at + (original_ends_at - original_starts_at)
elsif all_day
next_starts_at.end_of_day
else
next_starts_at + 3600
end
{ starts_at: next_starts_at, ends_at: next_ends_at }
end
def duration
return nil unless original_starts_at
duration_seconds = original_ends_at ? original_ends_at - original_starts_at : 3600
hours = (duration_seconds / 3600)
minutes = ((duration_seconds % 3600) / 60)
seconds = (duration_seconds % 60)
sprintf("%02d:%02d:%02d", hours, minutes, seconds)
end
def rrule_timezone
timezone || DEFAULT_TIMEZONE
end
def livestream_url
location || url
end
def is_zoom_livestream?
return false unless SiteSetting.livestream_zoom_enabled
return false unless livestream?
DiscourseCalendar::Livestream::ZoomUrlParser.zoom_url?(livestream_url)
end
private
def reset_invitees_topic_tracking
topic_id = post&.topic_id
return if topic_id.nil?
Invitee.reset_topic_tracking!(user_ids: invitees.pluck(:user_id), topic_id:)
end
def dates_changed?
saved_change_to_original_starts_at || saved_change_to_original_ends_at
end
def finish_previous_event_dates(current_starts_at)
existing_date = event_dates.find_by(starts_at: current_starts_at)
event_dates
.where.not(id: existing_date&.id)
.where(finished_at: nil)
.update_all(finished_at: Time.current)
end
def upsert_event_date(starts_at, ends_at)
finished_at = ends_at && ends_at < Time.current ? ends_at : nil
existing_date = event_dates.find_by(starts_at:)
if existing_date
# Only update if something actually changed
unless existing_date.ends_at == ends_at && existing_date.finished_at == finished_at
existing_date.update!(ends_at:, finished_at:)
end
else
event_dates.create!(starts_at:, ends_at:, finished_at:)
end
end
def reset_invitee_notifications
invitees.where(
"status != :going OR recurring = FALSE",
going: Invitee.statuses[:going],
).update_all(status: nil, notified: false, recurring: false)
end
def notify_if_new_event
is_generating_future_recurrence = recurrence.present? && original_starts_at <= Time.current
unless is_generating_future_recurrence
notify_invitees!
notify_missing_invitees!
end
end
def calculate_next_recurring_date
timezone_starts_at = original_starts_at.in_time_zone(timezone)
timezone_recurrence_until = recurrence_until&.in_time_zone(timezone)
RRuleGenerator.generate(
starts_at: timezone_starts_at,
timezone: rrule_timezone,
recurrence: recurrence,
recurrence_until: timezone_recurrence_until,
dtstart: timezone_starts_at,
).first
end
def calculate_next_recurring_date_from(from_time)
timezone_starts_at = original_starts_at.in_time_zone(timezone)
timezone_recurrence_until = recurrence_until&.in_time_zone(timezone)
from_time_in_tz = from_time.in_time_zone(timezone)
RRule::Rule
.new(
RRuleConfigurator.rule(
recurrence_until: timezone_recurrence_until,
recurrence: recurrence,
starts_at: timezone_starts_at,
),
dtstart: timezone_starts_at,
tzid: rrule_timezone,
)
.between(from_time_in_tz - 1.hour, from_time_in_tz + 1.year)
.find { |date| date >= from_time_in_tz }
end
end
end
# == Schema Information
#
# Table name: discourse_post_event_events
#
# id :bigint not null, primary key
# all_day :boolean default(FALSE), not null
# chat_enabled :boolean default(FALSE), not null
# closed :boolean default(FALSE), not null
# custom_fields :jsonb not null
# deleted_at :datetime
# description :string(1000)
# livestream :boolean default(FALSE), not null
# location :string(1000)
# max_attendees :integer
# minimal :boolean
# name :string
# original_ends_at :datetime
# original_starts_at :datetime not null
# raw_invitees :string is an Array
# recurrence :string
# recurrence_until :datetime
# reminders :string
# show_local_time :boolean default(FALSE), not null
# status :integer default(0), not null
# timezone :string
# url :string(1000)
# chat_channel_id :bigint
# image_upload_id :bigint
#
# Indexes
#
# index_discourse_post_event_events_on_image_upload_id (image_upload_id)
#