Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									ecf2b5b604
								
							
						
					
					
						commit
						43c14d2d92
					
				|  | @ -137,7 +137,7 @@ Dangerfile @gl-quality/eng-prod | |||
| /app/assets/javascripts/notes @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/merge_conflicts @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/mr_notes @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/mr_popover @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/issuable/popover @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/vue_merge_request_widget @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/merge_request.js @viktomas @jboyson @iamphill @thomasrandolph | ||||
| /app/assets/javascripts/merge_request_tabs.js @viktomas @jboyson @iamphill @thomasrandolph | ||||
|  |  | |||
|  | @ -24,11 +24,11 @@ $.fn.renderGFM = function renderGFM() { | |||
|   highlightCurrentUser(this.find('.gfm-project_member').get()); | ||||
|   initUserPopovers(this.find('.js-user-link').get()); | ||||
| 
 | ||||
|   const mrPopoverElements = this.find('.gfm-merge_request').get(); | ||||
|   if (mrPopoverElements.length) { | ||||
|     import(/* webpackChunkName: 'MrPopoverBundle' */ '~/mr_popover') | ||||
|       .then(({ default: initMRPopovers }) => { | ||||
|         initMRPopovers(mrPopoverElements); | ||||
|   const issuablePopoverElements = this.find('.gfm-merge_request').get(); | ||||
|   if (issuablePopoverElements.length) { | ||||
|     import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') | ||||
|       .then(({ default: initIssuablePopovers }) => { | ||||
|         initIssuablePopovers(issuablePopoverElements); | ||||
|       }) | ||||
|       .catch(() => {}); | ||||
|   } | ||||
|  |  | |||
|  | @ -130,7 +130,7 @@ export default { | |||
|       v-gl-tooltip.bottom | ||||
|       class="gl-ml-3" | ||||
|       :is-deleting="isDeleting" | ||||
|       button-variant="warning" | ||||
|       button-variant="default" | ||||
|       button-icon="archive" | ||||
|       button-category="secondary" | ||||
|       :title="s__('DesignManagement|Archive design')" | ||||
|  |  | |||
|  | @ -6,8 +6,8 @@ import MRPopover from './components/mr_popover.vue'; | |||
| let renderedPopover; | ||||
| let renderFn; | ||||
| 
 | ||||
| const handleUserPopoverMouseOut = ({ target }) => { | ||||
|   target.removeEventListener('mouseleave', handleUserPopoverMouseOut); | ||||
| const handleIssuablePopoverMouseOut = ({ target }) => { | ||||
|   target.removeEventListener('mouseleave', handleIssuablePopoverMouseOut); | ||||
| 
 | ||||
|   if (renderFn) { | ||||
|     clearTimeout(renderFn); | ||||
|  | @ -22,9 +22,11 @@ const handleUserPopoverMouseOut = ({ target }) => { | |||
|  * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes. | ||||
|  * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover | ||||
|  */ | ||||
| const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => { | ||||
| const handleIssuablePopoverMount = ({ apolloProvider, projectPath, title, iid }) => ({ | ||||
|   target, | ||||
| }) => { | ||||
|   // Add listener to actually remove it again
 | ||||
|   target.addEventListener('mouseleave', handleUserPopoverMouseOut); | ||||
|   target.addEventListener('mouseleave', handleIssuablePopoverMouseOut); | ||||
| 
 | ||||
|   renderFn = setTimeout(() => { | ||||
|     const MRPopoverComponent = Vue.extend(MRPopover); | ||||
|  | @ -33,7 +35,7 @@ const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => | |||
|         target, | ||||
|         projectPath, | ||||
|         mergeRequestIID: iid, | ||||
|         mergeRequestTitle: mrTitle, | ||||
|         mergeRequestTitle: title, | ||||
|       }, | ||||
|       apolloProvider, | ||||
|     }); | ||||
|  | @ -43,22 +45,22 @@ const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => | |||
| }; | ||||
| 
 | ||||
| export default (elements) => { | ||||
|   const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')]; | ||||
|   if (mrLinks.length > 0) { | ||||
|   if (elements.length > 0) { | ||||
|     Vue.use(VueApollo); | ||||
| 
 | ||||
|     const apolloProvider = new VueApollo({ | ||||
|       defaultClient: createDefaultClient(), | ||||
|     }); | ||||
|     const listenerAddedAttr = 'data-mr-listener-added'; | ||||
|     const listenerAddedAttr = 'data-popover-listener-added'; | ||||
| 
 | ||||
|     mrLinks.forEach((el) => { | ||||
|       const { projectPath, mrTitle, iid } = el.dataset; | ||||
|     elements.forEach((el) => { | ||||
|       const { projectPath, iid } = el.dataset; | ||||
|       const title = el.dataset.mrTitle || el.title; | ||||
| 
 | ||||
|       if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) { | ||||
|       if (!el.getAttribute(listenerAddedAttr) && projectPath && title && iid) { | ||||
|         el.addEventListener( | ||||
|           'mouseenter', | ||||
|           handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }), | ||||
|           handleIssuablePopoverMount({ apolloProvider, projectPath, title, iid }), | ||||
|         ); | ||||
|         el.setAttribute(listenerAddedAttr, true); | ||||
|       } | ||||
|  | @ -294,14 +294,20 @@ export default { | |||
|     /> | ||||
|     <emoji-picker | ||||
|       v-if="canAwardEmoji" | ||||
|       toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!" | ||||
|       toggle-class="note-action-button note-emoji-button btn-icon gl-shadow-none!" | ||||
|       data-testid="note-emoji-button" | ||||
|       @click="setAwardEmoji" | ||||
|     > | ||||
|       <template #button-content> | ||||
|         <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" /> | ||||
|         <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" /> | ||||
|         <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" /> | ||||
|         <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" /> | ||||
|         <gl-icon | ||||
|           class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!" | ||||
|           name="smiley" | ||||
|         /> | ||||
|         <gl-icon | ||||
|           class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!" | ||||
|           name="smile" | ||||
|         /> | ||||
|       </template> | ||||
|     </emoji-picker> | ||||
|     <reply-button | ||||
|  |  | |||
|  | @ -26,9 +26,9 @@ import { | |||
| import $ from 'jquery'; | ||||
| import { mapGetters, mapActions, mapState } from 'vuex'; | ||||
| import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; | ||||
| import '~/behaviors/markdown/render_gfm'; | ||||
| import axios from '~/lib/utils/axios_utils'; | ||||
| import { __ } from '~/locale'; | ||||
| import initMRPopovers from '~/mr_popover/'; | ||||
| import noteHeader from '~/notes/components/note_header.vue'; | ||||
| import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { spriteIcon } from '~/lib/utils/common_utils'; | ||||
|  | @ -94,7 +94,7 @@ export default { | |||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); | ||||
|     $(this.$refs['gfm-content']).renderGFM(); | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), | ||||
|  | @ -130,7 +130,7 @@ export default { | |||
|     <div class="timeline-content"> | ||||
|       <div class="note-header"> | ||||
|         <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> | ||||
|           <span v-safe-html="actionTextHtml"></span> | ||||
|           <span ref="gfm-content" v-safe-html="actionTextHtml"></span> | ||||
|           <template | ||||
|             v-if="canSeeDescriptionVersion || note.outdated_line_change_path" | ||||
|             #extra-controls | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import workItemTitleSubscription from '../graphql/work_item_title.subscription.g | |||
| import WorkItemActions from './work_item_actions.vue'; | ||||
| import WorkItemState from './work_item_state.vue'; | ||||
| import WorkItemTitle from './work_item_title.vue'; | ||||
| import WorkItemLinks from './work_item_links/work_item_links.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   i18n, | ||||
|  | @ -15,6 +16,7 @@ export default { | |||
|     WorkItemActions, | ||||
|     WorkItemTitle, | ||||
|     WorkItemState, | ||||
|     WorkItemLinks, | ||||
|   }, | ||||
|   props: { | ||||
|     workItemId: { | ||||
|  | @ -105,6 +107,7 @@ export default { | |||
|         @error="error = $event" | ||||
|         @updated="$emit('workItemUpdated')" | ||||
|       /> | ||||
|       <work-item-links :work-item-id="workItem.id" /> | ||||
|     </template> | ||||
|   </section> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,86 @@ | |||
| <script> | ||||
| import { GlButton } from '@gitlab/ui'; | ||||
| import { s__ } from '~/locale'; | ||||
| import WorkItemLinksForm from './work_item_links_form.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlButton, | ||||
|     WorkItemLinksForm, | ||||
|   }, | ||||
|   props: { | ||||
|     workItemId: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: null, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       isShownAddForm: false, | ||||
|       isOpen: false, | ||||
|       children: [], | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     // Only used for children for now but should be extended later to support parents and siblings | ||||
|     isChildrenEmpty() { | ||||
|       return this.children.length === 0; | ||||
|     }, | ||||
|     toggleIcon() { | ||||
|       return this.isOpen ? 'angle-up' : 'angle-down'; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     toggle() { | ||||
|       this.isOpen = !this.isOpen; | ||||
|     }, | ||||
|     toggleAddForm() { | ||||
|       this.isShownAddForm = !this.isShownAddForm; | ||||
|     }, | ||||
|   }, | ||||
|   i18n: { | ||||
|     title: s__('WorkItem|Child items'), | ||||
|     emptyStateMessage: s__( | ||||
|       'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!', | ||||
|     ), | ||||
|     addChildButtonLabel: s__('WorkItem|Add a child'), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100"> | ||||
|     <div | ||||
|       class="gl-p-4 gl-display-flex gl-justify-content-space-between" | ||||
|       :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" | ||||
|     > | ||||
|       <h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5> | ||||
|       <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4"> | ||||
|         <gl-button | ||||
|           category="tertiary" | ||||
|           :icon="toggleIcon" | ||||
|           data-testid="toggle-links" | ||||
|           @click="toggle" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div v-if="isOpen" class="gl-bg-gray-10 gl-p-4" data-testid="links-body"> | ||||
|       <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty"> | ||||
|         <p> | ||||
|           {{ $options.i18n.emptyStateMessage }} | ||||
|         </p> | ||||
|         <gl-button | ||||
|           v-if="!isShownAddForm" | ||||
|           category="secondary" | ||||
|           variant="confirm" | ||||
|           data-testid="toggle-add-form" | ||||
|           @click="toggleAddForm" | ||||
|         > | ||||
|           {{ $options.i18n.addChildButtonLabel }} | ||||
|         </gl-button> | ||||
|         <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,28 @@ | |||
| <script> | ||||
| import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlForm, | ||||
|     GlFormInput, | ||||
|     GlButton, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       relatedWorkItem: '', | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <gl-form @submit.prevent> | ||||
|     <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" /> | ||||
|     <gl-button type="submit" category="secondary" variant="confirm"> | ||||
|       {{ s__('WorkItem|Add') }} | ||||
|     </gl-button> | ||||
|     <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')"> | ||||
|       {{ s__('WorkItem|Cancel') }} | ||||
|     </gl-button> | ||||
|   </gl-form> | ||||
| </template> | ||||
|  | @ -358,8 +358,8 @@ | |||
|   line-height: 1; | ||||
|   padding: 0; | ||||
|   min-width: 16px; | ||||
|   color: $gray-400; | ||||
|   fill: $gray-400; | ||||
|   color: $gray-500; | ||||
|   fill: $gray-500; | ||||
| 
 | ||||
|   svg { | ||||
|     @include btn-svg; | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'digest/md5' | ||||
| require 'uri' | ||||
| 
 | ||||
| module ApplicationHelper | ||||
|  |  | |||
|  | @ -15,5 +15,18 @@ module Emails | |||
|       email = user.notification_email_or_default | ||||
|       mail to: email, subject: "Unsubscribed from GitLab administrator notifications" | ||||
|     end | ||||
| 
 | ||||
|     def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:) | ||||
|       admin = User.find(admin_id) | ||||
|       @user = User.find(user_id) | ||||
|       @max_project_downloads = max_project_downloads | ||||
|       @within_minutes = within_seconds / 60 | ||||
| 
 | ||||
|       Gitlab::I18n.with_locale(admin.preferred_language) do | ||||
|         email_with_layout( | ||||
|           to: admin.notification_email_or_default, | ||||
|           subject: subject(_("We've detected unusual activity"))) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -8,11 +8,9 @@ module Emails | |||
| 
 | ||||
|       add_project_headers | ||||
| 
 | ||||
|       mail(to: recipient, | ||||
|            subject: auto_devops_disabled_subject(@project.name)) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: recipient, | ||||
|         subject: auto_devops_disabled_subject(@project.name)) | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
|  |  | |||
|  | @ -94,10 +94,9 @@ module Emails | |||
|       @project = Project.find(project_id) | ||||
|       @results = results | ||||
| 
 | ||||
|       mail(to: @user.notification_email_for(@project.group), subject: subject('Imported issues')) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: @user.notification_email_for(@project.group), | ||||
|         subject: subject('Imported issues')) | ||||
|     end | ||||
| 
 | ||||
|     def issues_csv_email(user, project, csv_data, export_status) | ||||
|  | @ -110,10 +109,9 @@ module Emails | |||
| 
 | ||||
|       filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv" | ||||
|       attachments[filename] = { content: csv_data, mime_type: 'text/csv' } | ||||
|       mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(@project.group), | ||||
|         subject: subject("Exported issues")) | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ module Emails | |||
| 
 | ||||
|       user = User.find(recipient_id) | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(notification_group), | ||||
|         subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) | ||||
|     end | ||||
|  | @ -32,7 +32,7 @@ module Emails | |||
| 
 | ||||
|       return unless member_exists? | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: member.user.notification_email_for(notification_group), | ||||
|         subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) | ||||
|     end | ||||
|  | @ -47,7 +47,7 @@ module Emails | |||
| 
 | ||||
|       human_name = @source_hidden ? 'Hidden' : member_source.human_name | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(notification_group), | ||||
|         subject: subject("Access to the #{human_name} #{member_source.model_name.singular} was denied")) | ||||
|     end | ||||
|  | @ -83,7 +83,7 @@ module Emails | |||
| 
 | ||||
|       subject_line = subjects[reminder_index] % { inviter: member.created_by.name } | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         layout: 'unknown_user_mailer', | ||||
|         to: member.invite_email, | ||||
|         subject: subject(subject_line) | ||||
|  | @ -97,7 +97,7 @@ module Emails | |||
|       return unless member_exists? | ||||
|       return unless member.created_by | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: member.created_by.notification_email_for(notification_group), | ||||
|         subject: subject('Invitation accepted')) | ||||
|     end | ||||
|  | @ -111,7 +111,7 @@ module Emails | |||
| 
 | ||||
|       user = User.find(created_by_id) | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(notification_group), | ||||
|         subject: subject('Invitation declined')) | ||||
|     end | ||||
|  | @ -128,7 +128,7 @@ module Emails | |||
|                   _('Group membership expiration date removed') | ||||
|                 end | ||||
| 
 | ||||
|       member_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: member.user.notification_email_for(notification_group), | ||||
|         subject: subject(subject)) | ||||
|     end | ||||
|  | @ -176,13 +176,6 @@ module Emails | |||
|     def member_source_class | ||||
|       @member_source_type.classify.constantize | ||||
|     end | ||||
| 
 | ||||
|     def member_email_with_layout(to:, subject:, layout: 'mailer') | ||||
|       mail(to: to, subject: subject) do |format| | ||||
|         format.html { render layout: layout } | ||||
|         format.text { render layout: layout } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
|  |  | |||
|  | @ -149,10 +149,9 @@ module Emails | |||
| 
 | ||||
|       filename = "#{project.full_path.parameterize}_merge_requests_#{Date.current.iso8601}.csv" | ||||
|       attachments[filename] = { content: csv_data, mime_type: 'text/csv' } | ||||
|       mail(to: user.notification_email_for(@project.group), subject: subject("Exported merge requests")) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(@project.group), | ||||
|         subject: subject("Exported merge requests")) | ||||
|     end | ||||
| 
 | ||||
|     def approved_merge_request_email(recipient_id, merge_request_id, approved_by_user_id, reason = nil) | ||||
|  |  | |||
|  | @ -30,11 +30,9 @@ module Emails | |||
| 
 | ||||
|       add_headers | ||||
| 
 | ||||
|       mail(to: recipient, | ||||
|            subject: subject(pipeline_subject(status))) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: recipient, | ||||
|         subject: subject(pipeline_subject(status))) | ||||
|     end | ||||
| 
 | ||||
|     def add_headers | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ module Emails | |||
|       @user = user | ||||
|       @recipient = recipient | ||||
| 
 | ||||
|       profile_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: recipient.notification_email_or_default, | ||||
|         subject: subject(_("GitLab Account Request"))) | ||||
|     end | ||||
|  | @ -21,7 +21,7 @@ module Emails | |||
|     def user_admin_rejection_email(name, email) | ||||
|       @name = name | ||||
| 
 | ||||
|       profile_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: email, | ||||
|         subject: subject(_("GitLab account request rejected"))) | ||||
|     end | ||||
|  | @ -29,7 +29,7 @@ module Emails | |||
|     def user_deactivated_email(name, email) | ||||
|       @name = name | ||||
| 
 | ||||
|       profile_email_with_layout( | ||||
|       email_with_layout( | ||||
|         to: email, | ||||
|         subject: subject(_('Your account has been deactivated'))) | ||||
|     end | ||||
|  | @ -125,7 +125,7 @@ module Emails | |||
|       @target_url = edit_profile_password_url | ||||
| 
 | ||||
|       Gitlab::I18n.with_locale(@user.preferred_language) do | ||||
|         profile_email_with_layout( | ||||
|         email_with_layout( | ||||
|           to: @user.notification_email_or_default, | ||||
|           subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host })) | ||||
|       end | ||||
|  | @ -151,15 +151,6 @@ module Emails | |||
|         mail(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     def profile_email_with_layout(to:, subject:, layout: 'mailer') | ||||
|       mail(to: to, subject: subject) do |format| | ||||
|         format.html { render layout: layout } | ||||
|         format.text { render layout: layout } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
|  |  | |||
|  | @ -75,11 +75,9 @@ module Emails | |||
|       subject_text = "Action required: Project #{project.name} is scheduled to be deleted on " \ | ||||
|       "#{deletion_date} due to inactivity" | ||||
| 
 | ||||
|       mail(to: user.notification_email_for(project.group), | ||||
|            subject: subject(subject_text)) do |format| | ||||
|         format.html { render layout: 'mailer' } | ||||
|         format.text { render layout: 'mailer' } | ||||
|       end | ||||
|       email_with_layout( | ||||
|         to: user.notification_email_for(project.group), | ||||
|         subject: subject(subject_text)) | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
|  |  | |||
|  | @ -222,6 +222,13 @@ class Notify < ApplicationMailer | |||
|     headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',') | ||||
|     @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification) | ||||
|   end | ||||
| 
 | ||||
|   def email_with_layout(to:, subject:, layout: 'mailer') | ||||
|     mail(to: to, subject: subject) do |format| | ||||
|       format.html { render layout: layout } | ||||
|       format.text { render layout: layout } | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| Notify.prepend_mod_with('Notify') | ||||
|  |  | |||
|  | @ -205,6 +205,10 @@ class NotifyPreview < ActionMailer::Preview | |||
|     Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message | ||||
|   end | ||||
| 
 | ||||
|   def user_auto_banned_email | ||||
|     ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def project | ||||
|  |  | |||
|  | @ -1,7 +1,5 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'digest/md5' | ||||
| 
 | ||||
| class Key < ApplicationRecord | ||||
|   include AfterCommitQueue | ||||
|   include Sortable | ||||
|  |  | |||
|  | @ -121,16 +121,16 @@ module MergeRequests | |||
|     override :handle_quick_actions | ||||
|     def handle_quick_actions(merge_request) | ||||
|       super | ||||
|       handle_wip_event(merge_request) | ||||
|       handle_draft_event(merge_request) | ||||
|     end | ||||
| 
 | ||||
|     def handle_wip_event(merge_request) | ||||
|       if wip_event = params.delete(:wip_event) | ||||
|     def handle_draft_event(merge_request) | ||||
|       if draft_event = params.delete(:wip_event) | ||||
|         # We update the title that is provided in the params or we use the mr title | ||||
|         title = params[:title] || merge_request.title | ||||
|         params[:title] = case wip_event | ||||
|                          when 'wip' then MergeRequest.wip_title(title) | ||||
|                          when 'unwip' then MergeRequest.wipless_title(title) | ||||
|         params[:title] = case draft_event | ||||
|                          when 'wip' then MergeRequest.draft_title(title) | ||||
|                          when 'unwip' then MergeRequest.draftless_title(title) | ||||
|                          end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -46,7 +46,7 @@ module MergeRequests | |||
|              :source_branch_ref, | ||||
|              :source_project, | ||||
|              :compare_commits, | ||||
|              :wip_title, | ||||
|              :draft_title, | ||||
|              :description, | ||||
|              :first_multiline_commit, | ||||
|              :errors, | ||||
|  | @ -206,7 +206,7 @@ module MergeRequests | |||
|     def set_draft_title_if_needed | ||||
|       return unless compare_commits.empty? || Gitlab::Utils.to_boolean(params[:draft]) | ||||
| 
 | ||||
|       merge_request.title = wip_title | ||||
|       merge_request.title = draft_title | ||||
|     end | ||||
| 
 | ||||
|     # When your branch name starts with an iid followed by a dash this pattern will be | ||||
|  |  | |||
|  | @ -43,3 +43,5 @@ module MergeRequests | |||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| MergeRequests::ReloadMergeHeadDiffService.prepend_mod | ||||
|  |  | |||
|  | @ -24,12 +24,14 @@ module Packages | |||
|           file.write(content) | ||||
|           file.flush | ||||
| 
 | ||||
|           md5 = Gitlab::FIPS.enabled? ? nil : Digest::MD5.hexdigest(content) | ||||
| 
 | ||||
|           package.package_files.create!( | ||||
|             file: file, | ||||
|             size: file.size, | ||||
|             file_name: "#{gemspec.name}.gemspec", | ||||
|             file_sha1: Digest::SHA1.hexdigest(content), | ||||
|             file_md5: Digest::MD5.hexdigest(content), | ||||
|             file_md5: md5, | ||||
|             file_sha256: Digest::SHA256.hexdigest(content) | ||||
|           ) | ||||
|         ensure | ||||
|  |  | |||
|  | @ -20,7 +20,7 @@ | |||
|         %button.gl-button.btn.btn-default.award-control.has-tooltip.js-add-award{ type: 'button', | ||||
|           'aria-label': _('Add reaction'), | ||||
|           data: { title: _('Add reaction') } } | ||||
|           %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile') | ||||
|           %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley') | ||||
|           %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile') | ||||
|           %span{ class: "award-control-icon award-control-icon-neutral gl-icon" }= sprite_icon('slight-smile') | ||||
|           %span{ class: "award-control-icon award-control-icon-positive gl-icon" }= sprite_icon('smiley') | ||||
|           %span{ class: "award-control-icon award-control-icon-super-positive gl-icon" }= sprite_icon('smile') | ||||
|     = yield | ||||
|  |  | |||
|  | @ -0,0 +1,9 @@ | |||
| - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe | ||||
| - link_end = '</a>'.html_safe | ||||
| = email_default_heading(_("We've detected some unusual activity")) | ||||
| %p | ||||
|   = _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } | ||||
| %p | ||||
|   = _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end } | ||||
| %p | ||||
|   = _('You can adjust rules on auto-banning %{link_start}here%{link_end}.').html_safe % { link_start: link_start % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') }, link_end: link_end } | ||||
|  | @ -0,0 +1,7 @@ | |||
| <%= _("We've detected some unusual activity") %> | ||||
| 
 | ||||
| <%= _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } %> | ||||
| 
 | ||||
| <%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %> | ||||
| 
 | ||||
| <%= _('You can adjust rules on auto-banning here: %{url}.') % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') } %> | ||||
|  | @ -9,15 +9,14 @@ | |||
| - if can?(current_user, :award_emoji, note) | ||||
|   - if note.emoji_awardable? | ||||
|     .note-actions-item | ||||
|       = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary btn-transparent", data: { position: 'right', container: 'body' } do | ||||
|         = sprite_icon('slight-smile', css_class: 'link-highlight award-control-icon-neutral gl-button-icon gl-icon gl-text-gray-400') | ||||
|         = sprite_icon('smiley', css_class: 'link-highlight award-control-icon-positive gl-button-icon gl-icon gl-left-3!') | ||||
|         = sprite_icon('smile', css_class: 'link-highlight award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ') | ||||
|       = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary", data: { position: 'right', container: 'body' } do | ||||
|         = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral gl-button-icon gl-icon') | ||||
|         = sprite_icon('smiley', css_class: 'award-control-icon-positive gl-button-icon gl-icon gl-left-3!') | ||||
|         = sprite_icon('smile', css_class: 'award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ') | ||||
| 
 | ||||
|   - if note_editable | ||||
|     .note-actions-item.gl-ml-0 | ||||
|       = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-px-2!', data: { container: 'body', qa_selector: 'edit_comment_button' } do | ||||
|         %span.link-highlight | ||||
|           = sprite_icon('pencil', css_class: 'gl-button-icon gl-icon gl-text-gray-400 s16') | ||||
|       = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { container: 'body', qa_selector: 'edit_comment_button' } do | ||||
|         = sprite_icon('pencil', css_class: 'gl-button-icon gl-icon') | ||||
| 
 | ||||
|   = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable | ||||
|  |  | |||
|  | @ -2,8 +2,8 @@ | |||
| 
 | ||||
| - if note_editable || !is_current_user | ||||
|   %div{ class: "dropdown more-actions note-actions-item gl-ml-0!" } | ||||
|     = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-pl-2! gl-pr-0!', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do | ||||
|       = sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon gl-text-gray-400') | ||||
|     = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do | ||||
|       = sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon') | ||||
|     %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left | ||||
|       %li | ||||
|         = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) | ||||
|  |  | |||
|  | @ -1,15 +1,14 @@ | |||
| - if current_user | ||||
|   - if note.emoji_awardable? | ||||
|     .note-actions-item | ||||
|       = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do | ||||
|         %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') | ||||
|         %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') | ||||
|         %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') | ||||
|       = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary", data: { position: 'right' } do | ||||
|         = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral gl-button-icon gl-icon') | ||||
|         = sprite_icon('smiley', css_class: 'award-control-icon-positive gl-button-icon gl-icon gl-left-3!') | ||||
|         = sprite_icon('smile', css_class: 'award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ') | ||||
| 
 | ||||
|   - if note_editable | ||||
|     .note-actions-item | ||||
|       = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip gl-button btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do | ||||
|         %span.link-highlight | ||||
|           = custom_icon('icon_pencil') | ||||
|     .note-actions-item.gl-ml-0 | ||||
|       = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip gl-button btn btn-default-tertiary btn-icon', data: { container: 'body', qa_selector: 'edit_comment_button' } do | ||||
|         = sprite_icon('pencil') | ||||
| 
 | ||||
|   = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable | ||||
|  |  | |||
|  | @ -1,7 +1,6 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'settingslogic' | ||||
| require 'digest/md5' | ||||
| 
 | ||||
| class Settings < Settingslogic | ||||
|   source ENV.fetch('GITLAB_CONFIG') { Pathname.new(File.expand_path('..', __dir__)).join('config/gitlab.yml') } | ||||
|  |  | |||
|  | @ -1,7 +1,5 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'digest/md5' | ||||
| 
 | ||||
| REVIEW_ROULETTE_SECTION = <<MARKDOWN | ||||
| ## Reviewer roulette | ||||
| MARKDOWN | ||||
|  |  | |||
|  | @ -1,7 +1,5 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'digest/md5' | ||||
| 
 | ||||
| class Gitlab::Seeder::GroupLabels | ||||
|   def initialize(group, label_per_group: 10) | ||||
|     @group = group | ||||
|  |  | |||
|  | @ -4,13 +4,13 @@ group: Pipeline Execution | |||
| info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments | ||||
| --- | ||||
| 
 | ||||
| # Pipeline triggers API **(FREE)** | ||||
| # Pipeline trigger tokens API **(FREE)** | ||||
| 
 | ||||
| You can read more about [triggering pipelines through the API](../ci/triggers/index.md). | ||||
| 
 | ||||
| ## List project triggers | ||||
| ## List project trigger tokens | ||||
| 
 | ||||
| Get a list of project's build triggers. | ||||
| Get a list of a project's pipeline trigger tokens. | ||||
| 
 | ||||
| ```plaintext | ||||
| GET /projects/:id/triggers | ||||
|  | @ -41,9 +41,9 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a | |||
| The trigger token is displayed in full if the trigger token was created by the authenticated | ||||
| user. Trigger tokens created by other users are shortened to four characters. | ||||
| 
 | ||||
| ## Get trigger details | ||||
| ## Get trigger token details | ||||
| 
 | ||||
| Get details of project's build trigger. | ||||
| Get details of a project's pipeline trigger. | ||||
| 
 | ||||
| ```plaintext | ||||
| GET /projects/:id/triggers/:trigger_id | ||||
|  | @ -70,9 +70,9 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a | |||
| } | ||||
| ``` | ||||
| 
 | ||||
| ## Create a project trigger | ||||
| ## Create a trigger token | ||||
| 
 | ||||
| Create a trigger for a project. | ||||
| Create a pipeline trigger for a project. | ||||
| 
 | ||||
| ```plaintext | ||||
| POST /projects/:id/triggers | ||||
|  | @ -100,9 +100,9 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ | |||
| } | ||||
| ``` | ||||
| 
 | ||||
| ## Update a project trigger | ||||
| ## Update a project trigger token | ||||
| 
 | ||||
| Update a trigger for a project. | ||||
| Update a pipeline trigger token for a project. | ||||
| 
 | ||||
| ```plaintext | ||||
| PUT /projects/:id/triggers/:trigger_id | ||||
|  | @ -131,9 +131,9 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" \ | |||
| } | ||||
| ``` | ||||
| 
 | ||||
| ## Remove a project trigger | ||||
| ## Remove a project trigger token | ||||
| 
 | ||||
| Remove a project's build trigger. | ||||
| Remove a project's pipeline trigger token. | ||||
| 
 | ||||
| ```plaintext | ||||
| DELETE /projects/:id/triggers/:trigger_id | ||||
|  | @ -147,3 +147,78 @@ DELETE /projects/:id/triggers/:trigger_id | |||
| ```shell | ||||
| curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/triggers/5" | ||||
| ``` | ||||
| 
 | ||||
