Build 01
- initial beta version
This commit is contained in:
commit
672479116f
65
README.txt
Normal file
65
README.txt
Normal file
@ -0,0 +1,65 @@
|
||||
Pluggable Helpers Mod v1.0
|
||||
By Leslie E. Krause
|
||||
|
||||
Pluggable Helpers provides an API to fully automatate the process of downloading and
|
||||
installing Lua helper methods, classes, and libraries within Minetest.
|
||||
|
||||
Modularity is extremely important when it comes to maintaining large scale code-bases
|
||||
such as games and mods in Minetest. Helper classes and methods and even libraries serve
|
||||
this purpose. But so often they are re-implemented over-and-over again since nobody wants
|
||||
to rely on external dependencies in their mods and games. Eventually some helpers may be
|
||||
integrated into the engine, but even that is often a lengthy review process.
|
||||
|
||||
This is where Pluggable Helpers comes into the picture.
|
||||
|
||||
"The core philosophy of Pluggable Helpers is to empower the community to create an
|
||||
evolving game-development API through the use of a jointly maintained repository of
|
||||
helper classes, methods, and libraries that can be downloaded and installed on-the-fly
|
||||
with no intervention required by the end-user."
|
||||
|
||||
Although Pluggable Helpers has no dependencies in and of itself, it does need perform HTTP
|
||||
requests. Therefore it must be added to the list of "secure_http_mods" in minetest.conf.
|
||||
|
||||
|
||||
Repository
|
||||
----------------------
|
||||
|
||||
Browse source code...
|
||||
https://bitbucket.org/sorcerykid/plugins
|
||||
|
||||
Download archive...
|
||||
https://bitbucket.org/sorcerykid/plugins/get/master.zip
|
||||
https://bitbucket.org/sorcerykid/plugins/get/master.tar.gz
|
||||
|
||||
Installation
|
||||
----------------------
|
||||
|
||||
1) Unzip the archive into the mods directory of your game
|
||||
2) Rename the plugins-master directory to "plugins"
|
||||
3) Add "plugins" as a dependency to any mods using the API
|
||||
|
||||
License of source code
|
||||
----------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020, Leslie Krause (leslie@searstower.org)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more details:
|
||||
https://opensource.org/licenses/MIT
|
513
init.lua
Normal file
513
init.lua
Normal file
@ -0,0 +1,513 @@
|
||||
--------------------------------------------------------
|
||||
-- Minetest :: Pluggable Helpers Mod (plugins)
|
||||
--
|
||||
-- See README.txt for licensing and other information.
|
||||
-- Copyright (c) 2020, Leslie E. Krause
|
||||
--
|
||||
-- ./games/minetest_game/mods/plugins/init.lua
|
||||
--------------------------------------------------------
|
||||
|
||||
plugins = { }
|
||||
|
||||
local http_req = minetest.request_http_api( )
|
||||
local mod_path = minetest.get_modpath( "plugins" )
|
||||
local config = {
|
||||
remote_host = "plugins.mytuner.net",
|
||||
remote_path = "/source",
|
||||
}
|
||||
|
||||
assert( http_req ~= nil, "Failed to construct HTTP request object" )
|
||||
|
||||
local registered_helpers = { }
|
||||
local downloaded_helpers = { }
|
||||
local registered_classes = { }
|
||||
local helper_stats = { registered = 0, downloaded = 0 }
|
||||
|
||||
local globals = {
|
||||
"next",
|
||||
"pairs",
|
||||
"ipairs",
|
||||
"assert",
|
||||
"print",
|
||||
"error",
|
||||
"dofile",
|
||||
"loadfile",
|
||||
"loadstring",
|
||||
"getmetatable",
|
||||
"setmetatable",
|
||||
"pcall",
|
||||
"rawequal",
|
||||
"rawget",
|
||||
"rawset",
|
||||
"select",
|
||||
"tonumber",
|
||||
"tostring",
|
||||
"type",
|
||||
"unpack",
|
||||
"dump",
|
||||
}
|
||||
|
||||
local license_defs = {
|
||||
["AGPLv2"] = true,
|
||||
["AGPLv3"] = true,
|
||||
["Apache 2.0"] = true,
|
||||
["BSD 2-Clause"] = true,
|
||||
["BSD 3-Clause"] = true,
|
||||
["CC0"] = true,
|
||||
["CC BY 3.0"] = true,
|
||||
["CC BY 4.0"] = true,
|
||||
["CC BY-NC-SA 3.0"] = true,
|
||||
["CC BY-SA 3.0"] = true,
|
||||
["CC BY-SA 4.0"] = true,
|
||||
["EUPLv1.2"] = true,
|
||||
["GPLv2"] = true,
|
||||
["GPLv3"] = true,
|
||||
["ISC"] = true,
|
||||
["LGPLv2.1"] = true,
|
||||
["LGPLv3"] = true,
|
||||
["MIT"] = true,
|
||||
}
|
||||
|
||||
local _ = { }
|
||||
|
||||
local function is_match( text, glob )
|
||||
-- use array for captures
|
||||
_ = { string.match( text, glob ) }
|
||||
return #_ > 0 and _ or nil
|
||||
end
|
||||
|
||||
local function from_version( version )
|
||||
return version[ 1 ] .. "." .. version[ 2 ]
|
||||
end
|
||||
|
||||
local function to_version( val )
|
||||
if is_match( val, "^([0-9]+).([0-9]+)([ab]?)$" ) then
|
||||
return { tonumber( _[ 1 ] ), tonumber( _[ 2 ] ), _[ 3 ] }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function to_depends( val )
|
||||
local res = { }
|
||||
for _, cur_id in ipairs( val ) do
|
||||
local data = string.split( cur_id, "/" )
|
||||
|
||||
table.insert( res, { data[ 1 ], to_version( data[ 2 ] or "1.0" ) } )
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
local function split_id( id )
|
||||
if is_match( id, "^([a-z]+)%.([a-z][a-z0-9_]+)$" ) or is_match( id, "^([A-Z][A-Za-z0-9]+)$" ) then
|
||||
return _[ 1 ], _[ 2 ]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function split_extra_id( extra_id )
|
||||
local data = type( extra_id ) == "table" and extra_id or string.split( extra_id, "/" )
|
||||
|
||||
if #data == 1 or #data == 2 then
|
||||
return data[ 1 ], to_version( data[ 2 ] or "1.0" )
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function validate_extra_id( id )
|
||||
return string.find( id, "^[a-z]+%.[a-z][a-z0-9_]+/[0-9]+%.[0-9]+$" ) ~= nil or string.find( id, "^[A-Z][A-Za-z0-9]+/[0-9]+%.[0-9]+$" ) ~= nil
|
||||
end
|
||||
|
||||
local function validate_id( id )
|
||||
return string.find( id, "^[a-z]+%.[a-z][a-z0-9_]+$" ) ~= nil or string.find( id, "^[A-Z][A-Za-z0-9]+$" ) ~= nil
|
||||
end
|
||||
|
||||
local function check_version( cur_version, min_version )
|
||||
if cur_version[ 1 ] < min_version[ 1 ] or cur_version[ 1 ] == min_version[ 1 ] and cur_version[ 2 ] < min_version[ 2 ] then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function create_sandbox( func, imports )
|
||||
local env = { }
|
||||
|
||||
for _, name in ipairs( globals ) do
|
||||
env[ name ] = _G[ name ]
|
||||
end
|
||||
|
||||
for _, name in ipairs( imports ) do
|
||||
local class, method = split_id( name )
|
||||
|
||||
if not class or not registered_classes[ class ] or method and not registered_classes[ class ][ method ] then
|
||||
error( "create_sandbox(): Attempt to import unknown class or method" )
|
||||
elseif method then
|
||||
env[ method ] = registered_classes[ class ][ method ]
|
||||
else
|
||||
env[ class ] = registered_classes[ class ]
|
||||
end
|
||||
end
|
||||
|
||||
setfenv( func, env )
|
||||
setmetatable( env, { __index = registered_classes } )
|
||||
end
|
||||
|
||||
local function convert_record( def )
|
||||
if not def or def == "" then
|
||||
minetest.log( "error", "No helper definition found, aborting" )
|
||||
return nil
|
||||
end
|
||||
|
||||
local record = { }
|
||||
|
||||
setfenv( def, record )
|
||||
local status, func = pcall( def )
|
||||
|
||||
if type( func ) ~= "function" then
|
||||
minetest.log( "error", "Missing function in helper definition, aborting" )
|
||||
return nil
|
||||
elseif record.prototype ~= nil and type( record.prototype ) ~= "table" then
|
||||
minetest.log( "error", "Invalid prototype in helper definition, aborting" )
|
||||
return nil
|
||||
elseif type( record.version ) ~= "string" or not string.find( record.version, "^[0-9]+%.[0-9]+$" ) then
|
||||
minetest.log( "error", "Invalid or missing 'version' field in helper definition, aborting" )
|
||||
return nil
|
||||
elseif type( record.author ) ~= "string" or not string.find( record.author, "^[a-zA-Z0-9_-]+$" ) then
|
||||
minetest.log( "error", "Invalid or missing 'author' field in helper definition, aborting" )
|
||||
return nil
|
||||
elseif type( record.license ) ~= "string" or not license_defs[ record.license ] then
|
||||
minetest.log( "error", "Invalid or missing 'license' field in helper definition, aborting" )
|
||||
return nil
|
||||
elseif type( record.depends ) ~= "table" then
|
||||
minetest.log( "error", "Missing 'depends' field in helper definition, aborting" )
|
||||
return nil
|
||||
elseif type( record.imports ) ~= "table" then
|
||||
minetest.log( "error", "Missing 'imports' field in helper definition, aborting" )
|
||||
return nil
|
||||
end
|
||||
|
||||
for _, cur_id in ipairs( record.depends ) do
|
||||
if not validate_id( cur_id ) and not validate_extra_id( cur_id ) then
|
||||
minetest.log( "error", "Invalid 'depends' field in helper definition, aborting" )
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
for _, cur_id in ipairs( record.imports ) do
|
||||
if not validate_id( cur_id ) then
|
||||
minetest.log( "error", "Invalid 'imports' field in helper definition, aborting" )
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = record.id,
|
||||
author = record.author,
|
||||
license = record.license,
|
||||
version = to_version( record.version ),
|
||||
depends = to_depends( record.depends ),
|
||||
imports = record.imports,
|
||||
is_required = false,
|
||||
func = func,
|
||||
this = record.prototype,
|
||||
}
|
||||
end
|
||||
|
||||
local function load_repository( )
|
||||
minetest.log( "action", "Loading helper definitions from local repository" )
|
||||
|
||||
for _, id in ipairs( minetest.get_dir_list( mod_path .. "/source", false ) ) do
|
||||
local record = convert_record( loadfile( mod_path .. "/source/" .. id ) )
|
||||
|
||||
if not record then
|
||||
error( "load_repository(): Malformed helper definition" )
|
||||
end
|
||||
|
||||
local class, method = split_id( id )
|
||||
|
||||
if not class then
|
||||
error( "load_repository(): Malformed helper ID" )
|
||||
elseif method and ( not registered_classes[ class ] or type( registered_classes[ class ] ) ~= "table" ) then
|
||||
error( "load_repository(): Unrecognized helper class" )
|
||||
end
|
||||
|
||||
registered_helpers[ id ] = record
|
||||
helper_stats.registered = helper_stats.registered + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function simple_http_request( uri, timeout )
|
||||
local status = http_req.fetch_async( { url = uri, timeout = timeout, user_agent = "Pluggable Helpers/1.0" } )
|
||||
local result
|
||||
|
||||
-- sleep until request completed
|
||||
while true do
|
||||
local t = os.clock( )
|
||||
while os.clock( ) - t <= 0.1 do end
|
||||
|
||||
local result = http_req.fetch_async_get( status )
|
||||
|
||||
if result.completed then
|
||||
return result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function request( id, version )
|
||||
minetest.log( "action", "Requesting helper '" .. id .. "' from remote repository '" .. config.remote_host .. "'" )
|
||||
|
||||
local results = simple_http_request( string.format( "http://%s/%s", config.remote_host .. config.remote_path, id ), 2.0 )
|
||||
|
||||
if results.timeout then
|
||||
minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" )
|
||||
error( "request(): Connection timed out." )
|
||||
|
||||
elseif not results.succeeded then
|
||||
minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" )
|
||||
if results.code == 404 then
|
||||
error( "request(): Resource not found (status 404)" )
|
||||
elseif results.code == 403 then
|
||||
error( "request(): Permission denied (status 403)" )
|
||||
elseif results.code == 418 then
|
||||
error( "request(): I'm a teapot, short and stout (status 418)" )
|
||||
elseif results.code == 500 then
|
||||
error( "request(): Internal server error (status 500)" )
|
||||
elseif results.code == 504 then
|
||||
error( "request(): Gateway timed out (status 504)" )
|
||||
else
|
||||
error( "request(): Unhandled exception" )
|
||||
end
|
||||
end
|
||||
|
||||
local record = convert_record( loadstring( results.data ) )
|
||||
|
||||
if not record then
|
||||
minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" )
|
||||
error( "request(): Malformed helper definition, '" .. results.data .. "'" )
|
||||
elseif not check_version( record.version, version ) then
|
||||
minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" )
|
||||
error( "request(): Insufficient helper version, '" .. from_version( record.version ) .. "'" )
|
||||
end
|
||||
|
||||
if #record.depends > 0 then
|
||||
minetest.log( "action", "Resolving dependencies for helper '" .. id .. "'" )
|
||||
end
|
||||
|
||||
-- recursively request dependencies
|
||||
for idx, elem in ipairs( record.depends ) do
|
||||
local cur_id = elem[ 1 ]
|
||||
local cur_version = elem[ 2 ]
|
||||
|
||||
-- sanity check for dependency loop
|
||||
if cur_id == id or downloaded_helpers[ cur_id ] then
|
||||
minetest.log( "error", "Failed to request helper '" .. id .. "', aborting" )
|
||||
error( "request(): Unexpected dependency loop" )
|
||||
end
|
||||
|
||||
-- if helper is not registered or helper is inadequate version, then download
|
||||
if not registered_helpers[ cur_id ] or not check_version( registered_helpers[ cur_id ].version, cur_version ) then
|
||||
request( cur_id, cur_version )
|
||||
end
|
||||
end
|
||||
|
||||
registered_helpers[ id ] = record
|
||||
downloaded_helpers[ id ] = results.data
|
||||
helper_stats.downloaded = helper_stats.downloaded + 1
|
||||
|
||||
return record
|
||||
end
|
||||
|
||||
local function install_from_queue( )
|
||||
for id, source in pairs( downloaded_helpers ) do
|
||||
local helper = registered_helpers[ id ]
|
||||
local date_spec = os.date( "%c" )
|
||||
local host_spec = config.remote_host
|
||||
local path_spec = config.remote_path
|
||||
|
||||
minetest.log( "action", "Installing required helper '" .. id .. "' to local repository" )
|
||||
|
||||
local file = io.open( mod_path .. "/source/" .. id, "w" )
|
||||
if not file then
|
||||
minetest.log( "error", "Failed to install helper '" .. id .. "', aborting" )
|
||||
error( "install_from_queue(): Unable to write repository" )
|
||||
end
|
||||
|
||||
file:write( "----------------------------------------------------\n" )
|
||||
file:write( "-- Installed on " .. date_spec .. "\n" )
|
||||
file:write( "-- \n" )
|
||||
file:write( "-- http://" .. host_spec .. path_spec .. "/" .. id .. "\n" )
|
||||
file:write( "----------------------------------------------------\n\n" )
|
||||
file:write( source )
|
||||
file:close( )
|
||||
end
|
||||
|
||||
downloaded_helpers = { }
|
||||
end
|
||||
|
||||
local function uninstall_orphans( )
|
||||
-- uninstall orphaned helpers automatically
|
||||
for id, helper in pairs( registered_helpers ) do
|
||||
if not helper.is_required then
|
||||
minetest.log( "action", "Uninstalling orphaned helper '" .. id .. "' from local repository" )
|
||||
|
||||
if not os.remove( mod_path .. "/source/" .. id ) then
|
||||
minetest.log( "error", "Failed to uninstall helper '" .. id .. "', aborting" )
|
||||
error( "uninstall_orphans(): Unable to write repository" )
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function require( helper, class, method )
|
||||
if helper.is_required then
|
||||
return helper.this or helper.func -- it's already been required, so skip processing
|
||||
end
|
||||
|
||||
helper.is_required = true
|
||||
|
||||
-- recursively require dependencies
|
||||
for _, elem in ipairs( helper.depends ) do
|
||||
local cur_id = elem[ 1 ]
|
||||
local cur_helper = registered_helpers[ cur_id ]
|
||||
local cur_class, cur_method = split_id( cur_id )
|
||||
|
||||
if not cur_helper or not cur_class then
|
||||
minetest.log( "error", "Failed to require helper '" .. cur_id .. "', aborting" )
|
||||
error( "require(): Missing dependency" )
|
||||
end
|
||||
|
||||
require( cur_helper, cur_class, cur_method )
|
||||
end
|
||||
|
||||
-- prepare sandbox with globals and imports
|
||||
create_sandbox( helper.func, helper.imports )
|
||||
|
||||
if method then
|
||||
_G[ class ][ method ] = helper.func
|
||||
elseif not helper.this then
|
||||
_G[ class ] = helper.func
|
||||
registered_classes[ class ] = helper.func -- add class to sandbox
|
||||
else
|
||||
helper.func( helper.this )
|
||||
end
|
||||
|
||||
return helper.this or helper.func
|
||||
end
|
||||
|
||||
--------------------
|
||||
-- Public Methods --
|
||||
--------------------
|
||||
|
||||
plugins.include = function ( extra_id )
|
||||
local id, version = split_extra_id( extra_id )
|
||||
|
||||
assert( id, "plugins.include(): Malformed helper ID" )
|
||||
|
||||
local class, method = split_id( id )
|
||||
local helper = registered_helpers[ id ]
|
||||
|
||||
assert( class, "plugins.include(): Malformed helper ID" )
|
||||
assert( not method, "plugins.include()" )
|
||||
|
||||
if helper then
|
||||
if not check_version( helper.version, version ) then
|
||||
minetest.log( "warning", "Required helper '" .. id .. "' found, but insufficient version" )
|
||||
helper = request( id, version )
|
||||
install_from_queue( )
|
||||
return require( helper, class )
|
||||
else
|
||||
return require( helper, class )
|
||||
end
|
||||
else
|
||||
minetest.log( "warning", "Required helper '" .. id .. "' not found" )
|
||||
helper = request( id, version )
|
||||
install_from_queue( )
|
||||
return require( helper, class )
|
||||
end
|
||||
end
|
||||
|
||||
plugins.require = function ( extra_id )
|
||||
local id, version = split_extra_id( extra_id )
|
||||
|
||||
assert( id, "plugins.require(): Malformed helper ID" )
|
||||
|
||||
local class, method = split_id( id )
|
||||
local helper = registered_helpers[ id ]
|
||||
|
||||
assert( class, "plugins.require(): Malformed helper ID" )
|
||||
assert( not method or registered_classes[ class ], "plugins.require(): Unrecognized helper class" )
|
||||
|
||||
if helper then
|
||||
if not check_version( helper.version, version ) then
|
||||
minetest.log( "warning", "Required helper '" .. id .. "' found, but insufficient version" )
|
||||
helper = request( id, version )
|
||||
install_from_queue( )
|
||||
return require( helper, class, method )
|
||||
else
|
||||
return require( helper, class, method )
|
||||
end
|
||||
elseif class and method then
|
||||
if not _G[ class ][ method ] then
|
||||
minetest.log( "warning", "Required helper '" .. id .. "' not found" )
|
||||
helper = request( id, version )
|
||||
install_from_queue( )
|
||||
return require( helper, class, method )
|
||||
end
|
||||
elseif class then
|
||||
if not _G[ class ] then
|
||||
minetest.log( "warning", "Required helper '" .. id .. "' not found" )
|
||||
helper = request( id, version )
|
||||
install_from_queue( )
|
||||
return require( helper, class )
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
plugins.register_class = function ( name, ref )
|
||||
if type( ref ) == "function" and not string.find( name, "^[A-Z][A-Za-z0-9]+$" ) or type( ref ) == "table" and not string.find( name, "^[a-z0-9_]+$" ) then
|
||||
error( "register_class: Improperly formatted class name, '" .. name .. "'" )
|
||||
elseif not type( ref ) == "function" and not type( ref ) == "table" then
|
||||
error( "register_class: Unsupported class type, " .. type( ref ) )
|
||||
end
|
||||
registered_classes[ name ] = ref
|
||||
end
|
||||
|
||||
plugins.register_class( "minetest", minetest )
|
||||
plugins.register_class( "string", string )
|
||||
plugins.register_class( "math", math )
|
||||
plugins.register_class( "table", table )
|
||||
plugins.register_class( "os", table )
|
||||
plugins.register_class( "is", table )
|
||||
plugins.register_class( "debug", debug )
|
||||
plugins.register_class( "PerlinNoise", PerlinNoise )
|
||||
plugins.register_class( "PerlinNoiseMap", PerlinNoiseMap )
|
||||
plugins.register_class( "VoxelManip", VoxelManip )
|
||||
plugins.register_class( "VoxelArea", VoxelArea.new )
|
||||
|
||||
load_repository( )
|
||||
|
||||
minetest.after( 0.0, function ( )
|
||||
uninstall_orphans( )
|
||||
|
||||
plugins.require = function ( )
|
||||
error( "plugins.require(): Delayed invocation not permitted, aborting" )
|
||||
end
|
||||
plugins.include = function ( )
|
||||
error( "plugins.include(): Delayed invocation not permitted, aborting" )
|
||||
end
|
||||
end )
|
||||
|
||||
------------------------------
|
||||
-- Registered Chat Commands --
|
||||
------------------------------
|
||||
|
||||
minetest.register_chatcommand( "plugins", {
|
||||
description = "List all plugins installed in local registry",
|
||||
privs = { server = true },
|
||||
func = function( player_name, param )
|
||||
local res = ""
|
||||
|
||||
-- for i, v in pairs( registered_helpers )
|
||||
-- table.insert( res, v.id )
|
||||
-- end
|
||||
end
|
||||
} )
|
Loading…
x
Reference in New Issue
Block a user