86fc568bda
- Set SQLite busyTimeout(5000) to prevent 'database is locked' on concurrent requests - Await all loadEntries() in mutation functions to eliminate race conditions - Remove redundant loadEntries() from batch operations
329 lines
16 KiB
PHP
329 lines
16 KiB
PHP
<?php
|
|
error_reporting(0);
|
|
ini_set('display_errors', 0);
|
|
set_exception_handler(function($e) {
|
|
http_response_code(500);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => 'Server error: ' . $e->getMessage()]);
|
|
exit;
|
|
});
|
|
|
|
header('Content-Type: application/json');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit(0);
|
|
|
|
$db_path = __DIR__ . '/vault.db';
|
|
$db = new SQLite3($db_path);
|
|
$db->enableExceptions(true);
|
|
$db->busyTimeout(5000);
|
|
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
salt TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS folders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE(user_id, name)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS vault_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
site TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
encrypted_password TEXT NOT NULL,
|
|
iv TEXT NOT NULL,
|
|
encryption_method TEXT DEFAULT 'server',
|
|
folder TEXT DEFAULT 'All',
|
|
deleted INTEGER DEFAULT 0,
|
|
deleted_at DATETIME,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
");
|
|
|
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN encryption_method TEXT DEFAULT 'server'"); } catch (Exception $e) {}
|
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN folder TEXT DEFAULT 'All'"); } catch (Exception $e) {}
|
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted INTEGER DEFAULT 0"); } catch (Exception $e) {}
|
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted_at DATETIME"); } catch (Exception $e) {}
|
|
try { $db->exec("ALTER TABLE users DROP COLUMN encryption_key"); } catch (Exception $e) {}
|
|
|
|
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
|
|
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
|
$path = str_replace('/password-manager/api.php', '', $path);
|
|
$path = str_replace('/api.php', '', $path);
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
|
|
function authenticate($db) {
|
|
$headers = getallheaders();
|
|
$token = str_replace('Bearer ', '', $headers['Authorization'] ?? '');
|
|
if (!$token) { http_response_code(401); echo json_encode(['error'=>'No token']); exit; }
|
|
$tokenHash = hash('sha256', $token);
|
|
$st = $db->prepare('SELECT user_id, expires_at FROM sessions WHERE token_hash=:th');
|
|
$st->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
|
$session = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
|
if (!$session) { http_response_code(401); echo json_encode(['error'=>'Invalid session']); exit; }
|
|
if (strtotime($session['expires_at']) < time()) {
|
|
$del = $db->prepare('DELETE FROM sessions WHERE token_hash=:th');
|
|
$del->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
|
$del->execute();
|
|
http_response_code(401); echo json_encode(['error'=>'Session expired']); exit;
|
|
}
|
|
return ['userId' => (int)$session['user_id']];
|
|
}
|
|
|
|
try {
|
|
switch (true) {
|
|
case ($path === '/register' && $method === 'POST'):
|
|
$u = trim($input['username'] ?? '');
|
|
$p = $input['masterPassword'] ?? '';
|
|
if (strlen($u) < 3 || strlen($p) < 8) { http_response_code(400); echo json_encode(['error'=>'Min 3/8 chars']); break; }
|
|
$st = $db->prepare('SELECT id FROM users WHERE username=:u');
|
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
|
if ($st->execute()->fetchArray()) { http_response_code(409); echo json_encode(['error'=>'Username exists']); break; }
|
|
$salt = bin2hex(random_bytes(32));
|
|
$hash = hash_pbkdf2('sha256', $p, $salt, 100000);
|
|
$st = $db->prepare('INSERT INTO users (username, password_hash, salt) VALUES (:u, :h, :s)');
|
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
|
$st->bindValue(':h', $hash, SQLITE3_TEXT);
|
|
$st->bindValue(':s', $salt, SQLITE3_TEXT);
|
|
$st->execute();
|
|
$uid = $db->lastInsertRowID();
|
|
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
|
$stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)');
|
|
foreach ($defaultFolders as $name) {
|
|
$stFolder->bindValue(':uid', $uid, SQLITE3_INTEGER);
|
|
$stFolder->bindValue(':name', $name, SQLITE3_TEXT);
|
|
$stFolder->execute();
|
|
}
|
|
$token = bin2hex(random_bytes(32));
|
|
$tokenHash = hash('sha256', $token);
|
|
$expires = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
|
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)');
|
|
$stS->bindValue(':uid', $uid, SQLITE3_INTEGER);
|
|
$stS->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
|
$stS->bindValue(':exp', $expires, SQLITE3_TEXT);
|
|
$stS->execute();
|
|
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt]);
|
|
break;
|
|
|
|
case ($path === '/login' && $method === 'POST'):
|
|
$u = trim($input['username'] ?? '');
|
|
$p = $input['masterPassword'] ?? '';
|
|
$st = $db->prepare('SELECT * FROM users WHERE username=:u');
|
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
|
$user = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
|
if (!$user) { http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; }
|
|
if (!hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000))) {
|
|
http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break;
|
|
}
|
|
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
|
$stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)');
|
|
foreach ($defaultFolders as $name) {
|
|
$stFolder->bindValue(':uid', $user['id'], SQLITE3_INTEGER);
|
|
$stFolder->bindValue(':name', $name, SQLITE3_TEXT);
|
|
$stFolder->execute();
|
|
}
|
|
$token = bin2hex(random_bytes(32));
|
|
$tokenHash = hash('sha256', $token);
|
|
$expires = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
|
$stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)');
|
|
$stS->bindValue(':uid', $user['id'], SQLITE3_INTEGER);
|
|
$stS->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
|
$stS->bindValue(':exp', $expires, SQLITE3_TEXT);
|
|
$stS->execute();
|
|
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt']]);
|
|
break;
|
|
|
|
case ($path === '/folders' && $method === 'GET'):
|
|
$auth = authenticate($db);
|
|
$st = $db->prepare('SELECT name FROM folders WHERE user_id=:uid ORDER BY name');
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$res = $st->execute();
|
|
$folders = [];
|
|
while ($row = $res->fetchArray(SQLITE3_ASSOC)) { $folders[] = $row['name']; }
|
|
echo json_encode($folders);
|
|
break;
|
|
|
|
case ($path === '/folders' && $method === 'POST'):
|
|
$auth = authenticate($db);
|
|
$name = trim($input['name'] ?? '');
|
|
if (!$name) { http_response_code(400); echo json_encode(['error'=>'Folder name required']); break; }
|
|
if (strtolower($name) === 'all') { http_response_code(400); echo json_encode(['error'=>'Cannot use All']); break; }
|
|
$st = $db->prepare('INSERT INTO folders (user_id, name) VALUES (:uid, :name)');
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->bindValue(':name', $name, SQLITE3_TEXT);
|
|
try { $st->execute(); } catch (Exception $e) { http_response_code(409); echo json_encode(['error'=>'Folder exists']); break; }
|
|
echo json_encode(['message'=>'Created','name'=>$name]);
|
|
break;
|
|
|
|
case (preg_match('/^\/folders\/(.+)$/', $path, $m) && $method === 'DELETE'):
|
|
$auth = authenticate($db);
|
|
$folderName = urldecode($m[1]);
|
|
if ($folderName === 'All') { http_response_code(400); echo json_encode(['error'=>'Cannot delete All']); break; }
|
|
$st = $db->prepare('DELETE FROM folders WHERE user_id=:uid AND name=:name');
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->bindValue(':name', $folderName, SQLITE3_TEXT);
|
|
$st->execute();
|
|
if ($db->changes() === 0) { http_response_code(404); echo json_encode(['error'=>'Not found']); break; }
|
|
$stUp = $db->prepare("UPDATE vault_entries SET folder='All' WHERE user_id=:uid AND folder=:f");
|
|
$stUp->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$stUp->bindValue(':f', $folderName, SQLITE3_TEXT);
|
|
$stUp->execute();
|
|
echo json_encode(['message'=>'Deleted']);
|
|
break;
|
|
|
|
// Entries - exclude deleted by default
|
|
case ($path === '/entries' && $method === 'GET'):
|
|
$auth = authenticate($db);
|
|
$q = $_GET['search'] ?? '';
|
|
$showDeleted = $_GET['deleted'] ?? '0';
|
|
if ($q) {
|
|
$st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid AND deleted=:del AND (site LIKE :q OR username LIKE :q) ORDER BY updated_at DESC');
|
|
$st->bindValue(':q', "%$q%", SQLITE3_TEXT);
|
|
} else {
|
|
$st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid AND deleted=:del ORDER BY updated_at DESC');
|
|
}
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->bindValue(':del', $showDeleted === '1' ? 1 : 0, SQLITE3_INTEGER);
|
|
$res = $st->execute();
|
|
$entries = [];
|
|
while ($r = $res->fetchArray(SQLITE3_ASSOC)) {
|
|
$entries[] = [
|
|
'id' => $r['id'],
|
|
'site' => $r['site'],
|
|
'username' => $r['username'],
|
|
'encrypted_password' => $r['encrypted_password'],
|
|
'iv' => $r['iv'],
|
|
'encryption_method' => $r['encryption_method'] ?? 'server',
|
|
'folder' => $r['folder'] ?? 'All',
|
|
'deleted' => $r['deleted'],
|
|
'deleted_at' => $r['deleted_at'],
|
|
'created_at' => $r['created_at'],
|
|
'updated_at' => $r['updated_at']
|
|
];
|
|
}
|
|
echo json_encode($entries);
|
|
break;
|
|
|
|
case ($path === '/entries' && $method === 'POST'):
|
|
$auth = authenticate($db);
|
|
$site = trim($input['site'] ?? '');
|
|
$username = trim($input['username'] ?? '');
|
|
$folder = trim($input['folder'] ?? 'All');
|
|
$encPwd = $input['encrypted_password'] ?? '';
|
|
$iv = $input['iv'] ?? '';
|
|
if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; }
|
|
$now = date('Y-m-d H:i:s');
|
|
$st = $db->prepare('INSERT INTO vault_entries (user_id, site, username, encrypted_password, iv, encryption_method, folder, created_at, updated_at) VALUES (:uid,:s,:u,:e,:i,:m,:f,:c,:c)');
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->bindValue(':s', $site, SQLITE3_TEXT);
|
|
$st->bindValue(':u', $username, SQLITE3_TEXT);
|
|
$st->bindValue(':e', $encPwd, SQLITE3_TEXT);
|
|
$st->bindValue(':i', $iv, SQLITE3_TEXT);
|
|
$st->bindValue(':m', 'client', SQLITE3_TEXT);
|
|
$st->bindValue(':f', $folder, SQLITE3_TEXT);
|
|
$st->bindValue(':c', $now, SQLITE3_TEXT);
|
|
$st->execute();
|
|
echo json_encode(['id'=>$db->lastInsertRowID(), 'site'=>$site, 'username'=>$username, 'folder'=>$folder]);
|
|
break;
|
|
|
|
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'PUT'):
|
|
$auth = authenticate($db);
|
|
$site = trim($input['site'] ?? '');
|
|
$username = trim($input['username'] ?? '');
|
|
$encPwd = $input['encrypted_password'] ?? '';
|
|
$iv = $input['iv'] ?? '';
|
|
$folder = trim($input['folder'] ?? 'All');
|
|
if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; }
|
|
$now = date('Y-m-d H:i:s');
|
|
$st = $db->prepare('UPDATE vault_entries SET site=:s, username=:u, encrypted_password=:e, iv=:i, folder=:f, updated_at=:c WHERE id=:id AND user_id=:uid');
|
|
$st->bindValue(':s', $site, SQLITE3_TEXT);
|
|
$st->bindValue(':u', $username, SQLITE3_TEXT);
|
|
$st->bindValue(':e', $encPwd, SQLITE3_TEXT);
|
|
$st->bindValue(':i', $iv, SQLITE3_TEXT);
|
|
$st->bindValue(':f', $folder, SQLITE3_TEXT);
|
|
$st->bindValue(':c', $now, SQLITE3_TEXT);
|
|
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->execute();
|
|
echo json_encode(['message'=>'Updated']);
|
|
break;
|
|
|
|
// Soft delete
|
|
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'DELETE'):
|
|
$auth = authenticate($db);
|
|
$permanent = $_GET['permanent'] ?? '0';
|
|
if ($permanent === '1') {
|
|
$st = $db->prepare('DELETE FROM vault_entries WHERE id=:id AND user_id=:uid');
|
|
} else {
|
|
$st = $db->prepare("UPDATE vault_entries SET deleted=1, deleted_at=datetime('now') WHERE id=:id AND user_id=:uid");
|
|
}
|
|
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->execute();
|
|
echo json_encode(['message'=>'Deleted']);
|
|
break;
|
|
|
|
// Restore
|
|
case (preg_match('/^\/entries\/(\d+)\/restore$/', $path, $m) && $method === 'POST'):
|
|
$auth = authenticate($db);
|
|
$st = $db->prepare('UPDATE vault_entries SET deleted=0, deleted_at=NULL, updated_at=datetime(\'now\') WHERE id=:id AND user_id=:uid');
|
|
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->execute();
|
|
echo json_encode(['message'=>'Restored']);
|
|
break;
|
|
|
|
// Empty trash (permanently delete all soft-deleted)
|
|
case ($path === '/logout' && $method === 'POST'):
|
|
$headers = getallheaders();
|
|
$token = str_replace('Bearer ', '', $headers['Authorization'] ?? '');
|
|
if ($token) {
|
|
$tokenHash = hash('sha256', $token);
|
|
$del = $db->prepare('DELETE FROM sessions WHERE token_hash=:th');
|
|
$del->bindValue(':th', $tokenHash, SQLITE3_TEXT);
|
|
$del->execute();
|
|
}
|
|
echo json_encode(['message'=>'Logged out']);
|
|
break;
|
|
|
|
case ($path === '/entries/trash/empty' && $method === 'DELETE'):
|
|
$auth = authenticate($db);
|
|
$st = $db->prepare('DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1');
|
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
|
$st->execute();
|
|
echo json_encode(['message'=>'Trash emptied']);
|
|
break;
|
|
|
|
default:
|
|
http_response_code(404);
|
|
echo json_encode(['error'=>'Not found']);
|
|
}
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
|
|
$db->close();
|
|
?>
|