Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fec8f1c49 | ||
|
|
223494141a | ||
|
|
55d06f8bab | ||
|
|
c920b9fb39 | ||
|
|
e4890c5ccf | ||
|
|
6c956f2f0d |
@@ -34,6 +34,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(),
|
||||
EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(),
|
||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||
|
||||
@@ -76,6 +76,55 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
|
||||
return store
|
||||
}
|
||||
|
||||
func initDemoData(
|
||||
store portainer.DataStore,
|
||||
cryptoService portainer.CryptoService,
|
||||
) error {
|
||||
password, err := cryptoService.Hash("tryportainer")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin := &portainer.User{
|
||||
Username: "admin",
|
||||
Password: password,
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
|
||||
err = store.User().CreateUser(admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEndpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(1),
|
||||
Name: "local",
|
||||
URL: "unix:///var/run/docker.sock",
|
||||
PublicURL: "demo.portainer.io",
|
||||
Type: portainer.DockerEnvironment,
|
||||
GroupID: portainer.EndpointGroupID(1),
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: []portainer.TagID{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
Kubernetes: portainer.KubernetesDefault(),
|
||||
}
|
||||
|
||||
err = store.Endpoint().CreateEndpoint(localEndpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, proxyManager)
|
||||
if composeWrapper != nil {
|
||||
@@ -165,7 +214,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
|
||||
settings.LogoURL = *flags.Logo
|
||||
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||
settings.EnableTelemetry = true
|
||||
settings.EnableTelemetry = false
|
||||
settings.OAuthSettings.SSO = true
|
||||
|
||||
if *flags.Templates != "" {
|
||||
@@ -367,6 +416,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
err = initDemoData(dataStore, cryptoService)
|
||||
if err != nil {
|
||||
log.Printf("error init demo: %v", err)
|
||||
}
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
err = initKeyPair(fileService, digitalSignatureService)
|
||||
@@ -471,6 +525,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
BindAddress: *flags.Addr,
|
||||
AssetsPath: *flags.Assets,
|
||||
DataStore: dataStore,
|
||||
DemoEnvironment: *flags.DemoEnvironment,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
KubernetesDeployer: kubernetesDeployer,
|
||||
|
||||
@@ -9,4 +9,6 @@ var (
|
||||
ErrUnauthorized = errors.New("Unauthorized")
|
||||
// ErrResourceAccessDenied Access denied to resource error
|
||||
ErrResourceAccessDenied = errors.New("Access denied to resource")
|
||||
// ErrNotAvailableInDemo feature is not allowed in demo
|
||||
ErrNotAvailableInDemo = errors.New("This feature is not available in the demo version of Portainer")
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T)
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
adminMonitor := adminmonitor.New(time.Hour, nil, context.Background())
|
||||
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, false).backup(w, r)
|
||||
assert.Nil(t, handlerErr, "Handler should not fail")
|
||||
|
||||
response := w.Result()
|
||||
@@ -86,7 +86,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
adminMonitor := adminmonitor.New(time.Hour, nil, nil)
|
||||
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, false).backup(w, r)
|
||||
assert.Nil(t, handlerErr, "Handler should not fail")
|
||||
|
||||
response := w.Result()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/adminmonitor"
|
||||
@@ -24,7 +25,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// NewHandler creates an new instance of backup handler
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor, isDemo bool) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
bouncer: bouncer,
|
||||
@@ -35,8 +36,8 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore,
|
||||
adminMonitor: adminMonitor,
|
||||
}
|
||||
|
||||
h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
|
||||
h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
|
||||
h.Handle("/backup", restrictDemoEnv(isDemo, bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup))))).Methods(http.MethodPost)
|
||||
h.Handle("/restore", restrictDemoEnv(isDemo, bouncer.PublicAccess(httperror.LoggerHandler(h.restore)))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -46,20 +47,26 @@ func adminAccess(next http.Handler) http.Handler {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user info from request context", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perfom the action", nil)
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perform the action", nil)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func systemWasInitialized(dataStore portainer.DataStore) (bool, error) {
|
||||
users, err := dataStore.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(users) > 0, nil
|
||||
// restrict backup functionality on demo environments
|
||||
func restrictDemoEnv(isDemo bool, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isDemo {
|
||||
httperror.WriteError(w, http.StatusBadRequest, "This feature is not available in the demo version of Portainer", errors.New("this feature is not available in the demo version of Portainer"))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
35
api/http/handler/backup/handler_test.go
Normal file
35
api/http/handler/backup/handler_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_demoEnvironment_shouldFail(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
restrictDemoEnv(true, http.DefaultServeMux).ServeHTTP(w, r)
|
||||
|
||||
response := w.Result()
|
||||
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
assert.Contains(t, string(body), "This feature is not available in the demo version of Portainer")
|
||||
}
|
||||
|
||||
func Test_notDemoEnvironment_shouldSucceed(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
restrictDemoEnv(false, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {})).ServeHTTP(w, r)
|
||||
|
||||
response := w.Result()
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}), i.WithEdgeJobs([]portainer.EdgeJob{}))
|
||||
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
|
||||
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, false)
|
||||
|
||||
//backup
|
||||
archive := backup(t, h, test.backupPassword)
|
||||
@@ -74,7 +74,7 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{admin}), i.WithEdgeJobs([]portainer.EdgeJob{}))
|
||||
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
|
||||
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, false)
|
||||
|
||||
//backup
|
||||
archive := backup(t, h, "password")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
)
|
||||
|
||||
// @id EndpointDelete
|
||||
@@ -29,6 +30,10 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
if endpointID == 1 || endpointID == 2 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", httperrors.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == errors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
|
||||
@@ -81,6 +81,9 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||
}
|
||||
|
||||
*payload.EnableTelemetry = false
|
||||
*payload.LogoURL = ""
|
||||
|
||||
if payload.AuthenticationMethod != nil {
|
||||
settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,10 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", httperrors.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
if tokenData.ID == portainer.UserID(userID) {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", errAdminCannotRemoveSelf}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", httperrors.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
|
||||
@@ -53,6 +53,10 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", httperrors.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
|
||||
@@ -70,6 +70,7 @@ type Server struct {
|
||||
ProxyManager *proxy.Manager
|
||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
Handler *handler.Handler
|
||||
DemoEnvironment bool
|
||||
SSL bool
|
||||
SSLCert string
|
||||
SSLKey string
|
||||
@@ -101,7 +102,7 @@ func (server *Server) Start() error {
|
||||
adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, server.ShutdownCtx)
|
||||
adminMonitor.Start()
|
||||
|
||||
var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor)
|
||||
var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor, server.DemoEnvironment)
|
||||
|
||||
var roleHandler = roles.NewHandler(requestBouncer)
|
||||
roleHandler.DataStore = server.DataStore
|
||||
|
||||
@@ -47,6 +47,7 @@ type (
|
||||
AdminPasswordFile *string
|
||||
Assets *string
|
||||
Data *string
|
||||
DemoEnvironment *bool
|
||||
EnableEdgeComputeFeatures *bool
|
||||
EndpointURL *string
|
||||
Labels *[]Pair
|
||||
|
||||
@@ -9,12 +9,20 @@ angular.module('portainer.app').factory('Backup', [
|
||||
{
|
||||
download: {
|
||||
method: 'POST',
|
||||
responseType: 'blob',
|
||||
responseType: 'arraybuffer',
|
||||
ignoreLoadingBar: true,
|
||||
transformResponse: (data, headersGetter) => ({
|
||||
file: data,
|
||||
name: headersGetter('Content-Disposition').replace('attachment; filename=', ''),
|
||||
}),
|
||||
transformResponse: (data, headersGetter, status) => {
|
||||
if (status !== 200) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const str = decoder.decode(data);
|
||||
return JSON.parse(str);
|
||||
}
|
||||
|
||||
return {
|
||||
file: data,
|
||||
name: headersGetter('Content-Disposition').replace('attachment; filename=', ''),
|
||||
};
|
||||
},
|
||||
},
|
||||
getS3Settings: { method: 'GET', params: { subResource: 's3', action: 'settings' } },
|
||||
saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } },
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
<rd-header-content>User settings</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="UserId === 1">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Feature not available"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<span class="small text-muted">You cannot change the password of this account in the demo version of Portainer.</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
|
||||
@@ -237,7 +237,6 @@ class CustomTemplatesViewController {
|
||||
case 2:
|
||||
deployable = endpoint.mode.provider === this.DOCKER_STANDALONE;
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
return deployable;
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
<rd-header-content>Settings</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="UserId === 1">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Feature not available"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<span class="small text-muted">You cannot change the password of this account in the demo version of Portainer.</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
@@ -23,7 +34,10 @@
|
||||
<label for="toggle_logo" class="control-label text-left">
|
||||
Use custom logo
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" name="toggle_logo" ng-model="formValues.customLogo" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled name="toggle_logo" ng-model="formValues.customLogo" /><i></i> </label>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">You cannot use this feature in the demo version of Portainer.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -31,7 +45,12 @@
|
||||
<label for="toggle_enableTelemetry" class="control-label text-left">
|
||||
Allow the collection of anonymous statistics
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" name="toggle_enableTelemetry" ng-model="formValues.enableTelemetry" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" disabled name="toggle_enableTelemetry" ng-model="formValues.enableTelemetry" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">You cannot use this feature in the demo version of Portainer.</span>
|
||||
</div>
|
||||
<div class="col-sm-12 text-muted small" style="margin-top: 10px;">
|
||||
You can find more information about this in our
|
||||
|
||||
Reference in New Issue
Block a user