Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/authorizers/book_authorizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def readable_by?(user)

def updatable_by?(user)
return true if user && resource.user_id == user.id
return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id)
return true if user && resource.universe.present? && resource.universe.contributors.where(role: Contributor::EDITING_ROLES).pluck(:user_id).include?(user.id)

false
end
Expand Down
6 changes: 3 additions & 3 deletions app/authorizers/content_authorizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ def readable_by? user

def updatable_by? user
return true if PermissionService.user_owns_any_containing_universe?(user: user, content: resource)
return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource)
return true if PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource)
return true if [
PermissionService.content_has_no_containing_universe?(content: resource),
PermissionService.user_owns_content?(user: user, content: resource)
].all?

return false
end

Expand All @@ -34,7 +34,7 @@ def deletable_by? user
PermissionService.user_owns_content?(user: user, content: resource)
].all?
return true if [
PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource),
PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource),
PermissionService.user_owns_content?(user: user, content: resource)
].all?

Expand Down
6 changes: 3 additions & 3 deletions app/authorizers/content_page_authorizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ def readable_by?(user)

def updatable_by?(user)
return true if PermissionService.user_owns_content?(user: user, content: resource)

if resource.page_type == 'Universe'
return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource)
return true if PermissionService.user_can_edit_universe_content?(user: user, universe: resource)
else
return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource)
return true if PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource)
end

return false
Expand Down
4 changes: 2 additions & 2 deletions app/authorizers/timeline_authorizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def readable_by?(user)

def updatable_by?(user)
return true if user && resource.user_id == user.id
return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id)
return true if user && resource.universe.present? && resource.universe.contributors.where(role: Contributor::EDITING_ROLES).pluck(:user_id).include?(user.id)

return false
end

Expand Down
2 changes: 1 addition & 1 deletion app/authorizers/universe_core_content_authorizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def readable_by? user

def updatable_by? user
return true if PermissionService.user_owns_content?(user: user, content: resource)
return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource)
return true if PermissionService.user_can_edit_universe_content?(user: user, universe: resource)

return false
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/api_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def log_api_request
page = content_type.find_by(id: params[:id].to_i)

if page && page.readable_by?(@current_api_user || User.new)
render json: ApiContentSerializer.new(page, include_blank_fields: params.fetch(:include_blank_fields, false)).data
render json: ApiContentSerializer.new(page, include_blank_fields: params.fetch(:include_blank_fields, false), viewer: @current_api_user).data
else
render json: { error: "Page not found" }
end
Expand Down
12 changes: 6 additions & 6 deletions app/controllers/api/v1/gallery_images_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ def sort
content_id = params[:content_id]
content = content_type.constantize.find_by(id: content_id)

# Check permissions - must own or contribute to this content
unless content &&
(content.user_id == current_user.id ||
(content.respond_to?(:universe_id) &&
content.universe_id.present? &&
current_user.contributable_universe_ids.include?(content.universe_id)))
# Check permissions - must own or have edit access to this content
unless content &&
(content.user_id == current_user.id ||
(content.respond_to?(:universe_id) &&
content.universe_id.present? &&
current_user.editable_universe_ids.include?(content.universe_id)))
return render json: { error: 'Unauthorized' }, status: :unauthorized
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/attribute_fields_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ def content_param_list
:label, :description,
:entity_type,
:attribute_category_id,
:hidden, :position,
:hidden, :position, :privacy,
field_options: [
:display_style,
:input_size,
Expand Down
82 changes: 76 additions & 6 deletions app/controllers/content_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class ContentController < ApplicationController
before_action :set_navbar_actions, except: [:deleted, :api_sort]
before_action :set_sidenav_expansion, except: [:api_sort]

before_action :verify_entity_edit_permission, only: [
:link_field_update, :name_field_update, :text_field_update, :tags_field_update, :universe_field_update
]

def index
@content_type_class = content_type_from_controller(self.class)
@content_type_name = @content_type_class.name
Expand Down Expand Up @@ -250,9 +254,24 @@ def references
document_ids = DocumentAnalysis.where(id: analysis_ids).pluck(:document_id)
@documents = Document.where(id: document_ids)
@references = @content.incoming_page_references.preload(:referencing_page)

# Hide references coming from fields the viewer can't see on the referencing page
# (e.g. another page mentioning this one in a private or contributors-only field)
reference_fields = AttributeField.where(id: @references.map(&:attribute_field_id)).index_by(&:id)
@references = @references.select do |reference|
field = reference_fields[reference.attribute_field_id]
page = reference.referencing_page

field.nil? || page.nil? || PermissionService.attribute_field_visible_to?(
field: field,
content: page,
viewer: current_user
)
end

@mentioning_attributes = Attribute.where(
attribute_field_id: @references.pluck(:attribute_field_id),
entity_id: @references.pluck(:referencing_page_id)
attribute_field_id: @references.map(&:attribute_field_id),
entity_id: @references.map(&:referencing_page_id)
)
end

Expand All @@ -264,6 +283,14 @@ def new
content.universe_id = @universe_scope.try(:id) if content.respond_to?(:universe_id)
}

