mirror of
https://github.com/grafana/grafana.git
synced 2026-01-07 01:44:00 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e44bbc5f5 | ||
|
|
7031b922c6 | ||
|
|
a04ef6cefc | ||
|
|
c5ea64c2c2 |
@@ -48,7 +48,7 @@ jobs:
|
||||
- run:
|
||||
name: Install Grafana Build Pipeline
|
||||
command: |
|
||||
curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.1.0/grabpl
|
||||
curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.1.1/grabpl
|
||||
chmod +x grabpl
|
||||
mkdir bin
|
||||
mv grabpl bin/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "6.7.2"
|
||||
"version": "6.7.4"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"repository": "github:grafana/grafana",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.8.4",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -23,8 +23,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "6.7.2",
|
||||
"@grafana/ui": "6.7.2",
|
||||
"@grafana/data": "6.7.4",
|
||||
"@grafana/ui": "6.7.4",
|
||||
"systemjs": "0.20.19",
|
||||
"systemjs-plugin-css": "0.1.37"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -29,10 +29,10 @@
|
||||
"dependencies": {
|
||||
"@babel/core": "7.9.0",
|
||||
"@babel/preset-env": "7.9.0",
|
||||
"@grafana/data": "6.7.2",
|
||||
"@grafana/data": "6.7.4",
|
||||
"@grafana/eslint-config": "^1.0.0-rc1",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@grafana/ui": "6.7.2",
|
||||
"@grafana/ui": "6.7.4",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/execa": "^0.9.0",
|
||||
"@types/expect-puppeteer": "3.3.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "6.7.2",
|
||||
"version": "6.7.4",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/core": "^10.0.27",
|
||||
"@grafana/data": "6.7.2",
|
||||
"@grafana/data": "6.7.4",
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@torkelo/react-select": "3.0.8",
|
||||
|
||||
@@ -15,14 +15,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
@@ -73,9 +73,15 @@ type CacheServer struct {
|
||||
cache *gocache.Cache
|
||||
}
|
||||
|
||||
func (this *CacheServer) Handler(ctx *macaron.Context) {
|
||||
urlPath := ctx.Req.URL.Path
|
||||
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
|
||||
var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$")
|
||||
|
||||
func (this *CacheServer) Handler(ctx *models.ReqContext) {
|
||||
hash := ctx.Params("hash")
|
||||
|
||||
if len(hash) != 32 || !validMD5.MatchString(hash) {
|
||||
ctx.JsonApiErr(404, "Avatar not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var avatar *Avatar
|
||||
obj, exists := this.cache.Get(hash)
|
||||
|
||||
@@ -115,8 +115,10 @@ func main() {
|
||||
go listenToSystemSignals(server)
|
||||
|
||||
err := server.Run()
|
||||
|
||||
code := server.ExitCode(err)
|
||||
code := 0
|
||||
if err != nil {
|
||||
code = server.ExitCode(err)
|
||||
}
|
||||
trace.Stop()
|
||||
log.Close()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"net/mail"
|
||||
"reflect"
|
||||
@@ -146,6 +147,13 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) {
|
||||
return false, newError("Proxy authentication required", err)
|
||||
}
|
||||
|
||||
func HashCacheKey(key string) string {
|
||||
hasher := fnv.New128a()
|
||||
// according to the documentation, Hash.Write cannot error, but linter is complaining
|
||||
hasher.Write([]byte(key)) // nolint: errcheck
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// getKey forms a key for the cache based on the headers received as part of the authentication flow.
|
||||
// Our configuration supports multiple headers. The main header contains the email or username.
|
||||
// And the additional ones that allow us to specify extra attributes: Name, Email or Groups.
|
||||
@@ -156,7 +164,7 @@ func (auth *AuthProxy) getKey() string {
|
||||
key = strings.Join([]string{key, header}, "-") // compose the key with any additional headers
|
||||
})
|
||||
|
||||
hashedKey := base32.StdEncoding.EncodeToString([]byte(key))
|
||||
hashedKey := HashCacheKey(key)
|
||||
return fmt.Sprintf(CachePrefix, hashedKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -79,7 +78,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
|
||||
Convey("with a simple cache key", func() {
|
||||
// Set cache key
|
||||
key := fmt.Sprintf(CachePrefix, base32.StdEncoding.EncodeToString([]byte(name)))
|
||||
key := fmt.Sprintf(CachePrefix, HashCacheKey(name))
|
||||
err := store.Set(key, int64(33), 0)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
@@ -88,7 +87,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
id, err := auth.Login()
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:NVQXE23FNRXWO===")
|
||||
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:0a7f3374e9659b10980fd66247b0cf2f")
|
||||
So(id, ShouldEqual, 33)
|
||||
})
|
||||
|
||||
@@ -97,7 +96,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
group := "grafana-core-team"
|
||||
req.Header.Add("X-WEBAUTH-GROUPS", group)
|
||||
|
||||
key := fmt.Sprintf(CachePrefix, base32.StdEncoding.EncodeToString([]byte(name+"-"+group)))
|
||||
key := fmt.Sprintf(CachePrefix, HashCacheKey(name+"-"+group))
|
||||
err := store.Set(key, int64(33), 0)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
@@ -105,7 +104,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
|
||||
id, err := auth.Login()
|
||||
So(err, ShouldBeNil)
|
||||
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:NVQXE23FNRXWOLLHOJQWMYLOMEWWG33SMUWXIZLBNU======")
|
||||
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:14f69b7023baa0ac98c96b31cec07bc0")
|
||||
So(id, ShouldEqual, 33)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -364,7 +363,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
key := fmt.Sprintf(authproxy.CachePrefix, base32.StdEncoding.EncodeToString([]byte(name+"-"+group)))
|
||||
key := fmt.Sprintf(authproxy.CachePrefix, authproxy.HashCacheKey(name+"-"+group))
|
||||
err := sc.remoteCacheService.Set(key, int64(33), 0)
|
||||
So(err, ShouldBeNil)
|
||||
sc.fakeReq("GET", "/")
|
||||
|
||||
@@ -126,13 +126,15 @@ func (gcn *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
gcn.log.Error("evalContext returned an invalid rule URL")
|
||||
}
|
||||
|
||||
// add a text paragraph widget for the message
|
||||
widgets := []widget{
|
||||
textParagraphWidget{
|
||||
widgets := []widget{}
|
||||
if len(evalContext.Rule.Message) > 0 {
|
||||
// add a text paragraph widget for the message if there is a message
|
||||
// Google Chat API doesn't accept an empty text property
|
||||
widgets = append(widgets, textParagraphWidget{
|
||||
Text: text{
|
||||
Text: evalContext.Rule.Message,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// add a text paragraph widget for the fields
|
||||
|
||||
@@ -109,7 +109,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
|
||||
let folder: SelectableValue<number> = { value: -1 };
|
||||
|
||||
if (initialFolderId !== undefined && initialFolderId > -1) {
|
||||
if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) {
|
||||
folder = options.find(option => option.value === initialFolderId) || { value: -1 };
|
||||
} else if (enableReset && initialTitle) {
|
||||
folder = resetFolder;
|
||||
|
||||
@@ -27,16 +27,17 @@ describe('parseUrlFromOptions', () => {
|
||||
|
||||
describe('parseInitFromOptions', () => {
|
||||
it.each`
|
||||
method | data | expected
|
||||
${undefined} | ${undefined} | ${{ method: undefined, headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
${'GET'} | ${undefined} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
${'POST'} | ${{ id: '0' }} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
|
||||
${'PUT'} | ${{ id: '0' }} | ${{ method: 'PUT', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
|
||||
${'monkey'} | ${undefined} | ${{ method: 'monkey', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
method | data | withCredentials | expected
|
||||
${undefined} | ${undefined} | ${undefined} | ${{ method: undefined, headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
${'GET'} | ${undefined} | ${undefined} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
${'POST'} | ${{ id: '0' }} | ${undefined} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
|
||||
${'PUT'} | ${{ id: '0' }} | ${undefined} | ${{ method: 'PUT', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
|
||||
${'monkey'} | ${undefined} | ${undefined} | ${{ method: 'monkey', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
|
||||
${'GET'} | ${undefined} | ${true} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined, credentials: 'include' }}
|
||||
`(
|
||||
"when called with method: '$method' and data: '$data' then result should be '$expected'",
|
||||
({ method, data, expected }) => {
|
||||
expect(parseInitFromOptions({ method, data, url: '' })).toEqual(expected);
|
||||
"when called with method: '$method', data: '$data' and withCredentials: '$withCredentials' then result should be '$expected'",
|
||||
({ method, data, withCredentials, expected }) => {
|
||||
expect(parseInitFromOptions({ method, data, withCredentials, url: '' })).toEqual(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,15 @@ export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit =>
|
||||
const isAppJson = isContentTypeApplicationJson(headers);
|
||||
const body = parseBody(options, isAppJson);
|
||||
|
||||
if (options?.withCredentials) {
|
||||
return {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
credentials: 'include',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
headers,
|
||||
|
||||
@@ -82,7 +82,8 @@ export class UserProfile extends PureComponent<Props, State> {
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const { showDeleteModal, showDisableModal } = this.state;
|
||||
const lockMessage = 'Synced via LDAP';
|
||||
const authSource = user.authLabels?.length && user.authLabels[0];
|
||||
const lockMessage = authSource ? `Synced via ${authSource}` : '';
|
||||
const styles = getStyles(config.theme);
|
||||
|
||||
return (
|
||||
|
||||
@@ -72,7 +72,7 @@ export function annotationTooltipDirective(
|
||||
tooltip += '<div class="graph-annotation__body">';
|
||||
|
||||
if (text) {
|
||||
tooltip += '<div>' + sanitizeString(text.replace(/\n/g, '<br>')) + '</div>';
|
||||
tooltip += '<div ng-non-bindable>' + sanitizeString(text.replace(/\n/g, '<br>')) + '</div>';
|
||||
}
|
||||
|
||||
const tags = event.tags;
|
||||
|
||||
@@ -79,7 +79,10 @@ export class DashboardSrv {
|
||||
};
|
||||
|
||||
saveJSONDashboard(json: string) {
|
||||
return getBackendSrv().saveDashboard(JSON.parse(json), {});
|
||||
const parsedJson = JSON.parse(json);
|
||||
return getBackendSrv().saveDashboard(parsedJson, {
|
||||
folderId: this.dashboard.meta.folderId || parsedJson.folderId,
|
||||
});
|
||||
}
|
||||
|
||||
starDashboard(dashboardId: string, isStarred: any) {
|
||||
|
||||
@@ -135,6 +135,38 @@ describe('timeSrv', () => {
|
||||
expect(time.to.valueOf()).toEqual(1410337665699);
|
||||
});
|
||||
|
||||
it('should handle epochs that look like formatted date without time', () => {
|
||||
location = {
|
||||
search: jest.fn(() => ({
|
||||
from: '20149999',
|
||||
to: '20159999',
|
||||
})),
|
||||
};
|
||||
|
||||
timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any);
|
||||
|
||||
timeSrv.init(_dashboard);
|
||||
const time = timeSrv.timeRange();
|
||||
expect(time.from.valueOf()).toEqual(20149999);
|
||||
expect(time.to.valueOf()).toEqual(20159999);
|
||||
});
|
||||
|
||||
it('should handle epochs that look like formatted date', () => {
|
||||
location = {
|
||||
search: jest.fn(() => ({
|
||||
from: '201499991234567',
|
||||
to: '201599991234567',
|
||||
})),
|
||||
};
|
||||
|
||||
timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any);
|
||||
|
||||
timeSrv.init(_dashboard);
|
||||
const time = timeSrv.timeRange();
|
||||
expect(time.from.valueOf()).toEqual(201499991234567);
|
||||
expect(time.to.valueOf()).toEqual(201599991234567);
|
||||
});
|
||||
|
||||
it('should handle bad dates', () => {
|
||||
location = {
|
||||
search: jest.fn(() => ({
|
||||
|
||||
@@ -102,10 +102,15 @@ export class TimeSrv {
|
||||
return value;
|
||||
}
|
||||
if (value.length === 8) {
|
||||
return toUtc(value, 'YYYYMMDD');
|
||||
}
|
||||
if (value.length === 15) {
|
||||
return toUtc(value, 'YYYYMMDDTHHmmss');
|
||||
const utcValue = toUtc(value, 'YYYYMMDD');
|
||||
if (utcValue.isValid()) {
|
||||
return utcValue;
|
||||
}
|
||||
} else if (value.length === 15) {
|
||||
const utcValue = toUtc(value, 'YYYYMMDDTHHmmss');
|
||||
if (utcValue.isValid()) {
|
||||
return utcValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNaN(value)) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import angular, { auto, ILocationService, IPromise, IQService } from 'angular';
|
||||
import _ from 'lodash';
|
||||
// Utils & Services
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { variableTypes } from './variable';
|
||||
import { VariableActions, variableTypes } from './variable';
|
||||
import { Graph } from 'app/core/utils/dag';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
@@ -344,9 +344,9 @@ export class VariableSrv {
|
||||
}
|
||||
}
|
||||
|
||||
isVariableUrlValueDifferentFromCurrent(variable: any, urlValue: any) {
|
||||
isVariableUrlValueDifferentFromCurrent(variable: VariableActions, urlValue: any) {
|
||||
// lodash _.isEqual handles array of value equality checks as well
|
||||
return !_.isEqual(variable.current.value, urlValue);
|
||||
return !_.isEqual(variable.getValueForUrl(), urlValue);
|
||||
}
|
||||
|
||||
updateUrlParamsWithCurrentVariables() {
|
||||
|
||||
@@ -75,12 +75,12 @@ export class GraphiteQueryCtrl extends QueryCtrl {
|
||||
|
||||
checkOtherSegments(fromIndex: number, modifyLastSegment = true) {
|
||||
if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (fromIndex === 0) {
|
||||
this.addSelectMetricSegment();
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { ColumnRender, TableRenderModel, ColumnStyle } from './types';
|
||||
import { ColumnOptionsCtrl } from './column_options';
|
||||
import { sanitizeUrl } from 'app/core/utils/text';
|
||||
import { sanitizeUrl, escapeHtml } from 'app/core/utils/text';
|
||||
|
||||
export class TableRenderer {
|
||||
formatters: any[];
|
||||
@@ -56,7 +56,7 @@ export class TableRenderer {
|
||||
column.style = style;
|
||||
|
||||
if (style.alias) {
|
||||
column.title = column.text.replace(regex, style.alias);
|
||||
column.title = escapeHtml(column.text.replace(regex, style.alias));
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -300,7 +300,7 @@ export class TableRenderer {
|
||||
const cellLink = this.templateSrv.replace(column.style.linkUrl, scopedVars, encodeURIComponent);
|
||||
const sanitizedCellLink = sanitizeUrl(cellLink);
|
||||
|
||||
const cellLinkTooltip = this.templateSrv.replace(column.style.linkTooltip, scopedVars);
|
||||
const cellLinkTooltip = escapeHtml(this.templateSrv.replace(column.style.linkTooltip, scopedVars));
|
||||
const cellTarget = column.style.linkTargetBlank ? '_blank' : '';
|
||||
|
||||
cellClasses.push('table-panel-cell-link');
|
||||
|
||||
@@ -8,7 +8,7 @@ for ((i = 1; i <= $#; i++ )); do
|
||||
remainder="${!i}"
|
||||
# Find everything until last = character (= is included in the result)
|
||||
# This allows to add tags to metric names
|
||||
metricName=$(grep -o "\(.*\)=" <<< $remainder)
|
||||
metricName=$(grep -o "\(.*\)=" <<< "$remainder")
|
||||
# Get the metric value
|
||||
value=${remainder#"$metricName"}
|
||||
# Remove remaining = character from metric name
|
||||
|
||||
Reference in New Issue
Block a user