mirror of
https://github.com/imgproxy/imgproxy.git
synced 2025-10-10 12:12:40 +02:00
Security -> Checker
This commit is contained in:
@@ -25,7 +25,7 @@ type HandlerContext interface {
|
|||||||
FallbackImage() auximageprovider.Provider
|
FallbackImage() auximageprovider.Provider
|
||||||
WatermarkImage() auximageprovider.Provider
|
WatermarkImage() auximageprovider.Provider
|
||||||
ImageDataFactory() *imagedata.Factory
|
ImageDataFactory() *imagedata.Factory
|
||||||
Security() *security.Security
|
Security() *security.Checker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler handles image processing requests
|
// Handler handles image processing requests
|
||||||
|
@@ -2,11 +2,26 @@ package imagedata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/imgproxy/imgproxy/v3/fetcher"
|
"github.com/imgproxy/imgproxy/v3/fetcher"
|
||||||
"github.com/imgproxy/imgproxy/v3/ierrors"
|
"github.com/imgproxy/imgproxy/v3/ierrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FileSizeError struct{}
|
||||||
|
|
||||||
|
func newFileSizeError() error {
|
||||||
|
return ierrors.Wrap(
|
||||||
|
FileSizeError{},
|
||||||
|
1,
|
||||||
|
ierrors.WithStatusCode(http.StatusUnprocessableEntity),
|
||||||
|
ierrors.WithPublicMessage("Invalid source image"),
|
||||||
|
ierrors.WithShouldReport(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileSizeError) Error() string { return "Source image file is too big" }
|
||||||
|
|
||||||
func wrapDownloadError(err error, desc string) error {
|
func wrapDownloadError(err error, desc string) error {
|
||||||
return ierrors.Wrap(
|
return ierrors.Wrap(
|
||||||
fetcher.WrapError(err), 0,
|
fetcher.WrapError(err), 0,
|
||||||
|
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/imgproxy/imgproxy/v3/asyncbuffer"
|
"github.com/imgproxy/imgproxy/v3/asyncbuffer"
|
||||||
"github.com/imgproxy/imgproxy/v3/fetcher"
|
"github.com/imgproxy/imgproxy/v3/fetcher"
|
||||||
"github.com/imgproxy/imgproxy/v3/imagetype"
|
"github.com/imgproxy/imgproxy/v3/imagetype"
|
||||||
"github.com/imgproxy/imgproxy/v3/security"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Factory represents ImageData factory
|
// Factory represents ImageData factory
|
||||||
@@ -101,7 +100,7 @@ func (f *Factory) sendRequest(ctx context.Context, url string, opts DownloadOpti
|
|||||||
return req, nil, h, err
|
return req, nil, h, err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err = security.LimitResponseSize(res, opts.MaxSrcFileSize)
|
res, err = limitResponseSize(res, opts.MaxSrcFileSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if res != nil {
|
if res != nil {
|
||||||
res.Body.Close()
|
res.Body.Close()
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
package security
|
package imagedata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -28,11 +28,11 @@ func (lr *hardLimitReadCloser) Close() error {
|
|||||||
return lr.r.Close()
|
return lr.r.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitResponseSize limits the size of the response body to MaxSrcFileSize (if set).
|
// limitResponseSize limits the size of the response body to MaxSrcFileSize (if set).
|
||||||
// First, it tries to use Content-Length header to check the limit.
|
// First, it tries to use Content-Length header to check the limit.
|
||||||
// If Content-Length is not set, it limits the size of the response body by wrapping
|
// If Content-Length is not set, it limits the size of the response body by wrapping
|
||||||
// body reader with hard limit reader.
|
// body reader with hard limit reader.
|
||||||
func LimitResponseSize(r *http.Response, limit int) (*http.Response, error) {
|
func limitResponseSize(r *http.Response, limit int) (*http.Response, error) {
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
@@ -40,7 +40,7 @@ type Imgproxy struct {
|
|||||||
fetcher *fetcher.Fetcher
|
fetcher *fetcher.Fetcher
|
||||||
imageDataFactory *imagedata.Factory
|
imageDataFactory *imagedata.Factory
|
||||||
handlers ImgproxyHandlers
|
handlers ImgproxyHandlers
|
||||||
security *security.Security
|
security *security.Checker
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +196,6 @@ func (i *Imgproxy) ImageDataFactory() *imagedata.Factory {
|
|||||||
return i.imageDataFactory
|
return i.imageDataFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imgproxy) Security() *security.Security {
|
func (i *Imgproxy) Security() *security.Checker {
|
||||||
return i.security
|
return i.security
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ package options
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -120,6 +121,31 @@ type ProcessingOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewProcessingOptions() *ProcessingOptions {
|
func NewProcessingOptions() *ProcessingOptions {
|
||||||
|
// NOTE: This is temporary hack until ProcessingOptions does not have Factory
|
||||||
|
securityCfg, err := security.LoadConfigFromEnv(nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This is a temporary workaround for logrus bug that deadlocks
|
||||||
|
// if log is used within another log (issue 1448)
|
||||||
|
if len(securityCfg.Salts) == 0 {
|
||||||
|
securityCfg.Salts = [][]byte{[]byte("logrusbugworkaround")}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(securityCfg.Keys) == 0 {
|
||||||
|
securityCfg.Keys = [][]byte{[]byte("logrusbugworkaround")}
|
||||||
|
}
|
||||||
|
// END OF WORKAROUND
|
||||||
|
|
||||||
|
security, err := security.New(securityCfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
securityOptions := security.NewOptions()
|
||||||
|
// NOTE: This is temporary hack until ProcessingOptions does not have Factory
|
||||||
|
|
||||||
po := ProcessingOptions{
|
po := ProcessingOptions{
|
||||||
ResizingType: ResizeFit,
|
ResizingType: ResizeFit,
|
||||||
Width: 0,
|
Width: 0,
|
||||||
@@ -151,7 +177,7 @@ func NewProcessingOptions() *ProcessingOptions {
|
|||||||
SkipProcessingFormats: append([]imagetype.Type(nil), config.SkipProcessingFormats...),
|
SkipProcessingFormats: append([]imagetype.Type(nil), config.SkipProcessingFormats...),
|
||||||
UsedPresets: make([]string, 0, len(config.Presets)),
|
UsedPresets: make([]string, 0, len(config.Presets)),
|
||||||
|
|
||||||
SecurityOptions: security.DefaultOptions(),
|
SecurityOptions: securityOptions,
|
||||||
|
|
||||||
// Basically, we need this to update ETag when `IMGPROXY_QUALITY` is changed
|
// Basically, we need this to update ETag when `IMGPROXY_QUALITY` is changed
|
||||||
defaultQuality: config.Quality,
|
defaultQuality: config.Quality,
|
||||||
|
22
security/checker.go
Normal file
22
security/checker.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
// Checker represents the security package instance
|
||||||
|
type Checker struct {
|
||||||
|
config *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Security instance
|
||||||
|
func New(config *Config) (*Checker, error) {
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Checker{
|
||||||
|
config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOptions creates a new security.Options instance
|
||||||
|
func (s *Checker) NewOptions() Options {
|
||||||
|
return s.config.DefaultOptions
|
||||||
|
}
|
@@ -6,72 +6,31 @@ import (
|
|||||||
|
|
||||||
"github.com/imgproxy/imgproxy/v3/config"
|
"github.com/imgproxy/imgproxy/v3/config"
|
||||||
"github.com/imgproxy/imgproxy/v3/ensure"
|
"github.com/imgproxy/imgproxy/v3/ensure"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OptionsConfig represents the configuration for processing limits and security options
|
|
||||||
type OptionsConfig struct {
|
|
||||||
MaxSrcResolution int // Maximum allowed source image resolution (width × height)
|
|
||||||
MaxSrcFileSize int // Maximum allowed source file size in bytes (0 = unlimited)
|
|
||||||
MaxAnimationFrames int // Maximum number of frames allowed in animated images
|
|
||||||
MaxAnimationFrameResolution int // Maximum resolution allowed for each frame in animated images (0 = unlimited)
|
|
||||||
MaxResultDimension int // Maximum allowed dimension (width or height) for result images (0 = unlimited)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDefaultOptionsConfig returns a new OptionsConfig instance with default values
|
|
||||||
func NewDefaultOptionsConfig() OptionsConfig {
|
|
||||||
return OptionsConfig{
|
|
||||||
MaxSrcResolution: 50000000,
|
|
||||||
MaxSrcFileSize: 0,
|
|
||||||
MaxAnimationFrames: 1,
|
|
||||||
MaxAnimationFrameResolution: 0,
|
|
||||||
MaxResultDimension: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadOptionsConfigFromEnv loads OptionsConfig from global config variables
|
|
||||||
func LoadOptionsConfigFromEnv(c *OptionsConfig) (*OptionsConfig, error) {
|
|
||||||
c.MaxSrcResolution = config.MaxSrcResolution
|
|
||||||
c.MaxSrcFileSize = config.MaxSrcFileSize
|
|
||||||
c.MaxAnimationFrames = config.MaxAnimationFrames
|
|
||||||
c.MaxAnimationFrameResolution = config.MaxAnimationFrameResolution
|
|
||||||
c.MaxResultDimension = config.MaxResultDimension
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate validates the OptionsConfig values
|
|
||||||
func (c *OptionsConfig) Validate() error {
|
|
||||||
if c.MaxSrcResolution <= 0 {
|
|
||||||
return fmt.Errorf("max src resolution should be greater than 0, now - %d", c.MaxSrcResolution)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxSrcFileSize < 0 {
|
|
||||||
return fmt.Errorf("max src file size should be greater than or equal to 0, now - %d", c.MaxSrcFileSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxAnimationFrames <= 0 {
|
|
||||||
return fmt.Errorf("max animation frames should be greater than 0, now - %d", c.MaxAnimationFrames)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is the package-local configuration
|
// Config is the package-local configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Options OptionsConfig // Processing limits and security options
|
|
||||||
AllowSecurityOptions bool // Whether to allow security-related processing options in URLs
|
AllowSecurityOptions bool // Whether to allow security-related processing options in URLs
|
||||||
AllowedSources []*regexp.Regexp // List of allowed source URL patterns (empty = allow all)
|
AllowedSources []*regexp.Regexp // List of allowed source URL patterns (empty = allow all)
|
||||||
Keys [][]byte // List of the HMAC keys
|
Keys [][]byte // List of the HMAC keys
|
||||||
Salts [][]byte // List of the HMAC salts
|
Salts [][]byte // List of the HMAC salts
|
||||||
SignatureSize int // Size of the HMAC signature in bytes
|
SignatureSize int // Size of the HMAC signature in bytes
|
||||||
TrustedSignatures []string // List of trusted signature sources
|
TrustedSignatures []string // List of trusted signature sources
|
||||||
|
DefaultOptions Options // Default security options
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDefaultConfig returns a new Config instance with default values.
|
// NewDefaultConfig returns a new Config instance with default values.
|
||||||
func NewDefaultConfig() Config {
|
func NewDefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Options: NewDefaultOptionsConfig(),
|
DefaultOptions: Options{
|
||||||
|
MaxSrcResolution: 50000000,
|
||||||
|
MaxSrcFileSize: 0,
|
||||||
|
MaxAnimationFrames: 1,
|
||||||
|
MaxAnimationFrameResolution: 0,
|
||||||
|
MaxResultDimension: 0,
|
||||||
|
},
|
||||||
AllowSecurityOptions: false,
|
AllowSecurityOptions: false,
|
||||||
SignatureSize: 32,
|
SignatureSize: 32,
|
||||||
}
|
}
|
||||||
@@ -81,10 +40,6 @@ func NewDefaultConfig() Config {
|
|||||||
func LoadConfigFromEnv(c *Config) (*Config, error) {
|
func LoadConfigFromEnv(c *Config) (*Config, error) {
|
||||||
c = ensure.Ensure(c, NewDefaultConfig)
|
c = ensure.Ensure(c, NewDefaultConfig)
|
||||||
|
|
||||||
if _, err := LoadOptionsConfigFromEnv(&c.Options); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.AllowSecurityOptions = config.AllowSecurityOptions
|
c.AllowSecurityOptions = config.AllowSecurityOptions
|
||||||
c.AllowedSources = config.AllowedSources
|
c.AllowedSources = config.AllowedSources
|
||||||
c.Keys = config.Keys
|
c.Keys = config.Keys
|
||||||
@@ -92,13 +47,27 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
|
|||||||
c.SignatureSize = config.SignatureSize
|
c.SignatureSize = config.SignatureSize
|
||||||
c.TrustedSignatures = config.TrustedSignatures
|
c.TrustedSignatures = config.TrustedSignatures
|
||||||
|
|
||||||
|
c.DefaultOptions.MaxSrcResolution = config.MaxSrcResolution
|
||||||
|
c.DefaultOptions.MaxSrcFileSize = config.MaxSrcFileSize
|
||||||
|
c.DefaultOptions.MaxAnimationFrames = config.MaxAnimationFrames
|
||||||
|
c.DefaultOptions.MaxAnimationFrameResolution = config.MaxAnimationFrameResolution
|
||||||
|
c.DefaultOptions.MaxResultDimension = config.MaxResultDimension
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the configuration
|
// Validate validates the configuration
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if err := c.Options.Validate(); err != nil {
|
if c.DefaultOptions.MaxSrcResolution <= 0 {
|
||||||
return err
|
return fmt.Errorf("max src resolution should be greater than 0, now - %d", c.DefaultOptions.MaxSrcResolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DefaultOptions.MaxSrcFileSize < 0 {
|
||||||
|
return fmt.Errorf("max src file size should be greater than or equal to 0, now - %d", c.DefaultOptions.MaxSrcFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DefaultOptions.MaxAnimationFrames <= 0 {
|
||||||
|
return fmt.Errorf("max animation frames should be greater than 0, now - %d", c.DefaultOptions.MaxAnimationFrames)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.Keys) != len(c.Salts) {
|
if len(c.Keys) != len(c.Salts) {
|
||||||
|
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
SignatureError string
|
SignatureError string
|
||||||
FileSizeError struct{}
|
|
||||||
ImageResolutionError string
|
ImageResolutionError string
|
||||||
SecurityOptionsError struct{}
|
SecurityOptionsError struct{}
|
||||||
SourceURLError string
|
SourceURLError string
|
||||||
@@ -27,18 +26,6 @@ func newSignatureError(msg string) error {
|
|||||||
|
|
||||||
func (e SignatureError) Error() string { return string(e) }
|
func (e SignatureError) Error() string { return string(e) }
|
||||||
|
|
||||||
func newFileSizeError() error {
|
|
||||||
return ierrors.Wrap(
|
|
||||||
FileSizeError{},
|
|
||||||
1,
|
|
||||||
ierrors.WithStatusCode(http.StatusUnprocessableEntity),
|
|
||||||
ierrors.WithPublicMessage("Invalid source image"),
|
|
||||||
ierrors.WithShouldReport(false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e FileSizeError) Error() string { return "Source image file is too big" }
|
|
||||||
|
|
||||||
func newImageResolutionError(msg string) error {
|
func newImageResolutionError(msg string) error {
|
||||||
return ierrors.Wrap(
|
return ierrors.Wrap(
|
||||||
ImageResolutionError(msg),
|
ImageResolutionError(msg),
|
||||||
|
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/imgproxy/imgproxy/v3/config"
|
"github.com/imgproxy/imgproxy/v3/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Security options (part of processing options)
|
||||||
type Options struct {
|
type Options struct {
|
||||||
MaxSrcResolution int
|
MaxSrcResolution int
|
||||||
MaxSrcFileSize int
|
MaxSrcFileSize int
|
||||||
@@ -12,18 +13,7 @@ type Options struct {
|
|||||||
MaxResultDimension int
|
MaxResultDimension int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Remove this function in imgproxy v4
|
// NOTE: This function is a part of processing option, we'll move it in the next PR
|
||||||
// TODO: Replace this with security.NewOptions() when ProcessingOptions gets config
|
|
||||||
func DefaultOptions() Options {
|
|
||||||
return Options{
|
|
||||||
MaxSrcResolution: config.MaxSrcResolution,
|
|
||||||
MaxSrcFileSize: config.MaxSrcFileSize,
|
|
||||||
MaxAnimationFrames: config.MaxAnimationFrames,
|
|
||||||
MaxAnimationFrameResolution: config.MaxAnimationFrameResolution,
|
|
||||||
MaxResultDimension: config.MaxResultDimension,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsSecurityOptionsAllowed() error {
|
func IsSecurityOptionsAllowed() error {
|
||||||
if config.AllowSecurityOptions {
|
if config.AllowSecurityOptions {
|
||||||
return nil
|
return nil
|
||||||
|
@@ -1,28 +0,0 @@
|
|||||||
package security
|
|
||||||
|
|
||||||
// Security represents the security package instance
|
|
||||||
type Security struct {
|
|
||||||
config *Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Security instance
|
|
||||||
func New(config *Config) (*Security, error) {
|
|
||||||
if err := config.Validate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Security{
|
|
||||||
config: config,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOptions creates a new security.Options instance
|
|
||||||
func (s *Security) NewOptions() Options {
|
|
||||||
return Options{
|
|
||||||
MaxSrcResolution: s.config.Options.MaxSrcResolution,
|
|
||||||
MaxSrcFileSize: s.config.Options.MaxSrcFileSize,
|
|
||||||
MaxAnimationFrames: s.config.Options.MaxAnimationFrames,
|
|
||||||
MaxAnimationFrameResolution: s.config.Options.MaxAnimationFrameResolution,
|
|
||||||
MaxResultDimension: s.config.Options.MaxResultDimension,
|
|
||||||
}
|
|
||||||
}
|
|
@@ -7,7 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Security) VerifySignature(signature, path string) error {
|
func (s *Checker) VerifySignature(signature, path string) error {
|
||||||
if len(s.config.Keys) == 0 || len(s.config.Salts) == 0 {
|
if len(s.config.Keys) == 0 || len(s.config.Salts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -13,7 +13,7 @@ type SignatureTestSuite struct {
|
|||||||
testutil.LazySuite
|
testutil.LazySuite
|
||||||
|
|
||||||
config testutil.LazyObj[*Config]
|
config testutil.LazyObj[*Config]
|
||||||
security testutil.LazyObj[*Security]
|
security testutil.LazyObj[*Checker]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SignatureTestSuite) SetupSuite() {
|
func (s *SignatureTestSuite) SetupSuite() {
|
||||||
@@ -27,7 +27,7 @@ func (s *SignatureTestSuite) SetupSuite() {
|
|||||||
|
|
||||||
s.security, _ = testutil.NewLazySuiteObj(
|
s.security, _ = testutil.NewLazySuiteObj(
|
||||||
s,
|
s,
|
||||||
func() (*Security, error) {
|
func() (*Checker, error) {
|
||||||
return New(s.config())
|
return New(s.config())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@@ -2,7 +2,7 @@ package security
|
|||||||
|
|
||||||
// VerifySourceURL checks if the given imageURL is allowed based on
|
// VerifySourceURL checks if the given imageURL is allowed based on
|
||||||
// the configured AllowedSources.
|
// the configured AllowedSources.
|
||||||
func (s *Security) VerifySourceURL(imageURL string) error {
|
func (s *Checker) VerifySourceURL(imageURL string) error {
|
||||||
if len(s.config.AllowedSources) == 0 {
|
if len(s.config.AllowedSources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user