From 8d0dcd60568f2e66ce9b4e08e2795123dedf64a2 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 15 Apr 2026 11:30:39 +0200 Subject: [PATCH 1/8] Fix keep-scroll-position for reload banner --- app/helpers/application_helper.rb | 3 ++- app/helpers/stimulus_helper.rb | 8 ++++++++ .../controllers/keep-scroll-position.controller.ts | 8 +------- .../app/components/meetings/header_component.html.erb | 6 +++--- .../meetings/index_page_header_component.html.erb | 3 +-- .../show_page_header_component.html.erb | 3 +-- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 001f1e48d63..ea07d121f0b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -285,7 +285,8 @@ module ApplicationHelper def body_data_attributes(local_assigns) { - controller: "application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element", + controller: ["application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element", + stimulus_body_controller].compact.join(" "), relative_url_root: root_path, overflowing_identifier: ".__overflowing_body", external_links_enabled_value: Setting.capture_external_links?, diff --git a/app/helpers/stimulus_helper.rb b/app/helpers/stimulus_helper.rb index 33545acd811..afb619e9b61 100644 --- a/app/helpers/stimulus_helper.rb +++ b/app/helpers/stimulus_helper.rb @@ -39,5 +39,13 @@ module StimulusHelper @stimulus_content_data || {} end + def body_controller(name) + @stimulus_body_controller = name + end + + def stimulus_body_controller + @stimulus_body_controller + end + # rubocop:enable Rails/HelperInstanceVariable end diff --git a/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts index 17ac82b2cf2..a56a7cd0fe3 100644 --- a/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts +++ b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts @@ -31,16 +31,10 @@ import { ApplicationController } from 'stimulus-use'; export default class KeepScrollPositionController extends ApplicationController { - static values = { - url: String, - }; - static targets = ['triggerButton']; declare triggerButtonTarget:HTMLLinkElement; - declare urlValue:string; - connect() { super.connect(); @@ -80,6 +74,6 @@ export default class KeepScrollPositionController extends ApplicationController } private scrollPositionKey():string { - return `${this.urlValue}/scrollPosition`; + return `${window.location.pathname}/scrollPosition`; } } diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index 9da1ca193f9..67e98443def 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -1,8 +1,8 @@ <%= - helpers.content_controller "poll-for-changes keep-scroll-position", + helpers.content_controller "poll-for-changes", poll_for_changes_url_value: check_for_updates_project_meeting_path(@meeting.project, @meeting), - poll_for_changes_interval_value: check_for_updates_interval, - keep_scroll_position_url_value: meeting_path(@meeting) + poll_for_changes_interval_value: check_for_updates_interval + helpers.body_controller "keep-scroll-position" component_wrapper do render( diff --git a/modules/meeting/app/components/meetings/index_page_header_component.html.erb b/modules/meeting/app/components/meetings/index_page_header_component.html.erb index 6e05000b7c4..f28c8e3eab8 100644 --- a/modules/meeting/app/components/meetings/index_page_header_component.html.erb +++ b/modules/meeting/app/components/meetings/index_page_header_component.html.erb @@ -1,5 +1,4 @@ -<% helpers.content_controller "keep-scroll-position", - keep_scroll_position_url_value: polymorphic_path([@project, :meetings]) %> +<% helpers.content_controller "keep-scroll-position" %> <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { page_title } diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 05005d81a6b..bbbd4295ebd 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -1,6 +1,5 @@ <%= - helpers.content_controller "keep-scroll-position", - keep_scroll_position_url_value: polymorphic_path([@project, @meeting]) + helpers.content_controller "keep-scroll-position" component_wrapper do render(Primer::OpenProject::PageHeader.new) do |header| From 0175a80a33c9341b83552ae7c72b633debd2889c Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 15 Apr 2026 16:14:10 +0200 Subject: [PATCH 2/8] Add keep-scroll-position specs --- .../features/meeting_scroll_position_spec.rb | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 modules/meeting/spec/features/meeting_scroll_position_spec.rb diff --git a/modules/meeting/spec/features/meeting_scroll_position_spec.rb b/modules/meeting/spec/features/meeting_scroll_position_spec.rb new file mode 100644 index 00000000000..ee46a71e23e --- /dev/null +++ b/modules/meeting/spec/features/meeting_scroll_position_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../support/pages/meetings/show" +require_relative "../support/pages/meetings/index" +require_relative "../support/pages/recurring_meeting/show" + +RSpec.describe "Meeting scroll position", + :js do + shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) } + shared_let(:user) { create(:admin) } + + current_user { user } + + describe "is restored after clicking 'Reload' in the flash banner" do + let(:meeting) { create(:meeting, project:, author: user) } + let(:show_page) { Pages::Meetings::Show.new(meeting) } + + before do + 25.times { create(:meeting_agenda_item, meeting:, author: user) } + + # Disable automatic polling so we can trigger it manually + allow_any_instance_of(Meetings::HeaderComponent) # rubocop:disable RSpec/AnyInstance + .to receive(:check_for_updates_interval) + .and_return(0) + end + + it do + flash_component = ".op-primer-flash--item" + + show_page.visit! + first_window = current_window + second_window = open_new_window + + scroll_position = within_window(first_window) do + expect(page).to have_test_selector("meeting-page-header") + last_item = all("[data-test-selector='op-meeting-agenda-title']").last + page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", last_item) + page.evaluate_script("document.getElementById('content-body').scrollTop") + end + + within_window(second_window) do + show_page.visit! + + retry_block do + show_page.add_agenda_item do + fill_in "Title", with: "New item triggering update" + end + end + end + + within_window(first_window) do + show_page.trigger_change_poll + expect(page).to have_css(flash_component) + expect(page).to have_text(I18n.t(:notice_meeting_updated)) + + click_on I18n.t("label_meeting_reload") + + expect(page).to have_test_selector("meeting-page-header") + + retry_block do + restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop") + expect(restored_position).to be_within(25).of(scroll_position) + end + end + end + end + + describe "is restored after clicking 'Show more' in the meetings index page footer" do + let(:index_page) { Pages::Meetings::Index.new(project:) } + + # Freeze time at 8 AM so "today" meetings scheduled for 10 AM are always in the future + around do |example| + freeze_time_at = Time.zone.today.beginning_of_day + 8.hours + travel_to(freeze_time_at) { example.run } + end + + before do + 30.times { create(:meeting, :author_participates, project:, author: user, start_time: Time.zone.today + 10.hours) } + 8.times { create(:meeting, :author_participates, project:, author: user, start_time: 2.weeks.from_now) } + end + + it do + index_page.visit! + expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more)) + + trigger_button = find("[data-keep-scroll-position-target='triggerButton']") + page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button) + scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop") + + trigger_button.click + + expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more)) + + retry_block do + restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop") + expect(restored_position).to be_within(25).of(scroll_position) + end + end + end + + describe "is restored after clicking 'Show more' in the recurring meeting show page footer" do + let(:recurring_meeting) do + create(:recurring_meeting, + project:, + author: user, + start_time: Date.tomorrow + 10.hours, + frequency: "daily", + end_after: "iterations", + iterations: 31) + end + let(:show_page) { Pages::RecurringMeeting::Show.new(recurring_meeting) } + + before do + 25.times do |i| + meeting = create(:meeting, + project:, + author: user, + recurring_meeting:, + start_time: (i + 4).weeks.from_now + 10.hours) + create(:scheduled_meeting, + meeting:, + recurring_meeting:, + start_time: meeting.start_time) + end + end + + it do + show_page.visit! + expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more)) + + trigger_button = find("[data-keep-scroll-position-target='triggerButton']") + page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button) + scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop") + + trigger_button.click + + expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more)) + + retry_block do + restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop") + expect(restored_position).to be_within(25).of(scroll_position) + end + end + end +end From 56418c510760327b15db4ff1bcb85db96bf85850 Mon Sep 17 00:00:00 2001 From: Maya Berdygylyjova Date: Thu, 16 Apr 2026 14:32:40 +0200 Subject: [PATCH 3/8] agile-docs-update (#22799) * agile-docs-update * Apply suggestions from code review Co-authored-by: Maya Berdygylyjova --- docs/glossary/README.md | 2 +- docs/system-admin-guide/backlogs/README.md | 10 +++--- docs/user-guide/backlogs-scrum/README.md | 4 +-- .../backlogs-settings/README.md | 31 ++++++++++++++++--- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/docs/glossary/README.md b/docs/glossary/README.md index dc33abfc733..eeeca4da195 100644 --- a/docs/glossary/README.md +++ b/docs/glossary/README.md @@ -59,7 +59,7 @@ In OpenProject, artificial intelligence (AI) refers to the integration of AI sys OpenProject approaches AI with a clear focus on control, transparency, and data protection. AI is designed to support users in their work, not to replace human decision-making. Users remain in full control at all times and can decide whether and how AI features are used. -With features such as the [MCP Server](https://www.openproject.org/blog/openproject-17-2-0-release/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used. +With features such as the [MCP Server](../system-admin-guide/integrations/mcp-server/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used. ### Attribute help texts diff --git a/docs/system-admin-guide/backlogs/README.md b/docs/system-admin-guide/backlogs/README.md index 2ae65838df9..d700b3bda6e 100644 --- a/docs/system-admin-guide/backlogs/README.md +++ b/docs/system-admin-guide/backlogs/README.md @@ -7,10 +7,12 @@ keywords: configure backlogs, backlogs settings, story type, task type, burn cha --- # Backlogs configuration -Backlogs settings let you tailor OpenProject’s Scrum features to match how your team plans and tracks work. By configuring story and task types, burn charts, and sprint wiki templates, you can ensure your backlogs and boards show the right work items and support consistent sprint planning and documentation. +Backlogs settings let you tailor OpenProject’s Scrum features to match how your team plans and tracks work. Backlogs support teams in structuring, prioritizing, and refining work for upcoming sprints, helping ensure a clear and consistent approach to sprint planning and execution. ## Backlog administration settings are evolving -We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future. Project-level settings remain available. - - +> [!NOTE] +> +> We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future. +> +> Project-level settings remain available. diff --git a/docs/user-guide/backlogs-scrum/README.md b/docs/user-guide/backlogs-scrum/README.md index 8c0916f236f..8ec545a3834 100644 --- a/docs/user-guide/backlogs-scrum/README.md +++ b/docs/user-guide/backlogs-scrum/README.md @@ -13,12 +13,12 @@ keywords: backlogs, scrum, backlog, agile, sprint, sprint bucket Working in agile project teams is becoming increasingly important, and with OpenProject, it is easier than ever. -OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize user stories in sprints and the backlog, use sprint boards or burndown-charts, print story cards, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page. +OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize work packages in sprints and the backlog, use automated sprint boards or burndown-charts, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page.
-A **Backlog** is defined as a plugin that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings. +A **Backlog** is defined as a module that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings.
diff --git a/docs/user-guide/projects/project-settings/backlogs-settings/README.md b/docs/user-guide/projects/project-settings/backlogs-settings/README.md index 64ebc308713..a62967407ed 100644 --- a/docs/user-guide/projects/project-settings/backlogs-settings/README.md +++ b/docs/user-guide/projects/project-settings/backlogs-settings/README.md @@ -23,20 +23,43 @@ Press the **Save** button to apply your changes. ## Sharing sprints -Sharing is a project-level setting that allows you to choose whether sprints should be shared across projects or not. +Sharing is a **project-level setting** that allows you to choose whether sprints should be shared across projects or not. > [!NOTE] > This is not a sprint-level setting as is currently the case with versions. +Sharing sprints allows teams working across multiple projects to plan and track work in a coordinated way. Instead of managing separate, disconnected sprints in each project, you can define a sprint once and reuse it across projects. This is especially useful for cross-team Scrum setups, scaled agile environments, or when multiple teams contribute to the same increment. + +Depending on the selected option, a project can either provide sprints to others, use shared sprints, or remain independent: + **Don't share:** This is the default setting for projects. Sprints can be created in this project and are available and visible only within this project. None of the created sprints are shared with any other project or sub-projects. **Share sprints:** Sprints can be created in this project and shared with either **all projects** or **subprojects**: -**All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that other projects will not be able to use this option. +- **All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that no other project can share sprints with all projects. -**Subprojects:** Sprints created in this project will be available to all subprojects of the current project. +- **Subprojects:** Sprints created in this project will be available to all subprojects of the current project. -**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by other projects can be used. +**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by another project can be used. ![Manage backlogs settings under project settings in OpenProject](openproject_user_guide_project_settings_backlogs_sharing.png) +### What is shared + +When sprints are shared, the sprint itself is shared across projects. This includes: + +- Sprint name +- Start and finish dates +- Sprint status (e.g. planning, active, completed) + +This ensures that all participating projects work with the same sprint definition and timeline. + +### What is not shared + +- Work packages remain in their respective projects +- Backlogs and their structure remain project-specific +- Permissions and visibility are still managed per project + +Even when using shared sprints, each project keeps its own work items and configuration. + +Read more on [how to work with Backlogs in OpenProject](../../../backlogs-scrum/). From e757ef55b60f6d1c71d1ce16e909b36f4322e327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 10:23:07 +0200 Subject: [PATCH 4/8] Properly check move_work_packages in source project https://community.openproject.org/work_packages/73924 --- .../work_packages/update_contract.rb | 17 ++++++++ .../work_packages/update_contract_spec.rb | 42 +++++++++++++++++-- .../v3/work_packages/update_resource_spec.rb | 1 + 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/contracts/work_packages/update_contract.rb b/app/contracts/work_packages/update_contract.rb index 87d6bd5c7ab..83c3e6ac1e0 100644 --- a/app/contracts/work_packages/update_contract.rb +++ b/app/contracts/work_packages/update_contract.rb @@ -72,6 +72,8 @@ module WorkPackages validate :user_allowed_to_edit + validate :user_allowed_to_move_from_source_project + validate :can_move_to_milestone validate :user_allowed_to_change_parent @@ -90,6 +92,21 @@ module WorkPackages end end + # When moving a work package to a different project, require + # :move_work_packages in the source project (the project the work + # package currently belongs to). The per-attribute permission check + # (reduce_by_writable_permissions) evaluates :move_work_packages + # against the target project; this validation covers the source side. + def user_allowed_to_move_from_source_project + return unless model.project_id_changed? + + with_unchanged_project_id do + unless user.allowed_in_project?(:move_work_packages, model.project) + errors.add :project_id, :error_readonly + end + end + end + def user_allowed_to_access unless ::WorkPackage.visible(@user).exists?(model.id) errors.add :base, :error_not_found diff --git a/spec/contracts/work_packages/update_contract_spec.rb b/spec/contracts/work_packages/update_contract_spec.rb index a74c7cb88c5..e9393b6a2d0 100644 --- a/spec/contracts/work_packages/update_contract_spec.rb +++ b/spec/contracts/work_packages/update_contract_spec.rb @@ -98,24 +98,60 @@ RSpec.describe WorkPackages::UpdateContract do describe "project_id" do let(:target_project) { persisted_other_project } + let(:source_permissions) { %i[view_work_packages edit_work_packages move_work_packages] } let(:target_permissions) { [:move_work_packages] } before do mock_permissions_for(user) do |mock| - mock.allow_in_project *permissions, project: persisted_project + mock.allow_in_project *source_permissions, project: persisted_project mock.allow_in_project *target_permissions, project: target_project end work_package.project = target_project end - it_behaves_like "contract is valid" + context "with move_work_packages in both source and target" do + it_behaves_like "contract is valid" + end - context "if the user lacks the permissions" do + context "if the user lacks move_work_packages in the target project" do let(:target_permissions) { [] } it_behaves_like "contract is invalid", project_id: :error_readonly end + + context "if the user lacks move_work_packages in the source project" do + let(:source_permissions) { %i[view_work_packages edit_work_packages] } + + it_behaves_like "contract is invalid", project_id: :error_readonly + end + + context "when modifying attributes while moving (authorization bypass prevention)" do + before do + work_package.subject = "modified-subject" + end + + context "with edit_work_packages in target project" do + let(:target_permissions) { %i[move_work_packages edit_work_packages] } + + it_behaves_like "contract is valid" + end + + context "without edit_work_packages in target project" do + let(:target_permissions) { [:move_work_packages] } + + it_behaves_like "contract is invalid", subject: :error_readonly + end + + context "without move_work_packages in source project" do + let(:source_permissions) { %i[view_work_packages change_work_package_status] } + let(:target_permissions) { %i[move_work_packages edit_work_packages] } + + it "blocks the move even when the target project grants all permissions" do + expect(validated_contract.errors.symbols_for(:project_id)).to include(:error_readonly) + end + end + end end describe "remaining_hours" do diff --git a/spec/requests/api/v3/work_packages/update_resource_spec.rb b/spec/requests/api/v3/work_packages/update_resource_spec.rb index 975e65f605f..5260ece3f86 100644 --- a/spec/requests/api/v3/work_packages/update_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/update_resource_spec.rb @@ -416,6 +416,7 @@ RSpec.describe "API v3 Work package resource", let(:target_project) do create(:project, public: false) end + let(:permissions) { super() + [:move_work_packages] } let(:project_link) { api_v3_paths.project target_project.id } let(:project_parameter) { { _links: { project: { href: project_link } } } } let(:params) { valid_params.merge(project_parameter) } From e1049c9cf699b269b71e86ecbb5df96ff9b29475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 13:27:33 +0200 Subject: [PATCH 5/8] Try to fix ./spec/features/projects/project_custom_fields/overview_page/update_spec.rb:94 --- spec/support/form_fields/primerized/input_field.rb | 4 ++++ spec/support/pages/projects/show.rb | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb index df0f41c459f..47a002b5129 100644 --- a/spec/support/form_fields/primerized/input_field.rb +++ b/spec/support/form_fields/primerized/input_field.rb @@ -10,11 +10,15 @@ module FormFields # Capybara's native .click on a checkbox can update the DOM property directly # without dispatching a browser click event, so Stimulus event handlers won't fire. # Using execute_script with element.click() fires a real browser event. + # We first wait for the element via Capybara's find (which retries until it appears) + # to avoid null reference errors when the DOM is still being updated by Turbo. def check + page.find(selector) page.execute_script("document.querySelector(\"#{selector}\").click()") end def uncheck + page.find(selector) page.execute_script("document.querySelector(\"#{selector}\").click()") end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 8b494d3ed16..2525d95e4d8 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -104,9 +104,8 @@ module Pages def open_inplace_edit_field_for_custom_field(custom_field) scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']")) field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym) - field.open_field - wait_for_network_idle + wait_for_turbo_stream { field.open_field } field end From 90383cc7ff3c139abe03f6ceaa9c0fd31772c7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 15:21:44 +0200 Subject: [PATCH 6/8] Prevent moving news between projects This is not expected to be possible --- app/contracts/news/base_contract.rb | 1 - app/contracts/news/create_contract.rb | 1 + .../api/v3/news/update_resource_spec.rb | 27 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/contracts/news/base_contract.rb b/app/contracts/news/base_contract.rb index 2a91b8e61bc..92b93ca03ad 100644 --- a/app/contracts/news/base_contract.rb +++ b/app/contracts/news/base_contract.rb @@ -37,7 +37,6 @@ class News::BaseContract < ModelContract News end - attribute :project attribute :title attribute :summary attribute :description diff --git a/app/contracts/news/create_contract.rb b/app/contracts/news/create_contract.rb index d43e014da6a..8c1a1303ac5 100644 --- a/app/contracts/news/create_contract.rb +++ b/app/contracts/news/create_contract.rb @@ -29,4 +29,5 @@ #++ class News::CreateContract < News::BaseContract + attribute :project end diff --git a/spec/requests/api/v3/news/update_resource_spec.rb b/spec/requests/api/v3/news/update_resource_spec.rb index ce33bddc9bb..88b3aede3da 100644 --- a/spec/requests/api/v3/news/update_resource_spec.rb +++ b/spec/requests/api/v3/news/update_resource_spec.rb @@ -66,6 +66,33 @@ RSpec.describe API::V3::News::NewsAPI, it_behaves_like "updates the news" end + describe "user with only view_news on source project and manage_news on different project" do + let(:attacker_project) { create(:project, enabled_module_names: %w[news]) } + let(:user) do + create(:user, + member_with_permissions: { + project => %i[view_news], + attacker_project => %i[view_news manage_news] + }) + end + let(:parameters) do + { + _links: { + project: { + href: api_v3_paths.project(attacker_project.id) + } + } + } + end + + it "does not move the news to another project" do + expect(last_response.status).to eq(422) + expect(news.reload.project).to eq(project) + end + + it_behaves_like "read-only violation", "project", News + end + describe "unauthorized user" do let(:user) { build(:user) } From 96397d7388fdf98f6e4673da1a05a8f262fe800c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 16 Apr 2026 15:51:52 +0200 Subject: [PATCH 7/8] Explicit path validation on svn targets https://community.openproject.org/work_packages/73978 --- lib/open_project/scm/adapters/subversion.rb | 22 +++++++++++++ .../scm/adapters/subversion_adapter_spec.rb | 32 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/open_project/scm/adapters/subversion.rb b/lib/open_project/scm/adapters/subversion.rb index f20d4fb21e4..3dca8bf71f2 100644 --- a/lib/open_project/scm/adapters/subversion.rb +++ b/lib/open_project/scm/adapters/subversion.rb @@ -345,6 +345,7 @@ module OpenProject # Target path with optional peg revision # http://svnbook.red-bean.com/en/1.7/svn.advanced.pegrevs.html def target(path = "", peg: nil) + validate_path!(path) path = super(path) if peg @@ -354,6 +355,27 @@ module OpenProject end end + def validate_path!(path) + decoded = path.to_s + + # Unescape repeatedly to catch nested encoding such as %252e%252e. + loop do + unescaped = URI::DEFAULT_PARSER.unescape(decoded) + break if unescaped == decoded + + decoded = unescaped + end + + invalid_characters = [path.to_s, decoded].any? do |value| + value.include?("\0") || value.include?("\n") || value.include?("\r") + end + + traverses_parent = decoded.split("/").include?("..") + return unless invalid_characters || traverses_parent + + raise Exceptions::CommandFailed.new(client_command, "Invalid repository path") + end + ## # Builds the full git arguments from the parameters # and calls the given block with in, out, err, thread diff --git a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb index 8725ba80af3..7873774c7ed 100644 --- a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb +++ b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb @@ -128,6 +128,32 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do end end + describe "path validation" do + shared_examples "rejects encoded parent traversal" do |method_name, *args| + it "rejects encoded parent traversal in ##{method_name}" do + allow(adapter).to receive(:capture_svn) + allow(adapter).to receive(:popen3) + + expect { adapter.public_send(method_name, "%252e%252e/other_repo/credentials.txt", *args) } + .to raise_error(OpenProject::SCM::Exceptions::CommandFailed) { |error| + expect(error.message).to eq("Invalid repository path") + } + + expect(adapter).not_to have_received(:capture_svn) + expect(adapter).not_to have_received(:popen3) + end + end + + include_examples "rejects encoded parent traversal", :entries + include_examples "rejects encoded parent traversal", :cat + include_examples "rejects encoded parent traversal", :diff, 2, 1 + include_examples "rejects encoded parent traversal", :annotate + + it "allows dots that are not parent traversal" do + expect(adapter.send(:target, "subversion_test/file..txt")).to eq("#{url}/subversion_test/file..txt") + end + end + describe "empty repository" do include_context "with tmpdir" let(:root_url) { tmpdir } @@ -178,7 +204,7 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do out, process = Open3.capture2e("svn", "info", url) expect(process.exitstatus).to eq(0) - expect(out).to include("Repository UUID") + expect(out).to match(/UUID.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i) end it "is available" do @@ -389,7 +415,9 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do it "provides the selected diff for the given range" do diff = adapter.diff("subversion_test/helloworld.c", 8, 6).map(&:chomp) - expect(diff).to eq(<<~DIFF.split("\n")) + normalized_diff = diff.map { |line| line.gsub(/\((?i:revision) (\d+)\)/, "(revision \\1)") } + + expect(normalized_diff).to eq(<<~DIFF.split("\n")) Index: helloworld.c =================================================================== --- helloworld.c (revision 6) From aaf5c6b52615983365c81852590b1b0abc531ad1 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 17 Apr 2026 04:08:34 +0000 Subject: [PATCH 8/8] update locales from crowdin [ci skip] --- .../github_integration/config/locales/crowdin/js-cs.yml | 8 ++++---- modules/meeting/config/locales/crowdin/fr.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/github_integration/config/locales/crowdin/js-cs.yml b/modules/github_integration/config/locales/crowdin/js-cs.yml index b3499b8b37d..6da50e266ed 100644 --- a/modules/github_integration/config/locales/crowdin/js-cs.yml +++ b/modules/github_integration/config/locales/crowdin/js-cs.yml @@ -41,15 +41,15 @@ cs: empty: Zatím nejsou propojeny žádné požadavky na natažení. Propojit existující PR pomocí kódu OP#%{wp_id} v PR popisu nebo vytvořit nové PR. github_actions: Akce pull_requests: - message: 'Požadavek na natažení #%{pr_number} %{pr_link} pro %{repository_link} autora %{github_user_link} byl %{pr_state}.' - merged_message: 'Požadavek na natažení #%{pr_number} %{pr_link} pro %{repository_link} byl %{pr_state} od %{github_user_link}.' - referenced_message: 'Požadavek na natažení #%{pr_number} %{pr_link} pro %{repository_link} autora %{github_user_link} odkázal na tento pracovní balíček.' + message: 'Pull request #%{pr_number} %{pr_link} pro %{repository_link} od autora %{github_user_link} byl %{pr_state}.' + merged_message: 'Pull request #%{pr_number} %{pr_link} pro %{repository_link} byl %{pr_state} od %{github_user_link}.' + referenced_message: 'Pull request #%{pr_number} %{pr_link} pro %{repository_link} od autora %{github_user_link} odkázal na tento pracovní balíček.' states: opened: otevřeno closed: zavřeno draft: navrženo merged: sloučeno - ready_for_review: označeno jako připraveno k revizi + ready_for_review: označen jako připraven ke kontrole work_packages: tabs: github: GitHub diff --git a/modules/meeting/config/locales/crowdin/fr.yml b/modules/meeting/config/locales/crowdin/fr.yml index 61d3733ee22..d46972bcdfb 100644 --- a/modules/meeting/config/locales/crowdin/fr.yml +++ b/modules/meeting/config/locales/crowdin/fr.yml @@ -598,7 +598,7 @@ fr: label_agenda_outcome_edit: Modifier le résultat label_agenda_outcome_delete: Supprimer le résultat label_added_as_outcome: Ajouté comme résultat - label_write_outcome: Résultat de l'écriture + label_write_outcome: Rédiger un résultat label_existing_work_package: Lot de travaux existant text_outcome_not_editable_anymore: Ce résultat n'est plus modifiable. text_outcome_cannot_be_added: Un résultat ne peut plus être ajouté.