seperated user files and audit logs.

main
Steffen Pohle 2 weeks ago
parent e59c5cae4b
commit 5850b97c54

@ -4,65 +4,208 @@ 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(CONFIG_PATH)) {
if (!mkdir(CONFIG_PATH, 0755, true) && !is_dir(CONFIG_PATH)) {
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 getConfigData() {
ensureConfigDirectory();
if (!file_exists(CONFIG_FILE)) {
return ['defaultModel' => '', 'users' => []];
function sanitizeUsername($username) {
$username = trim((string) $username);
$username = preg_replace('/[^A-Za-z0-9._-]/', '', $username);
return $username !== '' ? $username : 'user';
}
$content = @file_get_contents(CONFIG_FILE);
if ($content === false) {
return ['defaultModel' => '', 'users' => []];
function getUserConfigPath($username) {
return USER_CONFIG_DIR . '/' . sanitizeUsername($username) . '-config.php';
}
$data = json_decode($content, true);
if (!is_array($data)) {
return ['defaultModel' => '', 'users' => []];
function getUserChatPath($username) {
return USER_CONFIG_DIR . '/' . sanitizeUsername($username) . '-chat.php';
}
if (!isset($data['users']) || !is_array($data['users'])) {
$data['users'] = [];
function readUserConfig($username) {
$path = getUserConfigPath($username);
if (!file_exists($path)) {
return [];
}
return $data;
$data = include $path;
return is_array($data) ? $data : [];
}
function saveConfigData($config) {
function writeUserConfig($username, $config) {
ensureConfigDirectory();
$data = getConfigData();
if (array_key_exists('defaultModel', $config)) {
$data['defaultModel'] = (string) $config['defaultModel'];
$username = trim((string) $username);
if ($username === '') {
return false;
}
if (array_key_exists('users', $config)) {
$data['users'] = is_array($config['users']) ? $config['users'] : [];
$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;
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return file_put_contents(CONFIG_FILE, $json) !== false;
function readUserChat($username) {
$path = getUserChatPath($username);
if (!file_exists($path)) {
return [];
}
function readConfig() {
$data = getConfigData();
$data = include $path;
return is_array($data) ? $data : [];
}
return [
'defaultModel' => isset($data['defaultModel']) ? (string) $data['defaultModel'] : '',
'users' => isset($data['users']) && is_array($data['users']) ? $data['users'] : []
];
function writeUserChat($username, $chats) {
ensureConfigDirectory();
$username = trim((string) $username);
if ($username === '') {
return false;
}
function writeConfig($config) {
return saveConfigData($config);
$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) {
@ -73,13 +216,92 @@ 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() {
$config = readConfig();
return $config['users'];
ensureConfigDirectory();
$files = glob(USER_CONFIG_DIR . '/*-config.php');
if ($files === false) {
return [];
}
function saveUsers($users) {
return writeConfig(['users' => $users]);
$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) {
@ -132,9 +354,14 @@ function createUser($username, $password, $role = 'user', $email = '') {
'username' => $username,
'email' => $email,
'passwordHash' => hashPassword($password),
'role' => $role
'role' => $role,
'defaultModel' => ''
];
if (!writeUserChat($username, [])) {
return false;
}
return saveUsers($users);
}
@ -222,7 +449,16 @@ function getAuthenticatedUser() {
return null;
}
return $_SESSION['auth_user'];
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() {
@ -243,5 +479,10 @@ function requireAdmin() {
}
function logoutUser() {
$username = trim((string) ($_SESSION['auth_user']['username'] ?? ''));
unset($_SESSION['auth_user']);
if ($username !== '') {
clearRememberMeCookie($username);
}
}

@ -2,6 +2,35 @@
header('Content-Type: application/json');
require_once __DIR__ . '/default.php';
require_once __DIR__ . '/auth.php';
function appendChatAuditEntry($username, $chatMessages, $resultPayload, $ipAddress = null) {
ensureConfigDirectory();
$auditPath = USER_CONFIG_DIR . '/chat-requests.json';
$entries = [];
if (file_exists($auditPath)) {
$raw = file_get_contents($auditPath);
if ($raw !== false) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$entries = $decoded;
}
}
}
$entries[] = [
'timestamp' => date('c'),
'username' => (string) $username,
'ipAddress' => (string) ($ipAddress ?? ($_SERVER['REMOTE_ADDR'] ?? '')),
'chat' => is_array($chatMessages) ? $chatMessages : [$chatMessages],
'result' => $resultPayload
];
$payload = json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $payload !== false && file_put_contents($auditPath, $payload . PHP_EOL) !== false;
}
// 1. Handle fetching available models
if (isset($_GET['action']) && $_GET['action'] === 'models') {
@ -24,6 +53,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$model = $input['model'] ?? 'llama3';
$messages = $input['messages'] ?? [];
$username = '';
if (!empty($_SESSION['auth_user']['username'])) {
$username = sanitizeUsername($_SESSION['auth_user']['username']);
}
$payload = json_encode([
'model' => $model,
@ -38,13 +71,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$response = curl_exec($ch);
$resultPayload = null;
if (curl_errno($ch)) {
echo json_encode(['error' => 'Error communicating with remote Ollama: ' . curl_error($ch)]);
$resultPayload = ['error' => 'Error communicating with remote Ollama: ' . curl_error($ch)];
echo json_encode($resultPayload);
} else {
$decodedResponse = json_decode($response, true);
$resultPayload = $decodedResponse !== null ? $decodedResponse : $response;
echo $response;
}
curl_close($ch);
appendChatAuditEntry($username, $messages, $resultPayload, $_SERVER['REMOTE_ADDR'] ?? '');
exit;
}

@ -1,40 +1,4 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/default.php';
require_once __DIR__ . '/auth.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'load':
echo json_encode(readConfig());
exit;
case 'save':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'POST required.']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
echo json_encode(['error' => 'Invalid JSON.']);
exit;
}
$config = readConfig();
$config['defaultModel'] = isset($input['defaultModel']) ? (string) $input['defaultModel'] : '';
if (!writeConfig($config)) {
echo json_encode(['error' => 'Unable to save config.']);
exit;
}
echo json_encode(['success' => true, 'defaultModel' => $config['defaultModel']]);
exit;
default:
echo json_encode(['error' => 'Unknown action.']);
exit;
}
define('USER_CONFIG_DIR', '/var/lib/KaI/users');
define('OLLAMA_URL', 'http://127.0.0.1:11434');

@ -1,10 +1,9 @@
<?php
define('OLLAMA_URL', 'http://127.0.0.1:11434');
define('CONFIG_PATH', '/var/lib/KaI');
define('CONFIG_FILE', CONFIG_PATH . '/config.json');
define('CHAT_HISTORY_FILE', CONFIG_PATH . '/chat.history.data');
require_once __DIR__ . '/config.php';
define('MAIL_FROM', getenv('MAIL_FROM') ?: 'noreply@kai.local');
define('MAIL_FROM_NAME', getenv('MAIL_FROM_NAME') ?: 'KaI');
define('APP_BASE_URL', getenv('APP_BASE_URL') ?: 'http://127.0.0.1/KaI-OllamaChat');
define('APP_SECRET', getenv('APP_SECRET') ?: 'change-this-secret');

@ -82,7 +82,7 @@ function appendMessage(role, text) {
async function loadConfig() {
try {
const response = await fetch('config.php?action=load');
const response = await fetch('savechat.php?action=load-config');
const data = await response.json();
return data.defaultModel || '';
} catch (err) {
@ -93,7 +93,7 @@ async function loadConfig() {
async function saveConfig(defaultModel) {
try {
await fetch('config.php?action=save', {
await fetch('savechat.php?action=save-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel })

@ -23,6 +23,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'username' => $user['username'],
'role' => $user['role'] ?? 'user'
];
if (!empty($_POST['remember'])) {
setRememberMeCookie($user['username']);
} else {
clearRememberMeCookie($user['username']);
}
header('Location: index.php');
exit;
}
@ -36,9 +43,7 @@ if (isLoggedIn()) {
exit;
}
if (!file_exists(CONFIG_FILE)) {
ensureConfigDirectory();
}
if (empty(loadUsers())) {
$created = createUser('admin', 'changeme', 'admin', 'admin@example.com');
@ -75,6 +80,11 @@ if (empty(loadUsers())) {
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<label style="font-weight:normal; margin-top:12px;">
<input type="checkbox" name="remember" value="1">
Remember me on this browser for 30 days
</label>
<button type="submit">Sign in</button>
</form>
<?php if (!empty($errors)): ?>

@ -2,101 +2,65 @@
header('Content-Type: application/json');
require_once __DIR__ . '/default.php';
require_once __DIR__ . '/auth.php';
/*----------------------------------------------------------
- Create storage file if necessary
----------------------------------------------------------*/
if (!file_exists(CHAT_HISTORY_FILE)) {
if (@file_put_contents(CHAT_HISTORY_FILE, '') === false) {
http_response_code(500);
echo json_encode([
'error' => 'Cannot create history file.'
]);
exit;
}
}
requireAuthentication();
/*----------------------------------------------------------
- Helper
----------------------------------------------------------*/
function readChats() {
$result = [];
$username = getAuthenticatedUsername();
$fp = fopen(CHAT_HISTORY_FILE, 'c+');
if (!$fp) {
return [];
}
flock($fp, LOCK_SH);
while (($line = fgets($fp)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
function createTitle($messages) {
foreach ($messages as $msg) {
if (($msg['role'] ?? '') === 'user') {
$title = trim($msg['content']);
if (mb_strlen($title) > 60) {
$title = mb_substr($title, 0, 60) . '...';
}
$obj = json_decode($line, true);
if (is_array($obj)) {
$result[] = $obj;
return $title;
}
}
flock($fp, LOCK_UN);
fclose($fp);
return $result;
return 'Untitled Chat';
}
function writeChats($chats) {
$fp = fopen(CHAT_HISTORY_FILE, 'w');
$action = $_GET['action'] ?? '';
switch ($action) {
case 'load-config':
$config = getUserConfig($username);
echo json_encode([
'defaultModel' => (string) ($config['defaultModel'] ?? '')
]);
exit;
if (!$fp) {
return false;
case 'save-config':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'POST required.']);
exit;
}
flock($fp, LOCK_EX);
foreach ($chats as $chat) {
fwrite($fp, json_encode($chat, JSON_UNESCAPED_UNICODE) . PHP_EOL);
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
echo json_encode(['error' => 'Invalid JSON.']);
exit;
}
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
$config = getUserConfig($username);
$config['defaultModel'] = isset($input['defaultModel']) ? (string) $input['defaultModel'] : '';
return true;
}
function createTitle($messages) {
foreach ($messages as $msg) {
if (($msg['role'] ?? '') === 'user') {
$title = trim($msg['content']);
if (mb_strlen($title) > 60) {
$title = mb_substr($title, 0, 60) . "...";
}
return $title;
}
}
return "Untitled Chat";
if (!saveUserConfigData($username, $config)) {
echo json_encode(['error' => 'Unable to save config.']);
exit;
}
/*----------------------------------------------------------
- Action
----------------------------------------------------------*/
echo json_encode(['success' => true, 'defaultModel' => $config['defaultModel']]);
exit;
$action = $_GET['action'] ?? '';
switch ($action) {
/*------------------------------------------------------
- LIST
------------------------------------------------------*/
case 'list':
$chats = readChats();
$chats = readUserChat($username);
$list = [];
foreach ($chats as $chat) {
$list[] = [
'id' => $chat['id'],
'title' => $chat['title'],
'created' => $chat['created'],
'id' => $chat['id'] ?? '',
'title' => $chat['title'] ?? 'Untitled Chat',
'created' => $chat['created'] ?? '',
'model' => $chat['model'] ?? ''
];
}
@ -108,63 +72,48 @@ switch ($action) {
echo json_encode($list);
exit;
/*------------------------------------------------------
- LOAD
------------------------------------------------------*/
case 'load':
$id = $_GET['id'] ?? '';
if ($id == '') {
echo json_encode([
'error' => 'Missing id.'
]);
if ($id === '') {
echo json_encode(['error' => 'Missing id.']);
exit;
}
$chats = readChats();
$chats = readUserChat($username);
foreach ($chats as $chat) {
if ($chat['id'] === $id) {
if (($chat['id'] ?? '') === $id) {
echo json_encode($chat);
exit;
}
}
echo json_encode([
'error' => 'Chat not found.'
]);
echo json_encode(['error' => 'Chat not found.']);
exit;
/*------------------------------------------------------
- SAVE
------------------------------------------------------*/
case 'save':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode([
'error' => 'POST required.'
]);
echo json_encode(['error' => 'POST required.']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
echo json_encode([
'error' => 'Invalid JSON.'
]);
echo json_encode(['error' => 'Invalid JSON.']);
exit;
}
$messages = $input['messages'] ?? [];
if (!is_array($messages) || count($messages) == 0) {
echo json_encode([
'error' => 'No messages.'
]);
if (!is_array($messages) || count($messages) === 0) {
echo json_encode(['error' => 'No messages.']);
exit;
}
$id = $input['id'] ?? '';
if ($id == '') {
if ($id === '') {
$id = uniqid('', true);
}
$title = createTitle($messages);
$model = $input['model'] ?? '';
$model = isset($input['model']) ? (string) $input['model'] : '';
$chat = [
'id' => $id,
'title' => $title,
@ -173,10 +122,10 @@ switch ($action) {
'messages' => $messages
];
$chats = readChats();
$chats = readUserChat($username);
$found = false;
foreach ($chats as &$existing) {
if ($existing['id'] === $id) {
if (($existing['id'] ?? '') === $id) {
$existing = $chat;
$found = true;
break;
@ -187,10 +136,10 @@ switch ($action) {
$chats[] = $chat;
}
if (!writeChats($chats)) {
echo json_encode([
'error' => 'Unable to save.'
]);
$config = getUserConfig($username);
$config['defaultModel'] = $model;
if (!saveUserConfigData($username, $config) || !writeUserChat($username, $chats)) {
echo json_encode(['error' => 'Unable to save.']);
exit;
}
@ -201,40 +150,26 @@ switch ($action) {
]);
exit;
/*------------------------------------------------------
- DELETE
------------------------------------------------------*/
case 'delete':
$id = $_GET['id'] ?? '';
if ($id == '') {
echo json_encode([
'error' => 'Missing id.'
]);
if ($id === '') {
echo json_encode(['error' => 'Missing id.']);
exit;
}
$chats = readChats();
$chats = readUserChat($username);
$newChats = [];
foreach ($chats as $chat) {
if ($chat['id'] !== $id) {
if (($chat['id'] ?? '') !== $id) {
$newChats[] = $chat;
}
}
writeChats($newChats);
echo json_encode([
'success' => true
]);
writeUserChat($username, $newChats);
echo json_encode(['success' => true]);
exit;
/*------------------------------------------------------
- DEFAULT
------------------------------------------------------*/
default:
echo json_encode([
'error' => 'Unknown action.'
]);
echo json_encode(['error' => 'Unknown action.']);
exit;
}

@ -11,6 +11,7 @@ requireAuthentication();
requireAdmin();
$users = loadUsers();
$previousUsers = $users;
$errors = [];
$success = '';
@ -49,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$users[$index]['passwordHash'] = hashPassword($password);
}
$users[$index]['role'] = $role;
if (saveUsers($users)) {
if (saveUsers($users, $previousUsers)) {
$success = 'User updated successfully.';
} else {
$errors[] = 'Unable to update user.';
@ -62,7 +63,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($index >= 0 && isset($users[$index])) {
unset($users[$index]);
$users = array_values($users);
if (saveUsers($users)) {
if (saveUsers($users, $previousUsers)) {
$success = 'User deleted successfully.';
} else {
$errors[] = 'Unable to delete user.';

Loading…
Cancel
Save