Merge branch 'master' into '18471-restrict-tag-pushes-protected-tags'

# Conflicts:
#   spec/lib/gitlab/import_export/all_models.yml
This commit is contained in:
Kushal Pandya 2017-04-06 21:12:16 +00:00
commit 8a5ca1121b
253 changed files with 6718 additions and 1854 deletions

1
.gitignore vendored
View File

@ -30,6 +30,7 @@ eslint-report.html
/config/unicorn.rb
/config/secrets.yml
/config/sidekiq.yml
/config/registry.key
/coverage/*
/coverage-javascript/
/db/*.sqlite3

View File

@ -1 +1 @@
0.5.0
0.6.0

View File

@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
gem 'rufus-scheduler', '~> 3.1.10'
# HTTP requests
gem 'httparty', '~> 0.13.3'

View File

@ -987,6 +987,7 @@ DEPENDENCIES
rubocop-rspec (~> 1.12.0)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
rugged (~> 0.25.1.1)
sanitize (~> 2.0)
sass-rails (~> 5.0.6)

View File

@ -0,0 +1,15 @@
function BlobForkSuggestion(openButton, cancelButton, suggestionSection) {
if (openButton) {
openButton.addEventListener('click', () => {
suggestionSection.classList.remove('hidden');
});
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
suggestionSection.classList.add('hidden');
});
}
}
export default BlobForkSuggestion;

View File

@ -84,10 +84,10 @@ window.Build = (function() {
var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
return $.ajax({
url: this.buildUrl,
url: this.pageUrl + "/trace.json",
dataType: 'json',
success: function(buildData) {
$('.js-build-output').html(buildData.trace_html);
$('.js-build-output').html(buildData.html);
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
if (window.location.hash === DOWN_BUILD_TRACE) {
$("html,body").scrollTop(this.$buildTrace.height());

View File

@ -38,9 +38,35 @@ showTooltip = function(target, title) {
};
$(function() {
var clipboard;
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError);
clipboard.on('error', genericError);
// This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
// The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text`
// attribute that ClipboardJS reads from.
// When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
// to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
// this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the
// `text/plain` and `text/x-gfm` copy data types to the intended values.
$(document).on('copy', 'body > textarea[readonly]', function(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
const text = e.target.value;
let json;
try {
json = JSON.parse(text);
} catch (ex) {
return;
}
if (!json.text || !json.gfm) return;
e.preventDefault();
clipboardData.setData('text/plain', json.text);
clipboardData.setData('text/x-gfm', json.gfm);
});
});

View File

@ -43,6 +43,7 @@ import GroupsList from './groups_list';
import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
@ -87,6 +88,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
skipResetBindings: true,
fileBlobPermalinkUrl,
});
new BlobForkSuggestion(
document.querySelector('.js-edit-blob-link-fork-toggler'),
document.querySelector('.js-cancel-fork-suggestion'),
document.querySelector('.js-file-fork-suggestion-section'),
);
}
switch (page) {

View File

@ -0,0 +1,87 @@
import eventHub from '../event_hub';
export default {
name: 'RecentSearchesDropdownContent',
props: {
items: {
type: Array,
required: true,
},
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(item);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
suffix: `${token.symbol}${token.value}`,
}));
return {
text: item,
tokens: resultantTokens,
searchToken,
};
});
},
hasItems() {
return this.items.length > 0;
},
},
methods: {
onItemActivated(text) {
eventHub.$emit('recentSearchesItemSelected', text);
},
onRequestClearRecentSearches(e) {
// Stop the dropdown from closing
e.stopPropagation();
eventHub.$emit('requestClearRecentSearches');
},
},
template: `
<div>
<ul v-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
<button
type="button"
class="filtered-search-history-dropdown-item"
@click="onItemActivated(item.text)">
<span>
<span
v-for="(token, tokenIndex) in item.tokens"
class="filtered-search-history-dropdown-token">
<span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
</span>
</span>
<span class="filtered-search-history-dropdown-search-token">
{{ item.searchToken }}
</span>
</button>
</li>
<li class="divider"></li>
<li>
<button
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)">
Clear recent searches
</button>
</li>
</ul>
<div
v-else
class="dropdown-info-note">
You don't have any recent searches
</div>
</div>
`,
};

View File

@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() {
const dropdownData = [];
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push(

View File

@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
}
});
return values.join(' ');
return values
.map(value => value.trim())
.join(' ');
}
static getSearchInput(filteredSearchInput) {

View File

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View File

@ -1,18 +1,56 @@
/* global Flash */
import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
(() => {
class FilteredSearchManager {
constructor(page) {
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.recentSearchesStore = new RecentSearchesStore();
let recentSearchesKey = 'issue-recent-searches';
if (page === 'merge_requests') {
recentSearchesKey = 'merge-request-recent-searches';
}
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch(() => {
// eslint-disable-next-line no-new
new Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
.then((searches) => {
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
});
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
document.querySelector('.js-filtered-search-history-dropdown'),
);
this.recentSearchesRoot.init();
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() {
this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
}
bindEvents() {
@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.clearSearchWrapper = this.clearSearch.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
unbindEvents() {
@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
checkForBackspace(e) {
@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
}
addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) {
inputContainer.classList.add('focus');
@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
}
removeInputContainerFocus(e) {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
}
unselectEditTokens(e) {
const inputContainer = this.container.querySelector('.filtered-search-input-container');
const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
}
}
clearSearch(e) {
onClearSearch(e) {
e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = '';
const removeElements = [];
@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search();
}
saveCurrentSearchQuery() {
// Don't save before we have fetched the already saved searches
this.fetchingRecentSearchesPromise.then(() => {
const searchQuery = gl.DropdownUtils.getSearchQuery();
if (searchQuery.length > 0) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
this.recentSearchesService.save(resultantSearches);
}
});
}
loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams();
@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
}
});
this.saveCurrentSearchQuery();
if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder();
@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery());
= this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent();
}
}
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
}
window.gl = window.gl || {};

View File

@ -0,0 +1,59 @@
import Vue from 'vue';
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
import eventHub from './event_hub';
class RecentSearchesRoot {
constructor(
recentSearchesStore,
recentSearchesService,
wrapperElement,
) {
this.store = recentSearchesStore;
this.service = recentSearchesService;
this.wrapperElement = wrapperElement;
}
init() {
this.bindEvents();
this.render();
}
bindEvents() {
this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
unbindEvents() {
eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
render() {
this.vm = new Vue({
el: this.wrapperElement,
data: this.store.state,
template: `
<recent-searches-dropdown-content
:items="recentSearches" />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
},
});
}
onRequestClearRecentSearches() {
const resultantSearches = this.store.setRecentSearches([]);
this.service.save(resultantSearches);
}
destroy() {
this.unbindEvents();
if (this.vm) {
this.vm.$destroy();
}
}
}
export default RecentSearchesRoot;

View File

@ -0,0 +1,26 @@
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
if (input && input.length > 0) {
try {
searches = JSON.parse(input);
} catch (err) {
return Promise.reject(err);
}
}
return Promise.resolve(searches);
}
save(searches = []) {
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
}
export default RecentSearchesService;

View File

@ -0,0 +1,23 @@
import _ from 'underscore';
class RecentSearchesStore {
constructor(initialState = {}) {
this.state = Object.assign({
recentSearches: [],
}, initialState);
}
addRecentSearch(newSearch) {
this.setRecentSearches([newSearch].concat(this.state.recentSearches));
return this.state.recentSearches;
}
setRecentSearches(searches = []) {
const trimmedSearches = searches.map(search => search.trim());
this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
return this.state.recentSearches;
}
}
export default RecentSearchesStore;

View File

@ -177,10 +177,6 @@
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading {
.dropdown-content {
display: none;
@ -467,6 +463,11 @@
overflow-y: auto;
}
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer {
padding-top: 10px;
margin-top: 10px;

View File

@ -281,3 +281,16 @@ span.idiff {
display: none;
}
}
.file-fork-suggestion {
display: flex;
align-items: center;
justify-content: flex-end;
background-color: $gray-light;
border-bottom: 1px solid $border-color;
padding: 5px $gl-padding;
}
.file-fork-suggestion-note {
margin-right: 1.5em;
}

View File

@ -22,7 +22,6 @@
}
@media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update {
.dropdown-menu-toggle {
width: 132px;
@ -56,7 +55,7 @@
}
}
.filtered-search-container {
.filtered-search-wrapper {
display: -webkit-flex;
display: flex;
@ -151,11 +150,13 @@
width: 100%;
}
.filtered-search-input-container {
.filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex;
display: flex;
position: relative;
width: 100%;
min-width: 0;
border: 1px solid $border-color;
background-color: $white-light;
@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
}
&:hover {
@ -229,6 +222,118 @@
}
}
.filtered-search-box-input-container {
flex: 1;
position: relative;
// Fix PhantomJS not supporting `flex: 1;` properly.
// This is important because it can change the expected `e.target` when clicking things in tests.
// See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
// - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
// - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
width: 100%;
min-width: 0;
}
.filtered-search-input-dropdown-menu {
max-width: 280px;
@media (max-width: $screen-xs-min) {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
}
.filtered-search-history-dropdown-toggle-button {
display: flex;
align-items: center;
width: auto;
height: 100%;
padding-top: 0;
padding-left: 0.75em;
padding-bottom: 0;
padding-right: 0.5em;
background-color: transparent;
border-radius: 0;
border-top: 0;
border-left: 0;
border-bottom: 0;
border-right: 1px solid $border-color;
color: $gl-text-color-secondary;
transition: color 0.1s linear;
&:hover,
&:focus {
color: $gl-text-color;
border-color: $dropdown-input-focus-border;
outline: none;
}
.dropdown-toggle-text {
color: inherit;
.fa {
color: inherit;
}
}
.fa {
position: initial;
}
}
.filtered-search-history-dropdown-wrapper {
position: initial;
flex-shrink: 0;
}
.filtered-search-history-dropdown {
width: 40%;
@media (max-width: $screen-xs-min) {
left: 0;
right: 0;
max-width: none;
}
}
.filtered-search-history-dropdown-content {
max-height: none;
}
.filtered-search-history-dropdown-item,
.filtered-search-history-clear-button {
@include dropdown-link;
overflow: hidden;
width: 100%;
margin: 0.5em 0;
background-color: transparent;
border: 0;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.filtered-search-history-dropdown-token {
display: inline;
&:not(:last-child) {
margin-right: 0.3em;
}
& > .value {
font-weight: 600;
}
}
.filter-dropdown-container {
display: -webkit-flex;
display: flex;
@ -248,10 +353,8 @@
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters {
.dropdown-menu-toggle {
width: 100px;
}
.issue-bulk-update-dropdown-toggle {
width: 100px;
}
}

View File

@ -0,0 +1,16 @@
/**
* Container Registry
*/
.container-image {
border-bottom: 1px solid $white-normal;
}
.container-image-head {
padding: 0 16px;
line-height: 4em;
}
.table.tags {
margin-bottom: 0;
}

View File

@ -4,14 +4,14 @@
*/
.event-item {
font-size: $gl-font-size;
padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top);
padding: $gl-padding-top 0 $gl-padding-top 40px;
border-bottom: 1px solid $white-normal;
color: $list-text-color;
position: relative;
&.event-inline {
.avatar {
position: relative;
top: -2px;
.profile-icon {
top: 20px;
}
.event-title,
@ -24,8 +24,28 @@
color: $gl-text-color;
}
.avatar {
margin-left: -($gl-avatar-size + $gl-padding-top);
.profile-icon {
position: absolute;
left: 0;
top: 14px;
svg {
width: 20px;
height: auto;
fill: $gl-text-color-secondary;
}
&.open-icon svg {
fill: $green-300;
}
&.closed-icon svg {
fill: $red-300;
}
&.fork-icon svg {
fill: $blue-300;
}
}
.event-title {
@ -163,7 +183,7 @@
max-width: 100%;
}
.avatar {
.profile-icon {
display: none;
}

View File

@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
:name,
:path,
:request_access_enabled,
:visibility_level
:visibility_level,
:require_two_factor_authentication,
:two_factor_grace_period
]
end
end

View File

@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
include PageLayoutHelper
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
before_action :check_2fa_requirement
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
end
end
def check_2fa_requirement
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
def ldap_security_check
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('gitlab_project')
end
def two_factor_authentication_required?
current_application_settings.require_two_factor_authentication
end
def two_factor_grace_period
current_application_settings.two_factor_grace_period
end
def two_factor_grace_period_expired?
date = current_user.otp_grace_period_started_at
date && (date + two_factor_grace_period.hours) < Time.current
end
def skip_two_factor?
session[:skip_tfa] && session[:skip_tfa] > Time.current
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html

View File

@ -0,0 +1,58 @@
# == EnforcesTwoFactorAuthentication
#
# Controller concern to enforce two-factor authentication requirements
#
# Upon inclusion, adds `check_two_factor_requirement` as a before_action,
# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?`
# available as view helpers.
module EnforcesTwoFactorAuthentication
extend ActiveSupport::Concern
included do
before_action :check_two_factor_requirement
helper_method :two_factor_grace_period_expired?, :two_factor_skippable?
end
def check_two_factor_requirement
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
def two_factor_authentication_required?
current_application_settings.require_two_factor_authentication? ||
current_user.try(:require_two_factor_authentication_from_group?)
end
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
if current_application_settings.require_two_factor_authentication?
global.call
else
groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
group.call(groups)
end
end
end
def two_factor_grace_period
periods = [current_application_settings.two_factor_grace_period]
periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
periods.min
end
def two_factor_grace_period_expired?
date = current_user.otp_grace_period_started_at
date && (date + two_factor_grace_period.hours) < Time.current
end
def two_factor_skippable?
two_factor_authentication_required? &&
!current_user.two_factor_enabled? &&
!two_factor_grace_period_expired?
end
def skip_two_factor?
session[:skip_two_factor] && session[:skip_two_factor] > Time.current
end
end

View File

@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController
:visibility_level,
:parent_id,
:create_chat_team,
:chat_team_name
:chat_team_name,
:require_two_factor_authentication,
:two_factor_grace_period
]
end

View File

@ -1,5 +1,5 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_2fa_requirement
skip_before_action :check_two_factor_requirement
def show
unless current_user.otp_secret
@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed?
if two_factor_authentication_required? && !current_user.two_factor_enabled?
if two_factor_grace_period_expired?
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
else
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
'The global settings require you to enable Two-Factor Authentication for your account.'
end,
group: lambda do |groups|
group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
flash.now[:alert] = %{
The group settings for #{group_links} require you to enable
Two-Factor Authentication for your account.
}.html_safe
end
)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}."
end
end
@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if two_factor_grace_period_expired?
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
else
session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
redirect_to root_path
end
end

View File

@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
# Raised when given an invalid file path
InvalidPathError = Class.new(StandardError)
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy]
before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
before_action :assign_blob_vars
before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create]
@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
end
def edit
blob.load_all_data!(@repository)
if can_collaborate_with_project?
blob.load_all_data!(@repository)
else
redirect_to action: 'show'
end
end
def update

View File

@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
respond_to do |format|
format.html
format.json do
render json: {
id: @build.id,
status: @build.status,
trace_html: @build.trace_html
}
end
end
end
def trace
respond_to do |format|
format.json do
state = params[:state].presence
render json: @build.trace_with_state(state: state).
merge!(id: @build.id, status: @build.status)
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController
end
def raw
if @build.has_trace_file?
send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
build.trace.read do |stream|
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
end

View File

@ -1,34 +0,0 @@
class Projects::ContainerRegistryController < Projects::ApplicationController
before_action :verify_registry_enabled
before_action :authorize_read_container_image!
before_action :authorize_update_container_image!, only: [:destroy]
layout 'project'
def index
@tags = container_registry_repository.tags
end
def destroy
url = namespace_project_container_registry_index_path(project.namespace, project)
if tag.delete
redirect_to url
else
redirect_to url, alert: 'Failed to remove tag'
end
end
private
def verify_registry_enabled
render_404 unless Gitlab.config.registry.enabled
end
def container_registry_repository
@container_registry_repository ||= project.container_registry_repository
end
def tag
@tag ||= container_registry_repository.tag(params[:id])
end
end

View File

@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
if pipeline
status = pipeline.status
coverage = pipeline.try(:coverage)
coverage = pipeline.coverage
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?

View File

@ -0,0 +1,16 @@
module Projects
module Registry
class ApplicationController < Projects::ApplicationController
layout 'project'
before_action :verify_registry_enabled!
before_action :authorize_read_container_image!
private
def verify_registry_enabled!
render_404 unless Gitlab.config.registry.enabled
end
end
end
end

View File

@ -0,0 +1,43 @@
module Projects
module Registry
class RepositoriesController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy]
before_action :ensure_root_container_repository!, only: [:index]
def index
@images = project.container_repositories
end
def destroy
if image.destroy
redirect_to project_container_registry_path(@project),
notice: 'Image repository has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
alert: 'Failed to remove image repository!'
end
end
private
def image
@image ||= project.container_repositories.find(params[:id])
end
##
# Container repository object for root project path.
#
# Needed to maintain a backwards compatibility.
#
def ensure_root_container_repository!
ContainerRegistry::Path.new(@project.full_path).tap do |path|
break if path.has_repository?
ContainerRepository.build_from_path(path).tap do |repository|
repository.save! if repository.has_tags?
end
end
end
end
end
end

View File

@ -0,0 +1,28 @@
module Projects
module Registry
class TagsController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy]
def destroy
if tag.delete
redirect_to project_container_registry_path(@project),
notice: 'Registry tag has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
alert: 'Failed to remove registry tag!'
end
end
private
def image
@image ||= project.container_repositories
.find(params[:repository_id])
end
def tag
@tag ||= image.tag(params[:id])
end
end
end
end

View File

@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper
skip_before_action :check_2fa_requirement, only: [:destroy]
skip_before_action :check_two_factor_requirement, only: [:destroy]
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,

View File

@ -64,18 +64,6 @@ module AuthHelper
current_user.identities.exists?(provider: provider.to_s)
end
def two_factor_skippable?
current_application_settings.require_two_factor_authentication &&
!current_user.two_factor_enabled? &&
current_application_settings.two_factor_grace_period &&
!two_factor_grace_period_expired?
end
def two_factor_grace_period_expired?
current_user.otp_grace_period_started_at &&
(current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current
end
def unlink_allowed?(provider)
%w(saml cas3).exclude?(provider.to_s)
end

View File

@ -8,31 +8,36 @@ module BlobHelper
%w(credits changelog news copying copyright license authors)
end
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless current_user
def edit_path(project = @project, ref = @ref, path = @path, options = {})
namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path),
options[:link_opts])
end
def fork_path(project = @project, ref = @ref, path = @path, options = {})
continue_params = {
to: edit_path,
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now
}
namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
end
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob
edit_path = namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path),
options[:link_opts])
common_classes = "btn js-edit-blob #{options[:extra_class]}"
if !on_top_of_branch?(project, ref)
button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
elsif can_edit_blob?(blob, project, ref)
link_to "Edit", edit_path, class: 'btn btn-sm'
elsif can?(current_user, :fork_project, project)
continue_params = {
to: edit_path,
notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now
}
fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
link_to "Edit", fork_path, class: 'btn', method: :post
button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
# This condition applies to anonymous or users who can edit directly
elsif !current_user || (current_user && can_edit_blob?(blob, project, ref))
link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
elsif current_user && can?(current_user, :fork_project, project)
button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler"
end
end
@ -205,13 +210,13 @@ module BlobHelper
end
def copy_file_path_button(file_path)
clipboard_button(clipboard_text: file_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
end
def copy_blob_content_button(blob)
return if markup?(blob.name)
clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
end
def open_raw_file_button(path)

View File

@ -1,23 +1,42 @@
module ButtonHelper
# Output a "Copy to Clipboard" button
#
# data - Data attributes passed to `content_tag`
# data - Data attributes passed to `content_tag` (default: {}):
# :text - Text to copy (optional)
# :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional)
# :target - Selector for target element to copy from (optional)
#
# Examples:
#
# # Define the clipboard's text
# clipboard_button(clipboard_text: "Foo")
# clipboard_button(text: "Foo")
# # => "<button class='...' data-clipboard-text='Foo'>...</button>"
#
# # Define the target element
# clipboard_button(clipboard_target: "div#foo")
# clipboard_button(target: "div#foo")
# # => "<button class='...' data-clipboard-target='div#foo'>...</button>"
#
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
title = data[:title] || 'Copy to clipboard'
# This supports code in app/assets/javascripts/copy_to_clipboard.js that
# works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
if text = data.delete(:text)
data[:clipboard_text] =
if gfm = data.delete(:gfm)
{ text: text, gfm: gfm }
else
text
end
end
target = data.delete(:target)
data[:clipboard_target] = target if target
data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
content_tag :button,
icon('clipboard', 'aria-hidden': 'true'),
class: "btn #{css_class}",

View File

@ -1,6 +1,6 @@
module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block)
content_tag :div, class: "dropdown" do
content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
if options.has_key?(:data)
@ -20,7 +20,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder])
end
output << content_tag(:div, class: "dropdown-content") do
output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
capture(&block) if block && !options.has_key?(:footer_content)
end

View File

@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
UPVOTE_NAME = "thumbsup".freeze
include Participable
include GhostUser
belongs_to :awardable, polymorphic: true
belongs_to :user
validates :awardable, :user, presence: true
validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names }
validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }
validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user?
participant :user

View File

@ -171,19 +171,6 @@ module Ci
latest_builds.where('stage_idx < ?', stage_idx)
end
def trace_html(**args)
trace_with_state(**args)[:html] || ''
end
def trace_with_state(state: nil, last_lines: nil)
trace_ansi = trace(last_lines: last_lines)
if trace_ansi.present?
Ci::Ansi2html.convert(trace_ansi, state)
else
{}
end
end
def timeout
project.build_timeout
end
@ -244,136 +231,35 @@ module Ci
end
def update_coverage
coverage = extract_coverage(trace, coverage_regex)
coverage = trace.extract_coverage(coverage_regex)
update_attributes(coverage: coverage) if coverage.present?
end
def extract_coverage(text, regex)
return unless regex
matches = text.scan(Regexp.new(regex)).last
matches = matches.last if matches.is_a?(Array)
coverage = matches.gsub(/\d+(\.\d+)?/).first
if coverage.present?
coverage.to_f
end
rescue
# if bad regex or something goes wrong we dont want to interrupt transition
# so we just silentrly ignore error for now
end
def has_trace_file?
File.exist?(path_to_trace) || has_old_trace_file?
def trace
Gitlab::Ci::Trace.new(self)
end
def has_trace?
raw_trace.present?
trace.exist?
end
def raw_trace(last_lines: nil)
if File.exist?(trace_file_path)
Gitlab::Ci::TraceReader.new(trace_file_path).
read(last_lines: last_lines)
else
# backward compatibility
read_attribute :trace
end
def trace=(data)
raise NotImplementedError
end
##
# Deprecated
#
# This is a hotfix for CI build data integrity, see #4246
def has_old_trace_file?
project.ci_id && File.exist?(old_path_to_trace)
def old_trace
read_attribute(:trace)
end
def trace(last_lines: nil)
hide_secrets(raw_trace(last_lines: last_lines))
end
def trace_length
if raw_trace
raw_trace.bytesize
else
0
end
end
def trace=(trace)
recreate_trace_dir
trace = hide_secrets(trace)
File.write(path_to_trace, trace)
end
def recreate_trace_dir
unless Dir.exist?(dir_to_trace)
FileUtils.mkdir_p(dir_to_trace)
end
end
private :recreate_trace_dir
def append_trace(trace_part, offset)
recreate_trace_dir
touch if needs_touch?
trace_part = hide_secrets(trace_part)
File.truncate(path_to_trace, offset) if File.exist?(path_to_trace)
File.open(path_to_trace, 'ab') do |f|
f.write(trace_part)
end
def erase_old_trace!
write_attribute(:trace, nil)
save
end
def needs_touch?
Time.now - updated_at > 15.minutes.to_i
end
def trace_file_path
if has_old_trace_file?
old_path_to_trace
else
path_to_trace
end
end
def dir_to_trace
File.join(
Settings.gitlab_ci.builds_path,
created_at.utc.strftime("%Y_%m"),
project.id.to_s
)
end
def path_to_trace
"#{dir_to_trace}/#{id}.log"
end
##
# Deprecated
#
# This is a hotfix for CI build data integrity, see #4246
# Should be removed in 8.4, after CI files migration has been done.
#
def old_dir_to_trace
File.join(
Settings.gitlab_ci.builds_path,
created_at.utc.strftime("%Y_%m"),
project.ci_id.to_s
)
end
##
# Deprecated
#
# This is a hotfix for CI build data integrity, see #4246
# Should be removed in 8.4, after CI files migration has been done.
#
def old_path_to_trace
"#{old_dir_to_trace}/#{id}.log"
end
##
# Deprecated
#
@ -555,6 +441,15 @@ module Ci
options[:dependencies]&.empty?
end
def hide_secrets(trace)
return unless trace
trace = trace.dup
Ci::MaskSecret.mask!(trace, project.runners_token) if project
Ci::MaskSecret.mask!(trace, token)
trace
end
private
def update_artifacts_size
@ -566,7 +461,7 @@ module Ci
end
def erase_trace!
self.trace = nil
trace.erase!
end
def update_erased!(user = nil)
@ -628,15 +523,6 @@ module Ci
pipeline.config_processor.build_attributes(name)
end
def hide_secrets(trace)
return unless trace
trace = trace.dup
Ci::MaskSecret.mask!(trace, project.runners_token) if project
Ci::MaskSecret.mask!(trace, token)
trace
end
def update_project_statistics
return unless project