| ## Trigger a pipeline with a token | ||||
| 
 | ||||
| Trigger a pipeline by using a pipeline [trigger token](../ci/triggers/index.md#create-a-trigger-token) | ||||
| or a [CI/CD job token](../ci/jobs/ci_job_token.md) for authentication. | ||||
| 
 | ||||
| With a CI/CD job token, the [triggered pipeline is a multi-project pipeline](../ci/jobs/ci_job_token.md#trigger-a-multi-project-pipeline-by-using-a-cicd-job-token). | ||||
| The job that authenticates the request becomes associated with the upstream pipeline, | ||||
| which is visible on the [pipeline graph](../ci/pipelines/multi_project_pipelines.md#multi-project-pipeline-visualization). | ||||
| 
 | ||||
| If you use a trigger token in a job, the job is not associated with the upstream pipeline. | ||||
| 
 | ||||
| ```plaintext | ||||
| POST /projects/:id/trigger/pipeline | ||||
| ``` | ||||
| 
 | ||||
| Supported attributes: | ||||
| 
 | ||||
| | Attribute   | Type           | Required               | Description | | ||||
| |:------------|:---------------|:-----------------------|:---------------------| | ||||
| | `id`        | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. | | ||||
| | `ref`       | string         | **{check-circle}** Yes | The branch or tag to run the pipeline on. | | ||||
| | `token`     | string         | **{check-circle}** Yes | The trigger token or CI/CD job token. | | ||||
| | `variables` | array          | **{dotted-circle}** No | An array containing the variables available in the pipeline, matching the structure `[{ 'key': 'UPLOAD_TO_S3', 'variable_type': 'file', 'value': 'true' }, {'key': 'TEST', 'value': 'test variable'}]`. If `variable_type` is excluded, it defaults to `env_var`. | | ||||
| 
 | ||||
| Example request: | ||||
| 
 | ||||
| ```shell | ||||
| curl --request POST "https://gitlab.example.com/api/v4/projects/123/trigger/pipeline?token=2cb1840fb9dfc9fb0b7b1609cd29cb&ref=main" | ||||
| ``` | ||||
| 
 | ||||
| Example response: | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "id": 257, | ||||
|   "iid": 118, | ||||
|   "project_id": 21, | ||||
|   "sha": "91e2711a93e5d9e8dddfeb6d003b636b25bf6fc9", | ||||
|   "ref": "main", | ||||
|   "status": "created", | ||||
|   "source": "trigger", | ||||
|   "created_at": "2022-03-31T01:12:49.068Z", | ||||
|   "updated_at": "2022-03-31T01:12:49.068Z", | ||||
|   "web_url": "http://127.0.0.1:3000/test-group/test-project/-/pipelines/257", | ||||
|   "before_sha": "0000000000000000000000000000000000000000", | ||||
|   "tag": false, | ||||
|   "yaml_errors": null, | ||||
|   "user": { | ||||
|     "id": 1, | ||||
|     "username": "root", | ||||
|     "name": "Administrator", | ||||
|     "state": "active", | ||||
|     "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", | ||||
|     "web_url": "http://127.0.0.1:3000/root" | ||||
|   }, | ||||
|   "started_at": null, | ||||
|   "finished_at": null, | ||||
|   "committed_at": null, | ||||
|   "duration": null, | ||||
|   "queued_duration": null, | ||||
|   "coverage": null, | ||||
|   "detailed_status": { | ||||
|     "icon": "status_created", | ||||
|     "text": "created", | ||||
|     "label": "created", | ||||
|     "group": "created", | ||||
|     "tooltip": "created", | ||||
|     "has_details": true, | ||||
|     "details_path": "/test-group/test-project/-/pipelines/257", | ||||
|     "illustration": null, | ||||
|     "favicon": "/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png" | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  |  | |||
|  | @ -104,13 +104,12 @@ The job token scope is only for controlling access to private projects. | |||
| There is [a proposal](https://gitlab.com/groups/gitlab-org/-/epics/3559) to improve | ||||
| the feature with more strategic control of the access permissions. | ||||
| 
 | ||||
| ## Trigger a multi-project pipeline by using a CI job token | ||||
| ## Trigger a multi-project pipeline by using a CI/CD job token | ||||
| 
 | ||||
| > `CI_JOB_TOKEN` for multi-project pipelines was [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/31573) from GitLab Premium to GitLab Free in 12.4. | ||||
| 
 | ||||
| You can use the `CI_JOB_TOKEN` to trigger [multi-project pipelines](../pipelines/multi_project_pipelines.md) | ||||
| from a CI/CD job. A pipeline triggered this way creates a dependent pipeline relation | ||||
| that is visible on the [pipeline graph](../pipelines/multi_project_pipelines.md#multi-project-pipeline-visualization). | ||||
| You can use the `CI_JOB_TOKEN` to [trigger multi-project pipelines](../../api/pipeline_triggers.md#trigger-a-pipeline-with-a-token) | ||||
| from a CI/CD job. | ||||
| 
 | ||||
| For example: | ||||
| 
 | ||||
|  |  | |||
|  | @ -72,11 +72,16 @@ For configuration information, see | |||
| 
 | ||||
| ### Git operations using SSH | ||||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78373) in GitLab 14.7. | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78373) in GitLab 14.7 [with a flag](../administration/feature_flags.md) named `rate_limit_gitlab_shell`. Disabled by default. | ||||
| > - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79419) in GitLab 14.8. | ||||
| 
 | ||||
| GitLab rate limits Git operations using SSH by user account and project. If a request from a user for a Git operation | ||||
| on a project exceeds the rate limit, GitLab drops further connection requests from that user for the project. | ||||
| FLAG: | ||||
| On self-managed GitLab, by default this feature is available. To disable the feature, ask an administrator to | ||||
| [disable the feature flag](../administration/feature_flags.md) named `rate_limit_gitlab_shell`. On GitLab.com, this feature | ||||
| is available. | ||||
| 
 | ||||
| GitLab applies rate limits to Git operations that use SSH by user account and project. When the rate limit is exceeded, GitLab rejects | ||||
| further connection requests from that user for the project. | ||||
| 
 | ||||
| The rate limit applies at the Git command ([plumbing](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain)) level. | ||||
| Each command has a rate limit of 600 per minute. For example: | ||||
|  | @ -86,9 +91,8 @@ Each command has a rate limit of 600 per minute. For example: | |||
| 
 | ||||
| Because the same commands are shared by `git-upload-pack`, `git pull`, and `git clone`, they share a rate limit. | ||||
| 
 | ||||
| The requests/minute threshold for this rate limit is not configurable. Self-managed customers can disable this | ||||
| rate limit by [disabling the feature flag](../administration/feature_flags.md#enable-or-disable-the-feature) | ||||
| with `Feature.disable(:rate_limit_gitlab_shell)`. | ||||
| The requests per minute threshold for this rate limit is not configurable. Self-managed customers can disable this | ||||
| rate limit. | ||||
| 
 | ||||
| ### Repository archives | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,11 +10,6 @@ The [certificate-based Kubernetes integration with GitLab](../index.md) | |||
| was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) | ||||
| in GitLab 14.5. To connect your clusters, use the [GitLab agent](../../../clusters/agent/index.md). | ||||
| 
 | ||||
| <!-- TBA: (We need to resolve https://gitlab.com/gitlab-org/gitlab/-/issues/343660 before adding this line) | ||||
| If you don't have a cluster yet, create one and connect it to GitLab through the agent. | ||||
| You can also create a new cluster from GitLab using [Infrastructure as Code](../../iac/index.md#create-a-new-cluster-through-iac). | ||||
| --> | ||||
| 
 | ||||
| ## Cluster levels (DEPRECATED) | ||||
| 
 | ||||
| > [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5. | ||||
|  |  | |||
|  | @ -32,8 +32,7 @@ To get started, choose the template that best suits your needs: | |||
| 
 | ||||
| All templates: | ||||
| 
 | ||||
| - Use the [GitLab-managed Terraform state](#gitlab-managed-terraform-state) as | ||||
|   the Terraform state storage backend. | ||||
| - Use the [GitLab-managed Terraform state](terraform_state.md) as the Terraform state storage backend. | ||||
| - Trigger four pipeline stages: `test`, `validate`, `build`, and `deploy`. | ||||
| - Run Terraform commands: `test`, `validate`, `plan`, and `plan-json`. It also runs the `apply` only on the default branch. | ||||
| - Run the [Terraform SAST scanner](../../application_security/iac_scanning/index.md#configure-iac-scanning-manually). | ||||
|  | @ -89,37 +88,19 @@ To use a Terraform template: | |||
|     # TF_ROOT: terraform/production | ||||
|    ``` | ||||
| 
 | ||||
| 1. (Optional) Override in your `.gitlab-ci.yml` file the attributes present | ||||
| 1. Optional. Override in your `.gitlab-ci.yml` file the attributes present | ||||
| in the template you fetched to customize your configuration. | ||||
| 
 | ||||
| ## GitLab-managed Terraform state | ||||
| 
 | ||||
| Use the [GitLab-managed Terraform state](terraform_state.md) to store state | ||||
| files in local storage or in a remote store of your choice. | ||||
| 
 | ||||
| ## Terraform module registry | ||||
| 
 | ||||
| Use GitLab as a [Terraform module registry](../../packages/terraform_module_registry/index.md) | ||||
| to create and publish Terraform modules to a private registry. | ||||
| 
 | ||||
| ## Terraform integration in merge requests | ||||
| 
 | ||||
| Use the [Terraform integration in merge requests](mr_integration.md) | ||||
| to collaborate on Terraform code changes and Infrastructure-as-Code | ||||
| workflows. | ||||
| 
 | ||||
| ## The GitLab Terraform provider | ||||
| 
 | ||||
| The [GitLab Terraform provider](https://github.com/gitlabhq/terraform-provider-gitlab) is a Terraform plugin to facilitate | ||||
| managing of GitLab resources such as users, groups, and projects. It is released separately from GitLab | ||||
| and its documentation is available on [Terraform](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs). | ||||
| 
 | ||||
| ## Create a new cluster through IaC | ||||
| 
 | ||||
| - Learn how to [create a new cluster on Amazon Elastic Kubernetes Service (EKS)](../clusters/connect/new_eks_cluster.md). | ||||
| - Learn how to [create a new cluster on Google Kubernetes Engine (GKE)](../clusters/connect/new_gke_cluster.md). | ||||
| 
 | ||||
| ## Related topics | ||||
| 
 | ||||
| - [Terraform images](https://gitlab.com/gitlab-org/terraform-images). | ||||
| - [Troubleshooting](troubleshooting.md) issues with GitLab and Terraform. | ||||
| - View [the images that contain the `gitlab-terraform` shell script](https://gitlab.com/gitlab-org/terraform-images). | ||||
| - Use GitLab as a [Terraform module registry](../../packages/terraform_module_registry/index.md). | ||||
| - To store state files in local storage or in a remote store, use the [GitLab-managed Terraform state](terraform_state.md). | ||||
| - To collaborate on Terraform code changes and Infrastructure-as-Code workflows, use the | ||||
|   [Terraform integration in merge requests](mr_integration.md). | ||||
| - To manage GitLab resources like users, groups, and projects, use the | ||||
|   [GitLab Terraform provider](https://github.com/gitlabhq/terraform-provider-gitlab). It is released separately from GitLab | ||||
|   and its documentation is available on [the Terraform docs site](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs). | ||||
| - [Create a new cluster on Amazon Elastic Kubernetes Service (EKS)](../clusters/connect/new_eks_cluster.md). | ||||
| - [Create a new cluster on Google Kubernetes Engine (GKE)](../clusters/connect/new_gke_cluster.md). | ||||
| - [Troubleshoot](troubleshooting.md) issues with GitLab and Terraform. | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w | |||
| > - [Deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5. | ||||
| 
 | ||||
| WARNING: | ||||
| This feature was deprecated in GitLab 14.5. Use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac) | ||||
| This feature was deprecated in GitLab 14.5. Use [Infrastructure as Code](../../infrastructure/iac/index.md) | ||||
| to create new clusters. | ||||
| 
 | ||||
| Through GitLab, you can create new clusters and add existing clusters hosted on Amazon Elastic | ||||
|  | @ -23,7 +23,7 @@ use the [GitLab agent](../../clusters/agent/index.md). | |||
| 
 | ||||
| ## Create a new EKS cluster | ||||
| 
 | ||||
| To create a new cluster from GitLab, use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac). | ||||
| To create a new cluster from GitLab, use [Infrastructure as Code](../../infrastructure/iac/index.md). | ||||
| 
 | ||||
| ### How to create a new cluster on EKS through cluster certificates (DEPRECATED) | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w | |||
| 
 | ||||
| WARNING: | ||||
| This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/327908) in GitLab 14.0. | ||||
| To create and manage a new cluster use [Infrastructure as Code](../../infrastructure/iac/index.md#create-a-new-cluster-through-iac). | ||||
| To create and manage a new cluster use [Infrastructure as Code](../../infrastructure/iac/index.md). | ||||
| 
 | ||||
| ## Disable a cluster | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,9 +3,9 @@ | |||
| module Gitlab | ||||
|   module Database | ||||
|     class ConsistencyChecker | ||||
|       BATCH_SIZE = 1000 | ||||
|       MAX_BATCHES = 25 | ||||
|       MAX_RUNTIME = 30.seconds # must be less than the scheduling frequency of the ConsistencyCheck jobs | ||||
|       BATCH_SIZE = 500 | ||||
|       MAX_BATCHES = 20 | ||||
|       MAX_RUNTIME = 5.seconds # must be less than the scheduling frequency of the ConsistencyCheck jobs | ||||
| 
 | ||||
|       delegate :monotonic_time, to: :'Gitlab::Metrics::System' | ||||
| 
 | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ module Gitlab | |||
|                           else | ||||
|                             # Only show what is new in the source branch | ||||
|                             # compared to the target branch, not the other way | ||||
|                             # around. The linex below with merge_base is | ||||
|                             # around. The line below with merge_base is | ||||
|                             # equivalent to diff with three dots (git diff | ||||
|                             # branch1...branch2) From the git documentation: | ||||
|                             # "git diff A...B" is equivalent to "git diff | ||||
|  |  | |||
|  | @ -4,60 +4,6 @@ require 'logger' | |||
| 
 | ||||
| namespace :gitlab do | ||||
|   namespace :pages do | ||||
|     desc "GitLab | Pages | Migrate legacy storage to zip format" | ||||
|     task migrate_legacy_storage: :gitlab_environment do | ||||
|       logger.info('Starting to migrate legacy pages storage to zip deployments') | ||||
| 
 | ||||
|       result = ::Pages::MigrateFromLegacyStorageService.new(logger, | ||||
|                                                             ignore_invalid_entries: ignore_invalid_entries, | ||||
|                                                             mark_projects_as_not_deployed: mark_projects_as_not_deployed) | ||||
|                  .execute_with_threads(threads: migration_threads, batch_size: batch_size) | ||||
| 
 | ||||
|       logger.info("A total of #{result[:migrated] + result[:errored]} projects were processed.") | ||||
|       logger.info("- The #{result[:migrated]} projects migrated successfully") | ||||
|       logger.info("- The #{result[:errored]} projects failed to be migrated") | ||||
|     end | ||||
| 
 | ||||
|     desc "GitLab | Pages | DANGER: Removes data which was migrated from legacy storage on zip storage. Can be used if some bugs in migration are discovered and migration needs to be restarted from scratch." | ||||
|     task clean_migrated_zip_storage: :gitlab_environment do | ||||
|       destroyed_deployments = 0 | ||||
| 
 | ||||
|       logger.info("Starting to delete migrated pages deployments") | ||||
| 
 | ||||
|       ::PagesDeployment.migrated_from_legacy_storage.each_batch(of: batch_size) do |batch| | ||||
|         destroyed_deployments += batch.count | ||||
| 
 | ||||
|         # we need to destroy associated files, so can't use delete_all | ||||
|         batch.destroy_all # rubocop: disable Cop/DestroyAll | ||||
| 
 | ||||
|         logger.info("#{destroyed_deployments} deployments were deleted") | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def logger | ||||
|       @logger ||= Logger.new($stdout) | ||||
|     end | ||||
| 
 | ||||
|     def migration_threads | ||||
|       ENV.fetch('PAGES_MIGRATION_THREADS', '3').to_i | ||||
|     end | ||||
| 
 | ||||
|     def batch_size | ||||
|       ENV.fetch('PAGES_MIGRATION_BATCH_SIZE', '10').to_i | ||||
|     end | ||||
| 
 | ||||
|     def ignore_invalid_entries | ||||
|       Gitlab::Utils.to_boolean( | ||||
|         ENV.fetch('PAGES_MIGRATION_IGNORE_INVALID_ENTRIES', 'false') | ||||
|       ) | ||||
|     end | ||||
| 
 | ||||
|     def mark_projects_as_not_deployed | ||||
|       Gitlab::Utils.to_boolean( | ||||
|         ENV.fetch('PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED', 'false') | ||||
|       ) | ||||
|     end | ||||
| 
 | ||||
|     namespace :deployments do | ||||
|       task migrate_to_object_storage: :gitlab_environment do | ||||
|         logger = Logger.new($stdout) | ||||
|  |  | |||
|  | @ -19162,6 +19162,12 @@ msgstr "" | |||
| msgid "If this email was added in error, you can remove it here: %{profile_emails_url}" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "If this is a mistake, you can %{link_start}unban them%{link_end}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "If this is a mistake, you can unban them: %{url}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -42469,6 +42475,9 @@ msgstr "" | |||
| msgid "We want to be sure it is you, please confirm you are not a robot." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -42484,6 +42493,12 @@ msgstr "" | |||
| msgid "We're experiencing difficulties and this tab content is currently unavailable." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "We've detected some unusual activity" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "We've detected unusual activity" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "We've found no vulnerabilities" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -43077,9 +43092,21 @@ msgstr "" | |||
| msgid "Work in progress Limit" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Add" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Add a child" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Cancel" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Child items" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Convert to work item" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -43092,6 +43119,9 @@ msgstr "" | |||
| msgid "WorkItem|New Task" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "WorkItem|Select type" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -43304,6 +43334,12 @@ msgstr "" | |||
| msgid "You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "You can adjust rules on auto-banning %{link_start}here%{link_end}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "You can adjust rules on auto-banning here: %{url}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "You can also create a project from the command line." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Verify', :runner, :reliable, quarantine: { | ||||
|     only: { subdomain: %i[staging staging-canary] }, | ||||
|     issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|     type: :investigating | ||||
|   } do | ||||
|   RSpec.describe 'Verify', :runner, :reliable do | ||||
|     describe 'Parent-child pipelines dependent relationship' do | ||||
|       let!(:project) do | ||||
|         Resource::Project.fabricate_via_api! do |project| | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Verify', :runner, :reliable, quarantine: { | ||||
|     only: { subdomain: %i[staging staging-canary] }, | ||||
|     issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|     type: :investigating | ||||
|   } do | ||||
|   RSpec.describe 'Verify', :runner, :reliable do | ||||
|     describe 'Parent-child pipelines independent relationship' do | ||||
|       let!(:project) do | ||||
|         Resource::Project.fabricate_via_api! do |project| | ||||
|  |  | |||
|  | @ -2,11 +2,7 @@ | |||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Package' do | ||||
|     describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] }, quarantine: { | ||||
|       only: { subdomain: %i[staging staging-canary] }, | ||||
|       issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|       type: :investigating | ||||
|     } do | ||||
|     describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] } do | ||||
|       let(:project) do | ||||
|         Resource::Project.fabricate_via_api! do |project| | ||||
|           project.name = 'project-with-registry' | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: { | ||||
|     only: { subdomain: %i[staging staging-canary] }, | ||||
|     issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|     type: :investigating | ||||
|   } do | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do | ||||
|     describe 'Maven group level endpoint' do | ||||
|       include Runtime::Fixtures | ||||
|       include_context 'packages registry qa scenario' | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: { | ||||
|     only: { subdomain: %i[staging staging-canary] }, | ||||
|     issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|     type: :investigating | ||||
|   } do | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do | ||||
|     describe 'Maven project level endpoint' do | ||||
|       let(:group_id) { 'com.gitlab.qa' } | ||||
|       let(:artifact_id) { "maven-#{SecureRandom.hex(8)}" } | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, quarantine: { | ||||
|     only: { subdomain: %i[staging staging-canary] }, | ||||
|     issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/363188", | ||||
|     type: :investigating | ||||
|   } do | ||||
|   RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do | ||||
|     describe 'NuGet group level endpoint' do | ||||
|       using RSpec::Parameterized::TableSyntax | ||||
|       include Runtime::Fixtures | ||||
|  |  | |||
|  | @ -1730,7 +1730,7 @@ RSpec.describe Projects::MergeRequestsController do | |||
| 
 | ||||
|   describe 'POST remove_wip' do | ||||
|     before do | ||||
|       merge_request.title = merge_request.wip_title | ||||
|       merge_request.title = merge_request.draft_title | ||||
|       merge_request.save! | ||||
| 
 | ||||
|       post :remove_wip, | ||||
|  | @ -1743,8 +1743,8 @@ RSpec.describe Projects::MergeRequestsController do | |||
|         xhr: true | ||||
|     end | ||||
| 
 | ||||
|     it 'removes the wip status' do | ||||
|       expect(merge_request.reload.title).to eq(merge_request.wipless_title) | ||||
|     it 'removes the draft status' do | ||||
|       expect(merge_request.reload.title).to eq(merge_request.draftless_title) | ||||
|     end | ||||
| 
 | ||||
|     it 'renders MergeRequest as JSON' do | ||||
|  |  | |||
|  | @ -56,7 +56,7 @@ exports[`Design management toolbar component renders design and updated data 1`] | |||
|     buttonclass="" | ||||
|     buttonicon="archive" | ||||
|     buttonsize="medium" | ||||
|     buttonvariant="warning" | ||||
|     buttonvariant="default" | ||||
|     class="gl-ml-3" | ||||
|     hasselecteddesigns="true" | ||||
|     title="Archive design" | ||||
|  |  | |||
|  | @ -1,10 +1,10 @@ | |||
| import { setHTMLFixture } from 'helpers/fixtures'; | ||||
| import * as createDefaultClient from '~/lib/graphql'; | ||||
| import initMRPopovers from '~/mr_popover/index'; | ||||
| import initIssuablePopovers from '~/issuable/popover/index'; | ||||
| 
 | ||||
| createDefaultClient.default = jest.fn(); | ||||
| 
 | ||||
| describe('initMRPopovers', () => { | ||||
| describe('initIssuablePopovers', () => { | ||||
|   let mr1; | ||||
|   let mr2; | ||||
|   let mr3; | ||||
|  | @ -14,7 +14,7 @@ describe('initMRPopovers', () => { | |||
|       <div id="one" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project"> | ||||
|         MR1 | ||||
|       </div> | ||||
|       <div id="two" class="gfm-merge_request" data-mr-title="title" data-iid="1" data-project-path="group/project"> | ||||
|       <div id="two" class="gfm-merge_request" title="title" data-iid="1" data-project-path="group/project"> | ||||
|         MR2 | ||||
|       </div> | ||||
|       <div id="three" class="gfm-merge_request"> | ||||
|  | @ -32,14 +32,14 @@ describe('initMRPopovers', () => { | |||
|   }); | ||||
| 
 | ||||
|   it('does not add the same event listener twice', () => { | ||||
|     initMRPopovers([mr1, mr1, mr2]); | ||||
|     initIssuablePopovers([mr1, mr1, mr2]); | ||||
| 
 | ||||
|     expect(mr1.addEventListener).toHaveBeenCalledTimes(1); | ||||
|     expect(mr2.addEventListener).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| 
 | ||||
|   it('does not add listener if it does not have the necessary data attributes', () => { | ||||
|     initMRPopovers([mr1, mr2, mr3]); | ||||
|     initIssuablePopovers([mr1, mr2, mr3]); | ||||
| 
 | ||||
|     expect(mr3.addEventListener).not.toHaveBeenCalled(); | ||||
|   }); | ||||
|  | @ -1,6 +1,6 @@ | |||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import { nextTick } from 'vue'; | ||||
| import MRPopover from '~/mr_popover/components/mr_popover.vue'; | ||||
| import MRPopover from '~/issuable/popover/components/mr_popover.vue'; | ||||
| import CiIcon from '~/vue_shared/components/ci_icon.vue'; | ||||
| 
 | ||||
| describe('MR Popover', () => { | ||||
|  | @ -1,13 +1,11 @@ | |||
| import MockAdapter from 'axios-mock-adapter'; | ||||
| import { mount } from '@vue/test-utils'; | ||||
| import $ from 'jquery'; | ||||
| import waitForPromises from 'helpers/wait_for_promises'; | ||||
| import initMRPopovers from '~/mr_popover/index'; | ||||
| import createStore from '~/notes/stores'; | ||||
| import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; | ||||
| import axios from '~/lib/utils/axios_utils'; | ||||
| 
 | ||||
| jest.mock('~/mr_popover/index', () => jest.fn()); | ||||
| 
 | ||||
| describe('system note component', () => { | ||||
|   let vm; | ||||
|   let props; | ||||
|  | @ -76,10 +74,12 @@ describe('system note component', () => { | |||
|     expect(vm.find('.system-note-message').html()).toContain('<span>closed</span>'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should initMRPopovers onMount', () => { | ||||
|   it('should renderGFM onMount', () => { | ||||
|     const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); | ||||
| 
 | ||||
|     createComponent(props); | ||||
| 
 | ||||
|     expect(initMRPopovers).toHaveBeenCalled(); | ||||
|     expect(renderGFMSpy).toHaveBeenCalled(); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders outdated code lines', async () => { | ||||
|  |  | |||
|  | @ -0,0 +1,65 @@ | |||
| import { nextTick } from 'vue'; | ||||
| import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; | ||||
| 
 | ||||
| describe('WorkItemLinks', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const createComponent = () => { | ||||
|     wrapper = shallowMountExtended(WorkItemLinks, { propsData: { workItemId: '123' } }); | ||||
|   }; | ||||
| 
 | ||||
|   const findToggleButton = () => wrapper.findByTestId('toggle-links'); | ||||
|   const findLinksBody = () => wrapper.findByTestId('links-body'); | ||||
|   const findEmptyState = () => wrapper.findByTestId('links-empty'); | ||||
|   const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); | ||||
|   const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     createComponent(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   it('is collapsed by default', () => { | ||||
|     expect(findToggleButton().props('icon')).toBe('angle-down'); | ||||
|     expect(findLinksBody().exists()).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   it('expands on click toggle button', async () => { | ||||
|     findToggleButton().vm.$emit('click'); | ||||
|     await nextTick(); | ||||
| 
 | ||||
|     expect(findToggleButton().props('icon')).toBe('angle-up'); | ||||
|     expect(findLinksBody().exists()).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('displays empty state if there are no links', async () => { | ||||
|     findToggleButton().vm.$emit('click'); | ||||
|     await nextTick(); | ||||
| 
 | ||||
|     expect(findEmptyState().exists()).toBe(true); | ||||
|     expect(findToggleAddFormButton().exists()).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   describe('add link form', () => { | ||||
|     it('displays form on click add button and hides form on cancel', async () => { | ||||
|       findToggleButton().vm.$emit('click'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findEmptyState().exists()).toBe(true); | ||||
| 
 | ||||
|       findToggleAddFormButton().vm.$emit('click'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findAddLinksForm().exists()).toBe(true); | ||||
| 
 | ||||
|       findAddLinksForm().vm.$emit('cancel'); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(findAddLinksForm().exists()).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -174,7 +174,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do | |||
| 
 | ||||
|     context 'with draft argument' do | ||||
|       before do | ||||
|         merge_request_4.update!(title: MergeRequest.wip_title(merge_request_4.title)) | ||||
|         merge_request_4.update!(title: MergeRequest.draft_title(merge_request_4.title)) | ||||
|       end | ||||
| 
 | ||||
|       context 'with draft: true argument' do | ||||
|  |  | |||
|  | @ -3,9 +3,62 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Emails::AdminNotification do | ||||
|   include EmailSpec::Matchers | ||||
|   include_context 'gitlab email notification' | ||||
| 
 | ||||
|   it 'adds email methods to Notify' do | ||||
|     subject.instance_methods.each do |email_method| | ||||
|       expect(Notify).to be_respond_to(email_method) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'user_auto_banned_email' do | ||||
|     let_it_be(:admin) { create(:user) } | ||||
|     let_it_be(:user) { create(:user) } | ||||
| 
 | ||||
|     let(:max_project_downloads) { 5 } | ||||
|     let(:time_period) { 600 } | ||||
| 
 | ||||
|     subject do | ||||
|       Notify.user_auto_banned_email( | ||||
|         admin.id, user.id, | ||||
|         max_project_downloads: max_project_downloads, | ||||
|         within_seconds: time_period | ||||
|       ) | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'an email sent from GitLab' | ||||
|     it_behaves_like 'it should not have Gmail Actions links' | ||||
|     it_behaves_like 'a user cannot unsubscribe through footer link' | ||||
|     it_behaves_like 'appearance header and footer enabled' | ||||
|     it_behaves_like 'appearance header and footer not enabled' | ||||
| 
 | ||||
|     it 'is sent to the administrator' do | ||||
|       is_expected.to deliver_to admin.email | ||||
|     end | ||||
| 
 | ||||
|     it 'has the correct subject' do | ||||
|       is_expected.to have_subject "We've detected unusual activity" | ||||
|     end | ||||
| 
 | ||||
|     it 'includes the name of the user' do | ||||
|       is_expected.to have_body_text user.name | ||||
|     end | ||||
| 
 | ||||
|     it 'includes the reason' do | ||||
|       is_expected.to have_body_text "due to them downloading more than 5 project repositories within 10 minutes" | ||||
|     end | ||||
| 
 | ||||
|     it 'includes a link to unban the user' do | ||||
|       is_expected.to have_body_text admin_users_url(filter: 'banned') | ||||
|     end | ||||
| 
 | ||||
|     it 'includes a link to change the settings' do | ||||
|       is_expected.to have_body_text network_admin_application_settings_url(anchor: 'js-ip-limits-settings') | ||||
|     end | ||||
| 
 | ||||
|     it 'includes the email reason' do | ||||
|       is_expected.to have_body_text "You're receiving this email because of your account on localhost" | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -1472,20 +1472,20 @@ RSpec.describe MergeRequest, factory_default: :keep do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe "#wipless_title" do | ||||
|   describe "#draftless_title" do | ||||
|     subject { build_stubbed(:merge_request) } | ||||
| 
 | ||||
|     ['draft:', 'Draft: ', '[Draft]', '[DRAFT] '].each do |draft_prefix| | ||||
|       it "removes a '#{draft_prefix}' prefix" do | ||||
|         wipless_title = subject.title | ||||
|         draftless_title = subject.title | ||||
|         subject.title = "#{draft_prefix}#{subject.title}" | ||||
| 
 | ||||
|         expect(subject.wipless_title).to eq wipless_title | ||||
|         expect(subject.draftless_title).to eq draftless_title | ||||
|       end | ||||
| 
 | ||||
|       it "is satisfies the #work_in_progress? method" do | ||||
|         subject.title = "#{draft_prefix}#{subject.title}" | ||||
|         subject.title = subject.wipless_title | ||||
|         subject.title = subject.draftless_title | ||||
| 
 | ||||
|         expect(subject.work_in_progress?).to eq false | ||||
|       end | ||||
|  | @ -1497,58 +1497,58 @@ RSpec.describe MergeRequest, factory_default: :keep do | |||
|       it "doesn't remove a '#{wip_prefix}' prefix" do | ||||
|         subject.title = "#{wip_prefix}#{subject.title}" | ||||
| 
 | ||||
|         expect(subject.wipless_title).to eq subject.title | ||||
|         expect(subject.draftless_title).to eq subject.title | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     it 'removes only draft prefix from the MR title' do | ||||
|       subject.title = 'Draft: Implement feature called draft' | ||||
| 
 | ||||
|       expect(subject.wipless_title).to eq 'Implement feature called draft' | ||||
|       expect(subject.draftless_title).to eq 'Implement feature called draft' | ||||
|     end | ||||
| 
 | ||||
|     it 'does not remove WIP in the middle of the title' do | ||||
|       subject.title = 'Something with WIP in the middle' | ||||
| 
 | ||||
|       expect(subject.wipless_title).to eq subject.title | ||||
|       expect(subject.draftless_title).to eq subject.title | ||||
|     end | ||||
| 
 | ||||
|     it 'does not remove Draft in the middle of the title' do | ||||
|       subject.title = 'Something with Draft in the middle' | ||||
| 
 | ||||
|       expect(subject.wipless_title).to eq subject.title | ||||
|       expect(subject.draftless_title).to eq subject.title | ||||
|     end | ||||
| 
 | ||||
|     it 'does not remove WIP at the end of the title' do | ||||
|       subject.title = 'Something ends with WIP' | ||||
| 
 | ||||
|       expect(subject.wipless_title).to eq subject.title | ||||
|       expect(subject.draftless_title).to eq subject.title | ||||
|     end | ||||
| 
 | ||||
|     it 'does not remove Draft at the end of the title' do | ||||
|       subject.title = 'Something ends with Draft' | ||||
| 
 | ||||
|       expect(subject.wipless_title).to eq subject.title | ||||
|       expect(subject.draftless_title).to eq subject.title | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe "#wip_title" do | ||||
|   describe "#draft_title" do | ||||
|     it "adds the Draft: prefix to the title" do | ||||
|       wip_title = "Draft: #{subject.title}" | ||||
|       draft_title = "Draft: #{subject.title}" | ||||
| 
 | ||||
|       expect(subject.wip_title).to eq wip_title | ||||
|       expect(subject.draft_title).to eq draft_title | ||||
|     end | ||||
| 
 | ||||
|     it "does not add the Draft: prefix multiple times" do | ||||
|       wip_title = "Draft: #{subject.title}" | ||||
|       subject.title = subject.wip_title | ||||
|       subject.title = subject.wip_title | ||||
|       draft_title = "Draft: #{subject.title}" | ||||
|       subject.title = subject.draft_title | ||||
|       subject.title = subject.draft_title | ||||
| 
 | ||||
|       expect(subject.wip_title).to eq wip_title | ||||
|       expect(subject.draft_title).to eq draft_title | ||||
|     end | ||||
| 
 | ||||
|     it "is satisfies the #work_in_progress? method" do | ||||
|       subject.title = subject.wip_title | ||||
|       subject.title = subject.draft_title | ||||
| 
 | ||||
|       expect(subject.work_in_progress?).to eq true | ||||
|     end | ||||
|  |  | |||
|  | @ -24,5 +24,18 @@ RSpec.describe Packages::Rubygems::CreateGemspecService do | |||
|       expect(gemspec_file.file_sha1).not_to be_nil | ||||
|       expect(gemspec_file.file_sha256).not_to be_nil | ||||
|     end | ||||
| 
 | ||||
|     context 'with FIPS mode', :fips_mode do | ||||
|       it 'does not generate file_md5' do | ||||
|         expect { subject }.to change { package.package_files.count }.by(1) | ||||
| 
 | ||||
|         gemspec_file = package.package_files.find_by(file_name: "#{gemspec.name}.gemspec") | ||||
|         expect(gemspec_file.file).not_to be_nil | ||||
|         expect(gemspec_file.size).not_to be_nil | ||||
|         expect(gemspec_file.file_md5).to be_nil | ||||
|         expect(gemspec_file.file_sha1).not_to be_nil | ||||
|         expect(gemspec_file.file_sha256).not_to be_nil | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -333,14 +333,14 @@ RSpec.describe QuickActions::InterpretService do | |||
| 
 | ||||
|     shared_examples 'undraft command' do | ||||
|       it 'returns wip_event: "unwip" if content contains /draft' do | ||||
|         issuable.update!(title: issuable.wip_title) | ||||
|         issuable.update!(title: issuable.draft_title) | ||||
|         _, updates, _ = service.execute(content, issuable) | ||||
| 
 | ||||
|         expect(updates).to eq(wip_event: 'unwip') | ||||
|       end | ||||
| 
 | ||||
|       it 'returns the unwip message' do | ||||
|         issuable.update!(title: issuable.wip_title) | ||||
|         issuable.update!(title: issuable.draft_title) | ||||
|         _, _, message = service.execute(content, issuable) | ||||
| 
 | ||||
|         expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.") | ||||
|  |  | |||
|  | @ -7,86 +7,6 @@ RSpec.describe 'gitlab:pages', :silence_stdout do | |||
|     Rake.application.rake_require 'tasks/gitlab/pages' | ||||
|   end | ||||
| 
 | ||||
|   describe 'migrate_legacy_storage task' do | ||||
|     subject { run_rake_task('gitlab:pages:migrate_legacy_storage') } | ||||
| 
 | ||||
|     it 'calls migration service' do | ||||
|       expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, | ||||
|                               ignore_invalid_entries: false, | ||||
|                               mark_projects_as_not_deployed: false) do |service| | ||||
|         expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original | ||||
|       end | ||||
| 
 | ||||
|       subject | ||||
|     end | ||||
| 
 | ||||
|     it 'uses PAGES_MIGRATION_THREADS environment variable' do | ||||
|       stub_env('PAGES_MIGRATION_THREADS', '5') | ||||
| 
 | ||||
|       expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, | ||||
|                               ignore_invalid_entries: false, | ||||
|                               mark_projects_as_not_deployed: false) do |service| | ||||
|         expect(service).to receive(:execute_with_threads).with(threads: 5, batch_size: 10).and_call_original | ||||
|       end | ||||
| 
 | ||||
|       subject | ||||
|     end | ||||
| 
 | ||||
|     it 'uses PAGES_MIGRATION_BATCH_SIZE environment variable' do | ||||
|       stub_env('PAGES_MIGRATION_BATCH_SIZE', '100') | ||||
| 
 | ||||
|       expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, | ||||
|                               ignore_invalid_entries: false, | ||||
|                               mark_projects_as_not_deployed: false) do |service| | ||||
|         expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 100).and_call_original | ||||
|       end | ||||
| 
 | ||||
|       subject | ||||
|     end | ||||
| 
 | ||||
|     it 'uses PAGES_MIGRATION_IGNORE_INVALID_ENTRIES environment variable' do | ||||
|       stub_env('PAGES_MIGRATION_IGNORE_INVALID_ENTRIES', 'true') | ||||
| 
 | ||||
|       expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, | ||||
|                               ignore_invalid_entries: true, | ||||
|                               mark_projects_as_not_deployed: false) do |service| | ||||
|         expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original | ||||
|       end | ||||
| 
 | ||||
|       subject | ||||
|     end | ||||
| 
 | ||||
|     it 'uses PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED environment variable' do | ||||
|       stub_env('PAGES_MIGRATION_MARK_PROJECTS_AS_NOT_DEPLOYED', 'true') | ||||
| 
 | ||||
|       expect_next_instance_of(::Pages::MigrateFromLegacyStorageService, anything, | ||||
|                               ignore_invalid_entries: false, | ||||
|                               mark_projects_as_not_deployed: true) do |service| | ||||
|         expect(service).to receive(:execute_with_threads).with(threads: 3, batch_size: 10).and_call_original | ||||
|       end | ||||
| 
 | ||||
|       subject | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'clean_migrated_zip_storage task' do | ||||
|     it 'removes only migrated deployments' do | ||||
|       regular_deployment = create(:pages_deployment) | ||||
|       migrated_deployment = create(:pages_deployment, :migrated) | ||||
| 
 | ||||
|       regular_deployment.project.update_pages_deployment!(regular_deployment) | ||||
|       migrated_deployment.project.update_pages_deployment!(migrated_deployment) | ||||
| 
 | ||||
|       expect(PagesDeployment.all).to contain_exactly(regular_deployment, migrated_deployment) | ||||
| 
 | ||||
|       run_rake_task('gitlab:pages:clean_migrated_zip_storage') | ||||
| 
 | ||||
|       expect(PagesDeployment.all).to contain_exactly(regular_deployment) | ||||
|       expect(PagesDeployment.find_by_id(regular_deployment.id)).not_to be_nil | ||||
|       expect(PagesDeployment.find_by_id(migrated_deployment.id)).to be_nil | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'gitlab:pages:deployments:migrate_to_object_storage' do | ||||
|     subject { run_rake_task('gitlab:pages:deployments:migrate_to_object_storage') } | ||||
| 
 | ||||
|  |  | |||
|  | @ -43,16 +43,12 @@ type artifactsUploadProcessor struct { | |||
| // Artifacts is like a Multipart but specific for artifacts upload.
 | ||||
| func Artifacts(myAPI *api.API, h http.Handler, p Preparer) http.Handler { | ||||
| 	return myAPI.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) { | ||||
| 		opts, err := p.Prepare(a) | ||||
| 		if err != nil { | ||||
| 			helper.Fail500(w, r, fmt.Errorf("UploadArtifacts: error preparing file storage options")) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		format := r.URL.Query().Get(ArtifactFormatKey) | ||||
| 
 | ||||
| 		mg := &artifactsUploadProcessor{format: format, SavedFileTracker: SavedFileTracker{Request: r}} | ||||
| 		interceptMultipartFiles(w, r, h, a, mg, opts) | ||||
| 		mg := &artifactsUploadProcessor{ | ||||
| 			format:           format, | ||||
| 			SavedFileTracker: SavedFileTracker{Request: r}, | ||||
| 		} | ||||
| 		interceptMultipartFiles(w, r, h, mg, &eagerAuthorizer{a}, p) | ||||
| 	}, "/authorize") | ||||
| } | ||||
| 
 | ||||
|  | @ -62,7 +58,6 @@ func (a *artifactsUploadProcessor) generateMetadataFromZip(ctx context.Context, | |||
| 
 | ||||
| 	metaOpts := &destination.UploadOpts{ | ||||
| 		LocalTempPath: os.TempDir(), | ||||
| 		TempFilePrefix: "metadata.gz", | ||||
| 	} | ||||
| 
 | ||||
| 	fileName := file.LocalPath | ||||
|  | @ -87,7 +82,7 @@ func (a *artifactsUploadProcessor) generateMetadataFromZip(ctx context.Context, | |||
| 	done := make(chan saveResult) | ||||
| 	go func() { | ||||
| 		var result saveResult | ||||
| 		result.FileHandler, result.error = destination.Upload(ctx, metaReader, -1, metaOpts) | ||||
| 		result.FileHandler, result.error = destination.Upload(ctx, metaReader, -1, "metadata.gz", metaOpts) | ||||
| 
 | ||||
| 		done <- result | ||||
| 	}() | ||||
|  |  | |||
|  | @ -23,7 +23,7 @@ func RequestBody(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler { | |||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		fh, err := destination.Upload(r.Context(), r.Body, r.ContentLength, opts) | ||||
| 		fh, err := destination.Upload(r.Context(), r.Body, r.ContentLength, "upload", opts) | ||||
| 		if err != nil { | ||||
| 			helper.Fail500(w, r, fmt.Errorf("RequestBody: upload failed: %v", err)) | ||||
| 			return | ||||
|  |  | |||
|  | @ -113,9 +113,9 @@ type consumer interface { | |||
| 
 | ||||
| // Upload persists the provided reader content to all the location specified in opts. A cleanup will be performed once ctx is Done
 | ||||
| // Make sure the provided context will not expire before finalizing upload with GitLab Rails.
 | ||||
| func Upload(ctx context.Context, reader io.Reader, size int64, opts *UploadOpts) (*FileHandler, error) { | ||||
| func Upload(ctx context.Context, reader io.Reader, size int64, name string, opts *UploadOpts) (*FileHandler, error) { | ||||
| 	fh := &FileHandler{ | ||||
| 		Name:      opts.TempFilePrefix, | ||||
| 		Name:      name, | ||||
| 		RemoteID:  opts.RemoteID, | ||||
| 		RemoteURL: opts.RemoteURL, | ||||
| 	} | ||||
|  | @ -204,8 +204,8 @@ func Upload(ctx context.Context, reader io.Reader, size int64, opts *UploadOpts) | |||
| 		"is_multipart": opts.IsMultipart(), | ||||
| 		"is_remote":    !opts.IsLocalTempFile(), | ||||
| 		"remote_id":    opts.RemoteID, | ||||
| 		"temp_file_prefix": opts.TempFilePrefix, | ||||
| 		"client_mode":  clientMode, | ||||
| 		"filename":     fh.Name, | ||||
| 	}) | ||||
| 
 | ||||
| 	if opts.IsLocalTempFile() { | ||||
|  | @ -226,7 +226,7 @@ func (fh *FileHandler) newLocalFile(ctx context.Context, opts *UploadOpts) (cons | |||
| 		return nil, fmt.Errorf("newLocalFile: mkdir %q: %v", opts.LocalTempPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	file, err := ioutil.TempFile(opts.LocalTempPath, opts.TempFilePrefix) | ||||
| 	file, err := ioutil.TempFile(opts.LocalTempPath, "gitlab-workhorse-upload") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("newLocalFile: create file: %v", err) | ||||
| 	} | ||||
|  |  | |||
|  | @ -47,8 +47,8 @@ func TestUploadWrongSize(t *testing.T) { | |||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tmpFolder) | ||||
| 
 | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file"} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, opts) | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize+1, "upload", opts) | ||||
| 	require.Error(t, err) | ||||
| 	_, isSizeError := err.(destination.SizeError) | ||||
| 	require.True(t, isSizeError, "Should fail with SizeError") | ||||
|  | @ -63,8 +63,8 @@ func TestUploadWithKnownSizeExceedLimit(t *testing.T) { | |||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tmpFolder) | ||||
| 
 | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file", MaximumSize: test.ObjectSize - 1} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, opts) | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) | ||||
| 	require.Error(t, err) | ||||
| 	_, isSizeError := err.(destination.SizeError) | ||||
| 	require.True(t, isSizeError, "Should fail with SizeError") | ||||
|  | @ -79,8 +79,8 @@ func TestUploadWithUnknownSizeExceedLimit(t *testing.T) { | |||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tmpFolder) | ||||
| 
 | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder, TempFilePrefix: "test-file", MaximumSize: test.ObjectSize - 1} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), -1, opts) | ||||
| 	opts := &destination.UploadOpts{LocalTempPath: tmpFolder, MaximumSize: test.ObjectSize - 1} | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), -1, "upload", opts) | ||||
| 	require.Equal(t, err, destination.ErrEntityTooLarge) | ||||
| 	require.Nil(t, fh) | ||||
| } | ||||
|  | @ -117,7 +117,7 @@ func TestUploadWrongETag(t *testing.T) { | |||
| 				osStub.InitiateMultipartUpload(test.ObjectPath) | ||||
| 			} | ||||
| 			ctx, cancel := context.WithCancel(context.Background()) | ||||
| 			fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, opts) | ||||
| 			fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", opts) | ||||
| 			require.Nil(t, fh) | ||||
| 			require.Error(t, err) | ||||
| 			require.Equal(t, 1, osStub.PutsCnt(), "File not uploaded") | ||||
|  | @ -191,13 +191,12 @@ func TestUpload(t *testing.T) { | |||
| 
 | ||||
| 			if spec.local { | ||||
| 				opts.LocalTempPath = tmpFolder | ||||
| 				opts.TempFilePrefix = "test-file" | ||||
| 			} | ||||
| 
 | ||||
| 			ctx, cancel := context.WithCancel(context.Background()) | ||||
| 			defer cancel() | ||||
| 
 | ||||
| 			fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts) | ||||
| 			fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) | ||||
| 			require.NoError(t, err) | ||||
| 			require.NotNil(t, fh) | ||||
| 
 | ||||
|  | @ -211,9 +210,6 @@ func TestUpload(t *testing.T) { | |||
| 
 | ||||
| 				dir := path.Dir(fh.LocalPath) | ||||
| 				require.Equal(t, opts.LocalTempPath, dir) | ||||
| 				filename := path.Base(fh.LocalPath) | ||||
| 				beginsWithPrefix := strings.HasPrefix(filename, opts.TempFilePrefix) | ||||
| 				require.True(t, beginsWithPrefix, fmt.Sprintf("LocalPath filename %q do not begin with TempFilePrefix %q", filename, opts.TempFilePrefix)) | ||||
| 			} else { | ||||
| 				require.Empty(t, fh.LocalPath, "LocalPath must be empty for non local uploads") | ||||
| 			} | ||||
|  | @ -291,7 +287,7 @@ func TestUploadWithS3WorkhorseClient(t *testing.T) { | |||
| 				MaximumSize: tc.maxSize, | ||||
| 			} | ||||
| 
 | ||||
| 			_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, &opts) | ||||
| 			_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), tc.objectSize, "upload", &opts) | ||||
| 
 | ||||
| 			if tc.expectedErr == nil { | ||||
| 				require.NoError(t, err) | ||||
|  | @ -324,7 +320,7 @@ func TestUploadWithAzureWorkhorseClient(t *testing.T) { | |||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts) | ||||
| 	_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	test.GoCloudObjectExists(t, bucketDir, remoteObject) | ||||
|  | @ -349,7 +345,7 @@ func TestUploadWithUnknownGoCloudScheme(t *testing.T) { | |||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts) | ||||
| 	_, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) | ||||
| 	require.Error(t, err) | ||||
| } | ||||
| 
 | ||||
|  | @ -375,7 +371,7 @@ func TestUploadMultipartInBodyFailure(t *testing.T) { | |||
| 	ctx, cancel := context.WithCancel(context.Background()) | ||||
| 	defer cancel() | ||||
| 
 | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, &opts) | ||||
| 	fh, err := destination.Upload(ctx, strings.NewReader(test.ObjectContent), test.ObjectSize, "upload", &opts) | ||||
| 	require.Nil(t, fh) | ||||
| 	require.Error(t, err) | ||||
| 	require.EqualError(t, err, test.MultipartUploadInternalError().Error()) | ||||
|  | @ -468,7 +464,7 @@ func TestUploadRemoteFileWithLimit(t *testing.T) { | |||
| 				ctx, cancel := context.WithCancel(context.Background()) | ||||
| 				defer cancel() | ||||
| 
 | ||||
| 				fh, err := destination.Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, &opts) | ||||
| 				fh, err := destination.Upload(ctx, strings.NewReader(tc.testData), tc.objectSize, "upload", &opts) | ||||
| 
 | ||||
| 				if tc.expectedErr == nil { | ||||
| 					require.NoError(t, err) | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ type partsEtagMap map[int]string | |||
| type ObjectstoreStub struct { | ||||
| 	// bucket contains md5sum of uploaded objects
 | ||||
| 	bucket   map[string]string | ||||
| 	contents map[string][]byte | ||||
| 	// overwriteMD5 contains overwrites for md5sum that should be return instead of the regular hash
 | ||||
| 	overwriteMD5 map[string]string | ||||
| 	// multipart is a map of MultipartUploads
 | ||||
|  | @ -48,6 +49,7 @@ func StartObjectStoreWithCustomMD5(md5Hashes map[string]string) (*ObjectstoreStu | |||
| 		multipart:    make(map[string]partsEtagMap), | ||||
| 		overwriteMD5: make(map[string]string), | ||||
| 		headers:      make(map[string]*http.Header), | ||||
| 		contents:     make(map[string][]byte), | ||||
| 	} | ||||
| 
 | ||||
| 	for k, v := range md5Hashes { | ||||
|  | @ -82,6 +84,15 @@ func (o *ObjectstoreStub) GetObjectMD5(path string) string { | |||
| 	return o.bucket[path] | ||||
| } | ||||
| 
 | ||||
| // GetObject returns the contents of the uploaded object. The caller must
 | ||||
| // not modify the byte slice.
 | ||||
| func (o *ObjectstoreStub) GetObject(path string) []byte { | ||||
| 	o.m.Lock() | ||||
| 	defer o.m.Unlock() | ||||
| 
 | ||||
| 	return o.contents[path] | ||||
| } | ||||
| 
 | ||||
| // GetHeader returns a given HTTP header of the object uploaded to the path
 | ||||
| func (o *ObjectstoreStub) GetHeader(path, key string) string { | ||||
| 	o.m.Lock() | ||||
|  | @ -154,11 +165,11 @@ func (o *ObjectstoreStub) putObject(w http.ResponseWriter, r *http.Request) { | |||
| 
 | ||||
| 	etag, overwritten := o.overwriteMD5[objectPath] | ||||
| 	if !overwritten { | ||||
| 		buf, _ := io.ReadAll(r.Body) | ||||
| 		o.contents[objectPath] = buf | ||||
| 		hasher := md5.New() | ||||
| 		io.Copy(hasher, r.Body) | ||||
| 
 | ||||
| 		checksum := hasher.Sum(nil) | ||||
| 		etag = hex.EncodeToString(checksum) | ||||
| 		hasher.Write(buf) | ||||
| 		etag = hex.EncodeToString(hasher.Sum(nil)) | ||||
| 	} | ||||
| 
 | ||||
| 	o.headers[objectPath] = &r.Header | ||||
|  |  | |||
|  | @ -29,8 +29,6 @@ type ObjectStorageConfig struct { | |||
| 
 | ||||
| // UploadOpts represents all the options available for saving a file to object store
 | ||||
| type UploadOpts struct { | ||||
| 	// TempFilePrefix is the prefix used to create temporary local file
 | ||||
| 	TempFilePrefix string | ||||
| 	// LocalTempPath is the directory where to write a local copy of the file
 | ||||
| 	LocalTempPath string | ||||
| 	// RemoteID is the remote ObjectID provided by GitLab
 | ||||
|  |  | |||
|  | @ -1,11 +1,9 @@ | |||
| package upload | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/api" | ||||
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper" | ||||
| ) | ||||
| 
 | ||||
| // Multipart is a request middleware. If the request has a MIME multipart
 | ||||
|  | @ -17,12 +15,19 @@ func Multipart(rails PreAuthorizer, h http.Handler, p Preparer) http.Handler { | |||
| 	return rails.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) { | ||||
| 		s := &SavedFileTracker{Request: r} | ||||
| 
 | ||||
| 		opts, err := p.Prepare(a) | ||||
| 		if err != nil { | ||||
| 			helper.Fail500(w, r, fmt.Errorf("Multipart: error preparing file storage options")) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		interceptMultipartFiles(w, r, h, a, s, opts) | ||||
| 		interceptMultipartFiles(w, r, h, s, &eagerAuthorizer{a}, p) | ||||
| 	}, "/authorize") | ||||
| } | ||||
| 
 | ||||
| // SkipRailsPreAuthMultipart behaves like Multipart except it does not
 | ||||
| // pre-authorize with Rails. It is intended for use on catch-all routes
 | ||||
| // where we cannot pre-authorize both because we don't know which Rails
 | ||||
| // endpoint to call, and because eagerly pre-authorizing would add too
 | ||||
| // much overhead.
 | ||||
| func SkipRailsPreAuthMultipart(tempPath string, h http.Handler, p Preparer) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		s := &SavedFileTracker{Request: r} | ||||
| 		fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}} | ||||
| 		interceptMultipartFiles(w, r, h, s, fa, p) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -63,12 +63,13 @@ var ( | |||
| 
 | ||||
| type rewriter struct { | ||||
| 	writer *multipart.Writer | ||||
| 	preauth         *api.Response | ||||
| 	fileAuthorizer | ||||
| 	Preparer | ||||
| 	filter          MultipartFormProcessor | ||||
| 	finalizedFields map[string]bool | ||||
| } | ||||
| 
 | ||||
| func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, preauth *api.Response, filter MultipartFormProcessor, opts *destination.UploadOpts) error { | ||||
| func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, filter MultipartFormProcessor, fa fileAuthorizer, preparer Preparer) error { | ||||
| 	// Create multipart reader
 | ||||
| 	reader, err := r.MultipartReader() | ||||
| 	if err != nil { | ||||
|  | @ -83,7 +84,8 @@ func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, pr | |||
| 
 | ||||
| 	rew := &rewriter{ | ||||
| 		writer:          writer, | ||||
| 		preauth:         preauth, | ||||
| 		fileAuthorizer:  fa, | ||||
| 		Preparer:        preparer, | ||||
| 		filter:          filter, | ||||
| 		finalizedFields: make(map[string]bool), | ||||
| 	} | ||||
|  | @ -108,7 +110,7 @@ func rewriteFormFilesFromMultipart(r *http.Request, writer *multipart.Writer, pr | |||
| 		} | ||||
| 
 | ||||
| 		if filename != "" { | ||||
| 			err = rew.handleFilePart(r.Context(), name, p, opts) | ||||
| 			err = rew.handleFilePart(r, name, p) | ||||
| 		} else { | ||||
| 			err = rew.copyPart(r.Context(), name, p) | ||||
| 		} | ||||
|  | @ -128,7 +130,7 @@ func parseAndNormalizeContentDisposition(header textproto.MIMEHeader) (string, s | |||
| 	return params["name"], params["filename"] | ||||
| } | ||||
| 
 | ||||
| func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipart.Part, opts *destination.UploadOpts) error { | ||||
| func (rew *rewriter) handleFilePart(r *http.Request, name string, p *multipart.Part) error { | ||||
| 	if rew.filter.Count() >= maxFilesAllowed { | ||||
| 		return ErrTooManyFilesUploaded | ||||
| 	} | ||||
|  | @ -141,30 +143,34 @@ func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipa | |||
| 		return fmt.Errorf("illegal filename: %q", filename) | ||||
| 	} | ||||
| 
 | ||||
| 	opts.TempFilePrefix = filename | ||||
| 	apiResponse, err := rew.AuthorizeFile(r) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	opts, err := rew.Prepare(apiResponse) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	var inputReader io.ReadCloser | ||||
| 	var err error | ||||
| 
 | ||||
| 	imageType := exif.FileTypeFromSuffix(filename) | ||||
| 	switch { | ||||
| 	case imageType != exif.TypeUnknown: | ||||
| 	ctx := r.Context() | ||||
| 	if imageType := exif.FileTypeFromSuffix(filename); imageType != exif.TypeUnknown { | ||||
| 		inputReader, err = handleExifUpload(ctx, p, filename, imageType) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	case rew.preauth.ProcessLsif: | ||||
| 		inputReader, err = handleLsifUpload(ctx, p, opts.LocalTempPath, filename, rew.preauth) | ||||
| 	} else if apiResponse.ProcessLsif { | ||||
| 		inputReader, err = handleLsifUpload(ctx, p, opts.LocalTempPath, filename) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	default: | ||||
| 	} else { | ||||
| 		inputReader = ioutil.NopCloser(p) | ||||
| 	} | ||||
| 
 | ||||
| 	defer inputReader.Close() | ||||
| 
 | ||||
| 	fh, err := destination.Upload(ctx, inputReader, -1, opts) | ||||
| 	fh, err := destination.Upload(ctx, inputReader, -1, filename, opts) | ||||
| 	if err != nil { | ||||
| 		switch err { | ||||
| 		case destination.ErrEntityTooLarge, exif.ErrRemovingExif: | ||||
|  | @ -267,7 +273,7 @@ func isJPEG(r io.Reader) bool { | |||
| 	return http.DetectContentType(buf) == "image/jpeg" | ||||
| } | ||||
| 
 | ||||
| func handleLsifUpload(ctx context.Context, reader io.Reader, tempPath, filename string, preauth *api.Response) (io.ReadCloser, error) { | ||||
| func handleLsifUpload(ctx context.Context, reader io.Reader, tempPath, filename string) (io.ReadCloser, error) { | ||||
| 	parserConfig := parser.Config{ | ||||
| 		TempPath: tempPath, | ||||
| 	} | ||||
|  | @ -291,3 +297,15 @@ func (rew *rewriter) copyPart(ctx context.Context, name string, p *multipart.Par | |||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type fileAuthorizer interface { | ||||
| 	AuthorizeFile(*http.Request) (*api.Response, error) | ||||
| } | ||||
| 
 | ||||
| type eagerAuthorizer struct{ response *api.Response } | ||||
| 
 | ||||
| func (ea *eagerAuthorizer) AuthorizeFile(r *http.Request) (*api.Response, error) { | ||||
| 	return ea.response, nil | ||||
| } | ||||
| 
 | ||||
| var _ fileAuthorizer = &eagerAuthorizer{} | ||||
|  |  | |||
|  | @ -1,22 +0,0 @@ | |||
| package upload | ||||
| 
 | ||||
| import ( | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/api" | ||||
| ) | ||||
| 
 | ||||
| // SkipRailsAuthorizer implements a fake PreAuthorizer that does not call
 | ||||
| // the gitlab-rails API. It must be fast because it gets called on each
 | ||||
| // request proxied to Rails.
 | ||||
| type SkipRailsAuthorizer struct { | ||||
| 	// TempPath is a directory where workhorse can store files that can later
 | ||||
| 	// be accessed by gitlab-rails.
 | ||||
| 	TempPath string | ||||
| } | ||||
| 
 | ||||
| func (l *SkipRailsAuthorizer) PreAuthorizeHandler(next api.HandleFunc, _ string) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		next(w, r, &api.Response{TempPath: l.TempPath}) | ||||
| 	}) | ||||
| } | ||||
|  | @ -40,13 +40,13 @@ type MultipartFormProcessor interface { | |||
| 
 | ||||
| // interceptMultipartFiles is the core of the implementation of
 | ||||
| // Multipart.
 | ||||
| func interceptMultipartFiles(w http.ResponseWriter, r *http.Request, h http.Handler, preauth *api.Response, filter MultipartFormProcessor, opts *destination.UploadOpts) { | ||||
| func interceptMultipartFiles(w http.ResponseWriter, r *http.Request, h http.Handler, filter MultipartFormProcessor, fa fileAuthorizer, p Preparer) { | ||||
| 	var body bytes.Buffer | ||||
| 	writer := multipart.NewWriter(&body) | ||||
| 	defer writer.Close() | ||||
| 
 | ||||
| 	// Rewrite multipart form data
 | ||||
| 	err := rewriteFormFilesFromMultipart(r, writer, preauth, filter, opts) | ||||
| 	err := rewriteFormFilesFromMultipart(r, writer, filter, fa, p) | ||||
| 	if err != nil { | ||||
| 		switch err { | ||||
| 		case ErrInjectedClientParam: | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import ( | |||
| 	"net/http/httptest" | ||||
| 	"net/textproto" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | @ -66,19 +67,14 @@ func TestUploadHandlerForwardingRawData(t *testing.T) { | |||
| 	httpRequest, err := http.NewRequest("PATCH", ts.URL+"/url/path", bytes.NewBufferString("REQUEST")) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	tempPath := t.TempDir() | ||||
| 	response := httptest.NewRecorder() | ||||
| 
 | ||||
| 	handler := newProxy(ts.URL) | ||||
| 	apiResponse := &api.Response{TempPath: tempPath} | ||||
| 	fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 	opts, err := preparer.Prepare(apiResponse) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	interceptMultipartFiles(response, httpRequest, handler, apiResponse, nil, opts) | ||||
| 	interceptMultipartFiles(response, httpRequest, handler, nil, fa, preparer) | ||||
| 
 | ||||
| 	require.Equal(t, 202, response.Code) | ||||
| 	require.Equal(t, "RESPONSE", response.Body.String(), "response body") | ||||
|  | @ -86,10 +82,7 @@ func TestUploadHandlerForwardingRawData(t *testing.T) { | |||
| 
 | ||||
| func TestUploadHandlerRewritingMultiPartData(t *testing.T) { | ||||
| 	var filePath string | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 	tempPath := t.TempDir() | ||||
| 
 | ||||
| 	ts := testhelper.TestServerWithHandler(regexp.MustCompile(`/url/path\z`), func(w http.ResponseWriter, r *http.Request) { | ||||
| 		require.Equal(t, "PUT", r.Method, "method") | ||||
|  | @ -144,12 +137,10 @@ func TestUploadHandlerRewritingMultiPartData(t *testing.T) { | |||
| 
 | ||||
| 	handler := newProxy(ts.URL) | ||||
| 
 | ||||
| 	apiResponse := &api.Response{TempPath: tempPath} | ||||
| 	fa := &eagerAuthorizer{&api.Response{TempPath: tempPath}} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 	opts, err := preparer.Prepare(apiResponse) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts) | ||||
| 	interceptMultipartFiles(response, httpRequest, handler, &testFormProcessor{}, fa, preparer) | ||||
| 	require.Equal(t, 202, response.Code) | ||||
| 
 | ||||
| 	cancel() // this will trigger an async cleanup
 | ||||
|  | @ -159,10 +150,6 @@ func TestUploadHandlerRewritingMultiPartData(t *testing.T) { | |||
| func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) { | ||||
| 	var filePath string | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		field    string | ||||
|  | @ -213,12 +200,8 @@ func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) { | |||
| 			response := httptest.NewRecorder() | ||||
| 
 | ||||
| 			handler := newProxy(ts.URL) | ||||
| 			apiResponse := &api.Response{TempPath: tempPath} | ||||
| 			preparer := &DefaultPreparer{} | ||||
| 			opts, err := preparer.Prepare(apiResponse) | ||||
| 			require.NoError(t, err) | ||||
| 
 | ||||
| 			interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts) | ||||
| 			testInterceptMultipartFiles(t, response, httpRequest, handler, &testFormProcessor{}) | ||||
| 			require.Equal(t, test.response, response.Code) | ||||
| 
 | ||||
| 			cancel() // this will trigger an async cleanup
 | ||||
|  | @ -228,10 +211,6 @@ func TestUploadHandlerDetectingInjectedMultiPartData(t *testing.T) { | |||
| } | ||||
| 
 | ||||
| func TestUploadProcessingField(t *testing.T) { | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	var buffer bytes.Buffer | ||||
| 
 | ||||
| 	writer := multipart.NewWriter(&buffer) | ||||
|  | @ -243,12 +222,8 @@ func TestUploadProcessingField(t *testing.T) { | |||
| 	httpRequest.Header.Set("Content-Type", writer.FormDataContentType()) | ||||
| 
 | ||||
| 	response := httptest.NewRecorder() | ||||
| 	apiResponse := &api.Response{TempPath: tempPath} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 	opts, err := preparer.Prepare(apiResponse) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts) | ||||
| 	testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &testFormProcessor{}) | ||||
| 
 | ||||
| 	require.Equal(t, 500, response.Code) | ||||
| } | ||||
|  | @ -256,15 +231,11 @@ func TestUploadProcessingField(t *testing.T) { | |||
| func TestUploadingMultipleFiles(t *testing.T) { | ||||
| 	testhelper.ConfigureSecret() | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	var buffer bytes.Buffer | ||||
| 
 | ||||
| 	writer := multipart.NewWriter(&buffer) | ||||
| 	for i := 0; i < 11; i++ { | ||||
| 		_, err = writer.CreateFormFile(fmt.Sprintf("file %v", i), "my.file") | ||||
| 		_, err := writer.CreateFormFile(fmt.Sprintf("file %v", i), "my.file") | ||||
| 		require.NoError(t, err) | ||||
| 	} | ||||
| 	require.NoError(t, writer.Close()) | ||||
|  | @ -274,23 +245,18 @@ func TestUploadingMultipleFiles(t *testing.T) { | |||
| 	httpRequest.Header.Set("Content-Type", writer.FormDataContentType()) | ||||
| 
 | ||||
| 	response := httptest.NewRecorder() | ||||
| 	apiResponse := &api.Response{TempPath: tempPath} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 	opts, err := preparer.Prepare(apiResponse) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts) | ||||
| 	testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &testFormProcessor{}) | ||||
| 
 | ||||
| 	require.Equal(t, 400, response.Code) | ||||
| 	require.Equal(t, "upload request contains more than 10 files\n", response.Body.String()) | ||||
| } | ||||
| 
 | ||||
| func TestUploadProcessingFile(t *testing.T) { | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 	testhelper.ConfigureSecret() | ||||
| 	tempPath := t.TempDir() | ||||
| 
 | ||||
| 	_, testServer := test.StartObjectStore() | ||||
| 	objectStore, testServer := test.StartObjectStore() | ||||
| 	defer testServer.Close() | ||||
| 
 | ||||
| 	storeUrl := testServer.URL + test.ObjectPath | ||||
|  | @ -298,21 +264,24 @@ func TestUploadProcessingFile(t *testing.T) { | |||
| 	tests := []struct { | ||||
| 		name    string | ||||
| 		preauth *api.Response | ||||
| 		content func(t *testing.T) []byte | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:    "FileStore Upload", | ||||
| 			preauth: &api.Response{TempPath: tempPath}, | ||||
| 			content: func(t *testing.T) []byte { | ||||
| 				entries, err := os.ReadDir(tempPath) | ||||
| 				require.NoError(t, err) | ||||
| 				require.Len(t, entries, 1) | ||||
| 				content, err := os.ReadFile(path.Join(tempPath, entries[0].Name())) | ||||
| 				require.NoError(t, err) | ||||
| 				return content | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "ObjectStore Upload", | ||||
| 			preauth: &api.Response{RemoteObject: api.RemoteObject{StoreURL: storeUrl}}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "ObjectStore and FileStore Upload", | ||||
| 			preauth: &api.Response{ | ||||
| 				TempPath:     tempPath, | ||||
| 				RemoteObject: api.RemoteObject{StoreURL: storeUrl}, | ||||
| 			}, | ||||
| 			preauth: &api.Response{RemoteObject: api.RemoteObject{StoreURL: storeUrl, ID: "123"}}, | ||||
| 			content: func(*testing.T) []byte { return objectStore.GetObject(test.ObjectPath) }, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
|  | @ -330,26 +299,20 @@ func TestUploadProcessingFile(t *testing.T) { | |||
| 			httpRequest.Header.Set("Content-Type", writer.FormDataContentType()) | ||||
| 
 | ||||
| 			response := httptest.NewRecorder() | ||||
| 			apiResponse := &api.Response{TempPath: tempPath} | ||||
| 			fa := &eagerAuthorizer{test.preauth} | ||||
| 			preparer := &DefaultPreparer{} | ||||
| 			opts, err := preparer.Prepare(apiResponse) | ||||
| 			require.NoError(t, err) | ||||
| 
 | ||||
| 			interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &testFormProcessor{}, opts) | ||||
| 			interceptMultipartFiles(response, httpRequest, nilHandler, &testFormProcessor{}, fa, preparer) | ||||
| 
 | ||||
| 			require.Equal(t, 200, response.Code) | ||||
| 			require.Equal(t, "test", string(test.content(t))) | ||||
| 		}) | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| func TestInvalidFileNames(t *testing.T) { | ||||
| 	testhelper.ConfigureSecret() | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	for _, testCase := range []struct { | ||||
| 		filename       string | ||||
| 		code           int | ||||
|  | @ -376,24 +339,14 @@ func TestInvalidFileNames(t *testing.T) { | |||
| 		httpRequest.Header.Set("Content-Type", writer.FormDataContentType()) | ||||
| 
 | ||||
| 		response := httptest.NewRecorder() | ||||
| 		apiResponse := &api.Response{TempPath: tempPath} | ||||
| 		preparer := &DefaultPreparer{} | ||||
| 		opts, err := preparer.Prepare(apiResponse) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		interceptMultipartFiles(response, httpRequest, nilHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts) | ||||
| 		testInterceptMultipartFiles(t, response, httpRequest, nilHandler, &SavedFileTracker{Request: httpRequest}) | ||||
| 		require.Equal(t, testCase.code, response.Code) | ||||
| 		require.Equal(t, testCase.expectedPrefix, opts.TempFilePrefix) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestContentDispositionRewrite(t *testing.T) { | ||||
| 	testhelper.ConfigureSecret() | ||||
| 
 | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	tests := []struct { | ||||
| 		desc            string | ||||
| 		header          string | ||||
|  | @ -442,12 +395,7 @@ func TestContentDispositionRewrite(t *testing.T) { | |||
| 			}) | ||||
| 
 | ||||
| 			response := httptest.NewRecorder() | ||||
| 			apiResponse := &api.Response{TempPath: tempPath} | ||||
| 			preparer := &DefaultPreparer{} | ||||
| 			opts, err := preparer.Prepare(apiResponse) | ||||
| 			require.NoError(t, err) | ||||
| 
 | ||||
| 			interceptMultipartFiles(response, httpRequest, customHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts) | ||||
| 			testInterceptMultipartFiles(t, response, httpRequest, customHandler, &SavedFileTracker{Request: httpRequest}) | ||||
| 
 | ||||
| 			upstreamRequest, err := http.ReadRequest(bufio.NewReader(&upstreamRequestBuffer)) | ||||
| 			require.NoError(t, err) | ||||
|  | @ -534,10 +482,6 @@ func TestUploadHandlerRemovingExifCorruptedFile(t *testing.T) { | |||
| } | ||||
| 
 | ||||
| func runUploadTest(t *testing.T, image []byte, filename string, httpCode int, tsHandler func(http.ResponseWriter, *http.Request)) { | ||||
| 	tempPath, err := ioutil.TempDir("", "uploads") | ||||
| 	require.NoError(t, err) | ||||
| 	defer os.RemoveAll(tempPath) | ||||
| 
 | ||||
| 	var buffer bytes.Buffer | ||||
| 
 | ||||
| 	writer := multipart.NewWriter(&buffer) | ||||
|  | @ -565,12 +509,8 @@ func runUploadTest(t *testing.T, image []byte, filename string, httpCode int, ts | |||
| 	response := httptest.NewRecorder() | ||||
| 
 | ||||
| 	handler := newProxy(ts.URL) | ||||
| 	apiResponse := &api.Response{TempPath: tempPath} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 	opts, err := preparer.Prepare(apiResponse) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	interceptMultipartFiles(response, httpRequest, handler, apiResponse, &testFormProcessor{}, opts) | ||||
| 	testInterceptMultipartFiles(t, response, httpRequest, handler, &testFormProcessor{}) | ||||
| 	require.Equal(t, httpCode, response.Code) | ||||
| } | ||||
| 
 | ||||
|  | @ -587,3 +527,12 @@ func waitUntilDeleted(t *testing.T, path string) { | |||
| 	}, 10*time.Second, 10*time.Millisecond) | ||||
| 	require.True(t, os.IsNotExist(err), "expected the file to be deleted") | ||||
| } | ||||
| 
 | ||||
| func testInterceptMultipartFiles(t *testing.T, w http.ResponseWriter, r *http.Request, h http.Handler, filter MultipartFormProcessor) { | ||||
| 	t.Helper() | ||||
| 
 | ||||
| 	fa := &eagerAuthorizer{&api.Response{TempPath: t.TempDir()}} | ||||
| 	preparer := &DefaultPreparer{} | ||||
| 
 | ||||
| 	interceptMultipartFiles(w, r, h, filter, fa, preparer) | ||||
| } | ||||
|  |  | |||
|  | @ -223,7 +223,7 @@ func configureRoutes(u *upstream) { | |||
| 	mimeMultipartUploader := upload.Multipart(api, signingProxy, preparer) | ||||
| 
 | ||||
| 	uploadPath := path.Join(u.DocumentRoot, "uploads/tmp") | ||||
| 	tempfileMultipartProxy := upload.Multipart(&upload.SkipRailsAuthorizer{TempPath: uploadPath}, proxy, preparer) | ||||
| 	tempfileMultipartProxy := upload.SkipRailsPreAuthMultipart(uploadPath, proxy, preparer) | ||||
| 	ciAPIProxyQueue := queueing.QueueRequests("ci_api_job_requests", tempfileMultipartProxy, u.APILimit, u.APIQueueLimit, u.APIQueueTimeout) | ||||
| 	ciAPILongPolling := builds.RegisterHandler(ciAPIProxyQueue, redis.WatchKey, u.APICILongPollingDuration) | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue