mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-30 21:55:43 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			6647c6cd78
			...
			dependabot
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 53473509f8 | ||
|   | c5bb389258 | 
| @@ -1,5 +1,5 @@ | |||||||
| # Site builder | # Site builder | ||||||
| FROM oven/bun:1.3.0-alpine AS frontend-builder | FROM oven/bun:1.3.1-alpine AS frontend-builder | ||||||
|  |  | ||||||
| WORKDIR /frontend | WORKDIR /frontend | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| # Site builder | # Site builder | ||||||
| FROM oven/bun:1.3.0-alpine AS frontend-builder | FROM oven/bun:1.3.1-alpine AS frontend-builder | ||||||
|  |  | ||||||
| WORKDIR /frontend | WORKDIR /frontend | ||||||
|  |  | ||||||
|   | |||||||
| @@ -139,12 +139,14 @@ func (app *BootstrapApp) Setup() error { | |||||||
|  |  | ||||||
| 	// Create services | 	// Create services | ||||||
| 	dockerService := service.NewDockerService() | 	dockerService := service.NewDockerService() | ||||||
|  | 	aclsService := service.NewAccessControlsService(dockerService) | ||||||
| 	authService := service.NewAuthService(authConfig, dockerService, ldapService, database) | 	authService := service.NewAuthService(authConfig, dockerService, ldapService, database) | ||||||
| 	oauthBrokerService := service.NewOAuthBrokerService(oauthProviders) | 	oauthBrokerService := service.NewOAuthBrokerService(oauthProviders) | ||||||
|  |  | ||||||
| 	// Initialize services | 	// Initialize services (order matters) | ||||||
| 	services := []Service{ | 	services := []Service{ | ||||||
| 		dockerService, | 		dockerService, | ||||||
|  | 		aclsService, | ||||||
| 		authService, | 		authService, | ||||||
| 		oauthBrokerService, | 		oauthBrokerService, | ||||||
| 	} | 	} | ||||||
| @@ -246,7 +248,7 @@ func (app *BootstrapApp) Setup() error { | |||||||
|  |  | ||||||
| 	proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ | 	proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ | ||||||
| 		AppURL: app.config.AppURL, | 		AppURL: app.config.AppURL, | ||||||
| 	}, apiRouter, dockerService, authService) | 	}, apiRouter, aclsService, authService) | ||||||
|  |  | ||||||
| 	userController := controller.NewUserController(controller.UserControllerConfig{ | 	userController := controller.NewUserController(controller.UserControllerConfig{ | ||||||
| 		CookieDomain: cookieDomain, | 		CookieDomain: cookieDomain, | ||||||
|   | |||||||
| @@ -24,15 +24,15 @@ type ProxyControllerConfig struct { | |||||||
| type ProxyController struct { | type ProxyController struct { | ||||||
| 	config ProxyControllerConfig | 	config ProxyControllerConfig | ||||||
| 	router *gin.RouterGroup | 	router *gin.RouterGroup | ||||||
| 	docker *service.DockerService | 	acls   *service.AccessControlsService | ||||||
| 	auth   *service.AuthService | 	auth   *service.AuthService | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, docker *service.DockerService, auth *service.AuthService) *ProxyController { | func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController { | ||||||
| 	return &ProxyController{ | 	return &ProxyController{ | ||||||
| 		config: config, | 		config: config, | ||||||
| 		router: router, | 		router: router, | ||||||
| 		docker: docker, | 		acls:   acls, | ||||||
| 		auth:   auth, | 		auth:   auth, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| @@ -76,20 +76,21 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 	proto := c.Request.Header.Get("X-Forwarded-Proto") | 	proto := c.Request.Header.Get("X-Forwarded-Proto") | ||||||
| 	host := c.Request.Header.Get("X-Forwarded-Host") | 	host := c.Request.Header.Get("X-Forwarded-Host") | ||||||
|  |  | ||||||
| 	labels, err := controller.docker.GetLabels(host) | 	// Get acls | ||||||
|  | 	acls, err := controller.acls.GetAccessControls(host) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to get labels from Docker") | 		log.Error().Err(err).Msg("Failed to get access controls for resource") | ||||||
| 		controller.handleError(c, req, isBrowser) | 		controller.handleError(c, req, isBrowser) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.Trace().Interface("labels", labels).Msg("Labels for resource") | 	log.Trace().Interface("acls", acls).Msg("ACLs for resource") | ||||||
|  |  | ||||||
| 	clientIP := c.ClientIP() | 	clientIP := c.ClientIP() | ||||||
|  |  | ||||||
| 	if controller.auth.IsBypassedIP(labels.IP, clientIP) { | 	if controller.auth.IsBypassedIP(acls.IP, clientIP) { | ||||||
| 		controller.setHeaders(c, labels) | 		controller.setHeaders(c, acls) | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| 			"message": "Authenticated", | 			"message": "Authenticated", | ||||||
| @@ -97,7 +98,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	authEnabled, err := controller.auth.IsAuthEnabled(uri, labels.Path) | 	authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") | 		log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") | ||||||
| @@ -107,7 +108,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
|  |  | ||||||
| 	if !authEnabled { | 	if !authEnabled { | ||||||
| 		log.Debug().Msg("Authentication disabled for resource, allowing access") | 		log.Debug().Msg("Authentication disabled for resource, allowing access") | ||||||
| 		controller.setHeaders(c, labels) | 		controller.setHeaders(c, acls) | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| 			"message": "Authenticated", | 			"message": "Authenticated", | ||||||
| @@ -115,7 +116,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !controller.auth.CheckIP(labels.IP, clientIP) { | 	if !controller.auth.CheckIP(acls.IP, clientIP) { | ||||||
| 		if req.Proxy == "nginx" || !isBrowser { | 		if req.Proxy == "nginx" || !isBrowser { | ||||||
| 			c.JSON(401, gin.H{ | 			c.JSON(401, gin.H{ | ||||||
| 				"status":  401, | 				"status":  401, | ||||||
| @@ -160,7 +161,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if userContext.IsLoggedIn { | 	if userContext.IsLoggedIn { | ||||||
| 		appAllowed := controller.auth.IsResourceAllowed(c, userContext, labels) | 		appAllowed := controller.auth.IsResourceAllowed(c, userContext, acls) | ||||||
|  |  | ||||||
| 		if !appAllowed { | 		if !appAllowed { | ||||||
| 			log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") | 			log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") | ||||||
| @@ -194,7 +195,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if userContext.OAuth { | 		if userContext.OAuth { | ||||||
| 			groupOK := controller.auth.IsInOAuthGroup(c, userContext, labels.OAuth.Groups) | 			groupOK := controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups) | ||||||
|  |  | ||||||
| 			if !groupOK { | 			if !groupOK { | ||||||
| 				log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") | 				log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") | ||||||
| @@ -234,7 +235,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 		c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) | 		c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) | ||||||
| 		c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) | 		c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) | ||||||
|  |  | ||||||
| 		controller.setHeaders(c, labels) | 		controller.setHeaders(c, acls) | ||||||
|  |  | ||||||
| 		c.JSON(200, gin.H{ | 		c.JSON(200, gin.H{ | ||||||
| 			"status":  200, | 			"status":  200, | ||||||
| @@ -264,21 +265,21 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { | |||||||
| 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) | 	c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (controller *ProxyController) setHeaders(c *gin.Context, labels config.App) { | func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) { | ||||||
| 	c.Header("Authorization", c.Request.Header.Get("Authorization")) | 	c.Header("Authorization", c.Request.Header.Get("Authorization")) | ||||||
|  |  | ||||||
| 	headers := utils.ParseHeaders(labels.Response.Headers) | 	headers := utils.ParseHeaders(acls.Response.Headers) | ||||||
|  |  | ||||||
| 	for key, value := range headers { | 	for key, value := range headers { | ||||||
| 		log.Debug().Str("header", key).Msg("Setting header") | 		log.Debug().Str("header", key).Msg("Setting header") | ||||||
| 		c.Header(key, value) | 		c.Header(key, value) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	basicPassword := utils.GetSecret(labels.Response.BasicAuth.Password, labels.Response.BasicAuth.PasswordFile) | 	basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile) | ||||||
|  |  | ||||||
| 	if labels.Response.BasicAuth.Username != "" && basicPassword != "" { | 	if acls.Response.BasicAuth.Username != "" && basicPassword != "" { | ||||||
| 		log.Debug().Str("username", labels.Response.BasicAuth.Username).Msg("Setting basic auth header") | 		log.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header") | ||||||
| 		c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Response.BasicAuth.Username, basicPassword))) | 		c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword))) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -39,6 +39,11 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En | |||||||
|  |  | ||||||
| 	assert.NilError(t, dockerService.Init()) | 	assert.NilError(t, dockerService.Init()) | ||||||
|  |  | ||||||
|  | 	// Access controls | ||||||
|  | 	accessControlsService := service.NewAccessControlsService(dockerService) | ||||||
|  |  | ||||||
|  | 	assert.NilError(t, accessControlsService.Init()) | ||||||
|  |  | ||||||
| 	// Auth service | 	// Auth service | ||||||
| 	authService := service.NewAuthService(service.AuthServiceConfig{ | 	authService := service.NewAuthService(service.AuthServiceConfig{ | ||||||
| 		Users: []config.User{ | 		Users: []config.User{ | ||||||
| @@ -59,7 +64,7 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En | |||||||
| 	// Controller | 	// Controller | ||||||
| 	ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ | 	ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ | ||||||
| 		AppURL: "http://localhost:8080", | 		AppURL: "http://localhost:8080", | ||||||
| 	}, group, dockerService, authService) | 	}, group, accessControlsService, authService) | ||||||
| 	ctrl.SetupRoutes() | 	ctrl.SetupRoutes() | ||||||
|  |  | ||||||
| 	return router, recorder, authService | 	return router, recorder, authService | ||||||
|   | |||||||
							
								
								
									
										103
									
								
								internal/service/access_controls_service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								internal/service/access_controls_service.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | package service | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"os" | ||||||
|  | 	"strings" | ||||||
|  | 	"tinyauth/internal/config" | ||||||
|  | 	"tinyauth/internal/utils/decoders" | ||||||
|  |  | ||||||
|  | 	"github.com/rs/zerolog/log" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type AccessControlsService struct { | ||||||
|  | 	docker  *DockerService | ||||||
|  | 	envACLs config.Apps | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewAccessControlsService(docker *DockerService) *AccessControlsService { | ||||||
|  | 	return &AccessControlsService{ | ||||||
|  | 		docker: docker, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (acls *AccessControlsService) Init() error { | ||||||
|  | 	acls.envACLs = config.Apps{} | ||||||
|  | 	env := os.Environ() | ||||||
|  | 	appEnvVars := []string{} | ||||||
|  |  | ||||||
|  | 	for _, e := range env { | ||||||
|  | 		if strings.HasPrefix(e, "TINYAUTH_APPS_") { | ||||||
|  | 			appEnvVars = append(appEnvVars, e) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err := acls.loadEnvACLs(appEnvVars) | ||||||
|  |  | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error { | ||||||
|  | 	if len(appEnvVars) == 0 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	envAcls := map[string]string{} | ||||||
|  |  | ||||||
|  | 	for _, e := range appEnvVars { | ||||||
|  | 		parts := strings.SplitN(e, "=", 2) | ||||||
|  | 		if len(parts) != 2 { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Normalize key, this should use the same normalization logic as in utils/decoders/decoders.go | ||||||
|  | 		key := parts[0] | ||||||
|  | 		key = strings.ToLower(key) | ||||||
|  | 		key = strings.ReplaceAll(key, "_", ".") | ||||||
|  | 		value := parts[1] | ||||||
|  | 		envAcls[key] = value | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	apps, err := decoders.DecodeLabels(envAcls) | ||||||
|  |  | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	acls.envACLs = apps | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App { | ||||||
|  | 	if len(acls.envACLs.Apps) == 0 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for appName, appACLs := range acls.envACLs.Apps { | ||||||
|  | 		if appACLs.Config.Domain == appDomain { | ||||||
|  | 			return &appACLs | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if strings.SplitN(appDomain, ".", 2)[0] == appName { | ||||||
|  | 			return &appACLs | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) { | ||||||
|  | 	// First check environment variables | ||||||
|  | 	envACLs := acls.lookupEnvACLs(appDomain) | ||||||
|  |  | ||||||
|  | 	if envACLs != nil { | ||||||
|  | 		log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables") | ||||||
|  | 		return *envACLs, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Fallback to Docker labels | ||||||
|  | 	return acls.docker.GetLabels(appDomain) | ||||||
|  | } | ||||||
| @@ -289,21 +289,21 @@ func (auth *AuthService) UserAuthConfigured() bool { | |||||||
| 	return len(auth.config.Users) > 0 || auth.ldap != nil | 	return len(auth.config.Users) > 0 || auth.ldap != nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.App) bool { | func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, acls config.App) bool { | ||||||
| 	if context.OAuth { | 	if context.OAuth { | ||||||
| 		log.Debug().Msg("Checking OAuth whitelist") | 		log.Debug().Msg("Checking OAuth whitelist") | ||||||
| 		return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) | 		return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if labels.Users.Block != "" { | 	if acls.Users.Block != "" { | ||||||
| 		log.Debug().Msg("Checking blocked users") | 		log.Debug().Msg("Checking blocked users") | ||||||
| 		if utils.CheckFilter(labels.Users.Block, context.Username) { | 		if utils.CheckFilter(acls.Users.Block, context.Username) { | ||||||
| 			return false | 			return false | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.Debug().Msg("Checking users") | 	log.Debug().Msg("Checking users") | ||||||
| 	return utils.CheckFilter(labels.Users.Allow, context.Username) | 	return utils.CheckFilter(acls.Users.Allow, context.Username) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool { | func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool { | ||||||
| @@ -371,8 +371,8 @@ func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool { | ||||||
| 	for _, blocked := range labels.Block { | 	for _, blocked := range acls.Block { | ||||||
| 		res, err := utils.FilterIP(blocked, ip) | 		res, err := utils.FilterIP(blocked, ip) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") | 			log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") | ||||||
| @@ -384,7 +384,7 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, allowed := range labels.Allow { | 	for _, allowed := range acls.Allow { | ||||||
| 		res, err := utils.FilterIP(allowed, ip) | 		res, err := utils.FilterIP(allowed, ip) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") | 			log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") | ||||||
| @@ -396,7 +396,7 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if len(labels.Allow) > 0 { | 	if len(acls.Allow) > 0 { | ||||||
| 		log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") | 		log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") | ||||||
| 		return false | 		return false | ||||||
| 	} | 	} | ||||||
| @@ -405,8 +405,8 @@ func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { | |||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
|  |  | ||||||
| func (auth *AuthService) IsBypassedIP(labels config.AppIP, ip string) bool { | func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool { | ||||||
| 	for _, bypassed := range labels.Bypass { | 	for _, bypassed := range acls.Bypass { | ||||||
| 		res, err := utils.FilterIP(bypassed, ip) | 		res, err := utils.FilterIP(bypassed, ip) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") | 			log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user