diff options
Diffstat (limited to 'app/services/design_management')
7 files changed, 402 insertions, 0 deletions
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb new file mode 100644 index 00000000000..e69f07db5bf --- /dev/null +++ b/app/services/design_management/delete_designs_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module DesignManagement + class DeleteDesignsService < DesignService + include RunsDesignActions + include OnSuccessCallbacks + + def initialize(project, user, params = {}) + super + + @designs = params.fetch(:designs) + end + + def execute + return error('Forbidden!') unless can_delete_designs? + + version = delete_designs! + + success(version: version) + end + + def commit_message + n = designs.size + + <<~MSG + Removed #{n} #{'designs'.pluralize(n)} + + #{formatted_file_list} + MSG + end + + private + + attr_reader :designs + + def delete_designs! + DesignManagement::Version.with_lock(project.id, repository) do + run_actions(build_actions) + end + end + + def can_delete_designs? + Ability.allowed?(current_user, :destroy_design, issue) + end + + def build_actions + designs.map { |d| design_action(d) } + end + + def design_action(design) + on_success { counter.count(:delete) } + + DesignManagement::DesignAction.new(design, :delete) + end + + def counter + ::Gitlab::UsageDataCounters::DesignsCounter + end + + def formatted_file_list + designs.map { |design| "- #{design.full_path}" }.join("\n") + end + end +end + +DesignManagement::DeleteDesignsService.prepend_if_ee('EE::DesignManagement::DeleteDesignsService') diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb new file mode 100644 index 00000000000..54e53609646 --- /dev/null +++ b/app/services/design_management/design_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignService < ::BaseService + def initialize(project, user, params = {}) + super + + @issue = params.fetch(:issue) + end + + # Accessors common to all subclasses: + + attr_reader :issue + + def target_branch + repository.root_ref || "master" + end + + def collection + issue.design_collection + end + + def repository + collection.repository + end + + def project + issue.project + end + end +end diff --git a/app/services/design_management/design_user_notes_count_service.rb b/app/services/design_management/design_user_notes_count_service.rb new file mode 100644 index 00000000000..e49914ea6d3 --- /dev/null +++ b/app/services/design_management/design_user_notes_count_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module DesignManagement + # Service class for counting and caching the number of unresolved + # notes of a Design + class DesignUserNotesCountService < ::BaseCountService + # The version of the cache format. This should be bumped whenever the + # underlying logic changes. This removes the need for explicitly flushing + # all caches. + VERSION = 1 + + def initialize(design) + @design = design + end + + def relation_for_count + design.notes.user + end + + def raw? + # Since we're storing simple integers we don't need all of the + # additional Marshal data Rails includes by default. + true + end + + def cache_key + ['designs', 'notes_count', VERSION, design.id] + end + + private + + attr_reader :design + end +end diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb new file mode 100644 index 00000000000..213aac164ff --- /dev/null +++ b/app/services/design_management/generate_image_versions_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module DesignManagement + # This service generates smaller image versions for `DesignManagement::Design` + # records within a given `DesignManagement::Version`. + class GenerateImageVersionsService < DesignService + # We limit processing to only designs with file sizes that don't + # exceed `MAX_DESIGN_SIZE`. + # + # Note, we may be able to remove checking this limit, if when we come to + # implement a file size limit for designs, there are no designs that + # exceed 40MB on GitLab.com + # + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22860#note_281780387 + MAX_DESIGN_SIZE = 40.megabytes.freeze + + def initialize(version) + super(version.project, version.author, issue: version.issue) + + @version = version + end + + def execute + # rubocop: disable CodeReuse/ActiveRecord + version.actions.includes(:design).each do |action| + generate_image(action) + end + # rubocop: enable CodeReuse/ActiveRecord + + success(version: version) + end + + private + + attr_reader :version + + def generate_image(action) + raw_file = get_raw_file(action) + + unless raw_file + log_error("No design file found for Action: #{action.id}") + return + end + + # Skip attempting to process images that would be rejected by CarrierWave. + return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type) + + # Store and process the file + action.image_v432x230.store!(raw_file) + action.save! + rescue CarrierWave::UploadError => e + Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id) + log_error(e.message) + end + + # Returns the `CarrierWave::SanitizedFile` of the original design file + def get_raw_file(action) + raw_files_by_path[action.design.full_path] + end + + # Returns the `Carrierwave:SanitizedFile` instances for all of the original + # design files, mapping to { design.filename => `Carrierwave::SanitizedFile` }. + # + # As design files are stored in Git LFS, the only way to retrieve their original + # files is to first fetch the LFS pointer file data from the Git design repository. + # The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject` + # records, which have an Uploader (`LfsObjectUploader`) for the original design file. + def raw_files_by_path + @raw_files_by_path ||= begin + LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h| + blob = blobs_by_oid[lfs_object.oid] + file = lfs_object.file.file + # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type` + # of the file, due to the file not having an extension. + # + # Set the content_type from the `Blob`. + file.content_type = blob.content_type + h[blob.path] = file + end + end + end + + # Returns the `Blob`s that correspond to the design files in the repository. + # + # All design `Blob`s are LFS Pointer files, and are therefore small amounts + # of data to load. + # + # `Blob`s whose size are above a certain threshold: `MAX_DESIGN_SIZE` + # are filtered out. + def blobs_by_oid + @blobs ||= begin + items = version.designs.map { |design| [version.sha, design.full_path] } + blobs = repository.blobs_at(items) + blobs.reject! { |blob| blob.lfs_size > MAX_DESIGN_SIZE } + blobs.index_by(&:lfs_oid) + end + end + end +end diff --git a/app/services/design_management/on_success_callbacks.rb b/app/services/design_management/on_success_callbacks.rb new file mode 100644 index 00000000000..be55890a02d --- /dev/null +++ b/app/services/design_management/on_success_callbacks.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DesignManagement + module OnSuccessCallbacks + def on_success(&block) + success_callbacks.push(block) + end + + def success(*_) + while cb = success_callbacks.pop + cb.call + end + + super + end + + private + + def success_callbacks + @success_callbacks ||= [] + end + end +end diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb new file mode 100644 index 00000000000..4bd6bb45658 --- /dev/null +++ b/app/services/design_management/runs_design_actions.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module DesignManagement + module RunsDesignActions + NoActions = Class.new(StandardError) + + # this concern requires the following methods to be implemented: + # current_user, target_branch, repository, commit_message + # + # Before calling `run_actions`, you should ensure the repository exists, by + # calling `repository.create_if_not_exists`. + # + # @raise [NoActions] if actions are empty + def run_actions(actions) + raise NoActions if actions.empty? + + sha = repository.multi_action(current_user, + branch_name: target_branch, + message: commit_message, + actions: actions.map(&:gitaly_action)) + + ::DesignManagement::Version + .create_for_designs(actions, sha, current_user) + .tap { |version| post_process(version) } + end + + private + + def post_process(version) + version.run_after_commit_or_now do + ::DesignManagement::NewVersionWorker.perform_async(id) + end + end + end +end diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb new file mode 100644 index 00000000000..a09c19bc885 --- /dev/null +++ b/app/services/design_management/save_designs_service.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module DesignManagement + class SaveDesignsService < DesignService + include RunsDesignActions + include OnSuccessCallbacks + + MAX_FILES = 10 + + def initialize(project, user, params = {}) + super + + @files = params.fetch(:files) + end + + def execute + return error("Not allowed!") unless can_create_designs? + return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES + + uploaded_designs, version = upload_designs! + skipped_designs = designs - uploaded_designs + + success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs }) + rescue ::ActiveRecord::RecordInvalid => e + error(e.message) + end + + private + + attr_reader :files + + def upload_designs! + ::DesignManagement::Version.with_lock(project.id, repository) do + actions = build_actions + + [actions.map(&:design), actions.presence && run_actions(actions)] + end + end + + # Returns `Design` instances that correspond with `files`. + # New `Design`s will be created where a file name does not match + # an existing `Design` + def designs + @designs ||= files.map do |file| + collection.find_or_create_design!(filename: file.original_filename) + end + end + + def build_actions + files.zip(designs).flat_map do |(file, design)| + Array.wrap(build_design_action(file, design)) + end + end + + def build_design_action(file, design) + content = file_content(file, design.full_path) + return if design_unchanged?(design, content) + + action = new_file?(design) ? :create : :update + on_success { ::Gitlab::UsageDataCounters::DesignsCounter.count(action) } + + DesignManagement::DesignAction.new(design, action, content) + end + + # Returns true if the design file is the same as its latest version + def design_unchanged?(design, content) + content == existing_blobs[design]&.data + end + + def commit_message + <<~MSG + Updated #{files.size} #{'designs'.pluralize(files.size)} + + #{formatted_file_list} + MSG + end + + def formatted_file_list + filenames.map { |name| "- #{name}" }.join("\n") + end + + def filenames + @filenames ||= files.map(&:original_filename) + end + + def can_create_designs? + Ability.allowed?(current_user, :create_design, issue) + end + + def new_file?(design) + !existing_blobs[design] + end + + def file_content(file, full_path) + transformer = ::Lfs::FileTransformer.new(project, repository, target_branch) + transformer.new_file(full_path, file.to_io).content + end + + # Returns the latest blobs for the designs as a Hash of `{ Design => Blob }` + def existing_blobs + @existing_blobs ||= begin + items = designs.map { |d| ['HEAD', d.full_path] } + + repository.blobs_at(items).each_with_object({}) do |blob, h| + design = designs.find { |d| d.full_path == blob.path } + + h[design] = blob + end + end + end + end +end + +DesignManagement::SaveDesignsService.prepend_if_ee('EE::DesignManagement::SaveDesignsService') |