feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend (closes #53)

This commit is contained in:
Ryan
2025-10-15 23:56:39 -04:00
committed by GitHub
parent f2ab2a96bc
commit 25ce6a76be
14 changed files with 2554 additions and 2206 deletions

View File

@@ -53,6 +53,17 @@ class AdminController
public function getConfig(): void
{
header('Content-Type: application/json');
// Require authenticated admin to read config (prevents information disclosure)
if (
empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
empty($_SESSION['isAdmin'])
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
@@ -62,14 +73,14 @@ class AdminController
// Build a safe subset for the front-end
$safe = [
'header_title' => $config['header_title'],
'loginOptions' => $config['loginOptions'],
'globalOtpauthUrl' => $config['globalOtpauthUrl'],
'enableWebDAV' => $config['enableWebDAV'],
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'],
'header_title' => $config['header_title'] ?? '',
'loginOptions' => $config['loginOptions'] ?? [],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => $config['enableWebDAV'] ?? false,
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'] ?? 0,
'oidc' => [
'providerUrl' => $config['oidc']['providerUrl'],
'redirectUri' => $config['oidc']['redirectUri'],
'providerUrl' => $config['oidc']['providerUrl'] ?? '',
'redirectUri' => $config['oidc']['redirectUri'] ?? '',
// clientSecret and clientId never exposed here
],
];
@@ -137,106 +148,186 @@ class AdminController
* @return void Outputs a JSON response indicating success or failure.
*/
public function updateConfig(): void
{
header('Content-Type: application/json');
{
header('Content-Type: application/json');
// —– auth & CSRF checks —–
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
// —– start merge with existing as base —–
$merged = $existing;
// header_title
if (array_key_exists('header_title', $data)) {
$merged['header_title'] = trim($data['header_title']);
}
// loginOptions: inherit existing then override if provided
$merged['loginOptions'] = $existing['loginOptions'] ?? [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin'=> true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
];
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
// —– auth & CSRF checks —–
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim($data['loginOptions']['authHeaderName']);
if ($hdr !== '') {
$merged['loginOptions']['authHeaderName'] = $hdr;
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']);
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
if (isset($existing['error'])) {
http_response_code(500);
echo json_encode(['error' => $existing['error']]);
exit;
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms !== false) {
// —– start merge with existing as base —–
// Ensure minimal structure if the file was partially missing.
$merged = $existing + [
'header_title' => '',
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
],
'globalOtpauthUrl' => '',
'enableWebDAV' => false,
'sharedMaxUploadSize' => 0,
'oidc' => [
'providerUrl' => '',
'clientId' => '',
'clientSecret'=> '',
'redirectUri' => ''
],
];
// header_title (cap length and strip control chars)
if (array_key_exists('header_title', $data)) {
$title = trim((string)$data['header_title']);
$title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
if (mb_strlen($title) > 100) { // hard cap
$title = mb_substr($title, 0, 100);
}
$merged['header_title'] = $title;
}
// loginOptions: inherit existing then override if provided
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
// very restrictive header-name pattern: letters, numbers, dashes
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
$merged['loginOptions']['authHeaderName'] = $hdr;
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid authHeaderName.']);
exit;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms === false || $sms < 0) {
http_response_code(400);
echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
exit;
}
// Clamp to PHP limits to avoid confusing UX
$maxPost = self::iniToBytes(ini_get('post_max_size'));
$maxFile = self::iniToBytes(ini_get('upload_max_filesize'));
$phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
$sms = $phpCap;
}
$merged['sharedMaxUploadSize'] = $sms;
}
}
// oidc: only overwrite non-empty inputs
$merged['oidc'] = $existing['oidc'] ?? [
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>''
];
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim($data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim((string)$data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
}
$merged['oidc'][$f] = $val;
}
$merged['oidc'][$f] = $val;
}
// If OIDC login is enabled, ensure required fields are present and sane
$oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;
if ($oidcEnabled) {
$prov = $merged['oidc']['providerUrl'] ?? '';
$rid = $merged['oidc']['redirectUri'] ?? '';
$cid = $merged['oidc']['clientId'] ?? '';
// clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow.
if ($prov === '' || $rid === '' || $cid === '') {
http_response_code(400);
echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']);
exit;
}
// Require https except for localhost development
$httpsOk = function(string $url): bool {
if ($url === '') return false;
$parts = parse_url($url);
if (!$parts || empty($parts['scheme'])) return false;
if ($parts['scheme'] === 'https') return true;
if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) {
return true;
}
return false;
};
if (!$httpsOk($prov) || !$httpsOk($rid)) {
http_response_code(400);
echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']);
exit;
}
}
// —– persist merged config —–
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);
}
echo json_encode($result);
exit;
}
// —– persist merged config —–
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);
/** Convert php.ini shorthand like "128M" to bytes */
private static function iniToBytes($val)
{
if ($val === false || $val === null || $val === '') return 0;
$val = trim((string)$val);
$last = strtolower($val[strlen($val)-1]);
$num = (int)$val;
switch ($last) {
case 'g': $num *= 1024;
case 'm': $num *= 1024;
case 'k': $num *= 1024;
}
return $num;
}
echo json_encode($result);
exit;
}
}
?>

