提交 7e8278c0 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 bbf65812
import Mousetrap from 'mousetrap';
import { getLocationHash, visitUrl } from '../../lib/utils/url_utility';
import {
getLocationHash,
updateHistory,
urlIsDifferent,
urlContainsSha,
getShaFromUrl,
} from '~/lib/utils/url_utility';
import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
fileBlobPermalinkUrlElement: null,
};
function eventHasModifierKeys(event) {
// We ignore alt because I don't think alt clicks normally do anything special?
return event.ctrlKey || event.metaKey || event.shiftKey;
}
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
this.options = options;
this.shortcircuitPermalinkButton();
Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
if (this.options.fileBlobPermalinkUrl) {
const permalink = this.options.fileBlobPermalinkUrl;
if (permalink) {
const hash = getLocationHash();
const hashUrlString = hash ? `#${hash}` : '';
visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
if (urlIsDifferent(permalink)) {
updateHistory({
url: `${permalink}${hashUrlString}`,
title: document.title,
});
}
if (urlContainsSha({ url: permalink })) {
updateRefPortionOfTitle(getShaFromUrl({ url: permalink }));
}
}
}
shortcircuitPermalinkButton() {
const button = this.options.fileBlobPermalinkUrlElement;
const handleButton = e => {
if (!eventHasModifierKeys(e)) {
e.preventDefault();
this.moveToFilePermalink();
}
};
if (button) {
button.addEventListener('click', handleButton);
}
}
}
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
function resetRegExp(regex) {
regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
return regex;
}
// Returns a decoded url parameter value
// - Treats '+' as '%20'
......@@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) {
return hash && hash.includes(hashName);
}
export function urlContainsSha({ url = String(window.location) } = {}) {
return resetRegExp(SHA_REGEX).test(url);
}
export function getShaFromUrl({ url = String(window.location) } = {}) {
let sha = null;
if (urlContainsSha({ url })) {
[sha] = url.match(resetRegExp(SHA_REGEX));
}
return sha;
}
/**
* Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment
......@@ -154,6 +176,16 @@ export function visitUrl(url, external = false) {
}
}
export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
if (win.history) {
if (replace) {
win.history.replaceState(state, title, url);
} else {
win.history.pushState(state, title, url);
}
}
}
export function refreshCurrentPage() {
visitUrl(window.location.href);
}
......@@ -282,3 +314,7 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f
};
export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
export function urlIsDifferent(url, compare = String(window.location)) {
return url !== compare;
}
......@@ -25,6 +25,7 @@ export default () => {
new ShortcutsBlob({
skipResetBindings: true,
fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
});
new BlobForkSuggestion({
......
const DEFAULT_TITLE = '· GitLab';
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`;
......@@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => {
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
};
export function updateRefPortionOfTitle(sha, doc = document) {
const { title = '' } = doc;
const titleParts = title.split(' · ');
if (titleParts.length > 1) {
titleParts[1] = sha;
/* eslint-disable-next-line no-param-reassign */
doc.title = titleParts.join(' · ');
}
}
......@@ -15,6 +15,7 @@
@import 'framework/badges';
@import 'framework/calendar';
@import 'framework/callout';
@import 'framework/carousel';
@import 'framework/common';
@import 'framework/dropdowns';
@import 'framework/files';
......
// Notes on the classes:
//
// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
// even when their scroll action started on a carousel, but for compatibility (with Firefox)
// we're preventing all actions instead
// 2. The .carousel-item-left and .carousel-item-right is used to indicate where
// the active slide is heading.
// 3. .active.carousel-item is the current slide.
// 4. .active.carousel-item-left and .active.carousel-item-right is the current
// slide in its in-transition state. Only one of these occurs at a time.
// 5. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right
// is the upcoming slide in transition.
.carousel {
position: relative;
&.pointer-event {
touch-action: pan-y;
}
}
.carousel-inner {
position: relative;
width: 100%;
overflow: hidden;
@include clearfix();
}
.carousel-item {
position: relative;
display: none;
float: left;
width: 100%;
margin-right: -100%;
backface-visibility: hidden;
@include transition($carousel-transition);
}
.carousel-item.active,
.carousel-item-next,
.carousel-item-prev {
display: block;
}
.carousel-item-next:not(.carousel-item-left),
.active.carousel-item-right {
transform: translateX(100%);
}
.carousel-item-prev:not(.carousel-item-right),
.active.carousel-item-left {
transform: translateX(-100%);
}
//
// Alternate transitions
//
.carousel-fade {
.carousel-item {
opacity: 0;
transition-property: opacity;
transform: none;
}
.carousel-item.active,
.carousel-item-next.carousel-item-left,
.carousel-item-prev.carousel-item-right {
z-index: 1;
opacity: 1;
}
.active.carousel-item-left,
.active.carousel-item-right {
z-index: 0;
opacity: 0;
@include transition(0s $carousel-transition-duration opacity);
}
}
//
// Left/right controls for nav
//
.carousel-control-prev,
.carousel-control-next {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
// Use flex for alignment (1-3)
display: flex; // 1. allow flex styles
align-items: center; // 2. vertically center contents
justify-content: center; // 3. horizontally center contents
width: $carousel-control-width;
color: $carousel-control-color;
text-align: center;
opacity: $carousel-control-opacity;
@include transition($carousel-control-transition);
// Hover/focus state
@include hover-focus {
color: $carousel-control-color;
text-decoration: none;
outline: 0;
opacity: $carousel-control-hover-opacity;
}
}
.carousel-control-prev {
left: 0;
@if $enable-gradients {
background: linear-gradient(90deg, rgba($black, 0.25), rgba($black, 0.001));
}
}
.carousel-control-next {
right: 0;
@if $enable-gradients {
background: linear-gradient(270deg, rgba($black, 0.25), rgba($black, 0.001));
}
}
// Icons for within
.carousel-control-prev-icon,
.carousel-control-next-icon {
display: inline-block;
width: $carousel-control-icon-width;
height: $carousel-control-icon-width;
background: no-repeat 50% / 100% 100%;
}
.carousel-control-prev-icon {
background-image: $carousel-control-prev-icon-bg;
}
.carousel-control-next-icon {
background-image: $carousel-control-next-icon-bg;
}
// Optional indicator pips
//
// Add an ordered list with the following class and add a list item for each
// slide your carousel holds.
.carousel-indicators {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 15;
display: flex;
justify-content: center;
padding-left: 0; // override <ol> default
// Use the .carousel-control's width as margin so we don't overlay those
margin-right: $carousel-control-width;
margin-left: $carousel-control-width;
list-style: none;
li {
box-sizing: content-box;
flex: 0 1 auto;
width: $carousel-indicator-width;
height: $carousel-indicator-height;
margin-right: $carousel-indicator-spacer;
margin-left: $carousel-indicator-spacer;
text-indent: -999px;
cursor: pointer;
background-color: $carousel-indicator-active-bg;
background-clip: padding-box;
// Use transparent borders to increase the hit area by 10px on top and bottom.
border-top: $carousel-indicator-hit-area-height solid transparent;
border-bottom: $carousel-indicator-hit-area-height solid transparent;
opacity: 0.5;
@include transition($carousel-indicator-transition);
}
.active {
opacity: 1;
}
}
// Optional captions
//
//
.carousel-caption {
position: absolute;
right: (100% - $carousel-caption-width) / 2;
bottom: 20px;
left: (100% - $carousel-caption-width) / 2;
z-index: 10;
padding-top: 20px;
padding-bottom: 20px;
color: $carousel-caption-color;
text-align: center;
}
......@@ -578,7 +578,7 @@ module Ci
# Manually set the notes for a Ci::Pipeline
# There is no ActiveRecord relation between Ci::Pipeline and notes
# as they are related to a commit sha. This method helps importing
# them using the +Gitlab::ImportExport::RelationFactory+ class.
# them using the +Gitlab::ImportExport::ProjectRelationFactory+ class.
def notes=(notes)
notes.each do |note|
note[:id] = nil
......
# frozen_string_literal: true
module Groups
module ImportExport
class ImportService
attr_reader :current_user, :group, :params
def initialize(group:, user:)
@group = group
@current_user = user
@shared = Gitlab::ImportExport::Shared.new(@group)
end
def execute
validate_user_permissions
if import_file && restorer.restore
@group
else
raise StandardError.new(@shared.errors.to_sentence)
end
rescue => e
raise StandardError.new(e.message)
ensure
remove_import_file
end
private
def import_file
@import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group,
archive_file: nil,
shared: @shared)
end
def restorer
@restorer ||= Gitlab::ImportExport::GroupTreeRestorer.new(user: @current_user,
shared: @shared,
group: @group,
group_hash: nil)
end
def remove_import_file
upload = @group.import_export_upload
return unless upload&.import_file&.file
upload.remove_import_file!
upload.save!
end
def validate_user_permissions
unless current_user.can?(:admin_group, group)
raise ::Gitlab::ImportExport::Error.new(
"User with ID: %s does not have permission to Group %s with ID: %s." %
[current_user.id, group.name, group.id])
end
end
end
end
end
......@@ -137,6 +137,7 @@
- gitlab_shell
- group_destroy
- group_export
- group_import
- import_issues_csv
- invalid_gpg_signature_update
- irker
......
......@@ -4,11 +4,11 @@ class GroupExportWorker
include ApplicationWorker
include ExceptionBacktrace
feature_category :source_code_management
feature_category :importers
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
group = Group.find(group_id)
group = Group.find(group_id)
::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute
end
......
# frozen_string_literal: true
class GroupImportWorker
include ApplicationWorker
include ExceptionBacktrace
feature_category :importers
def perform(user_id, group_id)
current_user = User.find(user_id)
group = Group.find(group_id)
::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute
end
end
---
title: Fix advanced global search permissions for guest users
merge_request: 23177
author:
type: fixed
---
title: When switching to a file permalink, just change the URL instead of triggering
a useless page reload
merge_request: 22340
author:
type: added
......@@ -110,6 +110,8 @@
- 1
- - group_export
- 1
- - group_import
- 1
- - hashed_storage
- 1
- - import_issues_csv
......
......@@ -72,9 +72,11 @@ We need to manage the following secrets and make them match across hosts:
1. `PRAEFECT_SQL_PASSWORD`: this password is used by Praefect to connect to
PostgreSQL.
We will note in the instructions below where these secrets are required.
#### Network addresses
1. `POSTGRESQL_SERVER`: the host name or IP address of your PostgreSQL server
1. `POSTGRESQL_SERVER_ADDRESS`: the host name or IP address of your PostgreSQL server
#### PostgreSQL
......@@ -91,7 +93,7 @@ Below we assume that you have administrative access as the `postgres`
user. First open a `psql` session as the `postgres` user:
```shell
/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER -U postgres -d template1
/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER_ADDRESS -U postgres -d template1
```
Once you are connected, run the following command. Replace
......@@ -107,7 +109,7 @@ Now connect as the `praefect` user to create the database. This has
the side effect of verifying that you have access:
```shell
/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER -U praefect -d template1
/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER_ADDRESS -U praefect -d template1
```
Once you have connected as the `praefect` user, run:
......@@ -125,6 +127,12 @@ Gitaly node that will be connected to Praefect as members of the `praefect` hash
In the example below, the Gitaly nodes are named `gitaly-N`. Note that one
node is designated as primary by setting the primary to `true`.
If you are using an uncrypted connection to Postgres, set `praefect['database_sslmode']` to false.
If you are using an encrypted connection with a client certificate,
`praefect['database_sslcert']` and `praefect['database_sslkey']` will need to be set.
If you are using a custom CA, also set `praefect['database_sslrootcert']`:
```ruby
# /etc/gitlab/gitlab.rb on praefect server
......@@ -174,7 +182,7 @@ praefect['virtual_storages'] = {
}
# Replace POSTGRESQL_SERVER below with a real IP/host address of the database.
praefect['database_host'] = 'POSTGRESQL_SERVER'
praefect['database_host'] = 'POSTGRESQL_SERVER_ADDRESS'
praefect['database_port'] = 5432
praefect['database_user'] = 'praefect'
# Replace PRAEFECT_SQL_PASSWORD below with a real password of the database.
......@@ -195,6 +203,9 @@ praefect['database_dbname'] = 'praefect_production'
# praefect['database_sslrootcert'] = '/path/to/rootcert'
```
Replace `POSTGRESQL_SERVER_ADDRESS`, `PRAEFECT_EXTERNAL_TOKEN`, `PRAEFECT_INTERNAL_TOKEN`,
and `PRAEFECT_SQL_PASSWORD` with their respective values.
Save the file and [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure).
After you reconfigure, verify that Praefect can reach PostgreSQL:
......@@ -260,6 +271,9 @@ git_data_dirs({
})
```
Replace `GITLAB_SHELL_SECRET_TOKEN` and `PRAEFECT_INTERNAL_TOKEN`
with their respective values.
For more information on Gitaly server configuration, see our [Gitaly documentation](index.md#3-gitaly-server-configuration).
When finished editing the configuration file for each Gitaly server, run the
......@@ -302,6 +316,9 @@ git_data_dirs({
gitlab_shell['secret_token'] = 'GITLAB_SHELL_SECRET_TOKEN'
```
Replace `GITLAB_SHELL_SECRET_TOKEN` and `PRAEFECT_EXTERNAL_TOKEN`
with their respective values.
Note that the storage name used is the same as the `praefect['virtual_storage_name']` set
on the Praefect node.
......
......@@ -222,6 +222,7 @@ who are aware of the risks.
- [Troubleshooting PostgreSQL](troubleshooting/postgresql.md)
- [Guide to test environments](troubleshooting/test_environments.md) (for Support Engineers)
- [GitLab Rails console commands](troubleshooting/gitlab_rails_cheat_sheet.md) (for Support Engineers)
- [Troubleshooting SSL](troubleshooting/ssl.md)
- Useful links:
- [GitLab Developer Docs](../development/README.md)
- [Repairing and recovering broken Git repositories](https://git.seveas.net/repairing-and-recovering-broken-git-repositories.html)
......
---
type: reference
---
# Troubleshooting SSL
This page contains a list of common SSL-related errors and scenarios that you may face while working with GitLab.
It should serve as an addition to the main SSL docs available here:
- [Omniibus SSL Configuration](https://docs.gitlab.com/omnibus/settings/ssl.html)
- [Self-signed certificates or custom Certification Authorities for GitLab Runner](https://docs.gitlab.com/runner/configuration/tls-self-signed.html)
- [Manually configuring HTTPS](https://docs.gitlab.com/omnibus/settings/nginx.html#manually-configuring-https)
## Using an internal CA certificate with GitLab
After configuring a GitLab instance with an internal CA certificate, you might not be able to access it via various CLI tools. You may see the following symptoms:
- `curl` fails:
```shell
curl https://gitlab.domain.tld
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html
```
- Testing via the [rails console](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session) also fails:
```ruby
uri = URI.parse("https://gitlab.domain.tld")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = 1
response = http.request(Net::HTTP::Get.new(uri.request_uri))
...
Traceback (most recent call last):
1: from (irb):5
OpenSSL::SSL::SSLError (SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate))
```
- The error `SSL certificate problem: unable to get local issuer certificate` is shown when setting up a [mirror](../../user/project/repository/repository_mirroring.md#repository-mirroring) from this GitLab instance.
- `openssl` works when specifying the path to the certificate:
```shell
/opt/gitlab/embedded/bin/openssl s_client -CAfile /root/my-cert.crt -connect gitlab.domain.tld:443
```
If you have the problems listed above, add your certificate to `/etc/gitlab/trusted-certs` and run `sudo gitlab-ctl reconfigure`.
## Mirroring a remote GitLab repository that uses a self-signed SSL certificate
**Scenario:** When configuring a local GitLab instance to [mirror a repository](../../user/project/repository/repository_mirroring.md) from a remote GitLab instance that uses a self-signed certificate, you may see the `SSL certificate problem: self signed certificate` error in the UI.
The cause of the issue can be confirmed by checking if:
- `curl` fails:
```shell
$ curl https://gitlab.domain.tld
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.haxx.se/docs/sslcerts.html
```
- Testing via the Rails console also fails:
```ruby
uri = URI.parse("https://gitlab.domain.tld")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = 1
response = http.request(Net::HTTP::Get.new(uri.request_uri))
...
Traceback (most recent call last):
1: from (irb):5
OpenSSL::SSL::SSLError (SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate))
```
To fix this problem:
- Add the self-signed certificate from the remote GitLab instance to the `/etc/gitlab/trusted-certs` directory on the local GitLab instance and run `sudo gitlab-ctl reconfigure` as per the instructions for [installing custom public certificates](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates).
- If your local GitLab instance was installed using the Helm Charts, you can [add your self-signed certificate to your GitLab instance](https://docs.gitlab.com/runner/install/kubernetes.html#providing-a-custom-certificate-for-accessing-gitlab).
## Unable to perform Git operations due to an internal or self-signed certificate
If your GitLab instance is using a self-signed certificate, or the certificate is signed by an internal certificate authority (CA), you might run into the following errors when attempting to perform Git operations:
```bash
$ git clone https://gitlab.domain.tld/group/project.git
Cloning into 'project'...
fatal: unable to access 'https://gitlab.domain.tld/group/project.git/': SSL certificate problem: self signed certificate
```
```bash
$ git clone https://gitlab.domain.tld/group/project.git
Cloning into 'project'...
fatal: unable to access 'https://gitlab.domain.tld/group/project.git/': server certificate verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none
```
To fix this problem:
- If possible, use SSH remotes for all Git operations. This is considered more secure and convenient to use.
- If you must use HTTPS remotes, you can try the following:
- Copy the self signed certificate or the internal root CA certificate to a local directory (for example, `~/.ssl`) and configure Git to trust your certificate:
```shell
git config --global http.sslCAInfo ~/.ssl/gitlab.domain.tld.crt
```
- Disable SSL verification in your Git client. Note that this intended as a temporary measure as it could be considered a **security risk**.
```bash
git config --global http.sslVerify false
```
......@@ -161,7 +161,11 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. Make s
_("Hello %{name}") % { name: 'Joe' } => 'Hello Joe'
```
- In JavaScript:
- In Vue:
See the section on [Vue component interpolation](#vue-components-interpolation).
- In JavaScript (when Vue cannot be used):
```js
import { __, sprintf } from '~/locale';
......@@ -169,14 +173,30 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. Make s
sprintf(__('Hello %{username}'), { username: 'Joe' }); // => 'Hello Joe'
```
By default, `sprintf` escapes the placeholder values.
If you want to take care of that yourself, you can pass `false` as third argument.
If you want to use markup within the translation and are using Vue, you
**must** use the [`gl-sprintf`](#vue-components-interpolation) component. If
for some reason you cannot use Vue, use `sprintf` and stop it from escaping
placeholder values by passing `false` as its third argument. You **must**
escape any interpolated dynamic values yourself, for instance using
`escape` from `lodash`.
```js
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
sprintf(__('This is %{value}'), { value: '<strong>bold</strong>' }); // => 'This is &lt;strong&gt;bold&lt;/strong&gt;'
sprintf(__('This is %{value}'), { value: '<strong>bold</strong>' }, false); // => 'This is <strong>bold</strong>'
let someDynamicValue = '<script>alert("evil")</script>';
// Dangerous:
sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
// => 'This is <strong><script>alert('evil')</script></strong>'
// Incorrect:
sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
// => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
// OK:
sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>`, false);
// => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'
```
### Plurals
......@@ -326,7 +346,41 @@ This also applies when using links in between translated sentences, otherwise th
= s_('ClusterIntegration|Learn more about %{zones_link_start}zones%{zones_link_end}').html_safe % { zones_link_start: zones_link_start, zones_link_end: '</a>'.html_safe }
```
- In JavaScript, instead of:
- In Vue, instead of:
```html
<template>
<div>
<gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
<template #link>
<gl-link
href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
target="_blank"
>zones</gl-link>
</template>
</gl-sprintf>
</div>
</template>
```
Set the link starting and ending HTML fragments as placeholders like so:
```html
<template>
<div>
<gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
<template #link="{ content }">
<gl-link
href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
target="_blank"
>{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</template>
```
- In JavaScript (when Vue cannot be used), instead of:
```js
{{
......@@ -336,7 +390,7 @@ This also applies when using links in between translated sentences, otherwise th
}}
```
Set the link starting and ending HTML fragments as variables like so:
Set the link starting and ending HTML fragments as placeholders like so:
```js
{{
......
......@@ -24,7 +24,8 @@ module Gitlab
last_edited_by_id
merge_user_id
resolved_by_id
closed_by_id owner_id
closed_by_id
owner_id
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
......
# frozen_string_literal: true
module Gitlab
module ImportExport
# Given a class, it finds or creates a new object at group level.
#
# Example:
# `GroupObjectBuilder.build(Label, label_attributes)`
# finds or initializes a label with the given attributes.
class GroupObjectBuilder < BaseObjectBuilder
def self.build(*args)
Group.transaction do
super
end
end
def initialize(klass, attributes)
super
@group = @attributes['group']
end
private
attr_reader :group
def where_clauses
[
where_clause_base,
where_clause_for_title,
where_clause_for_description,
where_clause_for_created_at
].compact
end
# Returns Arel clause `"{table_name}"."group_id" = {group.id}`
def where_clause_base
table[:group_id].in(group_and_ancestor_ids)
end
def group_and_ancestor_ids
group.ancestors.map(&:id) << group.id
end
end
end
end
# frozen_string_literal: true
module Gitlab
module ImportExport
class GroupRelationFactory < BaseRelationFactory
OVERRIDES = {
labels: :group_labels,
priorities: :label_priorities,
label: :group_label,
parent: :epic
}.freeze
EXISTING_OBJECT_RELATIONS = %i[
epic
epics
milestone
milestones
label
labels
group_label
group_labels
].freeze
private
def setup_models
setup_note if @relation_name == :notes
update_group_references
end
def update_group_references
return unless self.class.existing_object_relations.include?(@relation_name)
return unless @relation_hash['group_id']
@relation_hash['group_id'] = @importable.id
end
end
end
end
# frozen_string_literal: true
module Gitlab
module ImportExport
class GroupTreeRestorer
attr_reader :user
attr_reader :shared
attr_reader :group
def initialize(user:, shared:, group:, group_hash:)
@path = File.join(shared.export_path, 'group.json')
@user = user
@shared = shared
@group = group
@group_hash = group_hash
end
def restore
@tree_hash = @group_hash || read_tree_hash
@group_members = @tree_hash.delete('members')
@children = @tree_hash.delete('children')
if members_mapper.map && restorer.restore
@children&.each do |group_hash|
group = create_group(group_hash: group_hash, parent_group: @group)
shared = Gitlab::ImportExport::Shared.new(group)
self.class.new(
user: @user,
shared: shared,
group: group,
group_hash: group_hash
).restore
end
end
return false if @shared.errors.any?
true
rescue => e
@shared.error(e)
false
end
private
def read_tree_hash
json = IO.read(@path)
ActiveSupport::JSON.decode(json)
rescue => e
@shared.logger.error(
group_id: @group.id,
group_name: @group.name,
message: "Import/Export error: #{e.message}"
)
raise Gitlab::ImportExport::Error.new('Incorrect JSON format')
end
def restorer
@relation_tree_restorer ||= RelationTreeRestorer.new(
user: @user,
shared: @shared,
importable: @group,
tree_hash: @tree_hash.except('name', 'path'),
members_mapper: members_mapper,
object_builder: object_builder,
relation_factory: relation_factory,
reader: reader
)
end
def create_group(group_hash:, parent_group:)
group_params = {
name: group_hash['name'],
path: group_hash['path'],
parent_id: parent_group&.id
}
::Groups::CreateService.new(@user, group_params).execute
end
def members_mapper
@members_mapper ||= Gitlab::ImportExport::MembersMapper.new(exported_members: @group_members, user: @user, importable: @group)
end
def relation_factory
Gitlab::ImportExport::GroupRelationFactory
end
def object_builder
Gitlab::ImportExport::GroupObjectBuilder
end
def reader
@reader ||= Gitlab::ImportExport::Reader.new(
shared: @shared,
config: Gitlab::ImportExport::Config.new(
config: Gitlab::ImportExport.group_config_file
).to_h
)
end
end
end
end
......@@ -9,7 +9,7 @@ module Gitlab
@importable = importable
# This needs to run first, as second call would be from #map
# which means project members already exist.
# which means Project/Group members already exist.
ensure_default_member!
end
......@@ -47,6 +47,8 @@ module Gitlab
end
def ensure_default_member!
return if user_already_member?
@importable.members.destroy_all # rubocop: disable DestroyAll
relation_class.create!(user: @user, access_level: relation_class::MAINTAINER, source_id: @importable.id, importing: true)
......@@ -54,6 +56,12 @@ module Gitlab
raise e, "Error adding importer user to #{@importable.class} members. #{e.message}"
end
def user_already_member?
member = @importable.members&.first
member&.user == @user && member.access_level >= relation_class::MAINTAINER
end
def add_team_member(member, existing_user = nil)
member['user'] = existing_user
......
......@@ -16,6 +16,9 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
msgid " %{start} to %{end}"
msgstr ""
msgid " (from %{timeoutSource})"
msgstr ""
......@@ -994,6 +997,9 @@ msgstr ""
msgid "Active Sessions"
msgstr ""
msgid "Active Users:"
msgstr ""
msgid "Activity"
msgstr ""
......@@ -6594,6 +6600,30 @@ msgstr ""
msgid "Discover projects, groups and snippets. Share your projects with others"
msgstr ""
msgid "Discover|Check your application for security vulnerabilities that may lead to unauthorized access, data leaks, and denial of services."
msgstr ""
msgid "Discover|For code that's already live in production, our dashboards give you an easy way to prioritize any issues that are found, empowering your team to ship quickly and securely."
msgstr ""
msgid "Discover|GitLab will perform static and dynamic tests on the code of your application, looking for known flaws and report them in the merge request so you can fix them before merging."
msgstr ""
msgid "Discover|Give feedback for this page"
msgstr ""
msgid "Discover|Security capabilities, integrated into your development lifecycle"
msgstr ""
msgid "Discover|See the other features of the %{linkStart}gold plan%{linkEnd}"
msgstr ""
msgid "Discover|Start a free trial"
msgstr ""
msgid "Discover|Upgrade now"
msgstr ""
msgid "Discuss a specific suggestion or question"
msgstr ""
......@@ -11550,6 +11580,9 @@ msgstr ""
msgid "Max seats used"
msgstr ""
msgid "Maximum Users:"
msgstr ""
msgid "Maximum allowable lifetime for personal access token (days)"
msgstr ""
......@@ -18508,6 +18541,12 @@ msgstr ""
msgid "The \"Require approval from CODEOWNERS\" setting was moved to %{banner_link_start}Protected Branches%{banner_link_end}"
msgstr ""
msgid "The %{link_start}true-up model%{link_end} allows having more users, and additional users will incur a retroactive charge on renewal."
msgstr ""
msgid "The %{true_up_link_start}true-up model%{link_end} has a retroactive charge for these users at the next renewal. If you want to update your license sooner to prevent this, %{support_link_start}please contact our Support team%{link_end}."
msgstr ""
msgid "The %{type} contains the following error:"
msgid_plural "The %{type} contains the following errors:"
msgstr[0] ""
......@@ -19163,9 +19202,15 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
msgid "This is the highest peak of users on your installation since the license started."
msgstr ""
msgid "This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license."
msgstr ""
msgid "This is the number of currently active users on your installation, and this is the minimum number you need to purchase when you renew your license."
msgstr ""
msgid "This is your current session"
msgstr ""
......@@ -20754,12 +20799,18 @@ msgstr ""
msgid "Users"
msgstr ""
msgid "Users in License:"
msgstr ""
msgid "Users or groups set as approvers in the project's or merge request's settings."
msgstr ""
msgid "Users outside of license"
msgstr ""
msgid "Users over License:"
msgstr ""
msgid "Users requesting access to"
msgstr ""
......@@ -21883,6 +21934,9 @@ msgstr ""
msgid "Your issues will be imported in the background. Once finished, you'll get a confirmation email."
msgstr ""
msgid "Your license is valid from"
msgstr ""
msgid "Your message here"
msgstr ""
......
import * as urlUtils from '~/lib/utils/url_utility';
const shas = {
valid: [
'ad9be38573f9ee4c4daec22673478c2dd1d81cd8',
'76e07a692f65a2f4fd72f107a3e83908bea9b7eb',
'9dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
'f2e0be58c4091b033203bae1cc0302febd54117d',
],
invalid: [
'zd9be38573f9ee4c4daec22673478c2dd1d81cd8',
':6e07a692f65a2f4fd72f107a3e83908bea9b7eb',
'-dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
' 2e0be58c4091b033203bae1cc0302febd54117d',
],
};
const setWindowLocation = value => {
Object.defineProperty(window, 'location', {
writable: true,
......@@ -154,6 +169,44 @@ describe('URL utility', () => {
});
});
describe('urlContainsSha', () => {
it('returns true when there is a valid 40-character SHA1 hash in the URL', () => {
shas.valid.forEach(sha => {
expect(
urlUtils.urlContainsSha({ url: `http://urlstuff/${sha}/moreurlstuff` }),
).toBeTruthy();
});
});
it('returns false when there is not a valid 40-character SHA1 hash in the URL', () => {
shas.invalid.forEach(str => {
expect(urlUtils.urlContainsSha({ url: `http://urlstuff/${str}/moreurlstuff` })).toBeFalsy();
});
});
});
describe('getShaFromUrl', () => {
let validUrls = [];
let invalidUrls = [];
beforeAll(() => {
validUrls = shas.valid.map(sha => `http://urlstuff/${sha}/moreurlstuff`);
invalidUrls = shas.invalid.map(str => `http://urlstuff/${str}/moreurlstuff`);
});
it('returns the valid 40-character SHA1 hash from the URL', () => {
validUrls.forEach((url, idx) => {
expect(urlUtils.getShaFromUrl({ url })).toBe(shas.valid[idx]);
});
});
it('returns null from a URL with no valid 40-character SHA1 hash', () => {
invalidUrls.forEach(url => {
expect(urlUtils.getShaFromUrl({ url })).toBeNull();
});
});
});
describe('setUrlFragment', () => {
it('should set fragment when url has no fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature', 'usage');
......@@ -174,6 +227,44 @@ describe('URL utility', () => {
});
});
describe('updateHistory', () => {
const state = { key: 'prop' };
const title = 'TITLE';
const url = 'URL';
const win = {
history: {
pushState: jest.fn(),
replaceState: jest.fn(),
},
};
beforeEach(() => {
win.history.pushState.mockReset();
win.history.replaceState.mockReset();
});
it('should call replaceState if the replace option is true', () => {
urlUtils.updateHistory({ state, title, url, replace: true, win });
expect(win.history.replaceState).toHaveBeenCalledWith(state, title, url);
expect(win.history.pushState).not.toHaveBeenCalled();
});
it('should call pushState if the replace option is missing', () => {
urlUtils.updateHistory({ state, title, url, win });
expect(win.history.replaceState).not.toHaveBeenCalled();
expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
});
it('should call pushState if the replace option is false', () => {
urlUtils.updateHistory({ state, title, url, replace: false, win });
expect(win.history.replaceState).not.toHaveBeenCalled();
expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
});
});
describe('getBaseURL', () => {
beforeEach(() => {
setWindowLocation({
......@@ -331,6 +422,22 @@ describe('URL utility', () => {
});
});
describe('urlIsDifferent', () => {
beforeEach(() => {
setWindowLocation('current');
});
it('should compare against the window location if no compare value is provided', () => {
expect(urlUtils.urlIsDifferent('different')).toBeTruthy();
expect(urlUtils.urlIsDifferent('current')).toBeFalsy();
});
it('should use the provided compare value', () => {
expect(urlUtils.urlIsDifferent('different', 'current')).toBeTruthy();
expect(urlUtils.urlIsDifferent('current', 'current')).toBeFalsy();
});
});
describe('setUrlParams', () => {
it('adds new params as query string', () => {
const url = 'https://gitlab.com/test';
......
import { setTitle } from '~/repository/utils/title';
import { setTitle, updateRefPortionOfTitle } from '~/repository/utils/title';
describe('setTitle', () => {
it.each`
......@@ -13,3 +13,26 @@ describe('setTitle', () => {
expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`);
});
});
describe('updateRefPortionOfTitle', () => {
const sha = 'abc';
const testCases = [
[
'updates the title with the SHA',
{ title: 'part 1 · part 2 · part 3' },
'part 1 · abc · part 3',
],
["makes no change if there's no title", { foo: null }, undefined],
[
"makes no change if the title doesn't split predictably",
{ title: 'part 1 - part 2 - part 3' },
'part 1 - part 2 - part 3',
],
];
it.each(testCases)('%s', (desc, doc, title) => {
updateRefPortionOfTitle(sha, doc);
expect(doc.title).toEqual(title);
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::GroupObjectBuilder do
let(:group) { create(:group) }
let(:base_attributes) do
{
'title' => 'title',
'description' => 'description',
'group' => group
}
end
context 'labels' do
let(:label_attributes) { base_attributes.merge('type' => 'GroupLabel') }
it 'finds the existing group label' do
group_label = create(:group_label, base_attributes)
expect(described_class.build(Label, label_attributes)).to eq(group_label)
end
it 'creates a new label' do
label = described_class.build(Label, label_attributes)
expect(label.persisted?).to be true
end
end
context 'milestones' do
it 'finds the existing group milestone' do
milestone = create(:milestone, base_attributes)
expect(described_class.build(Milestone, base_attributes)).to eq(milestone)
end
it 'creates a new milestone' do
milestone = described_class.build(Milestone, base_attributes)
expect(milestone.persisted?).to be true
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::GroupRelationFactory do
let(:group) { create(:group) }
let(:members_mapper) { double('members_mapper').as_null_object }
let(:user) { create(:admin) }
let(:excluded_keys) { [] }
let(:created_object) do
described_class.create(relation_sym: relation_sym,
relation_hash: relation_hash,
members_mapper: members_mapper,
object_builder: Gitlab::ImportExport::GroupObjectBuilder,
user: user,
importable: group,
excluded_keys: excluded_keys)
end
context 'label object' do
let(:relation_sym) { :group_label }
let(:id) { random_id }
let(:original_group_id) { random_id }
let(:relation_hash) do
{
'id' => 123456,
'title' => 'Bruffefunc',
'color' => '#1d2da4',
'project_id' => nil,
'created_at' => '2019-11-20T17:02:20.546Z',
'updated_at' => '2019-11-20T17:02:20.546Z',
'template' => false,
'description' => 'Description',
'group_id' => original_group_id,
'type' => 'GroupLabel',
'priorities' => [],
'textColor' => '#FFFFFF'
}
end
it 'does not have the original ID' do
expect(created_object.id).not_to eq(id)
end
it 'does not have the original group_id' do
expect(created_object.group_id).not_to eq(original_group_id)
end
it 'has the new group_id' do
expect(created_object.group_id).to eq(group.id)
end
context 'excluded attributes' do
let(:excluded_keys) { %w[description] }
it 'are removed from the imported object' do
expect(created_object.description).to be_nil
end
end
end
context 'Notes user references' do
let(:relation_sym) { :notes }
let(:new_user) { create(:user) }
let(:exported_member) do
{
'id' => 111,
'access_level' => 30,
'source_id' => 1,
'source_type' => 'Namespace',
'user_id' => 3,
'notification_level' => 3,
'created_at' => '2016-11-18T09:29:42.634Z',
'updated_at' => '2016-11-18T09:29:42.634Z',
'user' => {
'id' => 999,
'email' => new_user.email,
'username' => new_user.username
}
}
end
let(:relation_hash) do
{
'id' => 4947,
'note' => 'note',
'noteable_type' => 'Epic',
'author_id' => 999,
'created_at' => '2016-11-18T09:29:42.634Z',
'updated_at' => '2016-11-18T09:29:42.634Z',
'project_id' => 1,
'attachment' => {
'url' => nil
},
'noteable_id' => 377,
'system' => true,
'author' => {
'name' => 'Administrator'
},
'events' => []
}
end
let(:members_mapper) do
Gitlab::ImportExport::MembersMapper.new(
exported_members: [exported_member],
user: user,
importable: group)
end
it 'maps the right author to the imported note' do
expect(created_object.author).to eq(new_user)
end
end
def random_id
rand(1000..10000)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ImportExport::GroupTreeRestorer do
include ImportExport::CommonUtil
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
describe 'restore group tree' do
before(:context) do
# Using an admin for import, so we can check assignment of existing members
user = create(:admin, username: 'root')
create(:user, username: 'adriene.mcclure')
create(:user, username: 'gwendolyn_robel')
RSpec::Mocks.with_temporary_scope do
@group = create(:group, name: 'group', path: 'group')
@shared = Gitlab::ImportExport::Shared.new(@group)
setup_import_export_config('group_exports/complex')
group_tree_restorer = described_class.new(user: user, shared: @shared, group: @group, group_hash: nil)
@restored_group_json = group_tree_restorer.restore
end
end
context 'JSON' do
it 'restores models based on JSON' do
expect(@restored_group_json).to be_truthy
end
it 'has the group description' do
expect(Group.find_by_path('group').description).to eq('Group Description')
end
it 'has group labels' do
expect(@group.labels.count).to eq(10)
end
it 'has issue boards' do
expect(@group.boards.count).to eq(2)
end
it 'has badges' do
expect(@group.badges.count).to eq(1)
end
it 'has milestones' do
expect(@group.milestones.count).to eq(5)
end
it 'has group children' do
expect(@group.children.count).to eq(2)
end
it 'has group members' do
expect(@group.members.map(&:user).map(&:username)).to contain_exactly('root', 'adriene.mcclure', 'gwendolyn_robel')
end
end
end
context 'group.json file access check' do
let(:user) { create(:user) }
let!(:group) { create(:group, name: 'group2', path: 'group2') }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group, group_hash: nil) }
let(:restored_group_json) { group_tree_restorer.restore }
it 'does not read a symlink' do
Dir.mktmpdir do |tmpdir|
setup_symlink(tmpdir, 'group.json')
allow(shared).to receive(:export_path).and_call_original
expect(group_tree_restorer.restore).to eq(false)
expect(shared.errors).to include('Incorrect JSON format')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Groups::ImportExport::ImportService do
describe '#execute' do
let(:user) { create(:admin) }
let(:group) { create(:group) }
let(:service) { described_class.new(group: group, user: user) }
let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
subject { service.execute }
before do
ImportExportUpload.create(group: group, import_file: import_file)
end
context 'when user has correct permissions' do
it 'imports group structure successfully' do
expect(subject).to be_truthy
end
it 'removes import file' do
subject
expect(group.import_export_upload.import_file.file).to be_nil
end
end
context 'when user does not have correct permissions' do
let(:user) { create(:user) }
it 'raises exception' do
expect { subject }.to raise_error(StandardError)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GroupImportWorker do
let!(:user) { create(:user) }
let!(:group) { create(:group) }
subject { described_class.new }
describe '#perform' do
context 'when it succeeds' do
it 'calls the ImportService' do
expect_any_instance_of(::Groups::ImportExport::ImportService).to receive(:execute)
subject.perform(user.id, group.id)
end
end
context 'when it fails' do
it 'raises an exception when params are invalid' do
expect_any_instance_of(::Groups::ImportExport::ImportService).not_to receive(:execute)
expect { subject.perform(1234, group.id) }.to raise_exception(ActiveRecord::RecordNotFound)
expect { subject.perform(user.id, 1234) }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
end
end
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册