View File

@ -8,6 +8,7 @@ module Ci
belongs_to :owner, class_name: "User"
has_many :trigger_requests, dependent: :destroy
has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true

View File

@ -0,0 +1,30 @@
module Ci
class TriggerSchedule < ActiveRecord::Base
extend Ci::Model
include Importable
acts_as_paranoid
belongs_to :project
belongs_to :trigger
delegate :ref, to: :trigger
validates :trigger, presence: { unless: :importing? }
validates :cron, cron: true, presence: { unless: :importing? }
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
before_save :set_next_run_at
def set_next_run_at
self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
end
def schedule_next_run!
save! # with set_next_run_at
rescue ActiveRecord::RecordInvalid
update_attribute(:next_run_at, nil) # update without validation
end
end
end

View File

@ -0,0 +1,7 @@
module GhostUser
extend ActiveSupport::Concern
def ghost_user?
user && user.ghost?
end
end

View File

@ -83,6 +83,74 @@ module Routable
AND members.source_type = r2.source_type").
where('members.user_id = ?', user_id)
end
# Builds a relation to find multiple objects that are nested under user
# membership. Includes the parent, as opposed to `#member_descendants`
# which only includes the descendants.
#
# Usage:
#
# Klass.member_self_and_descendants(1)
#
# Returns an ActiveRecord::Relation.
def member_self_and_descendants(user_id)
joins(:route).
joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
OR routes.path = r2.path
INNER JOIN members ON members.source_id = r2.source_id
AND members.source_type = r2.source_type").
where('members.user_id = ?', user_id)
end
# Returns all objects in a hierarchy, where any node in the hierarchy is
# under the user membership.
#
# Usage:
#
# Klass.member_hierarchy(1)
#
# Examples:
#
# Given the following group tree...
#
# _______group_1_______
# | |
# | |
# nested_group_1 nested_group_2
# | |
# | |
# nested_group_1_1 nested_group_2_1
#
#
# ... the following results are returned:
#
# * the user is a member of group 1
# => 'group_1',
# 'nested_group_1', nested_group_1_1',
# 'nested_group_2', 'nested_group_2_1'
#
# * the user is a member of nested_group_2
# => 'group1',
# 'nested_group_2', 'nested_group_2_1'
#
# * the user is a member of nested_group_2_1
# => 'group1',
# 'nested_group_2', 'nested_group_2_1'
#
# Returns an ActiveRecord::Relation.
def member_hierarchy(user_id)
paths = member_self_and_descendants(user_id).pluck('routes.path')
return none if paths.empty?
wheres = paths.map do |path|
"#{connection.quote(path)} = routes.path
OR
#{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
end
joins(:route).where(wheres.join(' OR '))
end
end
def full_name

View File

@ -0,0 +1,77 @@
class ContainerRepository < ActiveRecord::Base
belongs_to :project
validates :name, length: { minimum: 0, allow_nil: false }
validates :name, uniqueness: { scope: :project_id }
delegate :client, to: :registry
before_destroy :delete_tags!
def registry
@registry ||= begin
token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
url = Gitlab.config.registry.api_url
host_port = Gitlab.config.registry.host_port
ContainerRegistry::Registry.new(url, token: token, path: host_port)
end
end
def path
@path ||= [project.full_path, name].select(&:present?).join('/')
end
def tag(tag)
ContainerRegistry::Tag.new(self, tag)
end
def manifest
@manifest ||= client.repository_tags(path)
end
def tags
return @tags if defined?(@tags)
return [] unless manifest && manifest['tags']
@tags = manifest['tags'].map do |tag|
ContainerRegistry::Tag.new(self, tag)
end
end
def blob(config)
ContainerRegistry::Blob.new(self, config)
end
def has_tags?
tags.any?
end
def root_repository?
name.empty?
end
def delete_tags!
return unless has_tags?
digests = tags.map { |tag| tag.digest }.to_set
digests.all? do |digest|
client.delete_repository_tag(self.path, digest)
end
end
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
end
def self.create_from_path!(path)
build_from_path(path).tap(&:save!)
end
def self.build_root_repository(project)
self.new(project: project, name: '')
end
end

View File

@ -27,11 +27,14 @@ class Group < Namespace
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
class << self
# Searches for groups matching the given query.
@ -223,4 +226,12 @@ class Group < Namespace
type: public? ? 'O' : 'I' # Open vs Invite-only
}
end
protected
def update_two_factor_requirement
return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
users.find_each(&:update_two_factor_requirement)
end
end

View File

@ -3,11 +3,16 @@ class GroupMember < Member
belongs_to :group, foreign_key: 'source_id'
delegate :update_two_factor_requirement, to: :user
# Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
def self.access_level_roles
Gitlab::Access.options_with_owner
end

View File