View File

@@ -3,9 +3,163 @@
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/FileModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
class FileController
{
/* =========================
* Permission helpers (fail-closed)
* ========================= */
private function isAdmin(array $perms): bool {
// explicit flags in permissions blob
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
// session-based flags commonly set at login
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
// sometimes apps store role in session
$role = $_SESSION['role'] ?? null;
if ($role === 'admin' || $role === '1' || $role === 1) return true;
// definitive fallback: read users.txt role ("1" means admin)
$u = $_SESSION['username'] ?? '';
if ($u) {
$roleStr = userModel::getUserRole($u);
if ($roleStr === '1') return true;
}
return false;
}
private function isFolderOnly(array $perms): bool {
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
}
private function getMetadataPath(string $folder): string {
$folder = trim($folder);
if ($folder === '' || strtolower($folder) === 'root') {
return META_DIR . 'root_metadata.json';
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
private function loadFolderMetadata(string $folder): array {
$meta = $this->getMetadataPath($folder);
if (file_exists($meta)) {
$data = json_decode(file_get_contents($meta), true);
if (is_array($data)) return $data;
}
return [];
}
// Always return an array for user permissions.
private function loadPerms(string $username): array
{
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($username);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$username])) return (array)$all[$username];
$lk = strtolower($username);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) { /* ignore */ }
return [];
}
/** Enforce that (a) folder-only users act only in their subtree, and
* (b) non-admins own all files in the provided list (metadata.uploader === $username).
* Returns an error string on violation, or null if ok. */
private function enforceScopeAndOwnership(string $folder, array $files, string $username, array $userPermissions): ?string {
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
// Folder-only users must stay in "<username>" subtree
if ($this->isFolderOnly($userPermissions) && !$this->isAdmin($userPermissions)) {
$folder = trim($folder);
if ($folder !== '' && strtolower($folder) !== 'root') {
if ($folder !== $username && strpos($folder, $username . '/') !== 0) {
return "Forbidden: folder scope violation.";
}
}
}
if ($ignoreOwnership) return null;
$metadata = $this->loadFolderMetadata($folder);
foreach ($files as $f) {
$name = basename((string)$f);
if (!isset($metadata[$name]['uploader']) || strcasecmp($metadata[$name]['uploader'], $username) !== 0) {
return "Forbidden: you are not the owner of '{$name}'.";
}
}
return null;
}
private function enforceFolderScope(string $folder, string $username, array $userPermissions): ?string {
if ($this->isAdmin($userPermissions)) return null;
if (!$this->isFolderOnly($userPermissions)) return null;
$folder = trim($folder);
if ($folder !== '' && strtolower($folder) !== 'root') {
if ($folder !== $username && strpos($folder, $username . '/') !== 0) {
return "Forbidden: folder scope violation.";
}
}
return null;
}
// --- JSON/session/error helpers (non-breaking additions) ---
private function _jsonStart(): void {
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
// Turn notices/warnings into exceptions so we can return JSON instead of HTML
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return; // respect @-silence
throw new ErrorException($message, 0, $severity, $file, $line);
});
}
private function _jsonEnd(): void {
restore_error_handler();
}
private function _jsonOut(array $payload, int $status = 200): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function _checkCsrf(): bool {
$headersArr = function_exists('getallheaders')
? array_change_key_case(getallheaders(), CASE_LOWER)
: [];
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
$this->_jsonOut(['error' => 'Invalid CSRF token'], 403);
return false;
}
return true;
}
private function _requireAuth(): bool {
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
$this->_jsonOut(['error' => 'Unauthorized'], 401);
return false;
}
return true;
}
private function _readJsonBody(): array {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
/**
* @OA\Post(
* path="/api/file/copyFiles.php",
@@ -73,8 +227,8 @@ class FileController
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
exit;
}
@@ -106,6 +260,12 @@ class FileController
exit;
}
// Scope + ownership on source; scope on destination
$violation = $this->enforceScopeAndOwnership($sourceFolder, $files, $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files);
echo json_encode($result);
@@ -177,7 +337,7 @@ class FileController
// Load user's permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
$userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit;
@@ -199,6 +359,10 @@ class FileController
}
$folder = trim($folder, "/\\ ");
// Scope + ownership
$violation = $this->enforceScopeAndOwnership($folder, $data['files'], $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
// Delegate to the FileModel.
$result = FileModel::deleteFiles($folder, $data['files']);
echo json_encode($result);
@@ -271,8 +435,8 @@ class FileController
// Verify that the user is not read-only.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to move files."]);
exit;
}
@@ -303,6 +467,12 @@ class FileController
exit;
}
// Scope + ownership on source; scope on destination
$violation = $this->enforceScopeAndOwnership($sourceFolder, $data['files'], $username, $userPermissions);
if ($violation) { http_response_code(403); echo json_encode(["error"=>$violation]); return; }
$dv = $this->enforceFolderScope($destinationFolder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']);
echo json_encode($result);
@@ -351,64 +521,63 @@ class FileController
* @return void Outputs a JSON response.
*/
public function renameFile()
{
header('Content-Type: application/json');
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify user permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to rename files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to rename files."], 403);
return;
}
// Get JSON input.
$data = json_decode(file_get_contents("php://input"), true);
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) {
http_response_code(400);
echo json_encode(["error" => "Invalid input"]);
exit;
$data = $this->_readJsonBody();
if (!$data || !isset($data['folder'], $data['oldName'], $data['newName'])) {
$this->_jsonOut(["error" => "Invalid input"], 400);
return;
}
$folder = trim($data['folder']) ?: 'root';
// Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes.
$folder = trim((string)$data['folder']) ?: 'root';
$oldName = basename(trim((string)$data['oldName']));
$newName = basename(trim((string)$data['newName']));
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
$this->_jsonOut(["error" => "Invalid folder name"], 400);
return;
}
if ($oldName === '' || !preg_match(REGEX_FILE_NAME, $oldName)) {
$this->_jsonOut(["error" => "Invalid old file name."], 400);
return;
}
if ($newName === '' || !preg_match(REGEX_FILE_NAME, $newName)) {
$this->_jsonOut(["error" => "Invalid new file name."], 400);
return;
}
$oldName = basename(trim($data['oldName']));
$newName = basename(trim($data['newName']));
// Non-admin must own the original
$violation = $this->enforceScopeAndOwnership($folder, [$oldName], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
// Validate file names.
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
echo json_encode(["error" => "Invalid file name."]);
exit;
}
// Delegate the renaming operation to the model.
$result = FileModel::renameFile($folder, $oldName, $newName);
echo json_encode($result);
if (!is_array($result)) {
throw new RuntimeException('FileModel::renameFile returned non-array');
}
if (isset($result['error'])) {
$this->_jsonOut($result, 400);
return;
}
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::renameFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while renaming file.'], 500);
} finally {
$this->_jsonEnd();
}
}
/**
* @OA\Post(
@@ -452,63 +621,75 @@ class FileController
* @return void Outputs a JSON response.
*/
public function saveFile()
{
header('Content-Type: application/json');
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// --- Authentication Check ---
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
$username = $_SESSION['username'] ?? '';
// --- Readonly check ---
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to save files."], 403);
return;
}
// --- Input parsing ---
$data = json_decode(file_get_contents("php://input"), true);
$data = $this->_readJsonBody();
if (empty($data) || !isset($data["fileName"], $data["content"])) {
http_response_code(400);
echo json_encode(["error" => "Invalid request data", "received" => $data]);
exit;
$this->_jsonOut(["error" => "Invalid request data"], 400);
return;
}
$fileName = basename($data["fileName"]);
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
$fileName = basename(trim((string)$data["fileName"]));
$folder = isset($data["folder"]) ? trim((string)$data["folder"]) : "root";
// --- Folder validation ---
if ($fileName === '' || !preg_match(REGEX_FILE_NAME, $fileName)) {
$this->_jsonOut(["error" => "Invalid file name."], 400);
return;
}
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
$this->_jsonOut(["error" => "Invalid folder name"], 400);
return;
}
$folder = trim($folder, "/\\ ");
// --- Delegate to model, passing the uploader ---
// Make sure FileModel::saveFile signature is:
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
$result = FileModel::saveFile(
$folder,
$fileName,
$data["content"],
$username // ← pass the real uploader here
);
// Folder-only users may only write within their scope
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
echo json_encode($result);
// If overwriting, enforce ownership for non-admins
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$dir = (strtolower($folder) === 'root') ? $baseDir : $baseDir . DIRECTORY_SEPARATOR . $folder;
$path = $dir . DIRECTORY_SEPARATOR . $fileName;
if (is_file($path)) {
$violation = $this->enforceScopeAndOwnership($folder, [$fileName], $username, $userPermissions);
if ($violation) { $this->_jsonOut(["error"=>$violation], 403); return; }
}
// Server-side guard: block saving executable/server-side script types
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','shtml','cgi','fcgi'];
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (in_array($ext, $deny, true)) {
$this->_jsonOut(['error' => 'Saving this file type is not allowed.'], 400);
return;
}
$result = FileModel::saveFile($folder, $fileName, (string)$data["content"], $username);
if (!is_array($result)) {
throw new RuntimeException('FileModel::saveFile returned non-array');
}
if (isset($result['error'])) {
$this->_jsonOut($result, 400);
return;
}
$this->_jsonOut($result);
} catch (Throwable $e) {
error_log('FileController::saveFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while saving file.'], 500);
} finally {
$this->_jsonEnd();
}
}
/**
* @OA\Get(
@@ -582,6 +763,23 @@ class FileController
exit;
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Retrieve download info from the model.
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
@@ -676,6 +874,13 @@ class FileController
exit;
}
if (!$this->isAdmin($userPermissions) && array_key_exists('canZip', $userPermissions) && !$userPermissions['canZip']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(["error" => "ZIP downloads are not allowed for your account."]);
exit;
}
// Read and decode JSON input.
$data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data) || !isset($data['folder']) || !isset($data['files']) || !is_array($data['files'])) {
@@ -701,6 +906,22 @@ class FileController
}
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Create ZIP archive using FileModel.
$result = FileModel::createZipArchive($folder, $files);
if (isset($result['error'])) {
@@ -819,6 +1040,12 @@ class FileController
}
}
// Folder-only users can only extract inside their subtree
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { http_response_code(403); echo json_encode(["error"=>$dv]); return; }
// Delegate to the model.
$result = FileModel::extractZipArchive($folder, $files);
echo json_encode($result);
@@ -1078,13 +1305,19 @@ class FileController
// Check user permissions.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if ($username && !empty($userPermissions['readOnly'])) {
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
http_response_code(403);
echo json_encode(["error" => "Read-only users are not allowed to create share links."]);
exit;
}
if (!$this->isAdmin($userPermissions) && array_key_exists('canShare', $userPermissions) && !$userPermissions['canShare']) {
http_response_code(403);
echo json_encode(["error" => "You are not allowed to create share links."]);
exit;
}
// Parse POST JSON input.
$input = json_decode(file_get_contents("php://input"), true);
if (!$input) {
@@ -1107,6 +1340,23 @@ class FileController
exit;
}
// Non-admins can only share their own files
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Convert the provided value+unit into seconds
switch ($unit) {
case 'seconds':
@@ -1349,7 +1599,7 @@ class FileController
// Delegate deletion to the model.
$result = FileModel::deleteTrashFiles($filesToDelete);
// Build a humanfriendly success or error message
// Build a human-friendly success or error message
if (!empty($result['deleted'])) {
$count = count($result['deleted']);
$msg = "Trash item" . ($count === 1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']);
@@ -1469,7 +1719,7 @@ class FileController
// Check that the user is not read-only.
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
$userPermissions = $this->loadPerms($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to file tags"]);
exit;
@@ -1502,6 +1752,22 @@ class FileController
exit;
}
// Ownership enforcement (allow admin OR bypassOwnership)
$username = $_SESSION['username'] ?? '';
$userPermissions = $this->loadPerms($username);
$ignoreOwnership = $this->isAdmin($userPermissions)
|| ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
if (!$ignoreOwnership) {
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || $meta[$file]['uploader'] !== $username) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
}
// Delegate to the model.
$result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete);
echo json_encode($result);
@@ -1545,32 +1811,96 @@ class FileController
* @return void Outputs JSON response.
*/
public function getFileList(): void
{
header('Content-Type: application/json');
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
header('Content-Type: application/json; charset=utf-8');
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return;
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
if (empty($_SESSION['username'])) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
echo json_encode(['error' => 'Unauthorized']);
return;
}
// Retrieve the folder from GET; default to "root".
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
if (!is_dir(META_DIR)) {
@mkdir(META_DIR, 0775, true);
}
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
if (!is_dir(UPLOAD_DIR)) {
http_response_code(500);
echo json_encode(['error' => 'Uploads directory not found.']);
return;
}
// Delegate to the model.
$result = FileModel::getFileList($folder);
if ($result === false || $result === null) {
http_response_code(500);
echo json_encode(['error' => 'File model failed.']);
return;
}
if (!is_array($result)) {
throw new RuntimeException('FileModel::getFileList returned a non-array.');
}
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
echo json_encode($result);
exit;
// --- viewOwnOnly (for non-admins) ---
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$admin = $this->isAdmin($perms);
$ownOnly = !$admin && !empty($perms['viewOwnOnly']);
if ($ownOnly && isset($result['files'])) {
$files = $result['files'];
if (is_array($files) && array_keys($files) !== range(0, count($files) - 1)) {
// associative: name => meta
$filtered = [];
foreach ($files as $name => $meta) {
if (!isset($meta['uploader']) || strcasecmp((string)$meta['uploader'], $username) === 0) {
$filtered[$name] = $meta;
}
}
$result['files'] = $filtered;
} elseif (is_array($files)) {
// list of objects
$result['files'] = array_values(array_filter($files, function ($f) use ($username) {
return !isset($f['uploader']) || strcasecmp((string)$f['uploader'], $username) === 0;
}));
}
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
} catch (Throwable $e) {
error_log('FileController::getFileList error: ' . $e->getMessage() .
' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo json_encode(['error' => 'Internal server error while listing files.']);
} finally {
restore_error_handler();
}
}
/**
* GET /api/file/getShareLinks.php
@@ -1631,26 +1961,44 @@ class FileController
* POST /api/file/createFile.php
*/
public function createFile(): void
{
{
$this->_jsonStart();
try {
if (!$this->_requireAuth()) return;
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
exit;
$userPermissions = $this->loadPerms($username);
if (!$this->isAdmin($userPermissions) && !empty($userPermissions['readOnly'])) {
$this->_jsonOut(["error" => "Read-only users are not allowed to create files."], 403);
return;
}
$body = json_decode(file_get_contents('php://input'), true);
$folder = $body['folder'] ?? 'root';
$filename = $body['name'] ?? '';
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
$body = $this->_readJsonBody();
$folder = isset($body['folder']) ? trim((string)$body['folder']) : 'root';
$filename = isset($body['name']) ? basename(trim((string)$body['name'])) : '';
if (!$result['success']) {
http_response_code($result['code'] ?? 400);
echo json_encode(['success'=>false,'error'=>$result['error']]);
} else {
echo json_encode(['success'=>true]);
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
$this->_jsonOut(["error" => "Invalid folder name."], 400); return;
}
if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) {
$this->_jsonOut(["error" => "Invalid file name."], 400); return;
}
$dv = $this->enforceFolderScope($folder, $username, $userPermissions);
if ($dv) { $this->_jsonOut(["error"=>$dv], 403); return; }
$result = FileModel::createFile($folder, $filename, $username);
if (empty($result['success'])) {
$this->_jsonOut(['success'=>false,'error'=>$result['error'] ?? 'Failed to create file'], $result['code'] ?? 400);
return;
}
$this->_jsonOut(['success'=>true]);
} catch (Throwable $e) {
error_log('FileController::createFile error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while creating file.'], 500);
} finally {
$this->_jsonEnd();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,106 @@
<?php
// UserController.php located in src/controllers/
// src/controllers/UserController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
/**
* UserController
* - Hardened CSRF/auth checks (works even when getallheaders() is unavailable)
* - Consistent method checks without breaking existing clients (accepts POST as fallback for some endpoints)
* - Stricter validation & safer defaults
* - Fixed TOTP setup bug for pending-login users
* - Standardized calls to UserModel (proper case)
*/
class UserController
{
/* ---------- Small internal helpers to reduce repetition ---------- */
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = [];
foreach ($headers as $k => $v) {
$out[strtolower($k)] = $v;
}
// Fallbacks from $_SERVER if needed
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$h = strtolower(str_replace('_', '-', substr($k, 5)));
if (!isset($out[$h])) $out[$h] = $v;
}
}
return $out;
}
/** Enforce allowed HTTP method(s); default to 405 if not allowed. */
private static function requireMethod(array $allowed): void
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, $allowed, true)) {
http_response_code(405);
header('Allow: ' . implode(', ', $allowed));
header('Content-Type: application/json');
echo json_encode(['error' => 'Method not allowed']);
exit;
}
}
/** Enforce authentication (401). */
private static function requireAuth(): void
{
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/** Enforce admin (401). */
private static function requireAdmin(): void
{
self::requireAuth();
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
private static function requireCsrf(): void
{
$h = self::headersLower();
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
}
/** Read JSON body (empty array if not valid). */
private static function readJson(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
/** Convenience: set JSON content type + no-store. */
private static function jsonHeaders(): void
{
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
}
/* ------------------------- End helpers -------------------------- */
/**
* @OA\Get(
* path="/api/getUsers.php",
@@ -31,24 +126,15 @@ class UserController
* )
* )
*/
public function getUsers()
{
header('Content-Type: application/json');
// Check authentication and admin privileges.
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
self::jsonHeaders();
self::requireAdmin();
// Retrieve users using the model
$users = userModel::getAllUsers();
$users = UserModel::getAllUsers();
echo json_encode($users);
exit;
}
/**
@@ -84,34 +170,33 @@ class UserController
* )
* )
*/
public function addUser()
{
// 1) Ensure JSON output and session
header('Content-Type: application/json');
self::jsonHeaders();
self::requireMethod(['POST']);
// 1a) Initialize CSRF token if missing
// Initialize CSRF token if missing (useful for initial page load)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 2) Determine setup mode (first-ever admin creation)
// Setup mode detection (first-run bootstrap)
$usersFile = USERS_DIR . USERS_FILE;
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
$setupMode = false;
if (
$isSetup && (! file_exists($usersFile)
$isSetup && (!file_exists($usersFile)
|| filesize($usersFile) === 0
|| trim(file_get_contents($usersFile)) === ''
|| trim(@file_get_contents($usersFile)) === ''
)
) {
$setupMode = true;
} else {
// 3) In non-setup, enforce CSRF + auth checks
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
// Not setup: enforce CSRF + admin auth
$h = self::headersLower();
$receivedToken = trim($h['x-csrf-token'] ?? '');
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
// Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX)
if ($receivedToken !== $_SESSION['csrf_token']) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
@@ -122,31 +207,15 @@ class UserController
exit;
}
// 3b) Must be logged in as admin
if (
empty($_SESSION['authenticated'])
|| $_SESSION['authenticated'] !== true
|| empty($_SESSION['isAdmin'])
|| $_SESSION['isAdmin'] !== true
) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
self::requireAdmin();
}
// 4) Parse input
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$data = self::readJson();
$newUsername = trim($data['username'] ?? '');
$newPassword = trim($data['password'] ?? '');
// 5) Determine admin flag
if ($setupMode) {
$isAdmin = '1';
} else {
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
}
$isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');
// 6) Validate fields
if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]);
exit;
@@ -157,11 +226,13 @@ class UserController
]);
exit;
}
// Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars
if (strlen($newPassword) < 6) {
echo json_encode(["error" => "Password must be at least 6 characters."]);
exit;
}
// 7) Delegate to model
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
// 8) Return model result
$result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
echo json_encode($result);
exit;
}
@@ -201,54 +272,33 @@ class UserController
* )
* )
*/
public function removeUser()
{
header('Content-Type: application/json');
self::jsonHeaders();
// Accept DELETE or POST for broader compatibility
self::requireMethod(['DELETE', 'POST']);
self::requireAdmin();
self::requireCsrf();
// CSRF token check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
$data = self::readJson();
$usernameToRemove = trim($data['username'] ?? '');
// Authentication and admin check.
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Retrieve JSON data.
$data = json_decode(file_get_contents("php://input"), true);
$usernameToRemove = trim($data["username"] ?? "");
if (!$usernameToRemove) {
if ($usernameToRemove === '') {
echo json_encode(["error" => "Username is required"]);
exit;
}
// Validate the username format.
if (!preg_match(REGEX_USER, $usernameToRemove)) {
echo json_encode(["error" => "Invalid username format"]);
exit;
}
// Prevent removal of the currently logged-in user.
if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
echo json_encode(["error" => "Cannot remove yourself"]);
exit;
}
// Delegate the removal logic to the model.
$result = userModel::removeUser($usernameToRemove);
$result = UserModel::removeUser($usernameToRemove);
echo json_encode($result);
exit;
}
/**
@@ -269,21 +319,14 @@ class UserController
* )
* )
*/
public function getUserPermissions()
{
header('Content-Type: application/json');
self::jsonHeaders();
self::requireAuth();
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Delegate to the model.
$permissions = userModel::getUserPermissions();
$permissions = UserModel::getUserPermissions();
echo json_encode($permissions);
exit;
}
/**
@@ -331,42 +374,24 @@ class UserController
* )
* )
*/
public function updateUserPermissions()
{
header('Content-Type: application/json');
self::jsonHeaders();
// Accept PUT or POST for compatibility with clients that can't send PUT
self::requireMethod(['PUT', 'POST']);
self::requireAdmin();
self::requireCsrf();
// Only admins can update permissions.
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify CSRF token from headers.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Get POST input.
$input = json_decode(file_get_contents("php://input"), true);
$input = self::readJson();
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
echo json_encode(["error" => "Invalid input"]);
exit;
}
$permissions = $input['permissions'];
// Delegate to the model.
$result = userModel::updateUserPermissions($permissions);
$result = UserModel::updateUserPermissions($permissions);
echo json_encode($result);
exit;
}
/**
@@ -406,41 +431,25 @@ class UserController
* )
* )
*/
public function changePassword()
{
header('Content-Type: application/json');
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
self::jsonHeaders();
self::requireMethod(['POST']);
self::requireAuth();
self::requireCsrf();
$username = $_SESSION['username'] ?? '';
if (!$username) {
if ($username === '') {
echo json_encode(["error" => "No username in session"]);
exit;
}
// CSRF token check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Get POST data.
$data = json_decode(file_get_contents("php://input"), true);
$oldPassword = trim($data["oldPassword"] ?? "");
$newPassword = trim($data["newPassword"] ?? "");
$data = self::readJson();
$oldPassword = trim($data["oldPassword"] ?? "");
$newPassword = trim($data["newPassword"] ?? "");
$confirmPassword = trim($data["confirmPassword"] ?? "");
// Validate input.
if (!$oldPassword || !$newPassword || !$confirmPassword) {
if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') {
echo json_encode(["error" => "All fields are required."]);
exit;
}
@@ -448,10 +457,14 @@ class UserController
echo json_encode(["error" => "New passwords do not match."]);
exit;
}
if (strlen($newPassword) < 6) {
echo json_encode(["error" => "Password must be at least 6 characters."]);
exit;
}
// Delegate password change logic to the model.
$result = userModel::changePassword($username, $oldPassword, $newPassword);
$result = UserModel::changePassword($username, $oldPassword, $newPassword);
echo json_encode($result);
exit;
}
/**
@@ -489,29 +502,15 @@ class UserController
* )
* )
*/
public function updateUserPanel()
{
header('Content-Type: application/json');
self::jsonHeaders();
// Accept PUT or POST for compatibility
self::requireMethod(['PUT', 'POST']);
self::requireAuth();
self::requireCsrf();
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify the CSRF token.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Get the POST input.
$data = json_decode(file_get_contents("php://input"), true);
$data = self::readJson();
if (!is_array($data)) {
http_response_code(400);
echo json_encode(["error" => "Invalid input"]);
@@ -519,18 +518,16 @@ class UserController
}
$username = $_SESSION['username'] ?? '';
if (!$username) {
if ($username === '') {
http_response_code(400);
echo json_encode(["error" => "No username in session"]);
exit;
}
// Extract totp_enabled, converting it to boolean.
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
// Delegate to the model.
$result = userModel::updateUserPanel($username, $totp_enabled);
$result = UserModel::updateUserPanel($username, $totp_enabled);
echo json_encode($result);
exit;
}
/**
@@ -558,43 +555,29 @@ class UserController
* )
* )
*/
public function disableTOTP()
{
header('Content-Type: application/json');
// Authentication check.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
echo json_encode(["error" => "Not authenticated"]);
exit;
}
self::jsonHeaders();
// Accept PUT or POST
self::requireMethod(['PUT', 'POST']);
self::requireAuth();
self::requireCsrf();
$username = $_SESSION['username'] ?? '';
if (empty($username)) {
if ($username === '') {
http_response_code(400);
echo json_encode(["error" => "Username not found in session"]);
exit;
}
// CSRF token check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Delegate the TOTP disabling logic to the model.
$result = userModel::disableTOTPSecret($username);
$result = UserModel::disableTOTPSecret($username);
if ($result) {
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
} else {
http_response_code(500);
echo json_encode(["error" => "Failed to disable TOTP."]);
}
exit;
}
/**
@@ -636,61 +619,45 @@ class UserController
* )
* )
*/
public function recoverTOTP()
{
header('Content-Type: application/json');
self::jsonHeaders();
self::requireMethod(['POST']);
self::requireCsrf();
// 1) Only allow POST.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
}
// 2) CSRF check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
}
// 3) Identify the user.
$userId = $_SESSION['username'] ?? $_SESSION['pending_login_user'] ?? null;
$userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null);
if (!$userId) {
http_response_code(401);
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
// 4) Validate userId format.
if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400);
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
exit;
}
// 5) Get the recovery code from input.
$inputData = json_decode(file_get_contents("php://input"), true);
$inputData = self::readJson();
$recoveryCode = $inputData['recovery_code'] ?? '';
// 6) Delegate to the model.
$result = userModel::recoverTOTP($userId, $recoveryCode);
$result = UserModel::recoverTOTP($userId, $recoveryCode);
if ($result['status'] === 'ok') {
// 7) Finalize login.
if (($result['status'] ?? '') === 'ok') {
// Finalize login
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $userId;
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
echo json_encode(['status' => 'ok']);
} else {
// Set appropriate HTTP code for errors.
if ($result['message'] === 'Too many attempts. Try again later.') {
if (($result['message'] ?? '') === 'Too many attempts. Try again later.') {
http_response_code(429);
} else {
http_response_code(400);
}
echo json_encode($result);
}
exit;
}
/**
@@ -722,49 +689,33 @@ class UserController
* )
* )
*/
public function saveTOTPRecoveryCode()
{
header('Content-Type: application/json');
self::jsonHeaders();
self::requireMethod(['POST']);
self::requireCsrf();
// 1) Only allow POST requests.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
}
// 2) CSRF token check.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
}
// 3) Ensure the user is authenticated.
if (empty($_SESSION['username'])) {
http_response_code(401);
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}");
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
exit;
}
// 4) Validate the username format.
$userId = $_SESSION['username'];
if (!preg_match(REGEX_USER, $userId)) {
http_response_code(400);
error_log("totp_saveCode: invalid username format: {$userId}");
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
exit;
}
// 5) Delegate to the model.
$result = userModel::saveTOTPRecoveryCode($userId);
if ($result['status'] === 'ok') {
$result = UserModel::saveTOTPRecoveryCode($userId);
if (($result['status'] ?? '') === 'ok') {
echo json_encode($result);
} else {
http_response_code(500);
echo json_encode($result);
}
exit;
}
/**
@@ -791,43 +742,40 @@ class UserController
* )
* )
*/
public function setupTOTP()
{
// Allow access if the user is authenticated or pending TOTP.
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) {
// Allow access if authenticated OR pending TOTP
if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) {
http_response_code(403);
exit(json_encode(["error" => "Not authorized to access TOTP setup"]));
}
// Verify CSRF token from headers.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
header('Content-Type: application/json');
echo json_encode(["error" => "Not authorized to access TOTP setup"]);
exit;
}
$username = $_SESSION['username'] ?? '';
if (!$username) {
self::requireCsrf();
// Fix: if username not present (pending flow), fall back to pending_login_user
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
if ($username === '') {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Username not available for TOTP setup']);
exit;
}
// Set header for PNG output.
header("Content-Type: image/png");
header('X-Content-Type-Options: nosniff');
// Delegate the TOTP setup work to the model.
$result = userModel::setupTOTP($username);
$result = UserModel::setupTOTP($username);
if (isset($result['error'])) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(["error" => $result['error']]);
exit;
}
// Output the QR code image.
echo $result['imageData'];
exit;
}
/**
@@ -866,11 +814,11 @@ class UserController
* )
* )
*/
public function verifyTOTP()
{
header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
header('X-Content-Type-Options: nosniff');
// Rate-limit
if (!isset($_SESSION['totp_failures'])) {
@@ -890,16 +838,10 @@ class UserController
}
// CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit;
}
self::requireCsrf();
// Parse & validate input
$inputData = json_decode(file_get_contents("php://input"), true);
$inputData = self::readJson();
$code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400);
@@ -916,11 +858,11 @@ class UserController
\RobThree\Auth\Algorithm::Sha1
);
// === Pending-login flow (we just came from auth and need to finish login) ===
// Pending-login flow
if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user'];
$username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
$_SESSION['totp_failures']++;
@@ -939,13 +881,14 @@ class UserController
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: [];
}
$perms = loadUserPermissions($username);
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
'username' => $username,
'expiry' => $expiry,
'isAdmin' => ((int)UserModel::getUserRole($username) === 1),
'folderOnly' => $perms['folderOnly'] ?? false,
'readOnly' => $perms['readOnly'] ?? false,
'disableUpload' => $perms['disableUpload'] ?? false
];
file_put_contents(
$tokFile,
@@ -957,17 +900,16 @@ class UserController
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
}
// === Finalize login into session exactly as finalizeLogin() would ===
// Finalize login
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
$_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1);
$perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
// Clean up pending markers
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
@@ -975,7 +917,6 @@ class UserController
$_SESSION['totp_failures']
);
// Send back full login payload
echo json_encode([
'status' => 'ok',
'success' => 'Login successful',
@@ -990,13 +931,13 @@ class UserController
// Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? '';
if (!$username) {
if ($username === '') {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
exit;
}
$totpSecret = userModel::getTOTPSecret($username);
$totpSecret = UserModel::getTOTPSecret($username);
if (!$totpSecret) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
@@ -1010,34 +951,22 @@ class UserController
exit;
}
// Successful setup/verification
unset($_SESSION['totp_failures']);
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
exit;
}
/**
* Upload profile picture (multipart/form-data)
*/
public function uploadPicture()
{
header('Content-Type: application/json');
self::jsonHeaders();
// 1) Auth check
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
// Auth & CSRF
self::requireAuth();
self::requireCsrf();
// 2) CSRF check
$headers = function_exists('getallheaders')
? array_change_key_case(getallheaders(), CASE_LOWER)
: [];
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// 3) File presence
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
@@ -1045,7 +974,7 @@ class UserController
}
$file = $_FILES['profile_picture'];
// 4) Validate MIME & size
// Validate MIME & size
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
@@ -1061,32 +990,29 @@ class UserController
exit;
}
// 5) Destination under public/uploads/profile_pics
$uploadDir = UPLOAD_DIR . '/profile_pics';
// Destination
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
// 6) Move file
$ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = "$uploadDir/$filename";
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
// 7) Build public URL
// Assuming /uploads maps to UPLOAD_DIR publicly
$url = '/uploads/profile_pics/' . $filename;
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!$result['success']) {
// on failure, remove the file we just wrote
if (!($result['success'] ?? false)) {
@unlink($dest);
http_response_code(500);
echo json_encode([
@@ -1095,9 +1021,7 @@ class UserController
]);
exit;
}
// ─────────────────────────────────────────────────
// 8) Return success
echo json_encode(['success' => true, 'url' => $url]);
exit;
}