You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
13 KiB

<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/default.php';
function getBrowserIdentifier() {
$agent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
if (preg_match('/(Edg|Chrome|Chromium|Firefox|Safari|Opera|MSIE|Trident)/i', $agent, $matches)) {
return trim($matches[1]);
}
return 'unknown';
}
function getRememberMeCookieName($username) {
return 'kai_remember_' . sanitizeUsername($username);
}
function createRememberMeToken($username) {
$browser = getBrowserIdentifier();
$randomValue = bin2hex(random_bytes(16));
$token = hash_hmac('sha256', $username . '|' . $browser . '|' . $randomValue, APP_SECRET);
return [
'token' => $token,
'browser' => $browser,
'createdAt' => time(),
'lastUsedAt' => time()
];
}
function setRememberMeCookie($username) {
$username = trim((string) $username);
if ($username === '') {
return false;
}
$tokenData = createRememberMeToken($username);
$config = getUserConfig($username);
$tokens = is_array($config['rememberMeTokens'] ?? null) ? $config['rememberMeTokens'] : [];
$tokens[] = $tokenData;
if (count($tokens) > 10) {
usort($tokens, function ($a, $b) {
return ($a['lastUsedAt'] ?? 0) <=> ($b['lastUsedAt'] ?? 0);
});
$tokens = array_slice($tokens, -10);
}
$config['rememberMeTokens'] = $tokens;
if (!saveUserConfigData($username, $config)) {
return false;
}
$cookieName = getRememberMeCookieName($username);
return setcookie($cookieName, $tokenData['token'], time() + 60 * 60 * 24 * 30, '/', '', false, true);
}
function clearRememberMeCookie($username) {
$username = trim((string) $username);
if ($username === '') {
return;
}
$cookieName = getRememberMeCookieName($username);
setcookie($cookieName, '', time() - 3600, '/', '', false, true);
}
function tryRememberMeLogin() {
if (!empty($_SESSION['auth_user']['username'])) {
return true;
}
foreach ($_COOKIE as $cookieName => $cookieValue) {
if (strpos($cookieName, 'kai_remember_') !== 0) {
continue;
}
$username = sanitizeUsername(substr($cookieName, strlen('kai_remember_')));
if ($username === '') {
continue;
}
$user = findUserByUsername($username);
if ($user === null) {
continue;
}
$config = getUserConfig($username);
$tokens = is_array($config['rememberMeTokens'] ?? null) ? $config['rememberMeTokens'] : [];
$browser = getBrowserIdentifier();
foreach ($tokens as &$entry) {
if (($entry['token'] ?? '') !== $cookieValue) {
continue;
}
if (!empty($entry['browser']) && $entry['browser'] !== $browser) {
continue;
}
$entry['lastUsedAt'] = time();
$config['rememberMeTokens'] = $tokens;
saveUserConfigData($username, $config);
$_SESSION['auth_user'] = [
'username' => $user['username'],
'role' => $user['role'] ?? 'user'
];
return true;
}
}
return false;
}
function ensureConfigDirectory() {
if (!is_dir(USER_CONFIG_DIR)) {
if (!mkdir(USER_CONFIG_DIR, 0755, true) && !is_dir(USER_CONFIG_DIR)) {
throw new RuntimeException('Unable to create config directory.');
}
}
}
function sanitizeUsername($username) {
$username = trim((string) $username);
$username = preg_replace('/[^A-Za-z0-9._-]/', '', $username);
return $username !== '' ? $username : 'user';
}
function getUserConfigPath($username) {
return USER_CONFIG_DIR . '/' . sanitizeUsername($username) . '-config.php';
}
function getUserChatPath($username) {
return USER_CONFIG_DIR . '/' . sanitizeUsername($username) . '-chat.php';
}
function readUserConfig($username) {
$path = getUserConfigPath($username);
if (!file_exists($path)) {
return [];
}
$data = include $path;
return is_array($data) ? $data : [];
}
function writeUserConfig($username, $config) {
ensureConfigDirectory();
$username = trim((string) $username);
if ($username === '') {
return false;
}
$data = is_array($config) ? $config : [];
$data['username'] = $username;
$data['role'] = (string) ($data['role'] ?? 'user');
$data['email'] = (string) ($data['email'] ?? '');
$data['passwordHash'] = (string) ($data['passwordHash'] ?? '');
$data['defaultModel'] = (string) ($data['defaultModel'] ?? '');
$php = "<?php\n\nreturn " . var_export($data, true) . ";\n";
return file_put_contents(getUserConfigPath($username), $php) !== false;
}
function readUserChat($username) {
$path = getUserChatPath($username);
if (!file_exists($path)) {
return [];
}
$data = include $path;
return is_array($data) ? $data : [];
}
function writeUserChat($username, $chats) {
ensureConfigDirectory();
$username = trim((string) $username);
if ($username === '') {
return false;
}
$php = "<?php\n\nreturn " . var_export(is_array($chats) ? $chats : [], true) . ";\n";
return file_put_contents(getUserChatPath($username), $php) !== false;
}
function renameUserFiles($oldUsername, $newUsername) {
$oldUsername = trim((string) $oldUsername);
$newUsername = trim((string) $newUsername);
if ($oldUsername === '' || $newUsername === '' || $oldUsername === $newUsername) {
return;
}
$oldConfigPath = getUserConfigPath($oldUsername);
$newConfigPath = getUserConfigPath($newUsername);
if (file_exists($oldConfigPath) && !file_exists($newConfigPath)) {
@rename($oldConfigPath, $newConfigPath);
}
$oldChatPath = getUserChatPath($oldUsername);
$newChatPath = getUserChatPath($newUsername);
if (file_exists($oldChatPath) && !file_exists($newChatPath)) {
@rename($oldChatPath, $newChatPath);
}
}
function hashPassword($password) {
return password_hash($password, PASSWORD_DEFAULT);
}
function verifyPassword($password, $hash) {
return password_verify($password, $hash);
}
function getUserConfig($username) {
$username = trim((string) $username);
if ($username === '') {
return [];
}
return readUserConfig($username);
}
function saveUserConfigData($username, $config) {
return writeUserConfig($username, $config);
}
function loadUsers() {
ensureConfigDirectory();
$files = glob(USER_CONFIG_DIR . '/*-config.php');
if ($files === false) {
return [];
}
$users = [];
foreach ($files as $file) {
$username = basename($file, '-config.php');
$user = readUserConfig($username);
if (!empty($user) && !empty($user['username'])) {
$users[] = $user;
}
}
usort($users, function ($a, $b) {
return strcmp(($a['username'] ?? ''), ($b['username'] ?? ''));
});
return $users;
}
function saveUsers($users, $previousUsers = null) {
ensureConfigDirectory();
$normalizedUsers = [];
foreach ($users as $index => $user) {
$entry = is_array($user) ? $user : [];
$username = trim((string) ($entry['username'] ?? ''));
if ($username === '') {
continue;
}
$entry['username'] = $username;
$entry['role'] = (string) ($entry['role'] ?? 'user');
$entry['email'] = (string) ($entry['email'] ?? '');
$entry['passwordHash'] = (string) ($entry['passwordHash'] ?? '');
$entry['defaultModel'] = (string) ($entry['defaultModel'] ?? '');
$normalizedUsers[] = $entry;
}
$previousUsers = is_array($previousUsers) ? $previousUsers : loadUsers();
foreach ($normalizedUsers as $index => $entry) {
$oldUsername = isset($previousUsers[$index]) ? trim((string) ($previousUsers[$index]['username'] ?? '')) : '';
$newUsername = trim((string) ($entry['username'] ?? ''));
if ($oldUsername !== '' && $oldUsername !== $newUsername) {
renameUserFiles($oldUsername, $newUsername);
}
if (!writeUserConfig($newUsername, $entry)) {
return false;
}
}
$currentUsernames = [];
foreach ($normalizedUsers as $entry) {
$currentUsernames[] = sanitizeUsername($entry['username']);
}
$files = glob(USER_CONFIG_DIR . '/*-config.php');
if ($files !== false) {
foreach ($files as $file) {
$name = basename($file, '-config.php');
if (!in_array($name, $currentUsernames, true)) {
@unlink(USER_CONFIG_DIR . '/' . $name . '-config.php');
@unlink(USER_CONFIG_DIR . '/' . $name . '-chat.php');
}
}
}
return true;
}
function findUserByUsername($username) {
$username = trim((string) $username);
if ($username === '') {
return null;
}
foreach (loadUsers() as $user) {
if (($user['username'] ?? '') === $username) {
return $user;
}
}
return null;
}
function findUserByEmail($email) {
$email = trim((string) $email);
if ($email === '') {
return null;
}
foreach (loadUsers() as $user) {
if (strtolower((string) ($user['email'] ?? '')) === strtolower($email)) {
return $user;
}
}
return null;
}
function createUser($username, $password, $role = 'user', $email = '') {
$username = trim((string) $username);
$email = trim((string) $email);
if ($username === '' || $password === '') {
return false;
}
$users = loadUsers();
if (findUserByUsername($username) !== null) {
return false;
}
if ($email !== '' && findUserByEmail($email) !== null) {
return false;
}
$users[] = [
'username' => $username,
'email' => $email,
'passwordHash' => hashPassword($password),
'role' => $role,
'defaultModel' => ''
];
if (!writeUserChat($username, [])) {
return false;
}
return saveUsers($users);
}
function requestPasswordReset($email) {
$email = trim((string) $email);
if ($email === '') {
return false;
}
$user = findUserByEmail($email);
if ($user === null) {
return false;
}
$token = bin2hex(random_bytes(32));
$users = loadUsers();
foreach ($users as &$entry) {
if (($entry['username'] ?? '') === ($user['username'] ?? '')) {
$entry['resetTokenHash'] = password_hash($token, PASSWORD_DEFAULT);
$entry['resetTokenExpires'] = time() + 3600;
break;
}
}
if (!saveUsers($users)) {
return false;
}
$link = APP_BASE_URL . '/reset.php?token=' . urlencode($token);
$subject = 'Password reset for KaI';
$message = "Hello {$user['username']},\n\n"
. "You requested a password reset for your KaI account.\n"
. "Please use the following link to set a new password:\n\n"
. $link . "\n\n"
. "If you did not request this, you can ignore this email.";
$headers = "From: " . MAIL_FROM_NAME . " <" . MAIL_FROM . ">\r\n"
. "Reply-To: " . MAIL_FROM . "\r\n"
. "X-Mailer: PHP/" . phpversion();
return mail($email, $subject, $message, $headers);
}
function validatePasswordResetToken($token) {
$token = (string) $token;
if ($token === '') {
return null;
}
foreach (loadUsers() as $user) {
$expiresAt = (int) ($user['resetTokenExpires'] ?? 0);
if ($expiresAt > time() && !empty($user['resetTokenHash']) && password_verify($token, (string) $user['resetTokenHash'])) {
return $user;
}
}
return null;
}
function completePasswordReset($token, $newPassword) {
$token = (string) $token;
$newPassword = (string) $newPassword;
if ($token === '' || $newPassword === '') {
return false;
}
$users = loadUsers();
foreach ($users as &$entry) {
$expiresAt = (int) ($entry['resetTokenExpires'] ?? 0);
if ($expiresAt > time() && !empty($entry['resetTokenHash']) && password_verify($token, (string) $entry['resetTokenHash'])) {
$entry['passwordHash'] = hashPassword($newPassword);
unset($entry['resetTokenHash'], $entry['resetTokenExpires']);
return saveUsers($users);
}
}
return false;
}
function isLoggedIn() {
return !empty($_SESSION['auth_user']['username']);
}
function getAuthenticatedUser() {
if (!isLoggedIn()) {
return null;
}
return findUserByUsername($_SESSION['auth_user']['username'] ?? '');
}
function getAuthenticatedUsername() {
$user = getAuthenticatedUser();
if ($user !== null) {
return (string) ($user['username'] ?? '');
}
return (string) ($_SESSION['auth_user']['username'] ?? '');
}
function requireAuthentication() {
if (!isLoggedIn()) {
http_response_code(403);
echo json_encode(['error' => 'Authentication required.']);
exit;
}
}
function requireAdmin() {
$user = getAuthenticatedUser();
if (!$user || (($user['role'] ?? 'user') !== 'admin')) {
http_response_code(403);
echo json_encode(['error' => 'Admin access required.']);
exit;
}
}
function logoutUser() {
$username = trim((string) ($_SESSION['auth_user']['username'] ?? ''));
unset($_SESSION['auth_user']);
if ($username !== '') {
clearRememberMeCookie($username);
}
}