@ -116,6 +116,7 @@ class Project < ActiveRecord::Base
has_one :mock_ci_service, dependent: :destroy
has_one :mock_deployment_service, dependent: :destroy
has_one :mock_monitoring_service, dependent: :destroy
has_one :microsoft_teams_service, dependent: :destroy
has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
has_one :forked_from_project, through: :forked_project_link
@ -160,6 +161,7 @@ class Project < ActiveRecord::Base
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
has_many :container_repositories, dependent: :destroy
has_many :commit_statuses, dependent: :destroy
has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
@ -407,32 +409,15 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(path_with_namespace, self)
end
def container_registry_path_with_namespace
path_with_namespace.downcase
end
def container_registry_repository
return unless Gitlab.config.registry.enabled
@container_registry_repository ||= begin
token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace)
url = Gitlab.config.registry.api_url
host_port = Gitlab.config.registry.host_port
registry = ContainerRegistry::Registry.new(url, token: token, path: host_port)
registry.repository(container_registry_path_with_namespace)
end
end
def container_registry_repository_url
def container_registry_url
if Gitlab.config.registry.enabled
"#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}"
"#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
end
end
def has_container_registry_tags?
return unless container_registry_repository
container_registry_repository.tags.any?
container_repositories.to_a.any?(&:has_tags?) ||
has_root_container_repository_tags?
end
def commit(ref = 'HEAD')
@ -907,10 +892,10 @@ class Project < ActiveRecord::Base
expire_caches_before_rename(old_path_with_namespace)
if has_container_registry_tags?
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present"
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
# we currently doesn't support renaming repository if it contains tags in container registry
raise StandardError.new('Project cannot be renamed, because tags are present in its container registry')
# we currently doesn't support renaming repository if it contains images in container registry
raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
end
if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
@ -1100,10 +1085,6 @@ class Project < ActiveRecord::Base
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
end
def build_coverage_enabled?
build_coverage_regex.present?
end
def build_timeout_in_minutes
build_timeout / 60
end
@ -1257,7 +1238,7 @@ class Project < ActiveRecord::Base
]
if container_registry_enabled?
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true }
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
end
variables
@ -1385,4 +1366,15 @@ class Project < ActiveRecord::Base
Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
end
##
# This method is here because of support for legacy container repository
# which has exactly the same path like project does, but which might not be
# persisted in `container_repositories` table.
#
def has_root_container_repository_tags?
return false unless Gitlab.config.registry.enabled
ContainerRepository.build_root_repository(self).has_tags?
end
end

View File

@ -2,11 +2,23 @@ require 'slack-notifier'
module ChatMessage
class BaseMessage
attr_reader :markdown
attr_reader :user_name
attr_reader :user_avatar
attr_reader :project_name
attr_reader :project_url
def initialize(params)
raise NotImplementedError
@markdown = params[:markdown] || false
@project_name = params.dig(:project, :path_with_namespace) || params[:project_name]
@project_url = params.dig(:project, :web_url) || params[:project_url]
@user_name = params.dig(:user, :username) || params[:user_name]
@user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar]
end
def pretext
return message if markdown
format(message)
end
@ -17,6 +29,10 @@ module ChatMessage
raise NotImplementedError
end
def activity
raise NotImplementedError
end
private
def message

View File

@ -1,9 +1,6 @@
module ChatMessage
class IssueMessage < BaseMessage
attr_reader :user_name
attr_reader :title
attr_reader :project_name
attr_reader :project_url
attr_reader :issue_iid
attr_reader :issue_url
attr_reader :action
@ -11,9 +8,7 @@ module ChatMessage
attr_reader :description
def initialize(params)
@user_name = params[:user][:username]
@project_name = params[:project_name]
@project_url = params[:project_url]
super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@ -27,15 +22,24 @@ module ChatMessage
def attachments
return [] unless opened_issue?
return description if markdown
description_message
end
def activity
{
title: "Issue #{state} by #{user_name}",
subtitle: "in #{project_link}",
text: issue_link,
image: user_avatar
}
end
private
def message
case state
when "opened"
if state == 'opened'
"[#{project_link}] Issue #{state} by #{user_name}"
else
"[#{project_link}] Issue #{issue_link} #{state} by #{user_name}"
@ -64,7 +68,7 @@ module ChatMessage
end
def issue_title
"##{issue_iid} #{title}"
"#{Issue.reference_prefix}#{issue_iid} #{title}"
end
end
end

View File

@ -1,36 +1,36 @@
module ChatMessage
class MergeMessage < BaseMessage
attr_reader :user_name
attr_reader :project_name
attr_reader :project_url
attr_reader :merge_request_id
attr_reader :merge_request_iid
attr_reader :source_branch
attr_reader :target_branch
attr_reader :state
attr_reader :title
def initialize(params)
@user_name = params[:user][:username]
@project_name = params[:project_name]
@project_url = params[:project_url]
super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@merge_request_id = obj_attr[:iid]
@merge_request_iid = obj_attr[:iid]
@source_branch = obj_attr[:source_branch]
@target_branch = obj_attr[:target_branch]
@state = obj_attr[:state]
@title = format_title(obj_attr[:title])
end
def pretext
format(message)
end
def attachments
[]
end
def activity
{
title: "Merge Request #{state} by #{user_name}",
subtitle: "in #{project_link}",
text: merge_request_link,
image: user_avatar
}
end
private
def format_title(title)
@ -50,11 +50,15 @@ module ChatMessage
end
def merge_request_link
link("merge request !#{merge_request_id}", merge_request_url)
link(merge_request_title, merge_request_url)
end
def merge_request_title
"#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}"
end
def merge_request_url
"#{project_url}/merge_requests/#{merge_request_id}"
"#{project_url}/merge_requests/#{merge_request_iid}"
end
end
end

View File

