diff --git a/app/authorizers/book_authorizer.rb b/app/authorizers/book_authorizer.rb index 7393cdb6d..87aa61f48 100644 --- a/app/authorizers/book_authorizer.rb +++ b/app/authorizers/book_authorizer.rb @@ -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 diff --git a/app/authorizers/content_authorizer.rb b/app/authorizers/content_authorizer.rb index e3a074bec..5e2ca1770 100644 --- a/app/authorizers/content_authorizer.rb +++ b/app/authorizers/content_authorizer.rb @@ -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 @@ -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? diff --git a/app/authorizers/content_page_authorizer.rb b/app/authorizers/content_page_authorizer.rb index 3bc57016c..d37eaef33 100644 --- a/app/authorizers/content_page_authorizer.rb +++ b/app/authorizers/content_page_authorizer.rb @@ -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 diff --git a/app/authorizers/timeline_authorizer.rb b/app/authorizers/timeline_authorizer.rb index 837cc1bd5..8550cad39 100644 --- a/app/authorizers/timeline_authorizer.rb +++ b/app/authorizers/timeline_authorizer.rb @@ -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 diff --git a/app/authorizers/universe_core_content_authorizer.rb b/app/authorizers/universe_core_content_authorizer.rb index 3f0203e83..e51c0a246 100644 --- a/app/authorizers/universe_core_content_authorizer.rb +++ b/app/authorizers/universe_core_content_authorizer.rb @@ -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 diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index cc4bca875..13bfdd2b3 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -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 diff --git a/app/controllers/api/v1/gallery_images_controller.rb b/app/controllers/api/v1/gallery_images_controller.rb index e94974a02..7fee118bc 100644 --- a/app/controllers/api/v1/gallery_images_controller.rb +++ b/app/controllers/api/v1/gallery_images_controller.rb @@ -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 diff --git a/app/controllers/attribute_fields_controller.rb b/app/controllers/attribute_fields_controller.rb index 6bae07782..de079070f 100644 --- a/app/controllers/attribute_fields_controller.rb +++ b/app/controllers/attribute_fields_controller.rb @@ -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, diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index e6d88b1fa..8127a846d 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -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 @@ -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 @@ -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) @@ -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) @@ -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}" @@ -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 @@ -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 } diff --git a/app/controllers/contributors_controller.rb b/app/controllers/contributors_controller.rb index 4fb9c1974..beca0992e 100644 --- a/app/controllers/contributors_controller.rb +++ b/app/controllers/contributors_controller.rb @@ -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: "
Private fields (and their answers) are only visible to you, even when your pages are shared publicly.
+ <%= form_for(field, method: :put, html: { class: 'space-y-3', 'data-type': 'json' }, remote: true) do |f| %> +This setting applies to this field on all of your <%= content_type.downcase %> pages, since fields are part of your <%= content_type.downcase %> template.
diff --git a/app/views/content/changelog/_date_changes.html.erb b/app/views/content/changelog/_date_changes.html.erb index d38a2dced..0a9ae0582 100644 --- a/app/views/content/changelog/_date_changes.html.erb +++ b/app/views/content/changelog/_date_changes.html.erb @@ -52,15 +52,12 @@ old_value = change.first.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.first.to_s new_value = change.second.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.second.to_s - # Privacy check - visible_change = true - if related_field.label.start_with?('Private') - visible_change = user_signed_in? && ( - (content.raw_model.is_a?(Universe) && content.user == current_user) || - (content.respond_to?(:universe) && content.universe && content.universe.user == current_user) || - (content.respond_to?(:universe) && content.universe.nil? && content.user == current_user) - ) - end + # Privacy check: hide values of fields the viewer doesn't have visibility into + visible_change = PermissionService.attribute_field_visible_to?( + field: related_field, + content: content.raw_model, + viewer: user_signed_in? ? current_user : nil + ) unless visible_change old_value = ContentChangeEvent::PRIVATE_PLACEHOLDER diff --git a/app/views/content/display/_contributors_user_list.html.erb b/app/views/content/display/_contributors_user_list.html.erb index 51aaeaf65..a7d61e02c 100644 --- a/app/views/content/display/_contributors_user_list.html.erb +++ b/app/views/content/display/_contributors_user_list.html.erb @@ -20,6 +20,9 @@<%= contributor.user ? link_to(contributor.user.name, contributor.user, class: "hover:underline") : "#{contributor.email} (invited)" %> + + <%= contributor.role_label %> +
Invited <%= time_ago_in_words contributor.created_at %> ago diff --git a/app/views/content/form/_contributors.html.erb b/app/views/content/form/_contributors.html.erb index c6170a938..1e0682d79 100644 --- a/app/views/content/form/_contributors.html.erb +++ b/app/views/content/form/_contributors.html.erb @@ -8,17 +8,17 @@ Universes can have an unlimited number of collaborators.
- When a user is added as a collaborator to a universe, they are allowed to: + What a collaborator can do depends on their role:
- They are NOT allowed to: + Regardless of role, they are NOT allowed to:
About Contributors
+About Contributor Roles