Reorganize PHP internals and static assets
Move shared PHP code into private/, move JavaScript files into js/, and block direct access to private/. Remove unused API key and cache artifacts from the working tree.
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
<?php
|
||||
/*
|
||||
* auth.php
|
||||
*
|
||||
* Encapsulated authentication/user module for the Racket sandbox.
|
||||
*
|
||||
* Database:
|
||||
* data/racket-sandbox.sqlite
|
||||
*
|
||||
* Public model:
|
||||
* RacketSandboxAuth
|
||||
* RacketSandboxUser
|
||||
*
|
||||
* Outside code should not inspect DB rows directly.
|
||||
*/
|
||||
|
||||
class RacketSandboxAuthException extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
class RacketSandboxUser
|
||||
{
|
||||
private $row;
|
||||
|
||||
public function __construct($row)
|
||||
{
|
||||
$this->row = is_array($row) ? $row : array();
|
||||
}
|
||||
|
||||
public function id()
|
||||
{
|
||||
return (int)($this->row['id'] ?? 0);
|
||||
}
|
||||
|
||||
public function email()
|
||||
{
|
||||
return (string)($this->row['email'] ?? '');
|
||||
}
|
||||
|
||||
public function fullName()
|
||||
{
|
||||
return (string)($this->row['full_name'] ?? '');
|
||||
}
|
||||
|
||||
public function displayName()
|
||||
{
|
||||
$name = $this->fullName();
|
||||
$email = $this->email();
|
||||
|
||||
if ($name !== '' && $email !== '') {
|
||||
return $name . ' <' . $email . '>';
|
||||
}
|
||||
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
public function isAdmin()
|
||||
{
|
||||
return (int)($this->row['is_admin'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function isEnabled()
|
||||
{
|
||||
return (int)($this->row['is_enabled'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function createdAt()
|
||||
{
|
||||
return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null;
|
||||
}
|
||||
|
||||
public function updatedAt()
|
||||
{
|
||||
return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null;
|
||||
}
|
||||
|
||||
public function lastLoginAt()
|
||||
{
|
||||
return isset($this->row['last_login_at']) && $this->row['last_login_at'] !== null
|
||||
? (int)$this->row['last_login_at']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function sessionExpiresAt()
|
||||
{
|
||||
return isset($this->row['session_expires_at']) && $this->row['session_expires_at'] !== null
|
||||
? (int)$this->row['session_expires_at']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function raw()
|
||||
{
|
||||
return $this->row;
|
||||
}
|
||||
}
|
||||
|
||||
class RacketSandboxAuth
|
||||
{
|
||||
private $db;
|
||||
private $sessionCookieName;
|
||||
private $sessionTtlSeconds;
|
||||
|
||||
public function __construct($dbFile, $options = array())
|
||||
{
|
||||
$dir = dirname($dbFile);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0700, true)) {
|
||||
throw new RacketSandboxAuthException('Could not create database directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessionCookieName = isset($options['cookie_name'])
|
||||
? (string)$options['cookie_name']
|
||||
: 'rkt_sandbox_session';
|
||||
|
||||
$this->sessionTtlSeconds = isset($options['session_ttl'])
|
||||
? (int)$options['session_ttl']
|
||||
: 12 * 3600;
|
||||
|
||||
$this->db = new PDO('sqlite:' . $dbFile);
|
||||
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->db->exec('PRAGMA journal_mode = WAL');
|
||||
$this->db->exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
$this->init();
|
||||
}
|
||||
|
||||
private function init()
|
||||
{
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
full_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER NULL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->migrateUsersTable();
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
user_agent TEXT NULL,
|
||||
remote_addr TEXT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
|
||||
ON auth_sessions(user_id)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
|
||||
ON auth_sessions(expires_at)'
|
||||
);
|
||||
}
|
||||
|
||||
private function migrateUsersTable()
|
||||
{
|
||||
$cols = $this->tableColumns('users');
|
||||
|
||||
if (isset($cols['username']) && !isset($cols['email'])) {
|
||||
$this->db->exec('ALTER TABLE users RENAME COLUMN username TO email');
|
||||
$cols = $this->tableColumns('users');
|
||||
}
|
||||
|
||||
if (!isset($cols['full_name'])) {
|
||||
$this->db->exec("ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''");
|
||||
$this->db->exec("UPDATE users SET full_name = email WHERE full_name = ''");
|
||||
}
|
||||
}
|
||||
|
||||
private function tableColumns($table)
|
||||
{
|
||||
$stmt = $this->db->query('PRAGMA table_info(' . $table . ')');
|
||||
$cols = array();
|
||||
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cols[$row['name']] = true;
|
||||
}
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
public function createUser($email, $fullName, $password, $isAdmin = false, $enabled = true)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
$fullName = $this->safeFullName($fullName);
|
||||
$this->assertPasswordUsable($password);
|
||||
|
||||
$now = time();
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
if ($hash === false) {
|
||||
throw new RacketSandboxAuthException('Could not hash password.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO users
|
||||
(email, full_name, password_hash, is_admin, is_enabled, created_at, updated_at)
|
||||
VALUES
|
||||
(:email, :full_name, :password_hash, :is_admin, :is_enabled, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':email' => $email,
|
||||
':full_name' => $fullName,
|
||||
':password_hash' => $hash,
|
||||
':is_admin' => $isAdmin ? 1 : 0,
|
||||
':is_enabled' => $enabled ? 1 : 0,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
));
|
||||
|
||||
return $this->getUserById((int)$this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function login($email, $password)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT *
|
||||
FROM users
|
||||
WHERE email = :email'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':email' => $email));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new RacketSandboxAuthException('Invalid email address or password.');
|
||||
}
|
||||
|
||||
if ((int)$row['is_enabled'] !== 1) {
|
||||
throw new RacketSandboxAuthException('This account is disabled.');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $row['password_hash'])) {
|
||||
throw new RacketSandboxAuthException('Invalid email address or password.');
|
||||
}
|
||||
|
||||
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$this->setPasswordById((int)$row['id'], $password, false);
|
||||
}
|
||||
|
||||
$token = $this->createSession((int)$row['id']);
|
||||
$this->setSessionCookie($token);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET last_login_at = :last_login_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':last_login_at' => time(),
|
||||
':id' => (int)$row['id'],
|
||||
));
|
||||
|
||||
return $this->getUserById((int)$row['id']);
|
||||
}
|
||||
|
||||
public function logout()
|
||||
{
|
||||
$token = $this->cookieToken();
|
||||
|
||||
if ($token !== null) {
|
||||
$this->deleteSession($token);
|
||||
}
|
||||
|
||||
$this->clearSessionCookie();
|
||||
}
|
||||
|
||||
public function currentUser()
|
||||
{
|
||||
$token = $this->cookieToken();
|
||||
|
||||
if ($token === null || !$this->validTokenSyntax($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.full_name,
|
||||
u.is_admin,
|
||||
u.is_enabled,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.last_login_at,
|
||||
s.expires_at AS session_expires_at
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':token' => $token));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int)$row['is_enabled'] !== 1) {
|
||||
$this->deleteSession($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (time() > (int)$row['session_expires_at']) {
|
||||
$this->deleteSession($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->touchSession($token);
|
||||
|
||||
return new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
public function requireLoginHtml()
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
if ($user !== null) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$this->loginRequiredHtml();
|
||||
}
|
||||
|
||||
public function requireAdminHtml()
|
||||
{
|
||||
$user = $this->requireLoginHtml();
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$this->messageHtml(
|
||||
'Not available',
|
||||
'Admin rights are required for this page.'
|
||||
);
|
||||
}
|
||||
|
||||
public function listUsers()
|
||||
{
|
||||
$stmt = $this->db->query(
|
||||
'SELECT
|
||||
id, email, full_name, is_admin, is_enabled,
|
||||
created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
ORDER BY email COLLATE NOCASE'
|
||||
);
|
||||
|
||||
$out = array();
|
||||
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function getUser($id)
|
||||
{
|
||||
return $this->getUserById($id);
|
||||
}
|
||||
|
||||
public function setAdmin($userId, $isAdmin)
|
||||
{
|
||||
$this->updateUserFlag($userId, 'is_admin', $isAdmin);
|
||||
}
|
||||
|
||||
public function setEnabled($userId, $isEnabled)
|
||||
{
|
||||
$this->updateUserFlag($userId, 'is_enabled', $isEnabled);
|
||||
|
||||
if (!$isEnabled) {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
$stmt->execute(array(':user_id' => (int)$userId));
|
||||
}
|
||||
}
|
||||
|
||||
public function updateUser($userId, $email, $fullName)
|
||||
{
|
||||
$userId = (int)$userId;
|
||||
$email = $this->safeEmail($email);
|
||||
$fullName = $this->safeFullName($fullName);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET email = :email,
|
||||
full_name = :full_name,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':email' => $email,
|
||||
':full_name' => $fullName,
|
||||
':updated_at' => time(),
|
||||
':id' => $userId,
|
||||
));
|
||||
|
||||
return $this->getUserById($userId);
|
||||
}
|
||||
|
||||
public function setPassword($email, $newPassword)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT id
|
||||
FROM users
|
||||
WHERE email = :email'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':email' => $email));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new RacketSandboxAuthException('Unknown user: ' . $email);
|
||||
}
|
||||
|
||||
$this->setPasswordById((int)$row['id'], $newPassword, true);
|
||||
}
|
||||
|
||||
public function deleteUser($userId)
|
||||
{
|
||||
$userId = (int)$userId;
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
$stmt->execute(array(':user_id' => $userId));
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM users
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute(array(':id' => $userId));
|
||||
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanupSessions()
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE expires_at < :now'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':now' => time()));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
private function getUserById($id)
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT
|
||||
id, email, full_name, is_admin, is_enabled,
|
||||
created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':id' => (int)$id));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
private function createSession($userId)
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$now = time();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO auth_sessions
|
||||
(token, user_id, created_at, expires_at, last_seen_at, user_agent, remote_addr)
|
||||
VALUES
|
||||
(:token, :user_id, :created_at, :expires_at, :last_seen_at, :user_agent, :remote_addr)'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':token' => $token,
|
||||
':user_id' => (int)$userId,
|
||||
':created_at' => $now,
|
||||
':expires_at' => $now + $this->sessionTtlSeconds,
|
||||
':last_seen_at' => $now,
|
||||
':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
':remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function touchSession($token)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE auth_sessions
|
||||
SET last_seen_at = :last_seen_at,
|
||||
expires_at = :expires_at
|
||||
WHERE token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':last_seen_at' => $now,
|
||||
':expires_at' => $now + $this->sessionTtlSeconds,
|
||||
':token' => $token,
|
||||
));
|
||||
}
|
||||
|
||||
private function deleteSession($token)
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':token' => $token));
|
||||
}
|
||||
|
||||
private function setPasswordById($userId, $newPassword, $deleteSessions)
|
||||
{
|
||||
$this->assertPasswordUsable($newPassword);
|
||||
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
|
||||
if ($hash === false) {
|
||||
throw new RacketSandboxAuthException('Could not hash password.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET password_hash = :password_hash,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':password_hash' => $hash,
|
||||
':updated_at' => time(),
|
||||
':id' => (int)$userId,
|
||||
));
|
||||
|
||||
if ($deleteSessions) {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':user_id' => (int)$userId));
|
||||
}
|
||||
}
|
||||
|
||||
private function updateUserFlag($userId, $column, $value)
|
||||
{
|
||||
if ($column !== 'is_admin' && $column !== 'is_enabled') {
|
||||
throw new RacketSandboxAuthException('Invalid flag column.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET ' . $column . ' = :value,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':value' => $value ? 1 : 0,
|
||||
':updated_at' => time(),
|
||||
':id' => (int)$userId,
|
||||
));
|
||||
}
|
||||
|
||||
private function cookieToken()
|
||||
{
|
||||
$token = $_COOKIE[$this->sessionCookieName] ?? null;
|
||||
|
||||
if (!is_string($token) || !$this->validTokenSyntax($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function setSessionCookie($token)
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
setcookie($this->sessionCookieName, $token, array(
|
||||
'expires' => time() + $this->sessionTtlSeconds,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function clearSessionCookie()
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
setcookie($this->sessionCookieName, '', array(
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function validTokenSyntax($token)
|
||||
{
|
||||
return is_string($token) && preg_match('/^[a-f0-9]{64}$/', $token);
|
||||
}
|
||||
|
||||
private function safeEmail($email)
|
||||
{
|
||||
$email = strtolower(trim((string)$email));
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RacketSandboxAuthException('Invalid email address.');
|
||||
}
|
||||
|
||||
if (strlen($email) > 160) {
|
||||
throw new RacketSandboxAuthException('Email address is too long.');
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function safeFullName($fullName)
|
||||
{
|
||||
$fullName = trim((string)$fullName);
|
||||
|
||||
if ($fullName === '') {
|
||||
throw new RacketSandboxAuthException('Full name is required.');
|
||||
}
|
||||
|
||||
if (strlen($fullName) > 160) {
|
||||
throw new RacketSandboxAuthException('Full name is too long.');
|
||||
}
|
||||
|
||||
return $fullName;
|
||||
}
|
||||
|
||||
private function assertPasswordUsable($password)
|
||||
{
|
||||
if (!is_string($password) || strlen($password) < 10) {
|
||||
throw new RacketSandboxAuthException('Password must be at least 10 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
private function loginRequiredHtml()
|
||||
{
|
||||
$this->messageHtml(
|
||||
'Login required',
|
||||
'Please log in to continue.',
|
||||
'<p><a href="/login.php">Login</a></p>'
|
||||
);
|
||||
}
|
||||
|
||||
private function messageHtml($title, $message, $extra = '')
|
||||
{
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
echo '<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>
|
||||
<p>' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>
|
||||
' . $extra . '
|
||||
</body>
|
||||
</html>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user