@ -1,70 +1,74 @@
module ChatMessage
class NoteMessage < BaseMessage
attr_reader :message
attr_reader :user_name
attr_reader :project_name
attr_reader :project_url
attr_reader :note
attr_reader :note_url
attr_reader :title
attr_reader :target
def initialize(params)
params = HashWithIndifferentAccess.new(params)
@user_name = params[:user][:username]
@project_name = params[:project_name]
@project_url = params[:project_url]
super
params = HashWithIndifferentAccess.new(params)
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@note = obj_attr[:note]
@note_url = obj_attr[:url]
noteable_type = obj_attr[:noteable_type]
case noteable_type
when "Commit"
create_commit_note(HashWithIndifferentAccess.new(params[:commit]))
when "Issue"
create_issue_note(HashWithIndifferentAccess.new(params[:issue]))
when "MergeRequest"
create_merge_note(HashWithIndifferentAccess.new(params[:merge_request]))
when "Snippet"
create_snippet_note(HashWithIndifferentAccess.new(params[:snippet]))
end
@target, @title = case obj_attr[:noteable_type]
when "Commit"
create_commit_note(params[:commit])
when "Issue"
create_issue_note(params[:issue])
when "MergeRequest"
create_merge_note(params[:merge_request])
when "Snippet"
create_snippet_note(params[:snippet])
end
end
def attachments
return note if markdown
description_message
end
def activity
{
title: "#{user_name} #{link('commented on ' + target, note_url)}",
subtitle: "in #{project_link}",
text: formatted_title,
image: user_avatar
}
end
private
def message
"#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
end
def format_title(title)
title.lines.first.chomp
end
def create_commit_note(commit)
commit_sha = commit[:id]
commit_sha = Commit.truncate_sha(commit_sha)
commented_on_message(
"commit #{commit_sha}",
format_title(commit[:message]))
def formatted_title
format_title(title)
end
def create_issue_note(issue)
commented_on_message(
"issue ##{issue[:iid]}",
format_title(issue[:title]))
["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]]
end
def create_commit_note(commit)
commit_sha = Commit.truncate_sha(commit[:id])
["commit #{commit_sha}", commit[:message]]
end
def create_merge_note(merge_request)
commented_on_message(
"merge request !#{merge_request[:iid]}",
format_title(merge_request[:title]))
["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]]
end
def create_snippet_note(snippet)
commented_on_message(
"snippet ##{snippet[:id]}",
format_title(snippet[:title]))
["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]]
end
def description_message
@ -74,9 +78,5 @@ module ChatMessage
def project_link
link(project_name, project_url)
end
def commented_on_message(target, title)
@message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*"
end
end
end

View File

@ -1,19 +1,22 @@
module ChatMessage
class PipelineMessage < BaseMessage
attr_reader :ref_type, :ref, :status, :project_name, :project_url,
:user_name, :duration, :pipeline_id
attr_reader :ref_type
attr_reader :ref
attr_reader :status
attr_reader :duration
attr_reader :pipeline_id
def initialize(data)
super
@user_name = data.dig(:user, :name) || 'API'
pipeline_attributes = data[:object_attributes]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status]
@duration = pipeline_attributes[:duration]
@pipeline_id = pipeline_attributes[:id]
@project_name = data[:project][:path_with_namespace]
@project_url = data[:project][:web_url]
@user_name = (data[:user] && data[:user][:name]) || 'API'
end
def pretext
@ -25,17 +28,24 @@ module ChatMessage
end
def attachments
return message if markdown
[{ text: format(message), color: attachment_color }]
end
def activity
{
title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
subtitle: "in #{project_link}",
text: "in #{duration} #{time_measure}",
image: user_avatar || ''
}
end
private
def message
"#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}"
end
def format(string)
Slack::Notifier::LinkFormatter.format(string)
"#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
end
def humanized_status
@ -74,5 +84,9 @@ module ChatMessage
def pipeline_link
"[##{pipeline_id}](#{pipeline_url})"
end
def time_measure
'second'.pluralize(duration)
end
end
end

View File

@ -3,33 +3,43 @@ module ChatMessage
attr_reader :after
attr_reader :before
attr_reader :commits
attr_reader :project_name
attr_reader :project_url
attr_reader :ref
attr_reader :ref_type
attr_reader :user_name
def initialize(params)
super
@after = params[:after]
@before = params[:before]
@commits = params.fetch(:commits, [])
@project_name = params[:project_name]
@project_url = params[:project_url]
@ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch'
@ref = Gitlab::Git.ref_name(params[:ref])
@user_name = params[:user_name]
end
def pretext
format(message)
end
def attachments
return [] if new_branch? || removed_branch?
return commit_messages if markdown
commit_message_attachments
end
def activity
action = if new_branch?
"created"
elsif removed_branch?
"removed"
else
"pushed to"
end
{
title: "#{user_name} #{action} #{ref_type}",
subtitle: "in #{project_link}",
text: compare_link,
image: user_avatar
}
end
private
def message
@ -59,7 +69,7 @@ module ChatMessage
end
def commit_messages
commits.map { |commit| compose_commit_message(commit) }.join("\n")
commits.map { |commit| compose_commit_message(commit) }.join("\n\n")
end
def commit_message_attachments

View File

@ -1,17 +1,12 @@
module ChatMessage
class WikiPageMessage < BaseMessage
attr_reader :user_name
attr_reader :title
attr_reader :project_name
attr_reader :project_url
attr_reader :wiki_page_url
attr_reader :action
attr_reader :description
def initialize(params)
@user_name = params[:user][:username]
@project_name = params[:project_name]
@project_url = params[:project_url]
super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@ -29,9 +24,20 @@ module ChatMessage
end
def attachments
return description if markdown
description_message
end
def activity
{
title: "#{user_name} #{action} #{wiki_page_link}",
subtitle: "in #{project_link}",
text: title,
image: user_avatar
}
end
private
def message

View File

@ -49,10 +49,7 @@ class ChatNotificationService < Service
object_kind = data[:object_kind]
data = data.merge(
project_url: project_url,
project_name: project_name
)
data = custom_data(data)
# WebHook events often have an 'update' event that follows a 'open' or
# 'close' action. Ignore update events for now to prevent duplicate
@ -68,8 +65,7 @@ class ChatNotificationService < Service
opts[:channel] = channel_name if channel_name
opts[:username] = username if username
notifier = Slack::Notifier.new(webhook, opts)
notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback)
return false unless notify(message, opts)
true
end
@ -92,6 +88,18 @@ class ChatNotificationService < Service
private
def notify(message, opts)
Slack::Notifier.new(webhook, opts).ping(
message.pretext,
attachments: message.attachments,
fallback: message.fallback
)
end
def custom_data(data)
data.merge(project_url: project_url, project_name: project_name)
end
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"

View File

@ -0,0 +1,56 @@
class MicrosoftTeamsService < ChatNotificationService
def title
'Microsoft Teams Notification'
end
def description
'Receive event notifications in Microsoft Teams'
end
def self.to_param
'microsoft_teams'
end
def help
'This service sends notifications about projects events to Microsoft Teams channels.<br />
To set up this service:
<ol>
<li><a href="https://msdn.microsoft.com/en-us/microsoft-teams/connectors">Getting started with 365 Office Connectors For Microsoft Teams</a>.</li>
<li>Paste the <strong>Webhook URL</strong> into the field below.</li>
<li>Select events below to enable notifications.</li>
</ol>'
end
def webhook_placeholder
'https://outlook.office.com/webhook/…'
end
def event_field(event)
end
def default_channel_placeholder
end
def default_fields
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{ type: 'checkbox', name: 'notify_only_default_branch' },
]
end
private
def notify(message, opts)
MicrosoftTeams::Notifier.new(webhook).ping(
title: message.project_name,
pretext: message.pretext,
activity: message.activity,
attachments: message.attachments
)
end
def custom_data(data)
super(data).merge(markdown: true)
end
end

View File

@ -6,6 +6,8 @@ class Repository
attr_accessor :path_with_namespace, :project
delegate :ref_name_for_sha, to: :raw_repository
CommitError = Class.new(StandardError)
CreateTreeError = Class.new(StandardError)
@ -700,14 +702,6 @@ class Repository
end
end
def ref_name_for_sha(ref_path, sha)
args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
# Not found -> ["", 0]
# Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
Gitlab::Popen.popen(args, path_to_repo).first.split.last
end
def refs_contains_sha(ref_type, sha)
args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
names = Gitlab::Popen.popen(args, path_to_repo).first

View File

@ -237,6 +237,7 @@ class Service < ActiveRecord::Base
slack_slash_commands
slack
teamcity
microsoft_teams
]
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]

View File

@ -89,7 +89,8 @@ class User < ActiveRecord::Base
has_many :subscriptions, dependent: :destroy
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
has_one :abuse_report, dependent: :destroy
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
has_many :spam_logs, dependent: :destroy
has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
@ -484,6 +485,14 @@ class User < ActiveRecord::Base
Group.member_descendants(id)
end
def all_expanded_groups
Group.member_hierarchy(id)
end
def expanded_groups_requiring_two_factor_authentication
all_expanded_groups.where(require_two_factor_authentication: true)
end
def nested_groups_projects
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
member_descendants(id)
@ -955,6 +964,15 @@ class User < ActiveRecord::Base
self.admin = (new_level == 'admin')
end
def update_two_factor_requirement
periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
self.require_two_factor_authentication_from_group = periods.any?
self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period']
save
end
protected
# override, from Devise::Validatable

View File

@ -17,6 +17,7 @@ module Auth
end
def self.full_access_token(*names)
names = names.flatten
registry = Gitlab.config.registry
token = JSONWebToken::RSAToken.new(registry.key)
token.issuer = registry.issuer
@ -37,13 +38,13 @@ module Auth
private
def authorized_token(*accesses)
token = JSONWebToken::RSAToken.new(registry.key)
token.issuer = registry.issuer
token.audience = params[:service]
token.subject = current_user.try(:username)
token.expire_time = self.class.token_expire_at
token[:access] = accesses.compact
token
JSONWebToken::RSAToken.new(registry.key).tap do |token|
token.issuer = registry.issuer
token.audience = params[:service]
token.subject = current_user.try(:username)
token.expire_time = self.class.token_expire_at
token[:access] = accesses.compact
end
end
def scope
@ -55,20 +56,43 @@ module Auth
def process_scope(scope)
type, name, actions = scope.split(':', 3)
actions = actions.split(',')
path = ContainerRegistry::Path.new(name)
return unless type == 'repository'
process_repository_access(type, name, actions)
process_repository_access(type, path, actions)
end
def process_repository_access(type, name, actions)
requested_project = Project.find_by_full_path(name)
def process_repository_access(type, path, actions)
return unless path.valid?
requested_project = path.repository_project
return unless requested_project
actions = actions.select do |action|
can_access?(requested_project, action)
end
{ type: type, name: name, actions: actions } if actions.present?
return unless actions.present?
# At this point user/build is already authenticated.
#
ensure_container_repository!(path, actions)
{ type: type, name: path.to_s, actions: actions }
end
##
# Because we do not have two way communication with registry yet,
# we create a container repository image resource when push to the
# registry is successfuly authorized.
#
def ensure_container_repository!(path, actions)
return if path.has_repository?
return unless actions.include?('push')
ContainerRepository.create_from_path!(path)
end
def can_access?(requested_project, requested_action)
@ -101,6 +125,11 @@ module Auth
can?(current_user, :read_container_image, requested_project)
end
##
# We still support legacy pipeline triggers which do not have associated
# actor. New permissions model and new triggers are always associated with
# an actor, so this should be improved in 10.0 version of GitLab.
#
def build_can_push?(requested_project)
# Build can push only to the project from which it originates
has_authentication_ability?(:build_create_container_image) &&
@ -113,14 +142,11 @@ module Auth
end
def error(code, status:, message: '')
{
errors: [{ code: code, message: message }],
http_status: status
}
{ errors: [{ code: code, message: message }], http_status: status }
end
def has_authentication_ability?(capability)
(@authentication_abilities || []).include?(capability)
@authentication_abilities.to_a.include?(capability)
end
end
end

View File

@ -31,16 +31,16 @@ module Projects
project.team.truncate
project.destroy!
unless remove_registry_tags
raise_error('Failed to remove project container registry. Please try again or contact administrator')
unless remove_legacy_registry_tags
raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
end
unless remove_repository(repo_path)
raise_error('Failed to remove project repository. Please try again or contact administrator')
raise_error('Failed to remove project repository. Please try again or contact administrator.')
end
unless remove_repository(wiki_path)
raise_error('Failed to remove wiki repository. Please try again or contact administrator')
raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
end
end
@ -68,10 +68,16 @@ module Projects
end
end
def remove_registry_tags
##
# This method makes sure that we correctly remove registry tags
# for legacy image repository (when repository path equals project path).
#
def remove_legacy_registry_tags
return true unless Gitlab.config.registry.enabled
project.container_registry_repository.delete_tags
ContainerRepository.build_root_repository(project).tap do |repository|
return repository.has_tags? ? repository.delete_tags! : true
end
end
def raise_error(message)

View File

@ -26,7 +26,7 @@ module Users
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
end
move_issues_to_ghost_user(user)
MigrateToGhostUserService.new(user).execute
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
namespace = user.namespace
@ -35,22 +35,5 @@ module Users
user_data
end
private
def move_issues_to_ghost_user(user)
# Block the user before moving issues to prevent a data race.
# If the user creates an issue after `move_issues_to_ghost_user`
# runs and before the user is destroyed, the destroy will fail with
# an exception. We block the user so that issues can't be created
# after `move_issues_to_ghost_user` runs and before the destroy happens.
user.block
ghost_user = User.ghost
user.issues.update_all(author_id: ghost_user.id)
user.reload
end
end
end

View File

@ -0,0 +1,59 @@
# When a user is destroyed, some of their associated records are
# moved to a "Ghost User", to prevent these associated records from
# being destroyed.
#
# For example, all the issues/MRs a user has created are _not_ destroyed
# when the user is destroyed.
module Users
class MigrateToGhostUserService
extend ActiveSupport::Concern
attr_reader :ghost_user, :user
def initialize(user)
@user = user
end
def execute
# Block the user before moving records to prevent a data race.
# For example, if the user creates an issue after `migrate_issues`
# runs and before the user is destroyed, the destroy will fail with
# an exception.
user.block
user.transaction do
@ghost_user = User.ghost
migrate_issues
migrate_merge_requests
migrate_notes
migrate_abuse_reports
migrate_award_emoji
end
user.reload
end
private
def migrate_issues
user.issues.update_all(author_id: ghost_user.id)
end
def migrate_merge_requests
user.merge_requests.update_all(author_id: ghost_user.id)
end
def migrate_notes
user.notes.update_all(author_id: ghost_user.id)
end
def migrate_abuse_reports
user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
end
def migrate_award_emoji
user.award_emoji.update_all(user_id: ghost_user.id)
end
end
end

View File

@ -0,0 +1,9 @@
# CronTimezoneValidator
#
# Custom validator for CronTimezone.
class CronTimezoneValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid?
end
end

View File

@ -0,0 +1,9 @@
# CronValidator
#
# Custom validator for Cron.
class CronValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
end
end

View File

@ -13,7 +13,7 @@
.col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f
= render 'groups/group_lfs_settings', f: f
= render 'groups/group_admin_settings', f: f
- if @group.new_record?
.form-group

View File

@ -3,8 +3,6 @@
.event-item-timestamp
#{time_ago_with_tooltip(event.created_at)}
= author_avatar(event, size: 40)
- if event.created_project?
= render "events/event/created_project", event: event
- elsif event.push?

View File

@ -1,5 +1,15 @@
- if event.target
- if event.action_name == "opened"
.profile-icon.open-icon
= custom_icon("icon_status_open")
- elsif event.action_name == "closed"
.profile-icon.closed-icon
= custom_icon("icon_status_closed")
- else
.profile-icon.fork-icon
= custom_icon("code_fork")
.event-title
%span.author_name= link_to_author event
%span{ class: event.action_name }
- if event.target
= event.action_name

View File

@ -1,5 +1,7 @@
.profile-icon.open-icon
= custom_icon("icon_status_open")
.event-title
%span.author_name= link_to_author event
%span{ class: event.action_name }
= event_action_name(event)

View File

@ -1,5 +1,7 @@
.profile-icon
= custom_icon("comment_o")
.event-title
%span.author_name= link_to_author event
= event.action_name
= event_note_title_html(event)

View File

@ -1,7 +1,12 @@
- project = event.project
.profile-icon
- if event.action_name == "deleted"
= custom_icon("trash_o")
- else
= custom_icon("icon_commit")
.event-title
%span.author_name= link_to_author event
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
@ -48,4 +53,3 @@
.event-body
%ul.well-list.event_commits
= render "events/commit", commit: last_commit, project: project, event: event

View File

@ -0,0 +1,28 @@
- if current_user.admin?
.form-group
= f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
.col-sm-10
.checkbox
= f.label :lfs_enabled do
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?
%strong
Allow projects within this group to use Git LFS
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
%br/
%span.descr This setting can be overridden in each project.
- if can? current_user, :admin_group, @group
.form-group
= f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
.col-sm-10
.checkbox
= f.label :require_two_factor_authentication do
= f.check_box :require_two_factor_authentication
%strong
Require all users in this group to setup Two-factor authentication
= link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.text_field :two_factor_grace_period, class: 'form-control'
.help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication

View File

@ -1,11 +0,0 @@
- if current_user.admin?
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :lfs_enabled do
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?
%strong
Allow projects within this group to use Git LFS
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
%br/
%span.descr This setting can be overridden in each project.

View File

@ -27,7 +27,7 @@
.col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f
= render 'group_lfs_settings', f: f
= render 'group_admin_settings', f: f
.form-group
%hr

View File

@ -137,6 +137,6 @@
- if build.has_trace?
%td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" }
%pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" }
= build.trace_html(last_lines: 10).html_safe
= build.trace.html(last_lines: 10).html_safe
- else
%td{ colspan: "2" }

View File

@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
Stage: <%= build.stage %>
Name: <%= build.name %>
<% if build.has_trace? -%>
Trace: <%= build.trace_with_state(last_lines: 10)[:text] %>
Trace: <%= build.trace.raw(last_lines: 10) %>
<% end -%>
<% end -%>

View File

