Commit b8153535 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'add-sentry-js-again-with-vue' into 'master'

Add sentry JS

See merge request !6764
parents c9b24633 e5a7ed3a
import RavenConfig from './raven_config';
const index = function index() {
RavenConfig.init({
sentryDsn: gon.sentry_dsn,
currentUserId: gon.current_user_id,
whitelistUrls: [gon.gitlab_url],
isProduction: process.env.NODE_ENV,
});
return RavenConfig;
};
index();
export default index;
import Raven from 'raven-js';
const IGNORE_ERRORS = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
'Can\'t find variable: ZiteReader',
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
const IGNORE_URLS = [
// Facebook flakiness
/graph\.facebook\.com/i,
// Facebook blocked
/connect\.facebook\.net\/en_US\/all\.js/i,
// Woopra flakiness
/eatdifferent\.com\.woopra-ns\.com/i,
/static\.woopra\.com\/js\/woopra\.js/i,
// Chrome extensions
/extensions\//i,
/^chrome:\/\//i,
// Other plugins
/127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
/webappstoolbarba\.texthelp\.com\//i,
/metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
];
const SAMPLE_RATE = 95;
const RavenConfig = {
IGNORE_ERRORS,
IGNORE_URLS,
SAMPLE_RATE,
init(options = {}) {
this.options = options;
this.configure();
this.bindRavenErrors();
if (this.options.currentUserId) this.setUser();
},
configure() {
Raven.config(this.options.sentryDsn, {
whitelistUrls: this.options.whitelistUrls,
environment: this.options.isProduction ? 'production' : 'development',
ignoreErrors: this.IGNORE_ERRORS,
ignoreUrls: this.IGNORE_URLS,
shouldSendCallback: this.shouldSendSample.bind(this),
}).install();
},
setUser() {
Raven.setUserContext({
id: this.options.currentUserId,
});
},
bindRavenErrors() {
window.$(document).on('ajaxError.raven', this.handleRavenErrors);
},
handleRavenErrors(event, req, config, err) {
const error = err || req.statusText;
const responseText = req.responseText || 'Unknown response text';
Raven.captureMessage(error, {
extra: {
type: config.type,
url: config.url,
data: config.data,
status: req.status,
response: responseText,
error,
event,
},
});
},
shouldSendSample() {
return Math.random() * 100 <= this.SAMPLE_RATE;
},
};
export default RavenConfig;
...@@ -133,6 +133,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ...@@ -133,6 +133,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:signup_enabled, :signup_enabled,
:sentry_dsn, :sentry_dsn,
:sentry_enabled, :sentry_enabled,
:clientside_sentry_dsn,
:clientside_sentry_enabled,
:send_user_confirmation_email, :send_user_confirmation_email,
:shared_runners_enabled, :shared_runners_enabled,
:shared_runners_text, :shared_runners_text,
......
...@@ -62,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -62,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true, presence: true,
if: :sentry_enabled if: :sentry_enabled
validates :clientside_sentry_dsn,
presence: true,
if: :clientside_sentry_enabled
validates :akismet_api_key, validates :akismet_api_key,
presence: true, presence: true,
if: :akismet_enabled if: :akismet_enabled
......
...@@ -394,8 +394,6 @@ ...@@ -394,8 +394,6 @@
%fieldset %fieldset
%legend Error Reporting and Logging %legend Error Reporting and Logging
%p
These settings require a restart to take effect.
.form-group .form-group
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
.checkbox .checkbox
...@@ -403,6 +401,7 @@ ...@@ -403,6 +401,7 @@
= f.check_box :sentry_enabled = f.check_box :sentry_enabled
Enable Sentry Enable Sentry
.help-block .help-block
%p This setting requires a restart to take effect.
Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here:
%a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com
...@@ -411,6 +410,21 @@ ...@@ -411,6 +410,21 @@
.col-sm-10 .col-sm-10
= f.text_field :sentry_dsn, class: 'form-control' = f.text_field :sentry_dsn, class: 'form-control'
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :clientside_sentry_enabled do
= f.check_box :clientside_sentry_enabled
Enable Clientside Sentry
.help-block
Sentry can also be used for reporting and logging clientside exceptions.
%a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/
.form-group
= f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :clientside_sentry_dsn, class: 'form-control'
%fieldset %fieldset
%legend Repository Storage %legend Repository Storage
.form-group .form-group
......
...@@ -28,9 +28,12 @@ ...@@ -28,9 +28,12 @@
= stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print" = stylesheet_link_tag "print", media: "print"
= Gon::Base.render_data
= webpack_bundle_tag "runtime" = webpack_bundle_tag "runtime"
= webpack_bundle_tag "common" = webpack_bundle_tag "common"
= webpack_bundle_tag "main" = webpack_bundle_tag "main"
= webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled
- if content_for?(:page_specific_javascripts) - if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts = yield :page_specific_javascripts
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
%html{ lang: I18n.locale, class: "#{page_class}" } %html{ lang: I18n.locale, class: "#{page_class}" }
= render "layouts/head" = render "layouts/head"
%body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
= Gon::Base.render_data
= render "layouts/header/default", title: header_title = render "layouts/header/default", title: header_title
= render 'layouts/page', sidebar: sidebar, nav: nav = render 'layouts/page', sidebar: sidebar, nav: nav
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
= render "layouts/head" = render "layouts/head"
%body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } } %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap .page-wrap
= Gon::Base.render_data
= render "layouts/header/empty" = render "layouts/header/empty"
= render "layouts/broadcast" = render "layouts/broadcast"
.container.navless-container .container.navless-container
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
%html{ lang: "en" } %html{ lang: "en" }
= render "layouts/head" = render "layouts/head"
%body.ui_charcoal.login-page.application.navless %body.ui_charcoal.login-page.application.navless
= Gon::Base.render_data
= render "layouts/header/empty" = render "layouts/header/empty"
= render "layouts/broadcast" = render "layouts/broadcast"
.container.navless-container .container.navless-container
......
...@@ -55,6 +55,7 @@ var config = { ...@@ -55,6 +55,7 @@ var config = {
terminal: './terminal/terminal_bundle.js', terminal: './terminal/terminal_bundle.js',
u2f: ['vendor/u2f'], u2f: ['vendor/u2f'],
users: './users/users_bundle.js', users: './users/users_bundle.js',
raven: './raven/index.js',
}, },
output: { output: {
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false
add_column :application_settings, :clientside_sentry_dsn, :string
end
def down
remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170503004425) do ActiveRecord::Schema.define(version: 20170504102911) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -121,6 +121,8 @@ ActiveRecord::Schema.define(version: 20170503004425) do ...@@ -121,6 +121,8 @@ ActiveRecord::Schema.define(version: 20170503004425) do
t.integer "cached_markdown_version" t.integer "cached_markdown_version"
t.boolean "usage_ping_enabled", default: true, null: false t.boolean "usage_ping_enabled", default: true, null: false
t.string "uuid" t.string "uuid"
t.boolean "clientside_sentry_enabled", default: false, null: false
t.string "clientside_sentry_dsn"
end end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
...@@ -1413,4 +1415,4 @@ ActiveRecord::Schema.define(version: 20170503004425) do ...@@ -1413,4 +1415,4 @@ ActiveRecord::Schema.define(version: 20170503004425) do
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users" add_foreign_key "u2f_registrations", "users"
end end
\ No newline at end of file
...@@ -58,6 +58,7 @@ module API ...@@ -58,6 +58,7 @@ module API
:restricted_visibility_levels, :restricted_visibility_levels,
:send_user_confirmation_email, :send_user_confirmation_email,
:sentry_enabled, :sentry_enabled,
:clientside_sentry_enabled,
:session_expire_delay, :session_expire_delay,
:shared_runners_enabled, :shared_runners_enabled,
:sidekiq_throttling_enabled, :sidekiq_throttling_enabled,
...@@ -138,6 +139,10 @@ module API ...@@ -138,6 +139,10 @@ module API
given sentry_enabled: ->(val) { val } do given sentry_enabled: ->(val) { val } do
requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name'
end end
optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/'
given clientside_sentry_enabled: ->(val) { val } do
requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name'
end
optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_storage, type: String, desc: 'Storage paths for new projects'
optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues."
optional :koding_enabled, type: Boolean, desc: 'Enable Koding' optional :koding_enabled, type: Boolean, desc: 'Enable Koding'
......
...@@ -10,6 +10,8 @@ module Gitlab ...@@ -10,6 +10,8 @@ module Gitlab
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css')
gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js')
gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled
gon.gitlab_url = Gitlab.config.gitlab.url
if current_user if current_user
gon.current_user_id = current_user.id gon.current_user_id = current_user.id
......
require 'spec_helper'
feature 'RavenJS', :feature, :js do
let(:raven_path) { '/raven.bundle.js' }
it 'should not load raven if sentry is disabled' do
visit new_user_session_path
expect(has_requested_raven).to eq(false)
end
it 'should load raven if sentry is enabled' do
stub_application_setting(clientside_sentry_dsn: 'https://key@domain.com/id', clientside_sentry_enabled: true)
visit new_user_session_path
expect(has_requested_raven).to eq(true)
end
def has_requested_raven
page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)}
end
end
import RavenConfig from '~/raven/raven_config';
import index from '~/raven/index';
describe('RavenConfig options', () => {
let sentryDsn;
let currentUserId;
let gitlabUrl;
let isProduction;
let indexReturnValue;
beforeEach(() => {
sentryDsn = 'sentryDsn';
currentUserId = 'currentUserId';
gitlabUrl = 'gitlabUrl';
isProduction = 'isProduction';
window.gon = {
sentry_dsn: sentryDsn,
current_user_id: currentUserId,
gitlab_url: gitlabUrl,
};
process.env.NODE_ENV = isProduction;
spyOn(RavenConfig, 'init');
indexReturnValue = index();
});
it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => {
expect(RavenConfig.init).toHaveBeenCalledWith({
sentryDsn,
currentUserId,
whitelistUrls: [gitlabUrl],
isProduction,
});
});
it('should return RavenConfig', () => {
expect(indexReturnValue).toBe(RavenConfig);
});
});
import Raven from 'raven-js';
import RavenConfig from '~/raven/raven_config';
describe('RavenConfig', () => {
describe('IGNORE_ERRORS', () => {
it('should be an array of strings', () => {
const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string');
expect(areStrings).toBe(true);
});
});
describe('IGNORE_URLS', () => {
it('should be an array of regexps', () => {
const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp);
expect(areRegExps).toBe(true);
});
});
describe('SAMPLE_RATE', () => {
it('should be a finite number', () => {
expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number');
});
});
describe('init', () => {
let options;
beforeEach(() => {
options = {
sentryDsn: '//sentryDsn',
ravenAssetUrl: '//ravenAssetUrl',
currentUserId: 1,
whitelistUrls: ['//gitlabUrl'],
isProduction: true,
};
spyOn(RavenConfig, 'configure');
spyOn(RavenConfig, 'bindRavenErrors');
spyOn(RavenConfig, 'setUser');
RavenConfig.init(options);
});
it('should set the options property', () => {
expect(RavenConfig.options).toEqual(options);
});
it('should call the configure method', () => {
expect(RavenConfig.configure).toHaveBeenCalled();
});
it('should call the error bindings method', () => {
expect(RavenConfig.bindRavenErrors).toHaveBeenCalled();
});
it('should call setUser', () => {
expect(RavenConfig.setUser).toHaveBeenCalled();
});
it('should not call setUser if there is no current user ID', () => {
RavenConfig.setUser.calls.reset();
RavenConfig.init({
sentryDsn: '//sentryDsn',
ravenAssetUrl: '//ravenAssetUrl',
currentUserId: undefined,
whitelistUrls: ['//gitlabUrl'],
isProduction: true,
});
expect(RavenConfig.setUser).not.toHaveBeenCalled();
});
});
describe('configure', () => {
let options;
let raven;
let ravenConfig;
beforeEach(() => {
options = {
sentryDsn: '//sentryDsn',
whitelistUrls: ['//gitlabUrl'],
isProduction: true,
};
ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']);
raven = jasmine.createSpyObj('raven', ['install']);
spyOn(Raven, 'config').and.returnValue(raven);
ravenConfig.options = options;
ravenConfig.IGNORE_ERRORS = 'ignore_errors';
ravenConfig.IGNORE_URLS = 'ignore_urls';
RavenConfig.configure.call(ravenConfig);
});
it('should call Raven.config', () => {
expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, {
whitelistUrls: options.whitelistUrls,
environment: 'production',
ignoreErrors: ravenConfig.IGNORE_ERRORS,
ignoreUrls: ravenConfig.IGNORE_URLS,
shouldSendCallback: jasmine.any(Function),
});
});
it('should call Raven.install', () => {
expect(raven.install).toHaveBeenCalled();
});
it('should set .environment to development if isProduction is false', () => {
ravenConfig.options.isProduction = false;
RavenConfig.configure.call(ravenConfig);
expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, {
whitelistUrls: options.whitelistUrls,
environment: 'development',
ignoreErrors: ravenConfig.IGNORE_ERRORS,
ignoreUrls: ravenConfig.IGNORE_URLS,
shouldSendCallback: jasmine.any(Function),
});
});
});
describe('setUser', () => {
let ravenConfig;
beforeEach(() => {
ravenConfig = { options: { currentUserId: 1 } };
spyOn(Raven, 'setUserContext');
RavenConfig.setUser.call(ravenConfig);
});
it('should call .setUserContext', function () {
expect(Raven.setUserContext).toHaveBeenCalledWith({
id: ravenConfig.options.currentUserId,
});
});
});
describe('bindRavenErrors', () => {
let $document;
let $;
beforeEach(() => {
$document = jasmine.createSpyObj('$document', ['on']);
$ = jasmine.createSpy('$').and.returnValue($document);
window.$ = $;
RavenConfig.bindRavenErrors();
});
it('should call .on', function () {
expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors);
});
});
describe('handleRavenErrors', () => {
let event;
let req;
let config;
let err;
beforeEach(() => {
event = {};
req = { status: 'status', responseText: 'responseText', statusText: 'statusText' };
config = { type: 'type', url: 'url', data: 'data' };
err = {};
spyOn(Raven, 'captureMessage');
RavenConfig.handleRavenErrors(event, req, config, err);
});
it('should call Raven.captureMessage', () => {
expect(Raven.captureMessage).toHaveBeenCalledWith(err, {
extra: {
type: config.type,
url: config.url,
data: config.data,
status: req.status,
response: req.responseText,
error: err,
event,
},
});
});
describe('if no err is provided', () => {
beforeEach(() => {
Raven.captureMessage.calls.reset();
RavenConfig.handleRavenErrors(event, req, config);
});
it('should use req.statusText as the error value', () => {
expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, {
extra: {
type: config.type,
url: config.url,
data: config.data,
status: req.status,
response: req.responseText,
error: req.statusText,
event,
},
});
});
});
describe('if no req.responseText is provided', () => {
beforeEach(() => {
req.responseText = undefined;
Raven.captureMessage.calls.reset();
RavenConfig.handleRavenErrors(event, req, config, err);
});
it('should use `Unknown response text` as the response', () => {
expect(Raven.captureMessage).toHaveBeenCalledWith(err, {
extra: {
type: config.type,
url: config.url,
data: config.data,
status: req.status,
response: 'Unknown response text',
error: err,
event,
},
});
});
});
});
describe('shouldSendSample', () => {
let randomNumber;
beforeEach(() => {
RavenConfig.SAMPLE_RATE = 50;
spyOn(Math, 'random').and.callFake(() => randomNumber);
});
it('should call Math.random', () => {
RavenConfig.shouldSendSample();
expect(Math.random).toHaveBeenCalled();
});
it('should return true if the sample rate is greater than the random number * 100', () => {
randomNumber = 0.1;
expect(RavenConfig.shouldSendSample()).toBe(true);
});
it('should return false if the sample rate is less than the random number * 100', () => {
randomNumber = 0.9;
expect(RavenConfig.shouldSendSample()).toBe(false);
});
it('should return true if the sample rate is equal to the random number * 100', () => {
randomNumber = 0.5;
expect(RavenConfig.shouldSendSample()).toBe(true);
});
});
});
...@@ -3190,7 +3190,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: ...@@ -3190,7 +3190,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1:
dependencies: dependencies:
jsonify "~0.0.0" jsonify "~0.0.0"
json-stringify-safe@~5.0.1: json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
...@@ -4584,6 +4584,12 @@ raphael@^2.2.7: ...@@ -4584,6 +4584,12 @@ raphael@^2.2.7:
dependencies: dependencies:
eve-raphael "0.5.0" eve-raphael "0.5.0"
raven-js@^3.14.0:
version "3.14.0"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.14.0.tgz#94dda81d975fdc4a42f193db437cf70021d654e0"
dependencies:
json-stringify-safe "^5.0.1"
raw-body@~2.2.0: raw-body@~2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment