0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 12:53:30 +08:00
discourse/app/models/site.rb
Martin Brennan e590d2a2d5
DEV: Add banned_acl concept to AccessControlList (#41254)
Introduces a server-side definition of banned_acl,
similar to mandatory_acl from 5823e4e3b2

This allows AclTarget implementing classes to define
which ACLs cannot be used for certain types. For example:

```
def self.banned_acl
  [{ type: :group, id: Group::AUTO_GROUPS[:anonymous_users], permission: "edit" }]
end
```

This prevents anonymous users group from being able to have the Edit
permission on the target, which is practical because the anonymous
user is not logged in and generally cannot create/edit anything.

This restriction is passed to the DAccessControl component via the Site
serializer, same as mandatory_acl, and is used to prevent the user from
selecting the banned ACLs in the UI.

Then, server-side this rule is enforced in `AccessControlListManager`
service as a policy.

**Before**

<img width="971" height="287" alt="image"
src="https://github.com/user-attachments/assets/af4ace0f-7b11-4005-8344-774d554693f0"
/>


**After**

<img width="914" height="194" alt="image"
src="https://github.com/user-attachments/assets/b756b976-f6e6-4331-b27d-c9a2d13997aa"
/>
2026-06-29 16:22:30 +10:00

324 lines
9.8 KiB
Ruby
Vendored

# frozen_string_literal: true
# A class we can use to serialize the site data
class Site
include ActiveModel::Serialization
cattr_accessor :preloaded_category_custom_fields
def self.reset_preloaded_category_custom_fields
self.preloaded_category_custom_fields = Set.new
end
reset_preloaded_category_custom_fields
##
# Sometimes plugins need to have additional data or options available
# when rendering custom markdown features/rules that are not available
# on the default opts.discourse object. These additional options should
# be namespaced to the plugin adding them.
#
# ```
# Site.markdown_additional_options["chat"] = { limited_pretty_text_markdown_rules: [] }
# ```
#
# These are passed down to markdown rules on opts.discourse.additionalOptions.
cattr_accessor :markdown_additional_options
self.markdown_additional_options = {}
def self.add_categories_callbacks(enabled: -> { true }, &block)
categories_callbacks << { block:, enabled: }
end
def self.categories_callbacks
@categories_callbacks ||= []
end
def initialize(guardian)
@guardian = guardian
end
def site_setting
SiteSetting
end
def notification_types
Notification.types
end
def trust_levels
TrustLevel.levels
end
def user_fields
UserField.includes(:user_field_options).order(:position).all
end
def access_control
self.class.access_control
end
def self.access_control
target_classes =
(
AclTarget.target_classes +
DiscoursePluginRegistry.acl_target_classes.filter_map do |target_class|
if target_class.is_a?(String)
target_class = target_class.safe_constantize
if target_class.nil?
Rails.logger.warn(
"[ACL] Unknown target class in plugin registry for site (#{target_class}) maybe the plugin is gone, the class has been renamed, or the class does not include AclTarget",
)
end
target_class
else
target_class
end
end
).compact.uniq
{
mandatory_acl:
target_classes.each_with_object({}) do |target_class, mandatory_acl|
next if !target_class.respond_to?(:has_mandatory_acl?)
next if !target_class.has_mandatory_acl?
mandatory_acl[target_class.acl_target_key] = target_class.mandatory_acl
end,
banned_acl:
target_classes.each_with_object({}) do |target_class, banned_acl|
next if !target_class.respond_to?(:has_banned_acl?)
next if !target_class.has_banned_acl?
banned_acl[target_class.acl_target_key] = target_class.banned_acl
end,
}
end
def self.categories_cache_key
"site_categories_#{I18n.locale}_#{Discourse.git_version}"
end
def self.clear_cache
Discourse.cache.delete(categories_cache_key)
end
def self.all_categories_cache
# Categories do not change often so there is no need for us to run the
# same query and spend time creating ActiveRecord objects for every requests.
#
# Do note that any new association added to the eager loading needs a
# corresponding ActiveRecord callback to clear the categories cache.
Discourse
.cache
.fetch(categories_cache_key, expires_in: 30.minutes) do
categories =
begin
query =
Category
.includes(
:uploaded_logo,
:uploaded_logo_dark,
:uploaded_background,
:uploaded_background_dark,
:tags,
:tag_groups,
:form_templates,
category_required_tag_groups: :tag_group,
)
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
.select("categories.*, t.slug topic_slug")
.order(:position)
query =
DiscoursePluginRegistry.apply_modifier(:site_all_categories_cache_query, query, self)
query.to_a
end
if preloaded_category_custom_fields.present?
Category.preload_custom_fields(categories, preloaded_category_custom_fields)
end
ActiveModel::ArraySerializer.new(
categories,
each_serializer: SiteCategorySerializer,
).as_json
end
end
def categories
if @guardian.can_lazy_load_categories?
preloaded_category_ids = []
if @guardian.authenticated?
sidebar_category_ids = @guardian.user.secured_sidebar_category_ids(@guardian)
preloaded_category_ids.concat(
Category.secured(@guardian).ancestors_of(sidebar_category_ids).pluck(:id),
)
preloaded_category_ids.concat(sidebar_category_ids)
end
end
@categories ||=
begin
categories = []
self.class.all_categories_cache.each do |category|
if (
!@guardian.can_lazy_load_categories? ||
preloaded_category_ids.include?(category[:id])
) &&
@guardian.can_see_serialized_category?(
category_id: category[:id],
read_restricted: category[:read_restricted],
)
categories << category
end
end
with_children = Set.new
categories.each { |c| with_children << c[:parent_category_id] if c[:parent_category_id] }
allowed_topic_create = nil
unless @guardian.is_admin?
allowed_topic_create_ids =
@guardian.anonymous? ? [] : Category.topic_create_allowed(@guardian).pluck(:id)
allowed_topic_create = Set.new(allowed_topic_create_ids)
end
by_id = {}
notification_levels = CategoryUser.notification_levels_for(@guardian.user)
default_notification_level = CategoryUser.default_notification_level
categories.each do |category|
category[:notification_level] = notification_levels[category[:id]] ||
default_notification_level
category[:permission] = CategoryGroup.permission_types[
:full
] if allowed_topic_create&.include?(category[:id]) || @guardian.is_admin?
category[:has_children] = with_children.include?(category[:id])
category[:can_edit] = @guardian.can_edit_serialized_category?(
category_id: category[:id],
read_restricted: category[:read_restricted],
)
by_id[category[:id]] = category
end
categories.reject! { |c| c[:parent_category_id] && !by_id[c[:parent_category_id]] }
self.class.categories_callbacks.each do |callback|
next unless callback[:enabled].call
callback[:block].call(categories, @guardian)
end
categories
end
end
def groups
query =
Group.visible_groups(
@guardian.user,
"groups.name ASC",
include_everyone: !SiteSetting.granular_anonymous_and_logged_in_groups_permissions,
include_pseudogroups: SiteSetting.granular_anonymous_and_logged_in_groups_permissions,
).includes(:flair_upload)
query = DiscoursePluginRegistry.apply_modifier(:site_groups_query, query, self)
query
end
def anonymous_sidebar_sections
SidebarSection
.public_sections
.includes(:sidebar_urls)
.order("(section_type IS NOT NULL) DESC, (public IS TRUE) DESC")
end
def archetypes
Archetype.list.reject { |t| t.id == Archetype.private_message }
end
def auth_providers
Discourse.enabled_auth_providers
end
def self.json_for(guardian)
if guardian.anonymous? && SiteSetting.login_required
return(
{
periods: TopTopic.periods.map(&:to_s),
filters: Discourse.filters.map(&:to_s),
user_fields:
UserField
.includes(:user_field_options)
.order(:position)
.all
.map { |userfield| UserFieldSerializer.new(userfield, root: false, scope: guardian) },
auth_providers:
Discourse.enabled_auth_providers.map do |provider|
AuthProviderSerializer.new(provider, root: false, scope: guardian)
end,
full_name_required_for_signup:,
full_name_visible_in_signup:,
tos_url: Discourse.tos_url,
privacy_policy_url: Discourse.privacy_policy_url,
upcoming_changes_with_css: UpcomingChanges.including_css,
}.to_json
)
end
seq = nil
use_localized_anon_cache = SiteSetting.content_localization_enabled && guardian.anonymous?
locale = I18n.locale
cache_key = "site_json"
seq_key = "site_json_seq"
version_key = "site_json_version"
if use_localized_anon_cache
cache_key += "_#{locale}"
seq_key += "_#{locale}"
version_key += "_#{locale}"
end
if guardian.anonymous?
seq = MessageBus.last_id("/site_json")
cached_json, cached_seq, cached_version =
Discourse.redis.mget(cache_key, seq_key, version_key)
if cached_json && seq == cached_seq.to_i && Discourse.git_version == cached_version
return cached_json
end
end
site = Site.new(guardian)
json = MultiJson.dump(SiteSerializer.new(site, root: false, scope: guardian))
if guardian.anonymous?
Discourse.redis.multi do |transaction|
transaction.setex cache_key, 1800, json
transaction.set seq_key, seq
transaction.set version_key, Discourse.git_version
end
end
json
end
SITE_JSON_CHANNEL = "/site_json"
def self.clear_anon_cache!
# publishing forces the sequence up
# the cache is validated based on the sequence
MessageBus.publish(SITE_JSON_CHANNEL, "")
end
def self.full_name_required_for_signup
SiteSetting.full_name_requirement == "required_at_signup"
end
def self.full_name_visible_in_signup
SiteSetting.full_name_requirement != "hidden_at_signup"
end
end