@ -19,7 +19,7 @@
Your New Personal Access Token
.form-group
= text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block"
= clipboard_button(clipboard_text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
= clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
%span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again.
%hr

View File

@ -10,7 +10,7 @@
- if @project && event.project != @project
%span at
%strong= link_to_project event.project
= clipboard_button(clipboard_text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
= clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
#{time_ago_with_tooltip(event.created_at)}
.pull-right

View File

@ -25,4 +25,11 @@
#blob-content-holder.blob-content-holder
%article.file-holder
= render "projects/blob/header", blob: blob
- if current_user
.js-file-fork-suggestion-section.file-fork-suggestion.hidden
%span.file-fork-suggestion-note
You don't have permission to edit this file. Try forking this project to edit the file.
= link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new'
%button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' }
Cancel
= render blob.to_partial_path(@project), blob: blob

View File

@ -32,8 +32,8 @@
= link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
- if current_user
.btn-group{ role: "group" }<
= edit_blob_link if blob_text_viewable?(blob)
.btn-group{ role: "group" }<
= edit_blob_link if blob_text_viewable?(blob)
- if current_user
= replace_blob_link
= delete_blob_link

View File

@ -68,7 +68,7 @@
- elsif @build.runner
\##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group }
- if @build.has_trace_file?
- if @build.has_trace?
= link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- if @build.active?
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post

View File

@ -20,6 +20,6 @@
%th Coverage
%th
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin }
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
= paginate builds, theme: 'gitlab'

View File

@ -4,7 +4,6 @@
- retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
- coverage = local_assigns.fetch(:coverage, false)
- allow_retry = local_assigns.fetch(:allow_retry, false)
%tr.build.commit{ class: ('retried' if retried) }
@ -88,7 +87,7 @@
%span= time_ago_with_tooltip(build.finished_at)
%td.coverage
- if coverage && build.try(:coverage)
- if build.try(:coverage)
#{build.coverage}%
%td

View File

@ -1,7 +1,7 @@
.page-content-header
.header-main-content
%strong Commit #{@commit.short_id}
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}
%span by

View File

@ -47,7 +47,6 @@
%th Job ID
%th Name
%th
- if pipeline.project.build_coverage_enabled?
%th Coverage
%th Coverage
%th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage

View File

@ -37,6 +37,6 @@
.commit-actions.flex-row.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
= clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard")
= clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
= link_to_browse_code(project, commit)

View File

@ -4,7 +4,6 @@
- retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
- coverage = local_assigns.fetch(:coverage, false)
%tr.generic_commit_status{ class: ('retried' if retried) }
%td.status
@ -80,7 +79,7 @@
%span= time_ago_with_tooltip(generic_commit_status.finished_at)
%td.coverage
- if coverage && generic_commit_status.try(:coverage)
- if generic_commit_status.try(:coverage)
#{generic_commit_status.coverage}%
%td

View File

@ -16,7 +16,7 @@
.email-modal-input-group.input-group
= text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-btn
= clipboard_button(clipboard_target: '#issue_email')
= clipboard_button(target: '#issue_email')
%p
The subject will be used as the title of the new issue, and the message will be the description.

View File

@ -8,7 +8,7 @@
%p
%strong Step 1.
Fetch and check out the branch for this merge request
= clipboard_button(clipboard_target: "pre#merge-info-1", title: "Copy commands to clipboard")
= clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
%pre.dark#merge-info-1
- if @merge_request.for_fork?
:preserve
@ -25,7 +25,7 @@
%p
%strong Step 3.
Merge the branch and fix any conflicts that come up
= clipboard_button(clipboard_target: "pre#merge-info-3", title: "Copy commands to clipboard")
= clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
%pre.dark#merge-info-3
- if @merge_request.for_fork?
:preserve
@ -38,7 +38,7 @@
%p
%strong Step 4.
Push the result of the merge to GitLab
= clipboard_button(clipboard_target: "pre#merge-info-4", title: "Copy commands to clipboard")
= clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
%pre.dark#merge-info-4
:preserve
git push origin #{h @merge_request.target_branch}

View File

@ -46,4 +46,4 @@
\...
%span.js-details-content.hide
= link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
= clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard")
= clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")

View File

@ -36,7 +36,6 @@
%th Job ID
%th Name
%th
- if pipeline.project.build_coverage_enabled?
%th Coverage
%th Coverage
%th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage

View File

@ -0,0 +1,32 @@
.container-image.js-toggle-container
.container-image-head
= link_to "#", class: "js-toggle-button" do
= icon('chevron-down', 'aria-hidden': 'true')
= escape_once(image.path)
= clipboard_button(clipboard_text: "docker pull #{image.path}")
.controls.hidden-xs.pull-right
= link_to namespace_project_container_registry_path(@project.namespace, @project, image),
class: 'btn btn-remove has-tooltip',
title: 'Remove repository',
data: { confirm: 'Are you sure?' },
method: :delete do
= icon('trash cred', 'aria-hidden': 'true')
.container-image-tags.js-toggle-content.hide
- if image.has_tags?
.table-holder
%table.table.tags
%thead
%tr
%th Tag
%th Tag ID
%th Size
%th Created
- if can?(current_user, :update_container_image, @project)
%th
= render partial: 'tag', collection: image.tags
- else
.nothing-here-block No tags in Container Registry for this container image.

View File

@ -1,7 +1,7 @@
%tr.tag
%td
= escape_once(tag.name)
= clipboard_button(clipboard_text: "docker pull #{tag.path}")
= clipboard_button(text: "docker pull #{tag.path}")
%td
- if tag.revision
%span.has-tooltip{ title: "#{tag.revision}" }
@ -25,5 +25,9 @@
- if can?(current_user, :update_container_image, @project)
%td.content
.controls.hidden-xs.pull-right
= link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do
= icon("trash cred")
= link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
method: :delete,
class: 'btn btn-remove has-tooltip',
title: 'Remove tag',
data: { confirm: 'Are you sure you want to delete this tag?' } do
= icon('trash cred')

View File

@ -15,25 +15,12 @@
%br
Then you are free to create and upload a container image with build and push commands:
%pre
docker build -t #{escape_once(@project.container_registry_repository_url)} .
docker build -t #{escape_once(@project.container_registry_url)}/image .
%br
docker push #{escape_once(@project.container_registry_repository_url)}
docker push #{escape_once(@project.container_registry_url)}/image
- if @tags.blank?
%li
.nothing-here-block No images in Container Registry for this project.
- if @images.blank?
.nothing-here-block No container image repositories in Container Registry for this project.
- else
.table-holder
%table.table.tags
%thead
%tr
%th Name
%th Image ID
%th Size
%th Created
- if can?(current_user, :update_container_image, @project)
%th
- @tags.each do |tag|
= render 'tag', tag: tag
= render partial: 'image', collection: @images

View File

@ -22,14 +22,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#display_name')
= clipboard_button(target: '#display_name')
.form-group
= label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#description')
= clipboard_button(target: '#description')
.form-group
= label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label'
@ -46,7 +46,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#request_url')
= clipboard_button(target: '#request_url')
.form-group
= label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label'
@ -57,14 +57,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#response_username')
= clipboard_button(target: '#response_username')
.form-group
= label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#response_icon')
= clipboard_button(target: '#response_icon')
.form-group
= label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label'
@ -75,14 +75,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#autocomplete_hint')
= clipboard_button(target: '#autocomplete_hint')
.form-group
= label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#autocomplete_description')
= clipboard_button(target: '#autocomplete_description')
%hr

View File

@ -40,7 +40,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#url')
= clipboard_button(target: '#url')
.form-group
= label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label'
@ -51,7 +51,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#customize_name')
= clipboard_button(target: '#customize_name')
.form-group
= label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label'
@ -68,21 +68,21 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#autocomplete_description')
= clipboard_button(target: '#autocomplete_description')
.form-group
= label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#autocomplete_usage_hint')
= clipboard_button(target: '#autocomplete_usage_hint')
.form-group
= label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(clipboard_target: '#descriptive_label')
= clipboard_button(target: '#descriptive_label')
%hr

View File

@ -6,8 +6,8 @@
= ci_icon_for_status(stage.status)
&nbsp;
= stage.name.titleize
= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true
= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true
= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true
= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true
%tr
%td{ colspan: 10 }
&nbsp;

View File

@ -10,7 +10,7 @@
%i.fa.fa-angle-right
%small.light
= link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
= time_ago_with_tooltip(@commit.committed_date)
\-
= @commit.full_title

View File

@ -2,7 +2,7 @@
%td
- if can?(current_user, :admin_trigger, trigger)
%span= trigger.token
= clipboard_button(clipboard_text: trigger.token, title: "Copy trigger token to clipboard")
= clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
- else
%span= trigger.short_token

Some files were not shown because too many files have changed in this diff Show More