# Contributors without create permissions (Editors, Read-Only) can't add new pages to a universe
if @content.respond_to?(:universe_id) && !user_can_create_content_in_universe?(@content.universe_id)
return redirect_back(
fallback_location: root_path,
notice: "You don't have permission to create new pages in that universe."
)
end

current_users_categories_and_fields = @content.class.attribute_categories(current_user)
if current_users_categories_and_fields.empty?
content_type_from_controller(self.class).create_default_attribute_categories(current_user)
Expand All @@ -276,7 +303,7 @@ def new
# For users who are creating premium content in a collaborated universe without premium of their own
# we want to default that content into one of their collaborated unvierses.
if !current_user.on_premium_plan? && Rails.application.config.content_types[:premium].map(&:name).include?(@content.class.name)
@content.universe_id = current_user.contributable_universes.first.try(:id)
@content.universe_id = current_user.creatable_universes.first.try(:id)
end

if params.key?(:document_entity)
Expand Down Expand Up @@ -347,10 +374,18 @@ def create

unless current_user.can_create?(content_type) \
|| PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name)

return redirect_back(fallback_location: root_path, notice: "Creating this type of page requires an active Premium subscription.")
end

# Contributors without create permissions (Editors, Read-Only) can't add new pages to a universe
if @content.respond_to?(:universe_id) && !user_can_create_content_in_universe?(@content.universe_id)
return redirect_back(
fallback_location: root_path,
notice: "You don't have permission to create new pages in that universe."
)
end

# Default names to untitled until one has been set
unless [AttributeCategory, AttributeField, Attribute].map(&:name).include?(@content.class.name)
@content.name ||= "Untitled #{content_type.name.downcase}"
Expand Down Expand Up @@ -683,11 +718,11 @@ def toggle_image_pin
return render json: { error: 'Content not found for this image' }, status: 422
end

# Need to check if user owns or contributes to the content directly
# Need to check if user owns or has edit access to the content directly
unless content.user_id == current_user.id ||
(content.respond_to?(:universe_id) &&
content.universe_id.present? &&
current_user.contributable_universe_ids.include?(content.universe_id))
current_user.editable_universe_ids.include?(content.universe_id))
return render json: { error: 'Unauthorized' }, status: 403
end

Expand Down Expand Up @@ -905,6 +940,41 @@ def universe_field_update

private

# Whether the current user is allowed to create new pages inside the given universe.
# Content outside of any universe is only limited by the usual ownership/billing checks.
def user_can_create_content_in_universe?(universe_id)
return true if universe_id.blank?

universe = Universe.find_by(id: universe_id)
return true if universe.nil?
return true if universe.user_id == current_user.id

current_user.creatable_universe_ids.include?(universe.id)
end

# Guards the per-field update endpoints so only users with edit access to the
# entity (owner, universe owner, or a contributor with an editing role) can write to it
def verify_entity_edit_permission
entity_type = entity_params.fetch(:entity_type, nil)
unless entity_type.present? && valid_content_types.include?(entity_type)
return render json: { error: 'Invalid entity type' }, status: 422
end

entity = entity_type.constantize.find_by(id: entity_params.fetch(:entity_id, nil).to_i)
return render json: { error: 'Not found' }, status: 404 if entity.nil?

unless entity.updatable_by?(current_user)
return render json: { error: 'Unauthorized' }, status: 403
end

# Even with edit access to the page, a field that isn't visible to this user
# (private, or contributors-only when they aren't one) can't be written to
field = AttributeField.find_by(id: params[:field_id].to_i)
if field.present? && !PermissionService.attribute_field_visible_to?(field: field, content: entity, viewer: current_user)
render json: { error: 'Unauthorized' }, status: 403
end
end

def group_events_by_date(events)
# Group events by date for timeline display
grouped = events.group_by { |event| event.created_at.to_date }
Expand Down
45 changes: 40 additions & 5 deletions app/controllers/contributors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,56 @@ def create
end

