mirror of
https://github.com/mattermost/focalboard.git
synced 2024-11-24 08:22:29 +02:00
[GH-3410] Healthcheck Endpoint with Server Metadata (#4151)
* add ping endpoint and tests * remove unnecessary newlines * fix: invalid Swagger YAML comment blocks * refactor and add 'suite' SKU * generate swagger docs Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Paul Esch-Laurent <paul.esch-laurent@mattermost.com>
This commit is contained in:
parent
a8bafb35c9
commit
0cd4257ebc
@ -52,7 +52,7 @@ func (a *API) handleCreateCard(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Disables notifications (for bulk data inserting)
|
||||
// required: false
|
||||
// type: bool
|
||||
// security:
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
@ -128,7 +128,7 @@ func (a *API) handleCreateCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) handleGetCards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/cards
|
||||
// swagger:operation GET /boards/{boardID}/cards getCards
|
||||
//
|
||||
// Fetches cards for the specified board.
|
||||
//
|
||||
@ -151,7 +151,7 @@ func (a *API) handleGetCards(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Number of cards to return per page(default=100)
|
||||
// required: false
|
||||
// type: integer
|
||||
// security:
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
@ -252,7 +252,7 @@ func (a *API) handlePatchCard(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Disables notifications (for bulk data patching)
|
||||
// required: false
|
||||
// type: bool
|
||||
// security:
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
@ -325,7 +325,7 @@ func (a *API) handlePatchCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) handleGetCard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /cards/{cardID}
|
||||
// swagger:operation GET /cards/{cardID} getCard
|
||||
//
|
||||
// Fetches the specified card.
|
||||
//
|
||||
@ -338,7 +338,7 @@ func (a *API) handleGetCard(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Card ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
|
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
func (a *API) registerSystemRoutes(r *mux.Router) {
|
||||
// System APIs
|
||||
r.HandleFunc("/hello", a.handleHello).Methods("GET")
|
||||
r.HandleFunc("/ping", a.handlePing).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
|
||||
@ -24,3 +26,32 @@ func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
|
||||
// description: success
|
||||
stringResponse(w, "Hello")
|
||||
}
|
||||
|
||||
func (a *API) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /ping ping
|
||||
//
|
||||
// Responds with server metadata if the web service is running.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
serverMetadata := a.app.GetServerMetadata()
|
||||
|
||||
if a.singleUserToken != "" {
|
||||
serverMetadata.SKU = "personal_desktop"
|
||||
}
|
||||
|
||||
if serverMetadata.Edition == "plugin" {
|
||||
serverMetadata.SKU = "suite"
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(serverMetadata)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
}
|
||||
|
||||
jsonStringResponse(w, 200, string(bytes))
|
||||
}
|
||||
|
143
server/api/system_test.go
Normal file
143
server/api/system_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/app"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
func TestHello(t *testing.T) {
|
||||
testAPI := API{logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)}
|
||||
|
||||
t.Run("Returns 'Hello' on success", func(t *testing.T) {
|
||||
request, _ := http.NewRequest(http.MethodGet, "/hello", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handleHello(response, request)
|
||||
|
||||
got := response.Body.String()
|
||||
want := "Hello"
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
testAPI := API{logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)}
|
||||
|
||||
t.Run("Returns metadata on success", func(t *testing.T) {
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sets SKU to 'personal_desktop' when in single-user mode", func(t *testing.T) {
|
||||
testAPI.singleUserToken = "abc-123-xyz-456"
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_desktop",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sets SKU to 'suite' when in plugin mode", func(t *testing.T) {
|
||||
model.Edition = "plugin"
|
||||
request, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
testAPI.handlePing(response, request)
|
||||
|
||||
var got app.ServerMetadata
|
||||
err := json.NewDecoder(response.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to JSON decode response body %q", response.Body)
|
||||
}
|
||||
|
||||
want := app.ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: "plugin",
|
||||
DBType: "",
|
||||
DBVersion: "",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "suite",
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("got HTTP %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
@ -283,7 +283,7 @@ func (a *API) handleUpdateUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// description: User config patch to apply
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UserPropPatch"
|
||||
// "$ref": "#/definitions/UserPreferencesPatch"
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
|
42
server/app/server_metadata.go
Normal file
42
server/app/server_metadata.go
Normal file
@ -0,0 +1,42 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
type ServerMetadata struct {
|
||||
Version string `json:"version"`
|
||||
BuildNumber string `json:"build_number"`
|
||||
BuildDate string `json:"build_date"`
|
||||
Commit string `json:"commit"`
|
||||
Edition string `json:"edition"`
|
||||
DBType string `json:"db_type"`
|
||||
DBVersion string `json:"db_version"`
|
||||
OSType string `json:"os_type"`
|
||||
OSArch string `json:"os_arch"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
func (a *App) GetServerMetadata() *ServerMetadata {
|
||||
var dbType string
|
||||
var dbVersion string
|
||||
if a != nil && a.store != nil {
|
||||
dbType = a.store.DBType()
|
||||
dbVersion = a.store.DBVersion()
|
||||
}
|
||||
|
||||
return &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: dbType,
|
||||
DBVersion: dbVersion,
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
}
|
37
server/app/server_metadata_test.go
Normal file
37
server/app/server_metadata_test.go
Normal file
@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
func TestGetServerMetadata(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().DBType().Return("TEST_DB_TYPE")
|
||||
th.Store.EXPECT().DBVersion().Return("TEST_DB_VERSION")
|
||||
|
||||
t.Run("Get Server Metadata", func(t *testing.T) {
|
||||
got := th.App.GetServerMetadata()
|
||||
want := &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "TEST_DB_TYPE",
|
||||
DBVersion: "TEST_DB_VERSION",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got: %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
@ -182,6 +182,20 @@ func (mr *MockStoreMockRecorder) DBType() *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBType", reflect.TypeOf((*MockStore)(nil).DBType))
|
||||
}
|
||||
|
||||
// DBVersion mocks base method.
|
||||
func (m *MockStore) DBVersion() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DBVersion")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DBVersion indicates an expected call of DBVersion.
|
||||
func (mr *MockStoreMockRecorder) DBVersion() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBVersion", reflect.TypeOf((*MockStore)(nil).DBVersion))
|
||||
}
|
||||
|
||||
// DeleteBlock mocks base method.
|
||||
func (m *MockStore) DeleteBlock(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -124,6 +124,11 @@ func (s *SQLStore) CreateUser(user *model.User) (*model.User, error) {
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) DBVersion() string {
|
||||
return s.dBVersion(s.db)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteBlock(blockID string, modifiedBy string) error {
|
||||
if s.dbType == model.SqliteDBType {
|
||||
return s.deleteBlock(s.db, blockID, modifiedBy)
|
||||
|
@ -175,3 +175,26 @@ func (s *SQLStore) searchUserChannels(db sq.BaseRunner, teamID, userID, query st
|
||||
func (s *SQLStore) getChannel(db sq.BaseRunner, teamID, channel string) (*mmModel.Channel, error) {
|
||||
return nil, store.NewNotSupportedError("get channel not supported on standalone mode")
|
||||
}
|
||||
|
||||
func (s *SQLStore) dBVersion(db sq.BaseRunner) string {
|
||||
var version string
|
||||
var row *sql.Row
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
row = s.db.QueryRow("SELECT VERSION()")
|
||||
case model.PostgresDBType:
|
||||
row = s.db.QueryRow("SHOW server_version")
|
||||
case model.SqliteDBType:
|
||||
row = s.db.QueryRow("SELECT sqlite_version()")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := row.Scan(&version); err != nil {
|
||||
s.logger.Error("error checking database version", mlog.Err(err))
|
||||
return ""
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
@ -153,6 +153,7 @@ type Store interface {
|
||||
UpdateCardLimitTimestamp(cardLimit int) (int64, error)
|
||||
|
||||
DBType() string
|
||||
DBVersion() string
|
||||
|
||||
GetLicense() *mmModel.License
|
||||
GetCloudLimits() (*mmModel.ProductLimits, error)
|
||||
|
@ -1 +1 @@
|
||||
6.0.1
|
||||
6.2.1
|
4080
server/swagger/docs/html/index.html
generated
4080
server/swagger/docs/html/index.html
generated
File diff suppressed because it is too large
Load Diff
162
server/swagger/swagger.yml
generated
162
server/swagger/swagger.yml
generated
@ -39,6 +39,15 @@ definitions:
|
||||
BoardsCloudLimits is the representation of the limits for the
|
||||
Boards server
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
BoardsStatistics:
|
||||
description: BoardsStatistics is the representation of the statistics for the Boards server
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
Card:
|
||||
title: Card represents a group of content blocks and properties.
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
CardPatch:
|
||||
description: CardPatch is a patch for modifying cards
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
Category:
|
||||
description: Category is a board category
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
@ -96,8 +105,8 @@ definitions:
|
||||
User:
|
||||
description: User is a user
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
UserPropPatch:
|
||||
description: UserPropPatch is a user property patch
|
||||
UserPreferencesPatch:
|
||||
description: UserPreferencesPatch is a user property patch
|
||||
x-go-package: github.com/mattermost/focalboard/server/model
|
||||
host: localhost
|
||||
info:
|
||||
@ -559,6 +568,71 @@ paths:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
/boards/{boardID}/cards:
|
||||
get:
|
||||
operationId: getCards
|
||||
parameters:
|
||||
- description: Board ID
|
||||
in: path
|
||||
name: boardID
|
||||
required: true
|
||||
type: string
|
||||
- description: The page to select (default=0)
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Number of cards to return per page(default=100)
|
||||
in: query
|
||||
name: per_page
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/Card'
|
||||
type: array
|
||||
default:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Fetches cards for the specified board.
|
||||
post:
|
||||
operationId: createCard
|
||||
parameters:
|
||||
- description: Board ID
|
||||
in: path
|
||||
name: boardID
|
||||
required: true
|
||||
type: string
|
||||
- description: the card to create
|
||||
in: body
|
||||
name: Body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/Card'
|
||||
- description: Disables notifications (for bulk data inserting)
|
||||
in: query
|
||||
name: disable_notify
|
||||
type: bool
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
schema:
|
||||
$ref: '#/definitions/Card'
|
||||
default:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Creates a new card for the specified board.
|
||||
/boards/{boardID}/duplicate:
|
||||
post:
|
||||
description: Returns the new created board and all the blocks
|
||||
@ -876,6 +950,62 @@ paths:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
/cards/{cardID}:
|
||||
get:
|
||||
operationId: getCard
|
||||
parameters:
|
||||
- description: Card ID
|
||||
in: path
|
||||
name: cardID
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
schema:
|
||||
$ref: '#/definitions/Card'
|
||||
default:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Fetches the specified card.
|
||||
/cards/{cardID}/cards:
|
||||
patch:
|
||||
operationId: patchCard
|
||||
parameters:
|
||||
- description: Card ID
|
||||
in: path
|
||||
name: cardID
|
||||
required: true
|
||||
type: string
|
||||
- description: the card patch
|
||||
in: body
|
||||
name: Body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/CardPatch'
|
||||
- description: Disables notifications (for bulk data patching)
|
||||
in: query
|
||||
name: disable_notify
|
||||
type: bool
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
schema:
|
||||
$ref: '#/definitions/Card'
|
||||
default:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Patches the specified card.
|
||||
/clientConfig:
|
||||
get:
|
||||
description: Returns the client configuration
|
||||
@ -994,6 +1124,15 @@ paths:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
/ping:
|
||||
get:
|
||||
operationId: ping
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
summary: Responds with server metadata if the web service is running.
|
||||
/register:
|
||||
post:
|
||||
description: Register new user
|
||||
@ -1016,6 +1155,23 @@ paths:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
/statistics:
|
||||
get:
|
||||
operationId: handleStatistics
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: success
|
||||
schema:
|
||||
$ref: '#/definitions/BoardStatistics'
|
||||
default:
|
||||
description: internal error
|
||||
schema:
|
||||
$ref: '#/definitions/ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Fetches the statistic of the server.
|
||||
/subscriptions:
|
||||
post:
|
||||
operationId: createSubscription
|
||||
@ -1748,7 +1904,7 @@ paths:
|
||||
name: Body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/UserPropPatch'
|
||||
$ref: '#/definitions/UserPreferencesPatch'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
Loading…
Reference in New Issue
Block a user