create backups via UI (#9136)

* create backups via UI

* Fix import of modal service

* introduced backup token and addressed remaining comments

* allow disabling permissions

* improvements

- only make user wait to use backup token in if really necessary
- notify admins of new backup token
- disable 'include attachments' option in UI if unavailable
- documentation
- misc

* spec fixes

* fixed feature spec

* allow setting capybara host in every case

* removed unused style file

* addressed review feedback, added further feature specs

* polish (code climate)

* Avoid empty attachments

* Don't raise filesize validation for internal exports

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9216/head
Markus Kahl 4 years ago committed by GitHub
parent 0bec3cb8c2
commit 8c8b8bbfa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 85
      app/contracts/backups/create_contract.rb
  2. 43
      app/contracts/concerns/single_table_inheritance_model_contract.rb
  3. 114
      app/controllers/admin/backups_controller.rb
  4. 83
      app/helpers/backup_helper.rb
  5. 22
      app/mailers/user_mailer.rb
  6. 7
      app/models/attachment.rb
  7. 45
      app/models/backup.rb
  8. 12
      app/models/export.rb
  9. 43
      app/models/token/backup.rb
  10. 4
      app/models/work_packages/export.rb
  11. 55
      app/services/backups/create_service.rb
  12. 34
      app/services/backups/set_attributes_service.rb
  13. 2
      app/services/concerns/contracted.rb
  14. 76
      app/views/admin/backups/reset_token.html.erb
  15. 96
      app/views/admin/backups/show.html.erb
  16. 33
      app/views/user_mailer/backup_ready.html.erb
  17. 31
      app/views/user_mailer/backup_ready.text.erb
  18. 45
      app/views/user_mailer/backup_token_reset.html.erb
  19. 35
      app/views/user_mailer/backup_token_reset.text.erb
  20. 239
      app/workers/backup_job.rb
  21. 7
      config/initializers/menus.rb
  22. 6
      config/initializers/permissions.rb
  23. 46
      config/locales/en.yml
  24. 19
      config/locales/js-en.yml
  25. 9
      config/routes.rb
  26. 15
      db/migrate/20210310101840_generalize_exports.rb
  27. 27
      docs/installation-and-operations/configuration/README.md
  28. 49
      docs/system-admin-guide/backup/README.md
  29. BIN
      docs/system-admin-guide/backup/openproject-backup.png
  30. 2
      frontend/src/app/angular4-modules.ts
  31. 73
      frontend/src/app/components/admin/backup.component.html
  32. 125
      frontend/src/app/components/admin/backup.component.ts
  33. 54
      frontend/src/app/components/api/op-backup/op-backup.service.ts
  34. 4
      frontend/src/app/global-dynamic-components.const.ts
  35. 6
      lib/api/errors/conflict.rb
  36. 44
      lib/api/errors/too_many_requests.rb
  37. 6
      lib/api/errors/unauthorized.rb
  38. 51
      lib/api/v3/backups/backup_representer.rb
  39. 82
      lib/api/v3/backups/backups_api.rb
  40. 1
      lib/api/v3/root.rb
  41. 2
      lib/api/v3/utilities/path_helper.rb
  42. 2
      lib/open_project/access_control.rb
  43. 9
      lib/open_project/access_control/permission.rb
  44. 6
      lib/open_project/configuration.rb
  45. 9
      lib/tasks/backup.rake
  46. 1
      modules/job_status/app/workers/job_status/application_job_with_status.rb
  47. 48
      spec/contracts/backups/create_contract_spec.rb
  48. 32
      spec/factories/backup_factory.rb
  49. 18
      spec/factories/token_factory.rb
  50. 13
      spec/factories/user_factory.rb
  51. 108
      spec/features/admin/backup_spec.rb
  52. 2
      spec/lib/open_project/access_control_spec.rb
  53. 153
      spec/requests/api/v3/backups/backups_api_spec.rb
  54. 75
      spec/services/backups/create_service_spec.rb
  55. 9
      spec/services/base_services/behaves_like_create_service.rb
  56. 2
      spec/support/capybara.rb
  57. 140
      spec/workers/backup_job_spec.rb

@ -0,0 +1,85 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module Backups
class CreateContract < ::ModelContract
include SingleTableInheritanceModelContract
validate :user_allowed_to_create_backup
validate :backup_token
validate :backup_limit
validate :no_pending_backups
private
def backup_token
token = Token::Backup.find_by_plaintext_value options[:backup_token].to_s
if token.blank? || token.user_id != user.id
errors.add :base, :invalid_token, message: I18n.t("backup.error.invalid_token")
else
check_waiting_period token
end
end
def check_waiting_period(token)
if token.waiting?
valid_at = token.created_at + OpenProject::Configuration.backup_initial_waiting_period
hours = ((valid_at - Time.zone.now) / 60.0 / 60.0).round
errors.add :base, :token_cooldown, message: I18n.t("backup.error.token_cooldown", hours: hours)
end
end
def backup_limit
limit = OpenProject::Configuration.backup_daily_limit
if Backup.where("created_at >= ?", Time.zone.today).count > limit
errors.add :base, :limit_reached, message: I18n.t("backup.error.limit_reached", limit: limit)
end
end
def no_pending_backups
current_backup = Backup.last
if pending_statuses.include? current_backup&.job_status&.status
errors.add :base, :backup_pending, message: I18n.t("backup.error.backup_pending")
end
end
def user_allowed_to_create_backup
errors.add :base, :error_unauthorized unless user_allowed_to_create_backup?
end
def user_allowed_to_create_backup?
user.allowed_to_globally? Backup.permission
end
def pending_statuses
::JobStatus::Status.statuses.slice(:in_queue, :in_process).values
end
end
end

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module SingleTableInheritanceModelContract
extend ActiveSupport::Concern
included do
attribute model.inheritance_column
validate do
if model.type != model.class.sti_name
errors.add :type, :error_readonly # as in users should not be passing this
end
end
end
end

@ -0,0 +1,114 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
class Admin::BackupsController < ApplicationController
include ActionView::Helpers::TagHelper
include BackupHelper
layout 'admin'
before_action :check_enabled
before_action :require_admin
menu_item :backups
def show
@backup_token = Token::Backup.find_by user: current_user
last_backup = Backup.last
if last_backup
@job_status_id = last_backup.job_status.job_id
@last_backup_date = I18n.localize(last_backup.updated_at)
@last_backup_attachment_id = last_backup.attachments.first&.id
end
@may_include_attachments = may_include_attachments? ? "true" : "false"
end
def reset_token
@backup_token = Token::Backup.find_by user: current_user
end
def perform_token_reset
token = create_backup_token user: current_user
token_reset_successful! token
rescue StandardError => e
token_reset_failed! e
ensure
redirect_to action: 'show'
end
def delete_token
Token::Backup.where(user: current_user).destroy_all
flash[:info] = t("backup.text_token_deleted")
redirect_to action: 'show'
end
def default_breadcrumb
t(:label_backup)
end
def show_local_breadcrumb
true
end
def check_enabled
render_404 unless OpenProject::Configuration.backup_enabled?
end
private
def token_reset_successful!(token)
notify_user_and_admins current_user, backup_token: token
flash[:warning] = token_reset_flash_message token
end
def token_reset_flash_message(token)
[
t('my.access_token.notice_reset_token', type: 'Backup').html_safe,
content_tag(:strong, token.plain_value),
t('my.access_token.token_value_warning')
]
end
def token_reset_failed!(e)
Rails.logger.error "Failed to reset user ##{current_user.id}'s Backup key: #{e}"
flash[:error] = t('my.access_token.failed_to_reset_token', error: e.message)
end
def may_include_attachments?
Backup.include_attachments? && Backup.attachments_size_in_bounds?
end
end

@ -0,0 +1,83 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module BackupHelper
##
# The idea here is to only allow users, who can confirm their password, to backup
# OpenProject without delay. Users who can't (since they use Google etc.) have to wait
# just to make sure no one else accessed the computer to trigger a backup.
#
# A better long-term solution might be to introduce a PIN for sensitive operations
# in general. Think the PIN for Windows users or trading passwords in online trade platforms.
#
# Also we make sure that in case there is a password that it wasn't just set by a would-be attacker.
#
# If OpenProject has just been installed we don't check any of this since there's likely nothing
# sensitive to backup yet and it would prevent a new admin from trying this feature.
def allow_instant_backup_for_user?(user, date: instant_backup_threshold_date)
return true if just_installed_openproject? after: date
# user doesn't use OpenIDConnect (so can be asked to confirm their password)
!user.uses_external_authentication? &&
# user cannot change password in OP (LDAP) or hasn't changed it recently
(user.passwords.empty? || user.passwords.first.updated_at < date)
end
def instant_backup_threshold_date
DateTime.now - OpenProject::Configuration.backup_initial_waiting_period
end
def just_installed_openproject?(after: instant_backup_threshold_date)
created_at = Project.order(created_at: :asc).limit(1).pick(:created_at)
created_at && created_at >= after
end
def create_backup_token(user: current_user)
token = Token::Backup.create! user: user
# activate token right away as user had to confirm password
date = instant_backup_threshold_date
if allow_instant_backup_for_user? user, date: date
token.update_column :created_at, date
end
token
end
def notify_user_and_admins(user, backup_token:)
waiting_period = backup_token.waiting? && OpenProject::Configuration.backup_initial_waiting_period
users = ([user] + User.admin.active).uniq
users.each do |recipient|
UserMailer.backup_token_reset(recipient, user: user, waiting_period: waiting_period).deliver_later
end
end
end

@ -90,6 +90,28 @@ class UserMailer < BaseMailer
end
end
def backup_ready(user)
User.execute_as user do
@download_url = admin_backups_url
with_locale_for(user) do
mail to: user.mail, subject: I18n.t("mail_subject_backup_ready")
end
end
end
def backup_token_reset(recipient, user:, waiting_period: OpenProject::Configuration.backup_initial_waiting_period)
@admin_notification = recipient != user # notification for other admins rather than oneself
@user_login = user.login
@waiting_period = waiting_period
User.execute_as recipient do
with_locale_for(recipient) do
mail to: recipient.mail, subject: I18n.t("mail_subject_backup_token_reset")
end
end
end
def password_lost(token)
return unless token.user # token's can have no user

@ -41,7 +41,8 @@ class Attachment < ApplicationRecord
validates_length_of :description, maximum: 255
validate :filesize_below_allowed_maximum,
:container_changed_more_than_once
if: -> { !internal_container? }
validate :container_changed_more_than_once
acts_as_journalized
acts_as_event title: -> { file.name },
@ -302,6 +303,10 @@ class Attachment < ApplicationRecord
end
end
def internal_container?
container&.is_a?(Export)
end
def container_changed_more_than_once
if container_id_changed_more_than_once? || container_type_changed_more_than_once?
errors.add(:container, :unchangeable)

@ -0,0 +1,45 @@
class Backup < Export
class << self
def permission
:create_backup
end
def include_attachments?
val = OpenProject::Configuration.backup_include_attachments
val.nil? ? true : val.to_s.to_bool # default to true
end
##
# Don't include attachments in archive if they are larger than
# this value combined.
def attachment_size_max_sum_mb
(OpenProject::Configuration.backup_attachment_size_max_sum_mb.presence || 1024).to_i
end
def attachments_query
Attachment
.where.not(container_type: nil)
.where.not(container_type: Export.name)
end
def attachments_size_in_mb(attachments_query = self.attachments_query)
attachments_query.pluck(:filesize).sum / 1024.0 / 1024.0
end
def attachments_size_in_bounds?(attachments_query = self.attachments_query, max: self.attachment_size_max_sum_mb)
attachments_size_in_mb(attachments_query) <= max
end
end
acts_as_attachable(
view_permission: permission,
add_permission: permission,
delete_permission: permission,
only_user_allowed: true
)
def ready?
attachments.any?
end
end

@ -0,0 +1,12 @@
class Export < ApplicationRecord
has_one(
:job_status,
-> { where(reference_type: "Export") },
class_name: "JobStatus::Status",
foreign_key: :reference
)
def ready?
raise "subclass responsibility"
end
end

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module Token
class Backup < HashedToken
def ready?
return false if created_at.nil?
created_at.since(OpenProject::Configuration.backup_initial_waiting_period).past?
end
def waiting?
!ready?
end
end
end

@ -1,6 +1,4 @@
class WorkPackages::Export < ApplicationRecord
self.table_name = 'work_package_exports'
class WorkPackages::Export < Export
acts_as_attachable view_permission: :export_work_packages,
add_permission: :export_work_packages,
delete_permission: :export_work_packages,

@ -0,0 +1,55 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module Backups
class CreateService < ::BaseServices::Create
def initialize(user:, backup_token:, include_attachments: true, contract_class: ::Backups::CreateContract)
super user: user, contract_class: contract_class, contract_options: { backup_token: backup_token }
@include_attachments = include_attachments
end
def include_attachments?
@include_attachments
end
def after_perform(call)
if call.success?
BackupJob.perform_later(
backup: call.result,
user: user,
include_attachments: include_attachments?
)
end
call
end
end
end

@ -0,0 +1,34 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module Backups
class SetAttributesService < ::BaseServices::SetAttributes
end
end

@ -37,7 +37,7 @@ module Contracted
def contract_class=(cls)
unless cls <= ::BaseContract
raise ArgumentError "#{cls.name} is not an instance of BaseContract."
raise ArgumentError, "#{cls.name} is not an instance of BaseContract."
end
@contract_class = cls

@ -0,0 +1,76 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% action = @backup_token.present? ? 'reset' : 'create' %>
<% icon = @backup_token.present? ? 'delete' : 'add' %>
<% html_title(t(:label_administration), t("backup.reset_token.heading_#{action}")) -%>
<%= labelled_tabular_form_for(
:user,
url: { action: 'reset_token' },
html: {
method: :post, class: 'confirm_required request-for-confirmation form danger-zone',
data: { "request-for-confirmation": true }
}
) do %>
<div class='wiki'>
<section class="form--section">
<h3 class="form--section-title">
<%= t("backup.reset_token.heading_#{action}") %>
</h3>
<p>
<%= t("backup.reset_token.implications") %>
</p>
<% if !allow_instant_backup_for_user? current_user %>
<p class="danger-zone--warning">
<span class="icon icon-error"></span>
<span><%= t("backup.reset_token.warning") %></span>
</p>
<% end %>
<p>
<%= t(
"backup.reset_token.verification",
word: "<em class=\"danger-zone--expected-value\">#{t("backup.reset_token.verification_word_#{action}")}</em>",
action: action
).html_safe %>
</p>
<div class="danger-zone--verification">
<input type="text" name="login_verification"/>
<%= styled_button_tag '', class: '-highlight', disabled: true do
concat content_tag :i, '', class: "button--icon icon-#{icon}"
concat content_tag :span, t("backup.reset_token.action_#{action}"), class: 'button--text'
end %>
</div>
</section>
</div>
<% end %>

@ -0,0 +1,96 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title t(:label_administration), t(:label_backup) -%>
<%= toolbar title: t('label_backup') do %>
<li class="toolbar-item">
<% label_action = @backup_token.present? ? 'reset' : 'create' %>
<% label = t("backup.label_#{label_action}_token") %>
<%=
link_to(
{ action: 'reset_token' },
method: :get,
class: 'button -alt-highlight',
aria: {label: label},
title: label
) do
%>
<%= op_icon("button--icon icon-#{@backup_token.present? ? 'reload' : 'add'}") %>
<span class="button--text"><%= t('backup.label_backup_token') %></span>
<% end %>
</li>
<% if @backup_token.present? %>
<li class="toolbar-item">
<% label = t("backup.label_delete_token") %>
<%=
link_to(
{ action: 'delete_token' },
method: :post,
class: 'button -alt-highlight',
aria: {label: label},
title: label
) do
%>
<%= op_icon("button--icon icon-delete") %>
<span class="button--text"><%= t('backup.label_backup_token') %></span>
<% end %>
</li>
<% end %>
<% end %>
<p>
<%= t("backup.reset_token.info") %>
</p>
<% if Token::Backup.count > 0 %>
<p>
<span><%= I18n.t("backup.label_token_users") %></span>:
<div class="wiki">
<ul>
<% Token::Backup.all.includes(:user).each do |token| %>
<li>
<%= link_to token.user.name, edit_user_path(token.user) %>
<%= token.user == current_user ? "(#{I18n.t(:you)})" : '' %>
</li>
<% end %>
</ul>
</div>
</p>
<% end %>
<% if @backup_token.present? %>
<%= tag :backup, data: {
'job-status-id': @job_status_id,
'last-backup-date': @last_backup_date,
'last-backup-attachment-id': @last_backup_attachment_id,
'may-include-attachments': @may_include_attachments
} %>
<% end %>

@ -0,0 +1,33 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<p>
<%= t(:mail_body_backup_ready) %><br />
<%= link_to(@download_url, @download_url) %>
</p>

@ -0,0 +1,31 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= t(:mail_body_backup_ready) %>
<%= @download_url %>

@ -0,0 +1,45 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<p>
<%= @admin_notification ?
t(:mail_body_backup_token_reset_admin_info, user: @user_login) :
t(:mail_body_backup_token_reset_user_info)
%>
<% if @waiting_period %>
&nbsp;
<%= t(:mail_body_backup_waiting_period hours: @waiting_period.in_hours.to_i) %>
<% end %>
</p>
<% if !@admin_notification %>
<p>
<%= t(:mail_body_backup_token_warning) %>
</p>
<% end %>

@ -0,0 +1,35 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= @admin_notification ?
t(:mail_body_backup_token_reset_admin_info, user: @user_login) :
t(:mail_body_backup_token_reset_user_info)
%><% if @waiting_period %> <%= t(:mail_body_backup_waiting_period, hours: @waiting_period.in_hours.to_i) %><% end %>
<% if !@admin_notification %><%= t(:mail_body_backup_token_warning) %><% end %>

@ -0,0 +1,239 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'tempfile'
require 'zip'
class BackupJob < ::ApplicationJob
queue_with_priority :low
attr_reader :backup, :user
def perform(
backup:,
user:,
include_attachments: Backup.include_attachments?,
attachment_size_max_sum_mb: Backup.attachment_size_max_sum_mb
)
@backup = backup
@user = user
@include_attachments = include_attachments
@attachment_size_max_sum_mb = attachment_size_max_sum_mb
run_backup!
rescue StandardError => e
failure! error: e.message
raise e
ensure
remove_files! db_dump_file_name, archive_file_name
attachments.each(&:destroy) unless success?
Rails.logger.info(
"BackupJob(include_attachments: #{include_attachments}) finished " \
"with status #{job_status.status} " \
"(dumped: #{dumped?}, archived: #{archived?})"
)
end
def run_backup!
@dumped = dump_database! db_dump_file_name # sets error on failure
return unless dumped?
file_name = create_backup_archive!(
file_name: archive_file_name,
db_dump_file_name: db_dump_file_name
)
store_backup file_name, backup: backup, user: user
cleanup_previous_backups!
UserMailer.backup_ready(user).deliver_later
end
def dumped?
@dumped
end
def archived?
@archived
end
def db_dump_file_name
@db_dump_file_name ||= tmp_file_name "openproject", ".sql"
end
def archive_file_name
@archive_file_name ||= tmp_file_name "openproject-backup", ".zip"
end
def status_reference
arguments.first[:backup]
end
def updates_own_status?
true
end
def cleanup_previous_backups!
Backup.where.not(id: backup.id).destroy_all
end
def success?
job_status.status == JobStatus::Status.statuses[:success]
end
def remove_files!(*files)
Array(files).each do |file|
FileUtils.rm file if File.exists? file
end
end
def store_backup(file_name, backup:, user:)
File.open(file_name) do |file|
attachment = Attachments::CreateService
.new(backup, author: user)
.call(uploaded_file: file, description: 'OpenProject backup')
download_url = ::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(attachment.id)
upsert_status(
status: :success,
message: I18n.t('export.succeeded'),
payload: download_payload(download_url)
)
end
end
def create_backup_archive!(file_name:, db_dump_file_name:, attachments: attachments_to_include)
Zip::File.open(file_name, Zip::File::CREATE) do |zipfile|
attachments.each do |attachment|
# If an attachment is destroyed on disk, skip i
diskfile = attachment.diskfile
next unless diskfile
path = diskfile.path
zipfile.add "attachment/file/#{attachment.id}/#{attachment[:file]}", path
end
zipfile.get_output_stream("openproject.sql") { |f| f.write File.read(db_dump_file_name) }
end
@archived = true
file_name
end
def attachments_to_include
return Attachment.none if skip_attachments?
Backup.attachments_query
end
def skip_attachments?
!(include_attachments? && Backup.attachments_size_in_bounds?(max: attachment_size_max_sum_mb))
end
def date_tag
Time.zone.today.iso8601
end
def tmp_file_name(name, ext)
file = Tempfile.new [name, ext]
file.path
ensure
file.close
file.unlink
end
def include_attachments?
@include_attachments
end
def attachment_size_max_sum_mb
@attachment_size_max_sum_mb
end
def dump_database!(path)
_out, err, st = Open3.capture3 pg_env, "pg_dump -x -O -f '#{path}'"
failure! error: err unless st.success?
st.success?
end
def success!
payload = download_payload(url_helpers.backups_path(target_project))
if errors.any?
payload[:errors] = errors
end
upsert_status status: :success,
message: I18n.t('copy_project.succeeded', target_project_name: target_project.name),
payload: payload
end
def failure!(error: nil)
msg = I18n.t 'backup.failed'
upsert_status(
status: :failure,
message: error.present? ? "#{msg}: #{error}" : msg
)
end
def pg_env
config = ActiveRecord::Base.connection_db_config.configuration_hash
entries = pg_env_to_connection_config.map do |key, config_key|
value = config[config_key].to_s
[key.to_s, value] if value.present?
end
entries.compact.to_h
end
##
# Maps the PG env variable name to the key in the AR connection config.
def pg_env_to_connection_config
{
PGHOST: :host,
PGPORT: :port,
PGUSER: :username,
PGPASSWORD: :password,
PGDATABASE: :database
}
end
end

@ -334,6 +334,13 @@ Redmine::MenuManager.map :admin_menu do |menu|
last: true,
icon: 'icon2 icon-plugins'
menu.push :backups,
{ controller: '/admin/backups', action: 'show' },
if: Proc.new { OpenProject::Configuration.backup_enabled? && User.current.admin? },
caption: :label_backup,
last: true,
icon: 'icon2 icon-save'
menu.push :info,
{ controller: '/admin', action: 'info' },
if: Proc.new { User.current.admin? },

@ -50,6 +50,12 @@ OpenProject::AccessControl.map do |map|
global: true,
contract_actions: { projects: %i[create] }
map.permission Backup.permission,
{ backups: %i[index] },
require: :loggedin,
global: true,
enabled: -> { OpenProject::Configuration.backup_enabled? }
map.permission :manage_user,
{
users: %i[index show new create edit update resend_invitation],

@ -874,6 +874,40 @@ en:
user: "User"
version: "Version"
work_package: "Work package"
backup:
label_backup_token: "Backup token"
label_create_token: "Create backup token"
label_delete_token: "Delete backup token"
label_reset_token: "Reset backup token"
label_token_users: "The following users have active backup tokens"
reset_token:
action_create: Create
action_reset: Reset
heading_reset: "Reset backup token"
heading_create: "Create backup token"
implications: >
Enabling backups will allow any user with the required permissions and this backup token
to download a backup containing all data of this OpenProject installation.
This includes the data of all other users.
info: >
You will need to generate a backup token to be able to create a backup.
Each time you want to request a backup you will have to provide this token.
You can delete the backup token to disable backups for this user.
verification: >
Enter %{word} to confirm you want to %{action} the backup token.
verification_word_reset: reset
verification_word_create: create
warning: >
When you create a new token you will only be allowed to request a backup after
24 hours. This is a safety measure. After that you can request a backup any time using that token.
text_token_deleted:
Backup token deleted. Backups are now disabled.
error:
invalid_token: Invalid or missing backup token
token_cooldown: The backup token will be valid in %{hours} hours.
backup_pending: There is already a backup pending.
limit_reached: You can only do %{limit} backups per day.
button_add: "Add"
button_add_comment: "Add comment"
@ -1338,6 +1372,7 @@ en:
label_available_project_versions: 'Available versions'
label_available_project_repositories: 'Available repositories'
label_api_documentation: "API documentation"
label_backup: "Backup"
label_between: "between"
label_blocked_by: "blocked by"
label_blocks: "blocks"
@ -1867,6 +1902,12 @@ en:
mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
mail_body_account_information: "Your account information"
mail_body_account_information_external: "You can use your %{value} account to log in."
mail_body_backup_ready: "Your requested backup is ready. You can download it here:"
mail_body_backup_token_reset_admin_info: The backup token for user '%{user}' has been reset.
mail_body_backup_token_reset_user_info: Your backup token has been reset.
mail_body_backup_token_info: The previous token is no longer valid.
mail_body_backup_waiting_period: The new token will be enabled in %{hours} hours.
mail_body_backup_token_warning: If this wasn't you, login to OpenProject immediately and reset it again.
mail_body_lost_password: "To change your password, click on the following link:"
mail_body_register: "Welcome to OpenProject. Please activate your account by clicking on this link:"
mail_body_register_header_title: "Project member invitation email"
@ -1883,6 +1924,8 @@ en:
mail_body_wiki_content_added: "The '%{id}' wiki page has been added by %{author}."
mail_body_wiki_content_updated: "The '%{id}' wiki page has been updated by %{author}."
mail_subject_account_activation_request: "%{value} account activation request"
mail_subject_backup_ready: "Your backup is ready"
mail_subject_backup_token_reset: "Your backup token has been reset"
mail_subject_lost_password: "Your %{value} password"
mail_subject_register: "Your %{value} account activation"
mail_subject_reminder: "%{count} work package(s) due in the next %{days} days"
@ -2843,6 +2886,7 @@ en:
code_403: "You are not authorized to access this resource."
code_404: "The requested resource could not be found."
code_409: "Could not update the resource because of conflicting modifications."
code_429: "Too many requests. Please try again later."
code_500: "An internal error has occurred."
expected:
date: "YYYY-MM-DD (ISO 8601 date only)"
@ -2976,3 +3020,5 @@ en:
authorization_error: "An authorization error has occurred."
revoke_my_application_confirmation: "Do you really want to remove this application? This will revoke %{token_count} active for it."
my_registered_applications: "Registered OAuth applications"
you: you

@ -43,6 +43,25 @@ en:
single: "Select \"%{name}\""
remove: "Remove %{name}"
active: "Active %{label} %{name}"
backup:
attachments_disabled:
Attachments may not be included since they exceed the maximum overall size allowed.
You can change this via the configuration (requires a server restart).
info: >
You can trigger a backup here. The process can take some time depending on the amount
of data (especially attachments) you have. You will receive an email once it's ready.
note: >
A new backup will override any previous one. Only a limited number of backups per day
can be requested.
last_backup: Last backup
last_backup_from: Last backup from
title: Backup OpenProject
options: Options
include_attachments: Include attachments
download_backup: Download backup
request_backup: Request backup
close_popup_title: "Close popup"
close_filter_title: "Close filter"
close_form_title: "Close form"

@ -407,6 +407,15 @@ OpenProject::Application.routes.draw do
get 'plugin/:id', action: :show_plugin
post 'plugin/:id', action: :update_plugin
end
resource :backups, controller: '/admin/backups', only: %i[show] do
collection do
get :reset_token
post :reset_token, action: :perform_token_reset
post :delete_token
end
end
end
resource :workflows, only: %i[edit update show] do

@ -0,0 +1,15 @@
class GeneralizeExports < ActiveRecord::Migration[6.1]
def change
rename_table :work_package_exports, :exports
change_table :exports do |t|
t.string :type
end
reversible do |dir|
dir.up do
execute "UPDATE exports SET type = 'WorkPackages::Export'"
end
end
end
end

@ -46,6 +46,7 @@ Configuring OpenProject through environment variables is detailed [in this separ
* [`global_basic_auth`](#global-basic-auth)
* [`apiv3_enable_basic_auth`](#apiv3_enable_basic_auth)
* [`enterprise_limits`](#enterprise-limits)
* [`backup_enabled`](#backup-enabled)
## Setting session options
@ -387,7 +388,33 @@ Or through the environment like this:
OPENPROJECT_ENTERPRISE_FAIL__FAST=true
```
### Backup enabled
*default: true*
If enabled, admins (or users with the necessary permission) can download backups of the OpenProject installation
via OpenProject's web interface or via the API.
There are further configurations you can use to adjust your backups.
```
backup_enabled: true # enable/disable backups feature
backup_daily_limit: 3 # number of times backups can be requested per day across all users
backup_initial_waiting_period: 24.hours # time after which new backup token is usable
backup_include_attachments: true # include/exclude attachments besides db dump
backup_attachment_size_max_sum_mb: 1024 # if all attachments together are larger than this, they will not be included
```
Per default the maximum overall size of all attachments must not exceed 1GB for them to be included
in the backup. If they are larger only the database dump will be included.
As usual this can be override via the environment, for example like this:
```
OPENPROJECT_BACKUP__ENABLED=true
OPENPROJECT_BACKUP__INCLUDE__ATTACHMENTS=true
OPENPROJECT_BACKUP__ATTACHMENT__SIZE__MAX__SUM__MB=1024
```
| ----------- | :---------- |
| [List of supported environment variables](./environment) | The full list of environment variables you can use to override the default configuration |

@ -0,0 +1,49 @@
---
sidebar_navigation:
title: Backup
priority: 501
description: Backing up OpenProject.
robots: index, follow
keywords: system backup
---
# Backup
Unless disabled via the [configuration](/installation-and-operations/configuration/#enable-user-initiated-backups)
users can make backups of the OpenProject installation from within the administration area.
They either need to be an administrator or have the global permission to do so.
![System-admin-guide-backup-11.3](openproject-backup.png)
## Backup token
To be able to create a backup, a so called _backup token_ has to be generated first.
This is supposed to add another level of security since backing up the whole installation
includes sensitive data.
You will be asked to confirm your password when you try to generate or reset a token.
The _backup token_ will only be displayed once after it has been generated.
Make sure you store it in a safe place.
Each time you request a backup this token has to be provided.
This also applies when requesting a backup via the API where on top of the API token
the _backup token_ will have to be provided as well.
## Delayed reset
If the user resetting (or creating) a backup token does not have a password, for instance because they
authenticate using Google, the newly generated backup token will only be valid after an initial waiting period.
This is to make sure that no unauthorised user can get their hands on a backup even when accessing
a logged-in user's desktop.
As a system administrator you can skip this period by running the following rake task on the server's terminal:
```
sudo openproject run rake backup:allow_now
```
__In a docker setup you can open a terminal on any of the web or worker processes and run the rake task there.__
## Notifications
Each time a _backup token_ is created or reset an email notification will be sent to all administrators
take make everyone aware that there is a new user with access to backups.

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

@ -78,6 +78,7 @@ import { OpenprojectInviteUserModalModule } from "core-app/modules/invite-user-m
import { OpenprojectModalModule } from "core-app/modules/modal/modal.module";
import { RevitAddInSettingsButtonService } from "core-app/modules/bim/revit_add_in/revit-add-in-settings-button.service";
import { OpenprojectAutocompleterModule } from "core-app/modules/autocompleter/openproject-autocompleter.module";
import { OpenProjectBackupService } from './components/api/op-backup/op-backup.service';
@NgModule({
imports: [
@ -153,6 +154,7 @@ import { OpenprojectAutocompleterModule } from "core-app/modules/autocompleter/o
{ provide: States, useValue: new States() },
{ provide: APP_INITIALIZER, useFactory: initializeServices, deps: [Injector], multi: true },
PaginationService,
OpenProjectBackupService,
OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
// Split view

@ -0,0 +1,73 @@
<form class="form -bordered -compressed" method="get" [action]="getDownloadUrl()" [hidden]="!isDownloadReady()">
<section class="form--section">
<h3 class="form--section-title">
{{ text.lastBackup }}
</h3>
<div class="form--field">
<label class="form--label">{{ text.lastBackupFrom }}:</label>
<div class="form--field-container">
<div class="form--text-field-container">
<em>{{ lastBackupDate }}</em>
</div>
</div>
</div>
<button name="button" type="submit" class="button">
<i class="button--icon icon-save"></i>
<span class="button--text">{{ text.downloadBackup }}</span>
</button>
</section>
</form>
<form class="form danger-zone" action="#">
<div class='wiki'>
<section class="form--section">
<h3 class="form--section-title">
{{ text.title }}
</h3>
<p>
{{ text.info }}
</p>
<p class="danger-zone--warning">
<span class="icon icon-error"></span>
<span>{{ text.note }}</span>
</p>
<div>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend">
{{ text.options }}
</legend>
<label class="form--label-with-check-box" [title]="includeAttachmentsTitle()">
<div class="form--check-box-container">
<input
type="checkbox"
class="form--check-box"
[checked]="includeAttachments"
(change)="includeAttachments = !includeAttachments"
[disabled]="!mayIncludeAttachments"
>
</div>
{{ text.includeAttachments }}
</label>
</fieldset>
</div>
<div class="danger-zone--verification">
<input
type="password"
name="backupToken"
placeholder="Backup token"
required="required"
[value]="backupToken"
(input)="backupToken = $event.target.value"
#backupTokenInput
/>
<button name="button" type="submit" class="-highlight button" (click)="triggerBackup($event)" [disabled]="backupToken.length == 0">
<i class="button--icon icon-export"></i>
<span class="button--text">{{ text.requestBackup }}</span>
</button>
</div>
</section>
</div>
</form>

@ -0,0 +1,125 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
//++
import { HttpErrorResponse } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, Injector, ViewChild } from '@angular/core';
import { InjectField } from 'core-app/helpers/angular/inject-field.decorator';
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { NotificationsService } from 'core-app/modules/common/notifications/notifications.service';
import { OpenProjectBackupService } from '../api/op-backup/op-backup.service';
import { JobStatusModal } from "core-app/modules/job-status/job-status-modal/job-status.modal";
import { PathHelperService } from 'core-app/modules/common/path-helper/path-helper.service';
import { OpModalService } from "core-app/modules/modal/modal.service";
export const backupSelector = 'backup';
@Component({
selector: backupSelector,
templateUrl: './backup.component.html',
})
export class BackupComponent implements AfterViewInit {
public text = {
info: this.i18n.t('js.backup.info'),
note: this.i18n.t('js.backup.note'),
title: this.i18n.t('js.backup.title'),
lastBackup: this.i18n.t('js.backup.last_backup'),
lastBackupFrom: this.i18n.t('js.backup.last_backup_from'),
includeAttachments: this.i18n.t('js.backup.include_attachments'),
options: this.i18n.t('js.backup.options'),
downloadBackup: this.i18n.t('js.backup.download_backup'),
requestBackup: this.i18n.t('js.backup.request_backup'),
attachmentsDisabled: this.i18n.t('js.backup.attachments_disabled'),
};
public jobStatusId:string = this.elementRef.nativeElement.dataset['jobStatusId'];
public lastBackupDate:string = this.elementRef.nativeElement.dataset['lastBackupDate'];
public lastBackupAttachmentId:string = this.elementRef.nativeElement.dataset['lastBackupAttachmentId'];
public mayIncludeAttachments:boolean = this.elementRef.nativeElement.dataset['mayIncludeAttachments'] != "false";
public isInProgress:boolean = false;
public includeAttachments:boolean = true;
public backupToken:string = "";
@InjectField() opBackup:OpenProjectBackupService;
@ViewChild("backupTokenInput") backupTokenInput: ElementRef;
constructor(
readonly elementRef:ElementRef,
public injector:Injector,
protected i18n:I18nService,
protected notificationsService:NotificationsService,
protected opModalService:OpModalService,
protected pathHelper:PathHelperService
) {
this.includeAttachments = this.mayIncludeAttachments;
}
ngAfterViewInit() {
this.backupTokenInput.nativeElement.focus();
}
public isDownloadReady():boolean {
return this.jobStatusId !== undefined && this.jobStatusId !== "" &&
this.lastBackupAttachmentId !== undefined && this.lastBackupAttachmentId !== "";
}
public getDownloadUrl():string {
return this.pathHelper.attachmentDownloadPath(this.lastBackupAttachmentId, undefined);
}
public includeAttachmentsDefault():boolean {
return this.mayIncludeAttachments;
}
public includeAttachmentsTitle():string {
return this.mayIncludeAttachments ? '' : this.text.attachmentsDisabled;
}
public triggerBackup(event?:JQuery.TriggeredEvent) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
var backupToken = this.backupToken;
this.backupToken = "";
this.opBackup
.triggerBackup(backupToken, this.includeAttachments)
.toPromise()
.then((resp:any) => {
this.jobStatusId = resp.jobStatusId;
this.opModalService.show(JobStatusModal, 'global', { jobId: resp.jobStatusId });
})
.catch((error:HttpErrorResponse) => {
this.notificationsService.addError(error.error);
});
}
}

@ -0,0 +1,54 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
//++
import {Injectable} from "@angular/core";
import {HttpClient, HttpEvent, HttpEventType, HttpResponse} from "@angular/common/http";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Observable} from "rxjs";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
@Injectable({ providedIn: 'root' })
export class OpenProjectBackupService {
constructor(protected http:HttpClient,
protected halResource:HalResourceService) {
}
public triggerBackup(backupToken:string, includeAttachments:boolean=true):Observable<HalResource> {
return this
.http
.request<HalResource>(
"post",
"/api/v3/backups",
{
body: { backupToken: backupToken, attachments: includeAttachments },
withCredentials: true,
responseType: "json" as any
}
);
}
}

@ -155,6 +155,7 @@ import {
editableQueryPropsSelector
} from "core-app/modules/admin/editable-query-props/editable-query-props.component";
import { SlideToggleComponent, slideToggleSelector } from "core-app/modules/common/slide-toggle/slide-toggle.component";
import { BackupComponent, backupSelector } from "./components/admin/backup.component";
export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: appBaseSelector, cls: ApplicationBaseComponent },
@ -201,7 +202,8 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: attributeLabelMacro, cls: AttributeLabelMacroComponent, embeddable: true },
{ selector: quickInfoMacroSelector, cls: WorkPackageQuickinfoMacroComponent, embeddable: true },
{ selector: editableQueryPropsSelector, cls: EditableQueryPropsComponent },
{ selector: slideToggleSelector, cls: SlideToggleComponent }
{ selector: slideToggleSelector, cls: SlideToggleComponent },
{ selector: backupSelector, cls: BackupComponent }
];

@ -34,8 +34,10 @@ module API
identifier 'UpdateConflict'
code 409
def initialize(*)
super I18n.t('api_v3.errors.code_409')
def initialize(*args)
opts = args.last.is_a?(Hash) ? args.last : {}
super opts[:message] || I18n.t('api_v3.errors.code_409')
end
end
end

@ -0,0 +1,44 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module API
module Errors
class TooManyRequests < ErrorBase
identifier 'TooManyRequests'
code 429
def initialize(*args)
opts = args.last.is_a?(Hash) ? args.last : {}
super opts[:message] || I18n.t('api_v3.errors.code_429')
end
end
end
end

@ -34,8 +34,10 @@ module API
identifier 'MissingPermission'
code 403
def initialize(*)
super I18n.t('api_v3.errors.code_403')
def initialize(*args)
opts = args.last.is_a?(Hash) ? args.last : {}
super opts[:message] || I18n.t('api_v3.errors.code_403')
end
end
end

@ -0,0 +1,51 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Backups
class BackupRepresenter < ::API::Decorators::Single
include API::Decorators::LinkedResource
include API::Caching::CachedRepresenter
property :job_status_id, getter: ->(*) { job_status.job_id }
link :job_status do
{
title: "Backup job status",
href: api_v3_paths.job_status(represented.job_status.job_id)
}
end
def _type
'Backup'
end
end
end
end
end

@ -0,0 +1,82 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Backups
class BackupsAPI < ::API::OpenProjectAPI
resources :backups do
before do
raise API::Errors::NotFound unless OpenProject::Configuration.backup_enabled?
end
after_validation do
authorize Backup.permission, global: true
end
params do
requires :backupToken, type: String
optional(
:attachments,
type: Boolean,
default: true,
desc: 'Whether or not to include attachments (default: true)'
)
end
post do
service = ::Backups::CreateService.new(
user: current_user,
backup_token: params[:backupToken],
include_attachments: params[:attachments]
)
call = service.call
if call.failure?
errors = call.errors.errors
if err = errors.find { |e| e.type == :invalid_token || e.type == :token_cooldown }
fail ::API::Errors::Unauthorized, message: err.full_message
elsif err = errors.find { |e| e.type == :backup_pending }
fail ::API::Errors::Conflict, message: err.full_message
elsif err = errors.find { |e| e.type == :limit_reached }
fail ::API::Errors::TooManyRequests, message: err.full_message
end
fail ::API::Errors::ErrorBase.create_and_merge_errors(call.errors)
end
status 202
BackupRepresenter.new call.result, current_user: current_user
end
end
end
end
end
end

@ -46,6 +46,7 @@ module API
mount ::API::V3::Activities::ActivitiesAPI
mount ::API::V3::Attachments::AttachmentsAPI
mount ::API::V3::Capabilities::CapabilitiesAPI
mount ::API::V3::Backups::BackupsAPI
mount ::API::V3::Categories::CategoriesAPI
mount ::API::V3::Configuration::ConfigurationAPI
mount ::API::V3::CustomActions::CustomActionsAPI

@ -169,6 +169,8 @@ module API
"#{capabilities}/contexts/global"
end
index :backup
index :category
show :category

@ -55,7 +55,7 @@ module OpenProject
end
def permissions
@permissions
@permissions.select(&:enabled?)
end
def modules

@ -43,6 +43,7 @@ module OpenProject
@public = options[:public] || false
@require = options[:require]
@global = options[:global] || false
@enabled = options.include?(:enabled) ? options[:enabled] : true
@dependencies = Array(options[:dependencies]) || []
@project_module = options[:project_module]
@contract_actions = options[:contract_actions] || []
@ -71,6 +72,14 @@ module OpenProject
def require_loggedin?
@require && (@require == :member || @require == :loggedin)
end
def enabled?
if @enabled.respond_to?(:call)
@enabled.call
else
@enabled
end
end
end
end
end

@ -46,6 +46,12 @@ module OpenProject
'autologin_cookie_name' => 'autologin',
'autologin_cookie_path' => '/',
'autologin_cookie_secure' => false,
# Allow users with the required permissions to create backups via the web interface or API.
'backup_enabled' => true,
'backup_daily_limit' => 3,
'backup_initial_waiting_period' => 24.hours,
'backup_include_attachments' => true,
'backup_attachment_size_max_sum_mb' => 1024,
'database_cipher_key' => nil,
# only applicable in conjunction with fog (effectively S3) attachments
# which will be uploaded directly to the cloud storage rather than via OpenProject's

@ -126,4 +126,13 @@ namespace :backup do
filename.gsub(/[^0-9A-Za-z.-]/, '_')
end
end
desc 'Allows user-initiated backups right away, skipping the cooldown period after a new token was created.'
task allow_now: :environment do
date = DateTime.now - OpenProject::Configuration.backup_initial_waiting_period
Token::Backup.where("created_at > ?", date).each do |token|
token.update_column :created_at, date
end
end
end

@ -68,6 +68,7 @@ module JobStatus
resource = ::JobStatus::Status.find_or_initialize_by(job_id: job_id)
if resource.new_record?
resource.user = User.current # needed so `resource.user` works below
resource.user_id = User.current.id
resource.reference = status_reference
end

@ -0,0 +1,48 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'contracts/shared/model_contract_shared_context'
describe Backups::CreateContract do
let(:backup) { Backup.new }
let(:contract) { described_class.new backup, current_user, options: { backup_token: backup_token.plain_value } }
let(:backup_token) { FactoryBot.create :backup_token, user: current_user }
include_context 'ModelContract shared context'
it_behaves_like 'contract is valid for active admins and invalid for regular users'
context 'with regular user who has the :create_backup permission' do
let(:current_user) { FactoryBot.create :user, global_permissions: [:create_backup] }
it_behaves_like 'contract is valid'
end
end

@ -0,0 +1,32 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
FactoryBot.define do
factory :backup, class: Backup do
end
end

@ -44,4 +44,22 @@ FactoryBot.define do
factory :recovery_token, class: ::Token::Recovery do
user
end
factory :backup_token, class: ::Token::Backup do
user
after(:build) do |token|
token.created_at = DateTime.now - OpenProject::Configuration.backup_initial_waiting_period
end
trait :with_waiting_period do
transient do
since { 0.seconds }
end
after(:build) do |token, factory|
token.created_at = DateTime.now - factory.since
end
end
end
end

@ -46,14 +46,23 @@ FactoryBot.define do
admin { false }
first_login { false if User.table_exists? and User.columns.map(&:name).include? 'first_login' }
transient do
global_permissions { [] }
end
callback(:after_build) do |user, evaluator|
evaluator.preferences.each do |key, val|
user.pref[key] = val
end
end
callback(:after_create) do |user, evaluator|
user.pref.save unless evaluator.preferences&.empty?
callback(:after_create) do |user, factory|
user.pref.save unless factory.preferences&.empty?
if factory.global_permissions.present?
global_role = FactoryBot.create :global_role, permissions: factory.global_permissions
FactoryBot.create :global_member, principal: user, roles: [global_role]
end
end
factory :admin do

@ -0,0 +1,108 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'backup', type: :feature, js: true do
let(:current_user) { FactoryBot.create :admin, password: user_password, password_confirmation: user_password }
let!(:backup_token) { FactoryBot.create :backup_token, user: current_user }
let(:user_password) { "adminadmin!" }
before do
@download_list = DownloadList.new
login_as current_user
end
after do
DownloadList.clear
end
subject { @download_list.refresh_from(page).latest_download.to_s }
it "can be downloaded" do
visit '/admin/backups'
fill_in 'backupToken', with: backup_token.plain_value
click_on "Request backup"
expect(page).to have_content I18n.t('js.job_status.generic_messages.in_queue'), wait: 10
begin
perform_enqueued_jobs
rescue StandardError
# nothing
end
expect(page).to have_text "The export has completed successfully"
expect(subject).to end_with ".zip"
end
context "with an error" do
it "shows the error" do
visit "/admin/backups"
fill_in "backupToken", with: "foobar"
click_on "Request backup"
expect(page).to have_content I18n.t("backup.error.invalid_token")
end
end
it "allows the backup token to be reset" do
visit "/admin/backups"
click_on I18n.t("backup.label_reset_token")
expect(page).to have_content /#{I18n.t('backup.reset_token.heading_reset')}/i
fill_in "login_verification", with: "reset"
click_on "Reset"
fill_in "request_for_confirmation_password", with: user_password
click_on "Confirm"
new_token = Token::Backup.find_by(user: current_user)
expect(new_token.plain_value).not_to eq backup_token.plain_value
expect(page).to have_content new_token.plain_value
end
it "allows the backup token to be deleted" do
visit "/admin/backups"
expect(page).to have_content /#{I18n.t('js.backup.title')}/i
click_on I18n.t("backup.label_delete_token")
expect(page).to have_content I18n.t("backup.text_token_deleted")
token = Token::Backup.find_by(user: current_user)
expect(token).to be_nil
expect(page).not_to have_content /#{I18n.t('js.backup.title')}/i
end
end

@ -32,7 +32,7 @@ describe OpenProject::AccessControl do
def stash_access_control_permissions
@stashed_permissions = OpenProject::AccessControl.permissions.dup
OpenProject::AccessControl.clear_caches
OpenProject::AccessControl.permissions.clear
OpenProject::AccessControl.instance_variable_get(:@permissions).clear
end
def restore_access_control_permissions

@ -0,0 +1,153 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe API::V3::Backups::BackupsAPI, type: :request, with_config: { backup_enabled: true } do
include API::V3::Utilities::PathHelper
let(:user) { FactoryBot.create :user, global_permissions: [:create_backup] }
let(:params) { { backupToken: backup_token.plain_value } }
let(:backup_token) { FactoryBot.create :backup_token, user: user }
before do
login_as user
end
def create_backup
post api_v3_paths.backups, params.to_json, "CONTENT_TYPE" => "application/json"
end
describe "POST /api/v3/backups" do
shared_context "request" do
before do
create_backup
end
end
context "with no pending backups" do
context "with no params" do
let(:params) { {} }
include_context "request"
it "results in a bad request error" do
expect(last_response.status).to eq 400
end
end
context "with no options" do
before do
expect(Backups::CreateService)
.to receive(:new)
.with(user: user, backup_token: backup_token.plain_value, include_attachments: true)
.and_call_original
create_backup
end
it "enqueues the backup including attachments" do
expect(last_response.status).to eq 202
end
end
context "with include_attachments: false" do
let(:params) { { backupToken: backup_token.plain_value, attachments: false } }
before do
expect(Backups::CreateService)
.to receive(:new)
.with(user: user, backup_token: backup_token.plain_value, include_attachments: false)
.and_call_original
create_backup
end
it "enqueues a backup not including attachments" do
expect(last_response.status).to eq 202
end
end
end
context "with pending backups" do
let!(:backup) { FactoryBot.create :backup }
let!(:status) { FactoryBot.create :delayed_job_status, user: user, reference: backup }
include_context "request"
it "results in a conflict" do
expect(last_response.status).to eq 409
end
end
context "with missing permissions" do
let(:user) { FactoryBot.create :user }
include_context "request"
it "is forbidden" do
expect(last_response.status).to eq 403
end
end
context "with another user's token" do
let(:other_user) { FactoryBot.create :user }
let(:backup_token) { FactoryBot.create :backup_token, user: other_user }
include_context "request"
it "is forbidden" do
expect(last_response.status).to eq 403
end
end
context "with daily backup limit reached", with_config: { backup_daily_limit: -1 } do
include_context "request"
it "is rate limited" do
expect(last_response.status).to eq 429
end
end
context "with backup token on cooldown", with_config: { backup_initial_waiting_period: 24.hours } do
let(:backup_token) { FactoryBot.create :backup_token, :with_waiting_period, user: user, since: 5.hours }
include_context "request"
it "is forbidden" do
expect(last_response.status).to eq 403
end
it "shows the remaining hours until the token is valid" do
expect(last_response.body).to include "19 hours"
end
end
end
end

@ -0,0 +1,75 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'services/base_services/behaves_like_create_service'
describe Backups::CreateService, type: :model do
let(:user) { FactoryBot.create :admin }
let(:service) { described_class.new user: user, backup_token: backup_token.plain_value }
let(:backup_token) { FactoryBot.create :backup_token, user: user }
it_behaves_like 'BaseServices create service' do
let(:instance) { service }
let(:contract_options) { { backup_token: backup_token.plain_value } }
end
context "with right permissions" do
context "with no further options" do
it "enqueues a BackupJob which includes attachments" do
expect { service.call }.to have_enqueued_job(BackupJob).with do |args|
expect(args["include_attachments"]).to eq true
end
end
end
context "with include_attachments: false" do
let(:service) do
described_class.new user: user, backup_token: backup_token.plain_value, include_attachments: false
end
it "enqueues a BackupJob which does not include attachments" do
expect(BackupJob)
.to receive(:perform_later)
.with(hash_including(include_attachments: false, user: user))
expect(service.call).to be_success
end
end
end
context "with missing permission" do
let(:user) { FactoryBot.create :user }
it "does not enqueue a BackupJob" do
expect { expect(service.call).to be_failure }.not_to have_enqueued_job(BackupJob)
end
end
end

@ -35,6 +35,7 @@ shared_examples 'BaseServices create service' do
let(:namespace) { service_class.to_s.deconstantize }
let(:model_class) { namespace.singularize.constantize }
let(:contract_class) { "#{namespace}::CreateContract".constantize }
let(:contract_options) { {} }
let(:factory) { namespace.singularize.underscore }
let(:set_attributes_class) { "#{namespace}::SetAttributesService".constantize }
@ -64,7 +65,7 @@ shared_examples 'BaseServices create service' do
.with(user: user,
model: model_instance,
contract_class: contract_class,
contract_options: {})
contract_options: contract_options)
.and_return(service)
allow(service)
@ -95,7 +96,7 @@ shared_examples 'BaseServices create service' do
end
describe '#call' do
context 'if contract validates and the user saves' do
context 'if contract validates and the model saves' do
it 'is successful' do
expect(subject).to be_success
end
@ -104,7 +105,7 @@ shared_examples 'BaseServices create service' do
expect(subject.errors).to eq(set_attributes_errors)
end
it 'returns the user as a result' do
it 'returns the model as a result' do
result = subject.result
expect(result).to be_a model_class
end
@ -126,7 +127,7 @@ shared_examples 'BaseServices create service' do
expect(subject).to_not be_success
end
it "returns the user's errors" do
it "returns the model's errors" do
allow(model_instance)
.to(receive(:errors))
.and_return errors

@ -21,7 +21,7 @@ RSpec.configure do |_config|
Capybara.server_host = ip_address
Capybara.app_host = "http://#{hostname}"
else
Capybara.server_host = "0.0.0.0"
Capybara.server_host = ENV.fetch('CAPYBARA_APP_HOSTNAME', '0.0.0.0')
end
end

@ -0,0 +1,140 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe BackupJob, type: :model do
shared_examples "it creates a backup" do |opts = {}|
let(:job) { BackupJob.new }
let(:previous_backup) { FactoryBot.create :backup }
let(:backup) { FactoryBot.create :backup }
let(:status) { :in_queue }
let(:job_id) { 42 }
let(:job_status) do
FactoryBot.create(
:delayed_job_status,
user: user,
reference: backup,
status: JobStatus::Status.statuses[status],
job_id: job_id
)
end
let(:db_dump_process_status) do
success = db_dump_success
Object.new.tap do |o|
o.define_singleton_method(:success?) { success }
end
end
let(:db_dump_success) { false }
let(:attachments) { [] }
let(:arguments) { [{ backup: backup, user: user, **opts }] }
let(:user) { FactoryBot.create :user }
before do
previous_backup; backup; status # create
allow(job).to receive(:job_status).and_return job_status
allow(job).to receive(:attachments).and_return attachments
allow(job).to receive(:arguments).and_return arguments
allow(job).to receive(:job_id).and_return job_id
expect(Open3).to receive(:capture3).and_return [nil, "Dump failed", db_dump_process_status]
allow_any_instance_of(BackupJob)
.to receive(:tmp_file_name).with("openproject", ".sql").and_return("/tmp/openproject.sql")
allow_any_instance_of(BackupJob)
.to receive(:tmp_file_name).with("openproject-backup", ".zip").and_return("/tmp/openproject.zip")
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with("/tmp/openproject.sql").and_return "SOME SQL"
end
def perform
job.perform **arguments.first
end
context "with a failed database dump" do
let(:db_dump_success) { false }
before { perform }
it "retains previous backups" do
expect(Backup.find_by(id: previous_backup.id)).not_to be_nil
end
end
context "with a successful database dump" do
let(:db_dump_success) { true }
let!(:attachment) { FactoryBot.create :attachment }
let(:stored_backup) { Attachment.where(container_type: "Export").last }
let(:backup_files) { Zip::File.open(stored_backup.file.path) { |zip| zip.entries.map(&:name) } }
let(:backed_up_attachment) { "attachment/file/#{attachment.id}/#{attachment.filename}" }
before { perform }
it "destroys any previous backups" do
expect(Backup.find_by(id: previous_backup.id)).to be_nil
end
it "stores a new backup as an attachment" do
expect(stored_backup.filename).to eq "openproject.zip"
end
it "includes the database dump in the backup" do
expect(backup_files).to include "openproject.sql"
end
if opts[:include_attachments] != false
it "includes attachments in the backup" do
expect(backup_files).to include backed_up_attachment
end
else
it "does not include attachments in the backup" do
expect(backup_files).not_to include backed_up_attachment
end
end
end
end
context "per default" do
it_behaves_like "it creates a backup"
end
context "with include_attachments: false" do
it_behaves_like "it creates a backup", include_attachments: false
end
end
Loading…
Cancel
Save