email = params[:contributor][:email]&.downcase

role = params[:contributor][:role]
role = 'full' unless Contributor::ROLES.key?(role)

# Check if this email is already a contributor
if universe.contributors.exists?(email: email)
redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'This user is already a contributor.'
return
end

# Use the ContributorService to handle the invitation
ContributorService.invite_contributor_to_universe(universe: universe, email: email)
ContributorService.invite_contributor_to_universe(universe: universe, email: email, role: role)

redirect_to edit_universe_path(universe, anchor: 'contributors'), notice: 'Contributor invitation sent!'
rescue StandardError => e
redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Failed to add contributor. Please try again.'
end


def update
contributor = Contributor.find(params[:id])
universe = contributor.universe

# Only the universe owner can change contributor roles
unless universe.user_id == current_user.id
redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Only the universe owner can change contributor roles.'
return
end

role = params.dig(:contributor, :role)
unless Contributor::ROLES.key?(role)
redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'That is not a valid contributor role.'
return
end

if contributor.update(role: role)
# Let the contributor know their access level changed
contributor.user.notifications.create(
message_html: "<div>Your role in the <span class='#{Universe.text_color}'>#{universe.name}</span> universe has been changed to <strong>#{contributor.role_label}</strong>.</div>",
icon: Universe.icon,
icon_color: Universe.color,
happened_at: DateTime.current,
passthrough_link: Rails.application.routes.url_helpers.universe_path(universe),
reference_code: 'contributor-role-changed'
) if contributor.user.present?

redirect_to edit_universe_path(universe, anchor: 'contributors'), notice: "#{contributor.user&.display_name || contributor.email} is now a #{contributor.role_label}."
else
redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Failed to update contributor role. Please try again.'
end
end

def destroy
contributor = Contributor.find(params[:id])
relevant_universe = Universe.find(contributor.universe_id)
Expand Down
30 changes: 29 additions & 1 deletion app/models/page_data/attribute_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ class AttributeField < ApplicationRecord
UNDELETEABLE_FIELD_TYPES = %w(name universe tags)
SETTABLE_FIELD_TYPES = %w(text_area page_link)

# Who can see this field (and its values) on pages that use it:
# - public: anyone who can view the page
# - contributors: the page owner, the universe owner, and contributors to the containing universe
# - private: only the page owner
VISIBILITIES = {
'public' => 'Everyone who can view the page',
'contributors' => 'Universe contributors only',
'private' => 'Only me'
}.freeze

validates :privacy, inclusion: { in: VISIBILITIES.keys }

# todo replace old_column_source etc
#json :acceptable_page_link_classes

Expand Down Expand Up @@ -68,7 +80,23 @@ def humanize
end

def private?
privacy != 'public'
effective_privacy != 'public'
end

# The privacy level actually in effect for this field. Legacy Private Notes
# fields (old_column_source == 'private_notes') have always been hidden from
# other viewers, so they can be opened up to contributors but never made fully
# public -- for everything else the privacy column is authoritative.
def effective_privacy
return privacy if VISIBILITIES.key?(privacy) && privacy != 'public'
return 'private' if old_column_source == 'private_notes'

'public'
end

# Legacy Private Notes fields can't be made fully public (see effective_privacy)
def can_be_public?
old_column_source != 'private_notes'
end

def system?
Expand Down
8 changes: 4 additions & 4 deletions app/models/serializers/api_content_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ApiContentSerializer

attr_accessor :data

def initialize(content, include_blank_fields: false)
def initialize(content, include_blank_fields: false, viewer: nil)
self.categories = content.class.attribute_categories(content.user).where(hidden: [false, nil]).eager_load(attribute_fields: :attribute_values)
self.fields = AttributeField.where(attribute_category_id: self.categories.map(&:id), hidden: [false, nil])
self.attribute_values = Attribute.where(attribute_field_id: self.fields.map(&:id), entity_type: content.page_type, entity_id: content.id).order('created_at desc')
Expand Down Expand Up @@ -54,9 +54,9 @@ def initialize(content, include_blank_fields: false)
id: category.id,
label: category.label,
icon: category.icon,
fields: category.attribute_fields.order(:position).reject { |field|
# Filter out private fields from API responses
field.old_column_source == 'private_notes'
fields: category.attribute_fields.order(:position).select { |field|
# Filter out fields the API viewer doesn't have visibility into
PermissionService.attribute_field_visible_to?(field: field, content: content, viewer: viewer)
}.map { |field|
{
id: field.id,
Expand Down
Loading