Add MySQL schema dump for phpMyAdmin
This commit is contained in:
36
app/Controllers/AuthController.php
Normal file
36
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(): Response
|
||||
{
|
||||
return $this->view('auth/login');
|
||||
}
|
||||
|
||||
public function login(): Response
|
||||
{
|
||||
$email = $_POST['email'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
$auth = AuthService::getInstance();
|
||||
if ($auth->attempt($email, $password)) {
|
||||
return $this->redirect('/');
|
||||
}
|
||||
|
||||
return $this->view('auth/login', [
|
||||
'error' => 'Invalid credentials or inactive account.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(): Response
|
||||
{
|
||||
AuthService::getInstance()->logout();
|
||||
return $this->redirect('/login');
|
||||
}
|
||||
}
|
||||
19
app/Controllers/DashboardController.php
Normal file
19
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$user = AuthService::getInstance()->user();
|
||||
|
||||
return $this->view('dashboard/index', [
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Core/Config.php
Normal file
93
app/Core/Config.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Config
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $values = [];
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadEnv();
|
||||
$this->loadConfigFiles();
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
$segments = explode('.', $key);
|
||||
$value = $this->values;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function set(array $config): void
|
||||
{
|
||||
$this->values = array_replace_recursive($this->values, $config);
|
||||
}
|
||||
|
||||
private function loadEnv(): void
|
||||
{
|
||||
$envPath = base_path('.env');
|
||||
if (!file_exists($envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = array_map('trim', explode('=', $line, 2) + [1 => '']);
|
||||
$value = trim($value, "\"' ");
|
||||
$_ENV[$name] = $value;
|
||||
putenv("{$name}={$value}");
|
||||
}
|
||||
}
|
||||
|
||||
private function loadConfigFiles(): void
|
||||
{
|
||||
$configDir = base_path('config');
|
||||
if (!is_dir($configDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($configDir . '/*.php');
|
||||
if ($files === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$key = basename($file, '.php');
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = require $file;
|
||||
$this->values[$key] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/Core/Controller.php
Normal file
17
app/Core/Controller.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Controller
|
||||
{
|
||||
protected function view(string $template, array $data = []): Response
|
||||
{
|
||||
$content = View::render($template, $data);
|
||||
return new Response($content);
|
||||
}
|
||||
|
||||
protected function redirect(string $url): Response
|
||||
{
|
||||
return new Response('', 302, ['Location' => $url]);
|
||||
}
|
||||
}
|
||||
44
app/Core/Database.php
Normal file
44
app/Core/Database.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $connection = null;
|
||||
|
||||
public static function connection(): PDO
|
||||
{
|
||||
if (self::$connection === null) {
|
||||
$config = config('database');
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['database']
|
||||
);
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
self::$connection = new PDO(
|
||||
$dsn,
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$options
|
||||
);
|
||||
} catch (PDOException $exception) {
|
||||
throw new \RuntimeException('Database connection failed: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
}
|
||||
39
app/Core/Response.php
Normal file
39
app/Core/Response.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
private string $body;
|
||||
private int $status;
|
||||
/** @var array<string, string> */
|
||||
private array $headers;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function __construct(string $body = '', int $status = 200, array $headers = [])
|
||||
{
|
||||
$this->body = $body;
|
||||
$this->status = $status;
|
||||
$this->headers = $headers;
|
||||
}
|
||||
|
||||
public function getBody(): string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function getStatus(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
}
|
||||
97
app/Core/Router.php
Normal file
97
app/Core/Router.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Router
|
||||
{
|
||||
/** @var array<string, array<int, array{path:string,action:callable,middleware:array}>> */
|
||||
private array $routes = [];
|
||||
|
||||
/** @var array<int, array<int, class-string>> */
|
||||
private array $middlewareStack = [];
|
||||
|
||||
public function get(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function post(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function put(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function delete(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function group(array $attributes, \Closure $callback): void
|
||||
{
|
||||
$groupMiddleware = $attributes['middleware'] ?? [];
|
||||
$this->middlewareStack[] = $groupMiddleware;
|
||||
$callback($this);
|
||||
array_pop($this->middlewareStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, class-string> $middleware
|
||||
*/
|
||||
private function addRoute(string $method, string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$stackedMiddleware = [];
|
||||
foreach ($this->middlewareStack as $group) {
|
||||
$stackedMiddleware = array_merge($stackedMiddleware, $group);
|
||||
}
|
||||
$combinedMiddleware = array_merge($stackedMiddleware, $middleware);
|
||||
|
||||
$this->routes[$method][] = [
|
||||
'path' => $path,
|
||||
'action' => $action,
|
||||
'middleware' => $combinedMiddleware,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(string $method, string $uri): Response
|
||||
{
|
||||
$uri = parse_url($uri, PHP_URL_PATH) ?? '/';
|
||||
$routes = $this->routes[$method] ?? [];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$pattern = '#^' . preg_replace('#\{(.*?)\}#', '(?P<$1>[^/]+)', $route['path']) . '$#';
|
||||
if (preg_match($pattern, $uri, $matches)) {
|
||||
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
$handler = $this->applyMiddleware($route['action'], $route['middleware']);
|
||||
$result = $handler($params);
|
||||
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new Response((string) $result);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable $action
|
||||
* @param array<int, class-string> $middleware
|
||||
*/
|
||||
private function applyMiddleware(callable $action, array $middleware): callable
|
||||
{
|
||||
return array_reduce(
|
||||
array_reverse($middleware),
|
||||
function ($next, $middlewareClass) {
|
||||
$middlewareInstance = new $middlewareClass();
|
||||
return fn(array $params) => $middlewareInstance->handle($params, $next);
|
||||
},
|
||||
$action
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Core/View.php
Normal file
19
app/Core/View.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class View
|
||||
{
|
||||
public static function render(string $template, array $data = []): string
|
||||
{
|
||||
$path = base_path('resources/views/' . $template . '.php');
|
||||
if (!file_exists($path)) {
|
||||
throw new \RuntimeException('View not found: ' . $template);
|
||||
}
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include $path;
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/AuthMiddleware.php
Normal file
19
app/Http/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
public function handle(array $params, callable $next): Response
|
||||
{
|
||||
$auth = AuthService::getInstance();
|
||||
if (!$auth->check()) {
|
||||
return new Response('', 302, ['Location' => '/login']);
|
||||
}
|
||||
|
||||
return $next($params);
|
||||
}
|
||||
}
|
||||
38
app/Models/Model.php
Normal file
38
app/Models/Model.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
use PDO;
|
||||
|
||||
abstract class Model
|
||||
{
|
||||
protected static string $table;
|
||||
protected static string $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* @return array<int, static>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$stmt = Database::connection()->query('SELECT * FROM ' . static::$table);
|
||||
$rows = $stmt->fetchAll();
|
||||
return array_map(fn($row) => static::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?static
|
||||
{
|
||||
$stmt = Database::connection()->prepare('SELECT * FROM ' . static::$table . ' WHERE ' . static::$primaryKey . ' = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return static::fromArray($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
abstract protected static function fromArray(array $attributes): static;
|
||||
}
|
||||
54
app/Models/User.php
Normal file
54
app/Models/User.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
protected static string $table = 'users';
|
||||
|
||||
public int $id;
|
||||
public string $role;
|
||||
public string $name;
|
||||
public string $email;
|
||||
public string $pass_hash;
|
||||
public ?string $phone;
|
||||
public ?string $address;
|
||||
public ?int $customer_id;
|
||||
public bool $is_active;
|
||||
public ?string $twofa_secret;
|
||||
public string $created_at;
|
||||
public string $updated_at;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
protected static function fromArray(array $attributes): static
|
||||
{
|
||||
$user = new static();
|
||||
$user->id = (int) $attributes['id'];
|
||||
$user->role = (string) $attributes['role'];
|
||||
$user->name = (string) $attributes['name'];
|
||||
$user->email = (string) $attributes['email'];
|
||||
$user->pass_hash = (string) $attributes['pass_hash'];
|
||||
$user->phone = $attributes['phone'] ?? null;
|
||||
$user->address = $attributes['address'] ?? null;
|
||||
$user->customer_id = isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null;
|
||||
$user->is_active = (bool) $attributes['is_active'];
|
||||
$user->twofa_secret = $attributes['twofa_secret'] ?? null;
|
||||
$user->created_at = (string) $attributes['created_at'];
|
||||
$user->updated_at = (string) $attributes['updated_at'];
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?self
|
||||
{
|
||||
$stmt = \App\Core\Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
|
||||
$stmt->execute(['email' => $email]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromArray($row);
|
||||
}
|
||||
}
|
||||
69
app/Services/AuthService.php
Normal file
69
app/Services/AuthService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\PasswordHasher;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
private ?User $user = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$this->user = User::find((int) $_SESSION['user_id']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function attempt(string $email, string $password): bool
|
||||
{
|
||||
$user = User::findByEmail($email);
|
||||
if (!$user || !$user->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PasswordHasher::verify($password, $user->pass_hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$_SESSION['user_id'] = $user->id;
|
||||
$this->user = $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
return $this->user !== null;
|
||||
}
|
||||
|
||||
public function user(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
125
app/Services/MailService.php
Normal file
125
app/Services/MailService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PHPMailer\PHPMailer\Exception as MailException;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
class MailService
|
||||
{
|
||||
/**
|
||||
* @param string|array<int|string, string|array{address:string,name?:string}> $to
|
||||
* @param array<int|string, string|array{address:string,name?:string}> $cc
|
||||
* @param array<int|string, string|array{address:string,name?:string}> $bcc
|
||||
*/
|
||||
public function send($to, string $subject, string $htmlBody, ?string $textBody = null, array $cc = [], array $bcc = []): void
|
||||
{
|
||||
$config = config('mail');
|
||||
$mailer = new PHPMailer(true);
|
||||
|
||||
$mailer->CharSet = 'UTF-8';
|
||||
$mailer->isHTML(true);
|
||||
|
||||
$driver = strtolower((string)($config['driver'] ?? 'smtp'));
|
||||
|
||||
if ($driver === 'sendmail') {
|
||||
$mailer->isSendmail();
|
||||
if (!empty($config['sendmail_path'])) {
|
||||
$mailer->Sendmail = $config['sendmail_path'];
|
||||
}
|
||||
} elseif ($driver === 'mail') {
|
||||
$mailer->isMail();
|
||||
} else {
|
||||
$mailer->isSMTP();
|
||||
$mailer->Host = (string)($config['host'] ?? 'localhost');
|
||||
$mailer->Port = (int)($config['port'] ?? 25);
|
||||
$mailer->Timeout = (int)($config['timeout'] ?? 30);
|
||||
|
||||
$username = $config['username'] ?? null;
|
||||
$password = $config['password'] ?? null;
|
||||
if ($username) {
|
||||
$mailer->SMTPAuth = true;
|
||||
$mailer->Username = $username;
|
||||
$mailer->Password = (string)$password;
|
||||
} else {
|
||||
$mailer->SMTPAuth = false;
|
||||
}
|
||||
|
||||
$encryption = strtolower((string)($config['encryption'] ?? ''));
|
||||
if ($encryption === 'ssl') {
|
||||
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} elseif ($encryption === 'tls') {
|
||||
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
}
|
||||
|
||||
$fromAddress = $config['from']['address'] ?? null;
|
||||
$fromName = $config['from']['name'] ?? '';
|
||||
if ($fromAddress) {
|
||||
$mailer->setFrom($fromAddress, $fromName ?: $fromAddress);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($to) as $recipient) {
|
||||
$mailer->addAddress($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($cc) as $recipient) {
|
||||
$mailer->addCC($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($bcc) as $recipient) {
|
||||
$mailer->addBCC($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
$mailer->Subject = $subject;
|
||||
$mailer->Body = $htmlBody;
|
||||
$mailer->AltBody = $textBody ?? strip_tags($htmlBody);
|
||||
|
||||
try {
|
||||
$mailer->send();
|
||||
} catch (MailException $exception) {
|
||||
throw new \RuntimeException('Unable to send mail: ' . $exception->getMessage(), previous: $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<int|string, string|array{address:string,name?:string}> $addresses
|
||||
* @return list<array{address:string,name:string}>
|
||||
*/
|
||||
private function normalizeRecipients($addresses): array
|
||||
{
|
||||
if ($addresses === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($addresses)) {
|
||||
return [['address' => (string)$addresses, 'name' => '']];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($addresses as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$address = $value['address'] ?? null;
|
||||
if (!$address) {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = [
|
||||
'address' => (string)$address,
|
||||
'name' => (string)($value['name'] ?? ''),
|
||||
];
|
||||
} elseif (is_string($key) && is_string($value)) {
|
||||
$normalized[] = [
|
||||
'address' => $key,
|
||||
'name' => $value,
|
||||
];
|
||||
} else {
|
||||
$normalized[] = [
|
||||
'address' => (string)$value,
|
||||
'name' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
16
app/Support/PasswordHasher.php
Normal file
16
app/Support/PasswordHasher.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class PasswordHasher
|
||||
{
|
||||
public static function make(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_ARGON2ID);
|
||||
}
|
||||
|
||||
public static function verify(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user