Merge pull request #9165 from wkloucek/collaboration-fixes
collaboration fixes
This commit is contained in:
@@ -71,10 +71,7 @@ func Server(cfg *config.Config) *cli.Command {
|
||||
)
|
||||
defer teardown()
|
||||
if err != nil {
|
||||
logger.Info().
|
||||
Err(err).
|
||||
Str("transport", "grpc").
|
||||
Msg("Failed to initialize server")
|
||||
logger.Error().Err(err).Str("transport", "grpc").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -85,11 +82,8 @@ func Server(cfg *config.Config) *cli.Command {
|
||||
}
|
||||
return grpcServer.Serve(l)
|
||||
},
|
||||
func(_ error) {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("server", "grpc").
|
||||
Msg("shutting down server")
|
||||
func(err error) {
|
||||
logger.Error().Err(err).Str("server", "grpc").Msg("shutting down server")
|
||||
cancel()
|
||||
})
|
||||
|
||||
@@ -100,7 +94,7 @@ func Server(cfg *config.Config) *cli.Command {
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
logger.Error().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -117,6 +111,10 @@ func Server(cfg *config.Config) *cli.Command {
|
||||
http.Context(ctx),
|
||||
http.TracerProvider(traceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "http").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
gr.Add(httpServer.Run, func(_ error) {
|
||||
cancel()
|
||||
})
|
||||
|
||||
@@ -6,4 +6,7 @@ type App struct {
|
||||
Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description" introductionVersion:"5.1"`
|
||||
Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app" introductionVersion:"5.1"`
|
||||
LockName string `yaml:"lockname" env:"COLLABORATION_APP_LOCKNAME" desc:"Name for the app lock" introductionVersion:"5.1"`
|
||||
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"5.1"`
|
||||
Insecure bool `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"5.1"`
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ type Config struct {
|
||||
Service Service `yaml:"-"`
|
||||
App App `yaml:"app"`
|
||||
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"Used as JWT token and to encrypt access token." introductionVersion:"5.1"`
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
GRPC GRPC `yaml:"grpc"`
|
||||
HTTP HTTP `yaml:"http"`
|
||||
WopiApp WopiApp `yaml:"wopiapp"`
|
||||
CS3Api CS3Api `yaml:"cs3api"`
|
||||
GRPC GRPC `yaml:"grpc"`
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Wopi Wopi `yaml:"wopi"`
|
||||
CS3Api CS3Api `yaml:"cs3api"`
|
||||
|
||||
Tracing *Tracing `yaml:"tracing"`
|
||||
Log *Log `yaml:"log"`
|
||||
|
||||
@@ -8,7 +8,7 @@ type CS3Api struct {
|
||||
|
||||
// Gateway defines the available configuration for the CS3 API gateway
|
||||
type Gateway struct {
|
||||
Name string `yaml: "name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway." introductionVersion:"5.1"`
|
||||
Name string `yaml:"name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway." introductionVersion:"5.1"`
|
||||
}
|
||||
|
||||
// DataGateway defines the available configuration for the CS3 API data gateway
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/generators"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ func FullDefaultConfig() *config.Config {
|
||||
|
||||
// DefaultConfig returns a basic default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
secret, _ := generators.GenerateRandomString(generators.AlphaNumChars, 32) // anything to do with the error?
|
||||
return &config.Config{
|
||||
Service: config.Service{
|
||||
Name: "collaboration",
|
||||
@@ -25,17 +24,16 @@ func DefaultConfig() *config.Config {
|
||||
Description: "Open office documents with Collabora",
|
||||
Icon: "image-edit",
|
||||
LockName: "com.github.owncloud.collaboration",
|
||||
Addr: "https://127.0.0.1:9980",
|
||||
Insecure: false,
|
||||
},
|
||||
JWTSecret: secret,
|
||||
GRPC: config.GRPC{
|
||||
Addr: "0.0.0.0:9301",
|
||||
Namespace: "com.owncloud.collaboration",
|
||||
Addr: "127.0.0.1:9301",
|
||||
Namespace: "com.owncloud.api",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9300",
|
||||
BindAddr: "0.0.0.0:9300",
|
||||
Namespace: "com.owncloud.collaboration",
|
||||
Scheme: "https",
|
||||
Namespace: "com.owncloud.web",
|
||||
},
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9304",
|
||||
@@ -43,13 +41,12 @@ func DefaultConfig() *config.Config {
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
WopiApp: config.WopiApp{
|
||||
Addr: "https://127.0.0.1:8080",
|
||||
Insecure: false,
|
||||
Wopi: config.Wopi{
|
||||
WopiSrc: "https://localhost:9300",
|
||||
},
|
||||
CS3Api: config.CS3Api{
|
||||
Gateway: config.Gateway{
|
||||
Name: "com.owncloud.api.gateway",
|
||||
Name: shared.DefaultRevaConfig().Address,
|
||||
},
|
||||
DataGateway: config.DataGateway{
|
||||
Insecure: false,
|
||||
@@ -83,6 +80,14 @@ func EnsureDefaults(cfg *config.Config) {
|
||||
} else if cfg.Tracing == nil {
|
||||
cfg.Tracing = &config.Tracing{}
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the collaboration service wihout a leading scheme. Either use an IP address or a hostname (127.0.0.1:9301 or wopi.private.prv). The configured 'Scheme' in another envvar will be used to finally build the public URL along with this address." introductionVersion:"5.1"`
|
||||
BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service. Use '<ip-address>:<port>', for example, '127.0.0.1:9301' or '0.0.0.0:9301'." introductionVersion:"5.1"`
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"5.1"`
|
||||
Namespace string `yaml:"-"`
|
||||
Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"The scheme to use for the HTTP address, which is either 'http' or 'https'." introductionVersion:"5.1"`
|
||||
TLS shared.HTTPServiceTLS `yaml:"tls"`
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
ocisdefaults "github.com/owncloud/ocis/v2/ocis-pkg/config/defaults"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
@@ -34,5 +37,27 @@ func ParseConfig(cfg *config.Config) error {
|
||||
|
||||
// Validate validates the configuration
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
if cfg.Wopi.Secret == "" {
|
||||
return shared.MissingWOPISecretError(cfg.Service.Name)
|
||||
}
|
||||
url, err := url.Parse(cfg.Wopi.WopiSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("The WOPI Src has not been set properly in your config for %s. "+
|
||||
"Make sure your %s config contains the proper values "+
|
||||
"(e.g. by running ocis init or setting it manually in "+
|
||||
"the config/corresponding environment variable): %s",
|
||||
cfg.Service.Name, ocisdefaults.BaseConfigPath(), err.Error())
|
||||
}
|
||||
if url.Path != "" {
|
||||
return fmt.Errorf("The WOPI Src must not contain a path in your config for %s. "+
|
||||
"Make sure your %s config contains the proper values "+
|
||||
"(e.g. by running ocis init or setting it manually in "+
|
||||
"the config/corresponding environment variable)",
|
||||
cfg.Service.Name, ocisdefaults.BaseConfigPath())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"pre5.0"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
// Wopi defines the available configuration for the WOPI endpoint.
|
||||
type Wopi struct {
|
||||
WopiSrc string `yaml:"wopisrc" env:"COLLABORATION_WOPI_SRC" desc:"The WOPISrc base URL containing schema, host and port. Set this to the schema and domain where the collaboration service is reachable for the wopi app, such as https://office.owncloud.test." introductionVersion:"5.1"`
|
||||
Secret string `yaml:"secret" env:"COLLABORATION_WOPI_SECRET" desc:"Used to mint and verify WOPI JWT tokens and encrypt and decrypt the REVA JWT token embedded in the WOPI JWT token." introductionVersion:"5.1"`
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package config
|
||||
|
||||
// WopiApp defines the available configuration in order to connect to a WOPI app.
|
||||
type WopiApp struct {
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"5.1"`
|
||||
Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"5.1"`
|
||||
}
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
// target WOPI app (onlyoffice, collabora, etc) via their "/hosting/discovery"
|
||||
// endpoint.
|
||||
func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]string, error) {
|
||||
wopiAppUrl := cfg.WopiApp.Addr + "/hosting/discovery"
|
||||
wopiAppUrl := cfg.App.Addr + "/hosting/discovery"
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.WopiApp.Insecure,
|
||||
InsecureSkipVerify: cfg.App.Insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var _ = Describe("Discovery", func() {
|
||||
Describe("GetAppURLs", func() {
|
||||
It("Good discovery URL", func() {
|
||||
cfg := &config.Config{
|
||||
WopiApp: config.WopiApp{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/good",
|
||||
Insecure: true,
|
||||
},
|
||||
@@ -95,7 +95,7 @@ var _ = Describe("Discovery", func() {
|
||||
|
||||
It("Wrong discovery URL", func() {
|
||||
cfg := &config.Config{
|
||||
WopiApp: config.WopiApp{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/bad",
|
||||
Insecure: true,
|
||||
},
|
||||
@@ -109,7 +109,7 @@ var _ = Describe("Discovery", func() {
|
||||
|
||||
It("Not XML formatted", func() {
|
||||
cfg := &config.Config{
|
||||
WopiApp: config.WopiApp{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/wrongformat",
|
||||
Insecure: true,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
// There are no explicit requirements for the context, and it will be passed
|
||||
// without changes to the underlying RegisterService method.
|
||||
func RegisterOcisService(ctx context.Context, cfg *config.Config, logger log.Logger) error {
|
||||
svc := registry.BuildGRPCService(cfg.GRPC.Namespace+"."+cfg.Service.Name, uuid.Must(uuid.NewV4()).String(), cfg.GRPC.Addr, "0.0.0")
|
||||
svc := registry.BuildGRPCService(cfg.GRPC.Namespace+"."+cfg.Service.Name, uuid.Must(uuid.NewV4()).String(), cfg.GRPC.Addr, version.GetString())
|
||||
return registry.RegisterService(ctx, svc, logger)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,11 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
http.TLSConfig(options.Config.HTTP.TLS),
|
||||
http.Logger(options.Logger),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Name("wopi"),
|
||||
http.Name(options.Config.Service.Name),
|
||||
http.Version(version.GetString()),
|
||||
http.Address(options.Config.HTTP.BindAddr),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.TraceProvider(options.TracerProvider),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
@@ -40,7 +41,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
middlewares := []func(stdhttp.Handler) stdhttp.Handler{
|
||||
chimiddleware.RequestID,
|
||||
middleware.Version(
|
||||
"userlog",
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
middleware.Logger(
|
||||
@@ -48,7 +49,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
),
|
||||
middleware.ExtractAccountUUID(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.JWTSecret), // previously, secret came from Config.TokenManager.JWTSecret
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
),
|
||||
/*
|
||||
// Need CORS? not in the original server
|
||||
@@ -68,7 +69,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
|
||||
mux.Use(
|
||||
otelchi.Middleware(
|
||||
"collaboration",
|
||||
options.Config.Service.Name,
|
||||
otelchi.WithChiRoutes(mux),
|
||||
otelchi.WithTracerProvider(options.TracerProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
@@ -77,6 +78,12 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
|
||||
prepareRoutes(mux, options)
|
||||
|
||||
// in debug mode print out the actual routes
|
||||
_ = chi.Walk(mux, func(method string, route string, handler stdhttp.Handler, middlewares ...func(stdhttp.Handler) stdhttp.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), mux); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
@@ -110,7 +117,7 @@ func prepareRoutes(r *chi.Mux, options Options) {
|
||||
|
||||
r.Use(func(h stdhttp.Handler) stdhttp.Handler {
|
||||
// authentication and wopi context
|
||||
return colabmiddleware.WopiContextAuthMiddleware(options.Config.JWTSecret, h)
|
||||
return colabmiddleware.WopiContextAuthMiddleware(options.Config.Wopi.Secret, h)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -123,11 +123,11 @@ func (s *Service) OpenInApp(
|
||||
viewAppURL = editAppURL
|
||||
}
|
||||
|
||||
wopiSrcURL := url.URL{
|
||||
Scheme: s.config.HTTP.Scheme,
|
||||
Host: s.config.HTTP.Addr,
|
||||
Path: path.Join("wopi", "files", fileRef),
|
||||
wopiSrcURL, err := url.Parse(s.config.Wopi.WopiSrc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wopiSrcURL.Path = path.Join("wopi", "files", fileRef)
|
||||
|
||||
addWopiSrcQueryParam := func(baseURL string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
@@ -169,7 +169,7 @@ func (s *Service) OpenInApp(
|
||||
appURL = editAppURL
|
||||
}
|
||||
|
||||
cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.JWTSecret), req.GetAccessToken())
|
||||
cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.Wopi.Secret), req.GetAccessToken())
|
||||
if err != nil {
|
||||
s.logger.Error().
|
||||
Err(err).
|
||||
@@ -184,7 +184,7 @@ func (s *Service) OpenInApp(
|
||||
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: cryptedReqAccessToken,
|
||||
ViewOnlyToken: utils.ReadPlainFromOpaque(req.Opaque, "viewOnlyToken"),
|
||||
ViewOnlyToken: utils.ReadPlainFromOpaque(req.GetOpaque(), "viewOnlyToken"),
|
||||
FileReference: providerFileRef,
|
||||
User: user,
|
||||
ViewMode: req.GetViewMode(),
|
||||
@@ -213,7 +213,7 @@ func (s *Service) OpenInApp(
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessToken, err := token.SignedString([]byte(s.config.JWTSecret))
|
||||
accessToken, err := token.SignedString([]byte(s.config.Wopi.Secret))
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error().
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
func TestService(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Service Suite")
|
||||
}
|
||||
|
||||
@@ -103,8 +103,7 @@ var _ = Describe("Discovery", func() {
|
||||
It("Invalid access token", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg.HTTP.Addr = "wopiserver.test.prv"
|
||||
cfg.HTTP.Scheme = "https"
|
||||
cfg.Wopi.WopiSrc = "https://wopiserver.test.prv"
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
@@ -140,9 +139,8 @@ var _ = Describe("Discovery", func() {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.HTTP.Addr = "wopiserver.test.prv"
|
||||
cfg.HTTP.Scheme = "https"
|
||||
cfg.JWTSecret = "my_supa_secret"
|
||||
cfg.Wopi.WopiSrc = "https://wopiserver.test.prv"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
@@ -163,7 +161,7 @@ var _ = Describe("Discovery", func() {
|
||||
Path: "/path/to/file.docx",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.JWTSecret, nowTime),
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
|
||||
Reference in New Issue
Block a user