From f3977153fbccec6ed77051206c56359b7d76806b Mon Sep 17 00:00:00 2001 From: Ryan Date: Sat, 3 May 2025 23:41:02 -0400 Subject: [PATCH] Refactor AdminPanel: extract module, add collapsible sections & shared-links management, enforce PascalCase controllers --- public/api/addUser.php | 2 +- public/api/admin/getConfig.php | 2 +- public/api/admin/readMetadata.php | 44 + public/api/admin/updateConfig.php | 2 +- public/api/auth/auth.php | 2 +- public/api/auth/checkAuth.php | 2 +- public/api/auth/login_basic.php | 2 +- public/api/auth/logout.php | 2 +- public/api/auth/token.php | 2 +- public/api/changePassword.php | 2 +- public/api/file/copyFiles.php | 2 +- public/api/file/createShareLink.php | 2 +- public/api/file/deleteFiles.php | 2 +- public/api/file/deleteShareLink.php | 6 + public/api/file/deleteTrashFiles.php | 2 +- public/api/file/download.php | 2 +- public/api/file/downloadZip.php | 2 +- public/api/file/extractZip.php | 2 +- public/api/file/getFileList.php | 2 +- public/api/file/getFileTag.php | 2 +- public/api/file/getShareLinks.php | 6 + public/api/file/getTrashItems.php | 2 +- public/api/file/moveFiles.php | 2 +- public/api/file/renameFile.php | 2 +- public/api/file/restoreFiles.php | 2 +- public/api/file/saveFile.php | 2 +- public/api/file/saveFileTag.php | 2 +- public/api/file/share.php | 2 +- public/api/folder/createFolder.php | 2 +- public/api/folder/createShareFolderLink.php | 2 +- public/api/folder/deleteFolder.php | 2 +- public/api/folder/deleteShareFolderLink.php | 6 + public/api/folder/downloadSharedFile.php | 2 +- public/api/folder/getFolderList.php | 2 +- public/api/folder/getShareFolderLinks.php | 6 + public/api/folder/renameFolder.php | 2 +- public/api/folder/shareFolder.php | 2 +- public/api/folder/uploadToSharedFolder.php | 2 +- public/api/getUserPermissions.php | 2 +- public/api/getUsers.php | 2 +- public/api/removeUser.php | 2 +- public/api/totp_disable.php | 2 +- public/api/totp_recover.php | 2 +- public/api/totp_saveCode.php | 2 +- public/api/totp_setup.php | 2 +- public/api/totp_verify.php | 2 +- public/api/updateUserPanel.php | 2 +- public/api/updateUserPermissions.php | 2 +- public/api/upload/removeChunks.php | 2 +- public/api/upload/upload.php | 2 +- public/js/adminPanel.js | 652 ++++++++ public/js/auth.js | 3 +- public/js/authModals.js | 537 ------- public/js/i18n.js | 14 + src/controllers/AdminController.php | 232 +++ src/controllers/AuthController.php | 626 ++++++++ src/controllers/FileController.php | 1604 +++++++++++++++++++ src/controllers/FolderController.php | 1107 +++++++++++++ src/controllers/UploadController.php | 199 +++ src/controllers/UserController.php | 1014 ++++++++++++ src/controllers/adminController.php | 2 +- src/controllers/authController.php | 2 +- src/controllers/fileController.php | 32 +- src/controllers/folderController.php | 32 +- src/controllers/uploadController.php | 2 +- src/controllers/userController.php | 2 +- src/models/FileModel.php | 25 + src/models/FolderModel.php | 25 + 68 files changed, 5678 insertions(+), 590 deletions(-) create mode 100644 public/api/admin/readMetadata.php create mode 100644 public/api/file/deleteShareLink.php create mode 100644 public/api/file/getShareLinks.php create mode 100644 public/api/folder/deleteShareFolderLink.php create mode 100644 public/api/folder/getShareFolderLinks.php create mode 100644 public/js/adminPanel.js create mode 100644 src/controllers/AdminController.php create mode 100644 src/controllers/AuthController.php create mode 100644 src/controllers/FileController.php create mode 100644 src/controllers/FolderController.php create mode 100644 src/controllers/UploadController.php create mode 100644 src/controllers/UserController.php diff --git a/public/api/addUser.php b/public/api/addUser.php index 7bed442..9c90059 100644 --- a/public/api/addUser.php +++ b/public/api/addUser.php @@ -2,7 +2,7 @@ // public/api/addUser.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->addUser(); \ No newline at end of file diff --git a/public/api/admin/getConfig.php b/public/api/admin/getConfig.php index fba6d4a..baf2182 100644 --- a/public/api/admin/getConfig.php +++ b/public/api/admin/getConfig.php @@ -2,7 +2,7 @@ // public/api/admin/getConfig.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/adminController.php'; +require_once PROJECT_ROOT . '/src/controllers/AdminController.php'; $adminController = new AdminController(); $adminController->getConfig(); \ No newline at end of file diff --git a/public/api/admin/readMetadata.php b/public/api/admin/readMetadata.php new file mode 100644 index 0000000..5a5baae --- /dev/null +++ b/public/api/admin/readMetadata.php @@ -0,0 +1,44 @@ +'Forbidden']); + exit; +} + +// Expect a ?file=share_links.json or share_folder_links.json +if (empty($_GET['file'])) { + http_response_code(400); + echo json_encode(['error'=>'Missing `file` parameter']); + exit; +} + +$file = basename($_GET['file']); +$allowed = ['share_links.json','share_folder_links.json']; +if (!in_array($file, $allowed, true)) { + http_response_code(403); + echo json_encode(['error'=>'Invalid file requested']); + exit; +} + +$path = META_DIR . $file; +if (!file_exists($path)) { + http_response_code(404); + echo json_encode((object)[]); // return empty object + exit; +} + +$data = file_get_contents($path); +$json = json_decode($data, true); +if (json_last_error() !== JSON_ERROR_NONE) { + http_response_code(500); + echo json_encode(['error'=>'Corrupted JSON']); + exit; +} + +header('Content-Type: application/json'); +echo json_encode($json); \ No newline at end of file diff --git a/public/api/admin/updateConfig.php b/public/api/admin/updateConfig.php index 9aeb185..412031c 100644 --- a/public/api/admin/updateConfig.php +++ b/public/api/admin/updateConfig.php @@ -2,7 +2,7 @@ // public/api/admin/updateConfig.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/adminController.php'; +require_once PROJECT_ROOT . '/src/controllers/AdminController.php'; $adminController = new AdminController(); $adminController->updateConfig(); \ No newline at end of file diff --git a/public/api/auth/auth.php b/public/api/auth/auth.php index a31d8f9..694b8cf 100644 --- a/public/api/auth/auth.php +++ b/public/api/auth/auth.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/../../../config/config.php'; require_once PROJECT_ROOT . '/vendor/autoload.php'; -require_once PROJECT_ROOT . '/src/controllers/authController.php'; +require_once PROJECT_ROOT . '/src/controllers/AuthController.php'; $authController = new AuthController(); $authController->auth(); \ No newline at end of file diff --git a/public/api/auth/checkAuth.php b/public/api/auth/checkAuth.php index f274438..379d824 100644 --- a/public/api/auth/checkAuth.php +++ b/public/api/auth/checkAuth.php @@ -2,7 +2,7 @@ // public/api/auth/checkAuth.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/authController.php'; +require_once PROJECT_ROOT . '/src/controllers/AuthController.php'; $authController = new AuthController(); $authController->checkAuth(); \ No newline at end of file diff --git a/public/api/auth/login_basic.php b/public/api/auth/login_basic.php index cd81ef8..23e9559 100644 --- a/public/api/auth/login_basic.php +++ b/public/api/auth/login_basic.php @@ -2,7 +2,7 @@ // public/api/auth/login_basic.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/authController.php'; +require_once PROJECT_ROOT . '/src/controllers/AuthController.php'; $authController = new AuthController(); $authController->loginBasic(); \ No newline at end of file diff --git a/public/api/auth/logout.php b/public/api/auth/logout.php index 3fe9012..d571c89 100644 --- a/public/api/auth/logout.php +++ b/public/api/auth/logout.php @@ -2,7 +2,7 @@ // public/api/auth/logout.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/authController.php'; +require_once PROJECT_ROOT . '/src/controllers/AuthController.php'; $authController = new AuthController(); $authController->logout(); \ No newline at end of file diff --git a/public/api/auth/token.php b/public/api/auth/token.php index e6dbc59..7a0fe1d 100644 --- a/public/api/auth/token.php +++ b/public/api/auth/token.php @@ -2,7 +2,7 @@ // public/api/auth/token.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/authController.php'; +require_once PROJECT_ROOT . '/src/controllers/AuthController.php'; $authController = new AuthController(); $authController->getToken(); \ No newline at end of file diff --git a/public/api/changePassword.php b/public/api/changePassword.php index 468287f..5535ed9 100644 --- a/public/api/changePassword.php +++ b/public/api/changePassword.php @@ -2,7 +2,7 @@ // public/api/changePassword.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->changePassword(); \ No newline at end of file diff --git a/public/api/file/copyFiles.php b/public/api/file/copyFiles.php index 2afed0d..bb83d7a 100644 --- a/public/api/file/copyFiles.php +++ b/public/api/file/copyFiles.php @@ -2,7 +2,7 @@ // public/api/file/copyFiles.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->copyFiles(); \ No newline at end of file diff --git a/public/api/file/createShareLink.php b/public/api/file/createShareLink.php index 6d5ac35..cddae48 100644 --- a/public/api/file/createShareLink.php +++ b/public/api/file/createShareLink.php @@ -2,7 +2,7 @@ // public/api/file/createShareLink.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->createShareLink(); \ No newline at end of file diff --git a/public/api/file/deleteFiles.php b/public/api/file/deleteFiles.php index 83c95c9..a6116f6 100644 --- a/public/api/file/deleteFiles.php +++ b/public/api/file/deleteFiles.php @@ -2,7 +2,7 @@ // public/api/file/deleteFiles.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->deleteFiles(); \ No newline at end of file diff --git a/public/api/file/deleteShareLink.php b/public/api/file/deleteShareLink.php new file mode 100644 index 0000000..59d8eef --- /dev/null +++ b/public/api/file/deleteShareLink.php @@ -0,0 +1,6 @@ +deleteShareLink(); \ No newline at end of file diff --git a/public/api/file/deleteTrashFiles.php b/public/api/file/deleteTrashFiles.php index 5f11c7a..a9d5153 100644 --- a/public/api/file/deleteTrashFiles.php +++ b/public/api/file/deleteTrashFiles.php @@ -2,7 +2,7 @@ // public/api/file/deleteTrashFiles.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->deleteTrashFiles(); \ No newline at end of file diff --git a/public/api/file/download.php b/public/api/file/download.php index d74c427..3bd6b87 100644 --- a/public/api/file/download.php +++ b/public/api/file/download.php @@ -2,7 +2,7 @@ // public/api/file/download.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->downloadFile(); \ No newline at end of file diff --git a/public/api/file/downloadZip.php b/public/api/file/downloadZip.php index 7d17c85..08b7c7c 100644 --- a/public/api/file/downloadZip.php +++ b/public/api/file/downloadZip.php @@ -2,7 +2,7 @@ // public/api/file/downloadZip.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->downloadZip(); \ No newline at end of file diff --git a/public/api/file/extractZip.php b/public/api/file/extractZip.php index f66efb3..8208900 100644 --- a/public/api/file/extractZip.php +++ b/public/api/file/extractZip.php @@ -2,7 +2,7 @@ // public/api/file/extractZip.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->extractZip(); \ No newline at end of file diff --git a/public/api/file/getFileList.php b/public/api/file/getFileList.php index 5345663..d2c94d2 100644 --- a/public/api/file/getFileList.php +++ b/public/api/file/getFileList.php @@ -2,7 +2,7 @@ // public/api/file/getFileList.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->getFileList(); \ No newline at end of file diff --git a/public/api/file/getFileTag.php b/public/api/file/getFileTag.php index 76c0e21..d09bc54 100644 --- a/public/api/file/getFileTag.php +++ b/public/api/file/getFileTag.php @@ -2,7 +2,7 @@ // public/api/file/getFileTag.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->getFileTags(); \ No newline at end of file diff --git a/public/api/file/getShareLinks.php b/public/api/file/getShareLinks.php new file mode 100644 index 0000000..8e72534 --- /dev/null +++ b/public/api/file/getShareLinks.php @@ -0,0 +1,6 @@ +getShareLinks(); \ No newline at end of file diff --git a/public/api/file/getTrashItems.php b/public/api/file/getTrashItems.php index 9bcf475..1d72f72 100644 --- a/public/api/file/getTrashItems.php +++ b/public/api/file/getTrashItems.php @@ -2,7 +2,7 @@ // public/api/file/getTrashItems.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->getTrashItems(); \ No newline at end of file diff --git a/public/api/file/moveFiles.php b/public/api/file/moveFiles.php index 040890c..659340d 100644 --- a/public/api/file/moveFiles.php +++ b/public/api/file/moveFiles.php @@ -2,7 +2,7 @@ // public/api/file/moveFiles.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->moveFiles(); \ No newline at end of file diff --git a/public/api/file/renameFile.php b/public/api/file/renameFile.php index a3f5da8..1529926 100644 --- a/public/api/file/renameFile.php +++ b/public/api/file/renameFile.php @@ -2,7 +2,7 @@ // public/api/file/renameFile.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->renameFile(); \ No newline at end of file diff --git a/public/api/file/restoreFiles.php b/public/api/file/restoreFiles.php index 3584741..453ad57 100644 --- a/public/api/file/restoreFiles.php +++ b/public/api/file/restoreFiles.php @@ -2,7 +2,7 @@ // public/api/file/restoreFiles.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->restoreFiles(); \ No newline at end of file diff --git a/public/api/file/saveFile.php b/public/api/file/saveFile.php index 29d1cb5..6ea3eb1 100644 --- a/public/api/file/saveFile.php +++ b/public/api/file/saveFile.php @@ -2,7 +2,7 @@ // public/api/file/saveFile.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->saveFile(); \ No newline at end of file diff --git a/public/api/file/saveFileTag.php b/public/api/file/saveFileTag.php index 87f5ae4..528ae91 100644 --- a/public/api/file/saveFileTag.php +++ b/public/api/file/saveFileTag.php @@ -2,7 +2,7 @@ // public/api/file/saveFileTag.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->saveFileTag(); \ No newline at end of file diff --git a/public/api/file/share.php b/public/api/file/share.php index 44971cd..df26dbc 100644 --- a/public/api/file/share.php +++ b/public/api/file/share.php @@ -2,7 +2,7 @@ // public/api/file/share.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/fileController.php'; +require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); $fileController->shareFile(); \ No newline at end of file diff --git a/public/api/folder/createFolder.php b/public/api/folder/createFolder.php index 77defd5..0143bb7 100644 --- a/public/api/folder/createFolder.php +++ b/public/api/folder/createFolder.php @@ -2,7 +2,7 @@ // public/api/folder/createFolder.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->createFolder(); \ No newline at end of file diff --git a/public/api/folder/createShareFolderLink.php b/public/api/folder/createShareFolderLink.php index 126b961..e6882da 100644 --- a/public/api/folder/createShareFolderLink.php +++ b/public/api/folder/createShareFolderLink.php @@ -2,7 +2,7 @@ // public/api/folder/createShareFolderLink.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->createShareFolderLink(); \ No newline at end of file diff --git a/public/api/folder/deleteFolder.php b/public/api/folder/deleteFolder.php index 8bcacbc..f9039d2 100644 --- a/public/api/folder/deleteFolder.php +++ b/public/api/folder/deleteFolder.php @@ -2,7 +2,7 @@ // public/api/folder/deleteFolder.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->deleteFolder(); \ No newline at end of file diff --git a/public/api/folder/deleteShareFolderLink.php b/public/api/folder/deleteShareFolderLink.php new file mode 100644 index 0000000..91871a9 --- /dev/null +++ b/public/api/folder/deleteShareFolderLink.php @@ -0,0 +1,6 @@ +deleteShareFolderLink(); \ No newline at end of file diff --git a/public/api/folder/downloadSharedFile.php b/public/api/folder/downloadSharedFile.php index 07c69d8..1dea168 100644 --- a/public/api/folder/downloadSharedFile.php +++ b/public/api/folder/downloadSharedFile.php @@ -2,7 +2,7 @@ // public/api/folder/downloadSharedFile.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->downloadSharedFile(); \ No newline at end of file diff --git a/public/api/folder/getFolderList.php b/public/api/folder/getFolderList.php index 9c44509..7693354 100644 --- a/public/api/folder/getFolderList.php +++ b/public/api/folder/getFolderList.php @@ -2,7 +2,7 @@ // public/api/folder/getFolderList.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->getFolderList(); \ No newline at end of file diff --git a/public/api/folder/getShareFolderLinks.php b/public/api/folder/getShareFolderLinks.php new file mode 100644 index 0000000..204c4b7 --- /dev/null +++ b/public/api/folder/getShareFolderLinks.php @@ -0,0 +1,6 @@ +getShareFolderLinks(); \ No newline at end of file diff --git a/public/api/folder/renameFolder.php b/public/api/folder/renameFolder.php index ce9a344..8007ac9 100644 --- a/public/api/folder/renameFolder.php +++ b/public/api/folder/renameFolder.php @@ -2,7 +2,7 @@ // public/api/folder/renameFolder.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->renameFolder(); \ No newline at end of file diff --git a/public/api/folder/shareFolder.php b/public/api/folder/shareFolder.php index 6773a38..9bb11de 100644 --- a/public/api/folder/shareFolder.php +++ b/public/api/folder/shareFolder.php @@ -2,7 +2,7 @@ // public/api/folder/shareFolder.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->shareFolder(); \ No newline at end of file diff --git a/public/api/folder/uploadToSharedFolder.php b/public/api/folder/uploadToSharedFolder.php index 643110f..71fe584 100644 --- a/public/api/folder/uploadToSharedFolder.php +++ b/public/api/folder/uploadToSharedFolder.php @@ -2,7 +2,7 @@ // public/api/folder/uploadToSharedFolder.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/folderController.php'; +require_once PROJECT_ROOT . '/src/controllers/FolderController.php'; $folderController = new FolderController(); $folderController->uploadToSharedFolder(); \ No newline at end of file diff --git a/public/api/getUserPermissions.php b/public/api/getUserPermissions.php index 9ebd320..3e66bad 100644 --- a/public/api/getUserPermissions.php +++ b/public/api/getUserPermissions.php @@ -2,7 +2,7 @@ // public/api/getUserPermissions.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->getUserPermissions(); \ No newline at end of file diff --git a/public/api/getUsers.php b/public/api/getUsers.php index b68da7b..93f63eb 100644 --- a/public/api/getUsers.php +++ b/public/api/getUsers.php @@ -2,7 +2,7 @@ // public/api/getUsers.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->getUsers(); // This will output the JSON response \ No newline at end of file diff --git a/public/api/removeUser.php b/public/api/removeUser.php index 350e9de..636c2bd 100644 --- a/public/api/removeUser.php +++ b/public/api/removeUser.php @@ -2,7 +2,7 @@ // public/api/removeUser.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->removeUser(); \ No newline at end of file diff --git a/public/api/totp_disable.php b/public/api/totp_disable.php index a196559..0afb368 100644 --- a/public/api/totp_disable.php +++ b/public/api/totp_disable.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/vendor/autoload.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->disableTOTP(); \ No newline at end of file diff --git a/public/api/totp_recover.php b/public/api/totp_recover.php index 32f36b4..0c3df39 100644 --- a/public/api/totp_recover.php +++ b/public/api/totp_recover.php @@ -2,7 +2,7 @@ // public/api/totp_recover.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->recoverTOTP(); \ No newline at end of file diff --git a/public/api/totp_saveCode.php b/public/api/totp_saveCode.php index 1b2cc95..d73c034 100644 --- a/public/api/totp_saveCode.php +++ b/public/api/totp_saveCode.php @@ -2,7 +2,7 @@ // public/api/totp_saveCode.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->saveTOTPRecoveryCode(); \ No newline at end of file diff --git a/public/api/totp_setup.php b/public/api/totp_setup.php index f9b8877..6d8bc7a 100644 --- a/public/api/totp_setup.php +++ b/public/api/totp_setup.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/vendor/autoload.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->setupTOTP(); \ No newline at end of file diff --git a/public/api/totp_verify.php b/public/api/totp_verify.php index fdd48b2..0dd90f6 100644 --- a/public/api/totp_verify.php +++ b/public/api/totp_verify.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/vendor/autoload.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->verifyTOTP(); \ No newline at end of file diff --git a/public/api/updateUserPanel.php b/public/api/updateUserPanel.php index 54be7c4..e30f9e5 100644 --- a/public/api/updateUserPanel.php +++ b/public/api/updateUserPanel.php @@ -2,7 +2,7 @@ // public/api/updateUserPanel.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->updateUserPanel(); \ No newline at end of file diff --git a/public/api/updateUserPermissions.php b/public/api/updateUserPermissions.php index d5406f4..5e93832 100644 --- a/public/api/updateUserPermissions.php +++ b/public/api/updateUserPermissions.php @@ -2,7 +2,7 @@ // public/api/updateUserPermissions.php require_once __DIR__ . '/../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/userController.php'; +require_once PROJECT_ROOT . '/src/controllers/UserController.php'; $userController = new UserController(); $userController->updateUserPermissions(); \ No newline at end of file diff --git a/public/api/upload/removeChunks.php b/public/api/upload/removeChunks.php index 3fd3edf..2577c1c 100644 --- a/public/api/upload/removeChunks.php +++ b/public/api/upload/removeChunks.php @@ -2,7 +2,7 @@ // public/api/upload/removeChunks.php require_once __DIR__ . '/../../../config/config.php'; -require_once PROJECT_ROOT . '/src/controllers/uploadController.php'; +require_once PROJECT_ROOT . '/src/controllers/UploadController.php'; $uploadController = new UploadController(); $uploadController->removeChunks(); \ No newline at end of file diff --git a/public/api/upload/upload.php b/public/api/upload/upload.php index 195b2cb..3cf7e73 100644 --- a/public/api/upload/upload.php +++ b/public/api/upload/upload.php @@ -1,7 +1,7 @@ handleUpload(); \ No newline at end of file diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js new file mode 100644 index 0000000..41e2d5e --- /dev/null +++ b/public/js/adminPanel.js @@ -0,0 +1,652 @@ +import { t } from './i18n.js'; +import { loadAdminConfigFunc } from './auth.js'; +import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; +import { sendRequest } from './networkUtils.js'; + +const version = "v1.3.0"; +const adminTitle = `${t("admin_panel")} ${version}`; + +// ————— Inject updated styles ————— +(function(){ + if (document.getElementById('adminPanelStyles')) return; + const style = document.createElement('style'); + style.id = 'adminPanelStyles'; + style.textContent = ` + /* Modal sizing */ + #adminPanelModal .modal-content { + max-width: 1100px; + width: 50%; + } + + /* Small phones: 90% width */ + @media (max-width: 900px) { + #adminPanelModal .modal-content { + width: 90% !important; + max-width: none !important; + } + } + + /* Dark-mode fixes */ + body.dark-mode #adminPanelModal .modal-content { + border-color: #555 !important; + } + + /* enforce light‐mode styling */ + #adminPanelModal .modal-content { + max-width: 1100px; + width: 50%; + background: #fff !important; + color: #000 !important; + border: 1px solid #ccc !important; + } + + /* enforce dark‐mode styling */ + body.dark-mode #adminPanelModal .modal-content { + background: #2c2c2c !important; + color: #e0e0e0 !important; + border-color: #555 !important; + } + + /* form controls in dark */ + body.dark-mode .form-control { + background-color: #333; + border-color: #555; + color: #eee; + } + body.dark-mode .form-control::placeholder { color: #888; } + + /* Section headers */ + .section-header { + background: #f5f5f5; + padding: 10px 15px; + cursor: pointer; + border-radius: 4px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + } + .section-header:first-of-type { margin-top: 0; } + .section-header.collapsed .material-icons { transform: rotate(-90deg); } + .section-header .material-icons { transition: transform .3s; color: #444; } + + body.dark-mode .section-header { + background: #3a3a3a; + color: #eee; + } + body.dark-mode .section-header .material-icons { color: #ccc; } + + /* Hidden by default */ + .section-content { + display: none; + margin-left: 20px; + margin-top: 8px; + margin-bottom: 8px; + } + + /* Close button */ + #adminPanelModal .editor-close-btn { + position: absolute; top:10px; right:10px; + display:flex; align-items:center; justify-content:center; + font-size:20px; font-weight:bold; cursor:pointer; + z-index:1000; width:32px; height:32px; border-radius:50%; + text-align:center; line-height:30px; + color:#ff4d4d; background:rgba(255,255,255,0.9); + border:2px solid transparent; transition:all .3s; + } + #adminPanelModal .editor-close-btn:hover { + color:white; background:#ff4d4d; + box-shadow:0 0 6px rgba(255,77,77,.8); + transform:scale(1.05); + } + body.dark-mode #adminPanelModal .editor-close-btn { + background:rgba(0,0,0,0.6); + color:#ff4d4d; + } + + /* Action-row */ + .action-row { + display:flex; + justify-content:space-between; + margin-top:15px; + } + `; + document.head.appendChild(style); + })(); +// ———————————————————————————————————— + +let originalAdminConfig = {}; +function captureInitialAdminConfig() { + originalAdminConfig = { + headerTitle: document.getElementById("headerTitle").value.trim(), + oidcProviderUrl: document.getElementById("oidcProviderUrl").value.trim(), + oidcClientId: document.getElementById("oidcClientId").value.trim(), + oidcClientSecret: document.getElementById("oidcClientSecret").value.trim(), + oidcRedirectUri: document.getElementById("oidcRedirectUri").value.trim(), + disableFormLogin: document.getElementById("disableFormLogin").checked, + disableBasicAuth: document.getElementById("disableBasicAuth").checked, + disableOIDCLogin: document.getElementById("disableOIDCLogin").checked, + enableWebDAV: document.getElementById("enableWebDAV").checked, + sharedMaxUploadSize: document.getElementById("sharedMaxUploadSize").value.trim(), + globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim() + }; +} +function hasUnsavedChanges() { + const o = originalAdminConfig; + return ( + document.getElementById("headerTitle").value.trim() !== o.headerTitle || + document.getElementById("oidcProviderUrl").value.trim() !== o.oidcProviderUrl || + document.getElementById("oidcClientId").value.trim() !== o.oidcClientId || + document.getElementById("oidcClientSecret").value.trim() !== o.oidcClientSecret || + document.getElementById("oidcRedirectUri").value.trim() !== o.oidcRedirectUri || + document.getElementById("disableFormLogin").checked !== o.disableFormLogin || + document.getElementById("disableBasicAuth").checked !== o.disableBasicAuth || + document.getElementById("disableOIDCLogin").checked !== o.disableOIDCLogin || + document.getElementById("enableWebDAV").checked !== o.enableWebDAV || + document.getElementById("sharedMaxUploadSize").value.trim() !== o.sharedMaxUploadSize || + document.getElementById("globalOtpauthUrl").value.trim() !== o.globalOtpauthUrl + ); +} + +function showCustomConfirmModal(message) { + return new Promise(resolve => { + const modal = document.getElementById("customConfirmModal"); + const msg = document.getElementById("confirmMessage"); + const yes = document.getElementById("confirmYesBtn"); + const no = document.getElementById("confirmNoBtn"); + msg.textContent = message; + modal.style.display = "block"; + function clean() { + modal.style.display = "none"; + yes.removeEventListener("click", onYes); + no.removeEventListener("click", onNo); + } + function onYes() { clean(); resolve(true); } + function onNo() { clean(); resolve(false); } + yes.addEventListener("click", onYes); + no.addEventListener("click", onNo); + }); +} + +function toggleSection(id) { + const hdr = document.getElementById(id + "Header"); + const cnt = document.getElementById(id + "Content"); + const isCollapsedNow = hdr.classList.toggle("collapsed"); + // collapsed class present => hide; absent => show + cnt.style.display = isCollapsedNow ? "none" : "block"; + if (!isCollapsedNow && id === "shareLinks") { + loadShareLinksSection(); + } +} + +function loadShareLinksSection() { + const container = document.getElementById("shareLinksContent"); + container.textContent = t("loading") + "..."; + + Promise.all([ + fetch("/api/admin/readMetadata.php?file=share_folder_links.json", { credentials: "include" }) + .then(r => r.ok ? r.json() : Promise.reject(`Folder fetch ${r.status}`)), + fetch("/api/admin/readMetadata.php?file=share_links.json", { credentials: "include" }) + .then(r => r.ok ? r.json() : Promise.reject(`File fetch ${r.status}`)) + ]) + .then(([folders, files]) => { + let html = `
${t("folder_shares")}
${t("file_shares")}
`; + container.innerHTML = html; + + // wire up delete buttons + container.querySelectorAll(".delete-share").forEach(btn => { + btn.addEventListener("click", evt => { + evt.preventDefault(); + const token = btn.dataset.key; + const isFolder = btn.dataset.type === "folder"; + const endpoint = isFolder + ? "/api/folder/deleteShareFolderLink.php" + : "/api/file/deleteShareLink.php"; + + fetch(endpoint, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token }) + }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then(json => { + if (json.success) { + showToast(t("share_deleted_successfully")); + loadShareLinksSection(); + } else { + showToast(t("error_deleting_share") + ": " + (json.error||""), "error"); + } + }) + .catch(err => { + console.error("Delete error:", err); + showToast(t("error_deleting_share"), "error"); + }); + }); + }); + }) + .catch(err => { + console.error("loadShareLinksSection error:", err); + container.textContent = t("error_loading_share_links"); + }); + } + + +export function openAdminPanel() { + fetch("/api/admin/getConfig.php",{credentials:"include"}) + .then(r=>r.json()) + .then(config=>{ + // apply header title + globals + if(config.header_title){ + document.querySelector(".header-title h1").textContent = config.header_title; + window.headerTitle = config.header_title; + } + if(config.oidc) Object.assign(window.currentOIDCConfig,config.oidc); + if(config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; + + const dark = document.body.classList.contains("dark-mode"); + const bg = dark?"rgba(0,0,0,0.7)":"rgba(0,0,0,0.3)"; + const inner= ` + background:${dark?"#2c2c2c":"#fff"}; + color:${dark?"#e0e0e0":"#000"}; + padding:20px; max-width:1100px; width:50%; + border-radius:8px; position:relative; + max-height:90vh; overflow:auto; + border:1px solid ${dark?"#555":"#ccc"}; + `; + + let mdl = document.getElementById("adminPanelModal"); + if(!mdl){ + mdl = document.createElement("div"); + mdl.id="adminPanelModal"; + mdl.style.cssText = ` + position:fixed; top:0; left:0; + width:100vw; height:100vh; + background:${bg}; + display:flex; justify-content:center; align-items:center; + z-index:3000; + `; + mdl.innerHTML = ` + + `; + document.body.appendChild(mdl); + + // Bind close & cancel + document.getElementById("closeAdminPanel") + .addEventListener("click", closeAdminPanel); + document.getElementById("cancelAdminSettings") + .addEventListener("click", closeAdminPanel); + + // Section toggles + ["userManagement","headerSettings","loginOptions","webdav","upload","oidc","shareLinks"] + .forEach(id=>{ + document.getElementById(id+"Header") + .addEventListener("click", ()=>toggleSection(id)); + }); + + // Populate each section’s CONTENT: + // — User Mgmt — + document.getElementById("userManagementContent").innerHTML = ` + + + + `; + document.getElementById("adminOpenAddUser") + .addEventListener("click",()=>{ + toggleVisibility("addUserModal",true); + document.getElementById("newUsername").focus(); + }); + document.getElementById("adminOpenRemoveUser") + .addEventListener("click",()=>{ + if(typeof window.loadUserList==="function") window.loadUserList(); + toggleVisibility("removeUserModal",true); + }); + document.getElementById("adminOpenUserPermissions") + .addEventListener("click", openUserPermissionsModal); + + // — Header Settings — + document.getElementById("headerSettingsContent").innerHTML = ` +
+ + +
+ `; + + // — Login Options — + document.getElementById("loginOptionsContent").innerHTML = ` +
+
+
+ `; + + // — WebDAV — + document.getElementById("webdavContent").innerHTML = ` +
+ `; + + // — Upload — + document.getElementById("uploadContent").innerHTML = ` +
+ + + ${t("max_bytes_shared_uploads_note")} +
+ `; + + // — OIDC & TOTP — + document.getElementById("oidcContent").innerHTML = ` +
+
+
+
+
+ `; + + // — Share Links — + document.getElementById("shareLinksContent").textContent = t("loading")+"…"; + + // — Save handler & constraints — + document.getElementById("saveAdminSettings") + .addEventListener("click", handleSave); + ["disableFormLogin","disableBasicAuth","disableOIDCLogin"].forEach(id=>{ + document.getElementById(id) + .addEventListener("change", e=>{ + const chk = ["disableFormLogin","disableBasicAuth","disableOIDCLogin"] + .filter(i=>document.getElementById(i).checked).length; + if(chk===3){ + showToast(t("at_least_one_login_method")); + e.target.checked = false; + } + }); + }); + + // Initialize inputs from config + capture + document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin===true; + document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth===true; + document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin===true; + document.getElementById("enableWebDAV").checked = config.enableWebDAV===true; + document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize||""; + captureInitialAdminConfig(); + + } else { + // modal already exists → just refresh values & re-show + mdl.style.display = "flex"; + // update dark/light as above... + document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin===true; + document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth===true; + document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin===true; + document.getElementById("enableWebDAV").checked = config.enableWebDAV===true; + document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize||""; + document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl; + document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId; + document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret; + document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri; + document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl||''; + captureInitialAdminConfig(); + } + }) + .catch(()=>{/* if even fetching fails, open empty panel */}); +} + +function handleSave(){ + const dFL = document.getElementById("disableFormLogin").checked; + const dBA = document.getElementById("disableBasicAuth").checked; + const dOIDC = document.getElementById("disableOIDCLogin").checked; + const eWD = document.getElementById("enableWebDAV").checked; + const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value,10)||0; + const nHT = document.getElementById("headerTitle").value.trim(); + const nOIDC = { + providerUrl : document.getElementById("oidcProviderUrl").value.trim(), + clientId : document.getElementById("oidcClientId").value.trim(), + clientSecret: document.getElementById("oidcClientSecret").value.trim(), + redirectUri : document.getElementById("oidcRedirectUri").value.trim() + }; + const gURL = document.getElementById("globalOtpauthUrl").value.trim(); + + if([dFL,dBA,dOIDC].filter(x=>x).length===3){ + showToast(t("at_least_one_login_method")); + return; + } + + sendRequest("/api/admin/updateConfig.php","POST",{ + header_title:nHT, oidc:nOIDC, + disableFormLogin:dFL, disableBasicAuth:dBA, disableOIDCLogin:dOIDC, + enableWebDAV:eWD, sharedMaxUploadSize:sMax, globalOtpauthUrl:gURL + },{ + "X-CSRF-Token":window.csrfToken + }).then(res=>{ + if(res.success){ + showToast(t("settings_updated_successfully"),"success"); + captureInitialAdminConfig(); + closeAdminPanel(); + loadAdminConfigFunc(); + } else { + showToast(t("error_updating_settings")+": "+(res.error||t("unknown_error")),"error"); + } + }).catch(()=>{/*noop*/}); +} + +export async function closeAdminPanel() { + if(hasUnsavedChanges()){ + const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); + if(!ok) return; + } + document.getElementById("adminPanelModal").style.display="none"; +} + +// --- New: User Permissions Modal --- +export function openUserPermissionsModal() { + let userPermissionsModal = document.getElementById("userPermissionsModal"); + const isDarkMode = document.body.classList.contains("dark-mode"); + const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; + const modalContentStyles = ` + background: ${isDarkMode ? "#2c2c2c" : "#fff"}; + color: ${isDarkMode ? "#e0e0e0" : "#000"}; + padding: 20px; + max-width: 500px; + width: 90%; + border-radius: 8px; + position: relative; + `; + + if (!userPermissionsModal) { + userPermissionsModal = document.createElement("div"); + userPermissionsModal.id = "userPermissionsModal"; + userPermissionsModal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: ${overlayBackground}; + display: flex; + justify-content: center; + align-items: center; + z-index: 3500; + `; + userPermissionsModal.innerHTML = ` + + `; + document.body.appendChild(userPermissionsModal); + document.getElementById("closeUserPermissionsModal").addEventListener("click", () => { + userPermissionsModal.style.display = "none"; + }); + document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => { + userPermissionsModal.style.display = "none"; + }); + document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => { + // Collect permissions data from each user row. + const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); + const permissionsData = []; + rows.forEach(row => { + const username = row.getAttribute("data-username"); + const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']"); + const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']"); + const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']"); + permissionsData.push({ + username, + folderOnly: folderOnlyCheckbox.checked, + readOnly: readOnlyCheckbox.checked, + disableUpload: disableUploadCheckbox.checked + }); + }); + // Send the permissionsData to the server. + sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken }) + .then(response => { + if (response.success) { + showToast(t("user_permissions_updated_successfully")); + userPermissionsModal.style.display = "none"; + } else { + showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error"))); + } + }) + .catch(() => { + showToast(t("error_updating_permissions")); + }); + }); + } else { + userPermissionsModal.style.display = "flex"; + } + // Load the list of users into the modal. + loadUserPermissionsList(); + } + + function loadUserPermissionsList() { + const listContainer = document.getElementById("userPermissionsList"); + if (!listContainer) return; + listContainer.innerHTML = ""; + + // First, fetch the current permissions from the server. + fetch("/api/getUserPermissions.php", { credentials: "include" }) + .then(response => response.json()) + .then(permissionsData => { + // Then, fetch the list of users. + return fetch("/api/getUsers.php", { credentials: "include" }) + .then(response => response.json()) + .then(usersData => { + const users = Array.isArray(usersData) ? usersData : (usersData.users || []); + if (users.length === 0) { + listContainer.innerHTML = "

" + t("no_users_found") + "

"; + return; + } + users.forEach(user => { + // Skip admin users. + if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return; + + // Use stored permissions if available; otherwise fall back to defaults. + const defaultPerm = { + folderOnly: false, + readOnly: false, + disableUpload: false, + }; + + // Normalize the username key to match server storage (e.g., lowercase) + const usernameKey = user.username.toLowerCase(); + + const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData)) + ? permissionsData[usernameKey] + : defaultPerm; + + // Create a row for the user. + const row = document.createElement("div"); + row.classList.add("user-permission-row"); + row.setAttribute("data-username", user.username); + row.style.padding = "10px 0"; + row.innerHTML = ` +
${user.username}
+
+ + + +
+
+ `; + listContainer.appendChild(row); + }); + }); + }) + .catch(() => { + listContainer.innerHTML = "

" + t("error_loading_users") + "

"; + }); + } \ No newline at end of file diff --git a/public/js/auth.js b/public/js/auth.js index 07c0eb4..96678c4 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -15,10 +15,9 @@ import { openUserPanel, openTOTPModal, closeTOTPModal, - openAdminPanel, - closeAdminPanel, setLastLoginData } from './authModals.js'; +import { openAdminPanel } from './adminPanel.js'; // Production OIDC configuration (override via API as needed) const currentOIDCConfig = { diff --git a/public/js/authModals.js b/public/js/authModals.js index 5244443..4df26cc 100644 --- a/public/js/authModals.js +++ b/public/js/authModals.js @@ -3,8 +3,6 @@ import { sendRequest } from './networkUtils.js'; import { t, applyTranslations, setLocale } from './i18n.js'; import { loadAdminConfigFunc } from './auth.js'; -const version = "v1.2.8"; // Update this version string as needed -const adminTitle = `${t("admin_panel")} ${version}`; let lastLoginData = null; export function setLastLoginData(data) { @@ -544,539 +542,4 @@ export function closeTOTPModal(disable = true) { }) .catch(() => { showToast(t("error_disabling_totp_setting")); }); } -} - -// Global variable to hold the initial state of the admin form. -let originalAdminConfig = {}; - -// Capture the initial state of the admin form fields. -function captureInitialAdminConfig() { - originalAdminConfig = { - headerTitle: document.getElementById("headerTitle").value.trim(), - oidcProviderUrl: document.getElementById("oidcProviderUrl").value.trim(), - oidcClientId: document.getElementById("oidcClientId").value.trim(), - oidcClientSecret: document.getElementById("oidcClientSecret").value.trim(), - oidcRedirectUri: document.getElementById("oidcRedirectUri").value.trim(), - disableFormLogin: document.getElementById("disableFormLogin").checked, - disableBasicAuth: document.getElementById("disableBasicAuth").checked, - disableOIDCLogin: document.getElementById("disableOIDCLogin").checked, - globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim() - }; -} - -// Compare current values to the captured initial state. -function hasUnsavedChanges() { - return ( - document.getElementById("headerTitle").value.trim() !== originalAdminConfig.headerTitle || - document.getElementById("oidcProviderUrl").value.trim() !== originalAdminConfig.oidcProviderUrl || - document.getElementById("oidcClientId").value.trim() !== originalAdminConfig.oidcClientId || - document.getElementById("oidcClientSecret").value.trim() !== originalAdminConfig.oidcClientSecret || - document.getElementById("oidcRedirectUri").value.trim() !== originalAdminConfig.oidcRedirectUri || - document.getElementById("disableFormLogin").checked !== originalAdminConfig.disableFormLogin || - document.getElementById("disableBasicAuth").checked !== originalAdminConfig.disableBasicAuth || - document.getElementById("disableOIDCLogin").checked !== originalAdminConfig.disableOIDCLogin || - document.getElementById("globalOtpauthUrl").value.trim() !== originalAdminConfig.globalOtpauthUrl - ); -} - -// Use your custom confirmation modal. -function showCustomConfirmModal(message) { - return new Promise((resolve) => { - // Get modal elements from DOM. - const modal = document.getElementById("customConfirmModal"); - const messageElem = document.getElementById("confirmMessage"); - const yesBtn = document.getElementById("confirmYesBtn"); - const noBtn = document.getElementById("confirmNoBtn"); - - // Set the message in the modal. - messageElem.textContent = message; - modal.style.display = "block"; - - // Define event handlers. - function onYes() { - cleanup(); - resolve(true); - } - function onNo() { - cleanup(); - resolve(false); - } - // Remove event listeners and hide modal after choice. - function cleanup() { - yesBtn.removeEventListener("click", onYes); - noBtn.removeEventListener("click", onNo); - modal.style.display = "none"; - } - - yesBtn.addEventListener("click", onYes); - noBtn.addEventListener("click", onNo); - }); -} - -export function openAdminPanel() { - fetch("/api/admin/getConfig.php", { credentials: "include" }) - .then(response => response.json()) - .then(config => { - if (config.header_title) { - document.querySelector(".header-title h1").textContent = config.header_title; - window.headerTitle = config.header_title || "FileRise"; - } - if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc); - if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl; - - const isDarkMode = document.body.classList.contains("dark-mode"); - const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; - const modalContentStyles = ` - background: ${isDarkMode ? "#2c2c2c" : "#fff"}; - color: ${isDarkMode ? "#e0e0e0" : "#000"}; - padding: 20px; - max-width: 600px; - width: 90%; - border-radius: 8px; - position: relative; - overflow-y: auto; - max-height: 90vh; - border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; - `; - - let adminModal = document.getElementById("adminPanelModal"); - - if (!adminModal) { - adminModal = document.createElement("div"); - adminModal.id = "adminPanelModal"; - adminModal.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: ${overlayBackground}; - display: flex; - justify-content: center; - align-items: center; - z-index: 3000; - `; - adminModal.innerHTML = ` - - `; - document.body.appendChild(adminModal); - - // Bind closing - document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); - adminModal.addEventListener("click", e => { if (e.target === adminModal) closeAdminPanel(); }); - document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); - - // Bind other buttons - document.getElementById("adminOpenAddUser").addEventListener("click", () => { - toggleVisibility("addUserModal", true); - document.getElementById("newUsername").focus(); - }); - document.getElementById("adminOpenRemoveUser").addEventListener("click", () => { - if (typeof window.loadUserList === "function") window.loadUserList(); - toggleVisibility("removeUserModal", true); - }); - document.getElementById("adminOpenUserPermissions").addEventListener("click", () => { - openUserPermissionsModal(); - }); - - // Save handler - document.getElementById("saveAdminSettings").addEventListener("click", () => { - const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); - const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); - const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); - const enableWebDAVCheckbox = document.getElementById("enableWebDAV"); - const sharedMaxUploadSizeInput = document.getElementById("sharedMaxUploadSize"); - - const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox] - .filter(cb => cb.checked).length; - if (totalDisabled === 3) { - showToast(t("at_least_one_login_method")); - disableOIDCLoginCheckbox.checked = false; - localStorage.setItem("disableOIDCLogin", "false"); - if (typeof window.updateLoginOptionsUI === "function") { - window.updateLoginOptionsUI({ - disableFormLogin: disableFormLoginCheckbox.checked, - disableBasicAuth: disableBasicAuthCheckbox.checked, - disableOIDCLogin: disableOIDCLoginCheckbox.checked - }); - } - return; - } - - const newHeaderTitle = document.getElementById("headerTitle").value.trim(); - const newOIDCConfig = { - providerUrl: document.getElementById("oidcProviderUrl").value.trim(), - clientId: document.getElementById("oidcClientId").value.trim(), - clientSecret: document.getElementById("oidcClientSecret").value.trim(), - redirectUri: document.getElementById("oidcRedirectUri").value.trim() - }; - const disableFormLogin = disableFormLoginCheckbox.checked; - const disableBasicAuth = disableBasicAuthCheckbox.checked; - const disableOIDCLogin = disableOIDCLoginCheckbox.checked; - const enableWebDAV = enableWebDAVCheckbox.checked; - const sharedMaxUploadSize = parseInt(sharedMaxUploadSizeInput.value, 10) || 0; - const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim(); - - sendRequest("/api/admin/updateConfig.php", "POST", { - header_title: newHeaderTitle, - oidc: newOIDCConfig, - disableFormLogin, - disableBasicAuth, - disableOIDCLogin, - enableWebDAV, - sharedMaxUploadSize, - globalOtpauthUrl - }, { "X-CSRF-Token": window.csrfToken }) - .then(response => { - if (response.success) { - showToast(t("settings_updated_successfully")); - localStorage.setItem("disableFormLogin", disableFormLogin); - localStorage.setItem("disableBasicAuth", disableBasicAuth); - localStorage.setItem("disableOIDCLogin", disableOIDCLogin); - localStorage.setItem("enableWebDAV", enableWebDAV); - localStorage.setItem("sharedMaxUploadSize", sharedMaxUploadSize); - if (typeof window.updateLoginOptionsUI === "function") { - window.updateLoginOptionsUI({ - disableFormLogin, - disableBasicAuth, - disableOIDCLogin - }); - } - captureInitialAdminConfig(); - closeAdminPanel(); - loadAdminConfigFunc(); - } else { - showToast(t("error_updating_settings") + ": " + (response.error || t("unknown_error"))); - } - }) - .catch(() => { }); - }); - - // Enforce login option constraints. - const disableFormLoginCheckbox = document.getElementById("disableFormLogin"); - const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth"); - const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin"); - function enforceLoginOptionConstraint(changedCheckbox) { - const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox] - .filter(cb => cb.checked).length; - if (changedCheckbox.checked && totalDisabled === 3) { - showToast(t("at_least_one_login_method")); - changedCheckbox.checked = false; - } - } - disableFormLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); - disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); - disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); }); - - // Initial checkbox and input states - document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; - document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; - document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; - document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; - document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; - - captureInitialAdminConfig(); - - } else { - // Update existing modal and show - adminModal.style.backgroundColor = overlayBackground; - const modalContent = adminModal.querySelector(".modal-content"); - if (modalContent) { - modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; - modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; - modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc"; - } - document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl; - document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId; - document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret; - document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri; - document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'; - document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; - document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; - document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; - document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; - document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; - adminModal.style.display = "flex"; - captureInitialAdminConfig(); - } - }) - .catch(() => { - let adminModal = document.getElementById("adminPanelModal"); - if (adminModal) { - adminModal.style.backgroundColor = "rgba(0,0,0,0.5)"; - const modalContent = adminModal.querySelector(".modal-content"); - if (modalContent) { - modalContent.style.background = "#fff"; - modalContent.style.color = "#000"; - modalContent.style.border = "1px solid #ccc"; - } - document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl; - document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId; - document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret; - document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri; - document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'; - document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true"; - document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true"; - document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true"; - document.getElementById("enableWebDAV").checked = localStorage.getItem("enableWebDAV") === "true"; - document.getElementById("sharedMaxUploadSize").value = localStorage.getItem("sharedMaxUploadSize") || ""; - adminModal.style.display = "flex"; - captureInitialAdminConfig(); - } else { - openAdminPanel(); - } - }); -} - -export async function closeAdminPanel() { - if (hasUnsavedChanges()) { - const userConfirmed = await showCustomConfirmModal(t("unsaved_changes_confirm")); - if (!userConfirmed) { - return; - } - } - const adminModal = document.getElementById("adminPanelModal"); - if (adminModal) adminModal.style.display = "none"; -} - -// --- New: User Permissions Modal --- -export function openUserPermissionsModal() { - let userPermissionsModal = document.getElementById("userPermissionsModal"); - const isDarkMode = document.body.classList.contains("dark-mode"); - const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; - const modalContentStyles = ` - background: ${isDarkMode ? "#2c2c2c" : "#fff"}; - color: ${isDarkMode ? "#e0e0e0" : "#000"}; - padding: 20px; - max-width: 500px; - width: 90%; - border-radius: 8px; - position: relative; - `; - - if (!userPermissionsModal) { - userPermissionsModal = document.createElement("div"); - userPermissionsModal.id = "userPermissionsModal"; - userPermissionsModal.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: ${overlayBackground}; - display: flex; - justify-content: center; - align-items: center; - z-index: 3500; - `; - userPermissionsModal.innerHTML = ` - - `; - document.body.appendChild(userPermissionsModal); - document.getElementById("closeUserPermissionsModal").addEventListener("click", () => { - userPermissionsModal.style.display = "none"; - }); - document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => { - userPermissionsModal.style.display = "none"; - }); - document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => { - // Collect permissions data from each user row. - const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); - const permissionsData = []; - rows.forEach(row => { - const username = row.getAttribute("data-username"); - const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']"); - const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']"); - const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']"); - permissionsData.push({ - username, - folderOnly: folderOnlyCheckbox.checked, - readOnly: readOnlyCheckbox.checked, - disableUpload: disableUploadCheckbox.checked - }); - }); - // Send the permissionsData to the server. - sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken }) - .then(response => { - if (response.success) { - showToast(t("user_permissions_updated_successfully")); - userPermissionsModal.style.display = "none"; - } else { - showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error"))); - } - }) - .catch(() => { - showToast(t("error_updating_permissions")); - }); - }); - } else { - userPermissionsModal.style.display = "flex"; - } - // Load the list of users into the modal. - loadUserPermissionsList(); -} - -function loadUserPermissionsList() { - const listContainer = document.getElementById("userPermissionsList"); - if (!listContainer) return; - listContainer.innerHTML = ""; - - // First, fetch the current permissions from the server. - fetch("/api/getUserPermissions.php", { credentials: "include" }) - .then(response => response.json()) - .then(permissionsData => { - // Then, fetch the list of users. - return fetch("/api/getUsers.php", { credentials: "include" }) - .then(response => response.json()) - .then(usersData => { - const users = Array.isArray(usersData) ? usersData : (usersData.users || []); - if (users.length === 0) { - listContainer.innerHTML = "

" + t("no_users_found") + "

"; - return; - } - users.forEach(user => { - // Skip admin users. - if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return; - - // Use stored permissions if available; otherwise fall back to defaults. - const defaultPerm = { - folderOnly: false, - readOnly: false, - disableUpload: false, - }; - - // Normalize the username key to match server storage (e.g., lowercase) - const usernameKey = user.username.toLowerCase(); - - const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData)) - ? permissionsData[usernameKey] - : defaultPerm; - - // Create a row for the user. - const row = document.createElement("div"); - row.classList.add("user-permission-row"); - row.setAttribute("data-username", user.username); - row.style.padding = "10px 0"; - row.innerHTML = ` -
${user.username}
-
- - - -
-
- `; - listContainer.appendChild(row); - }); - }); - }) - .catch(() => { - listContainer.innerHTML = "

" + t("error_loading_users") + "

"; - }); } \ No newline at end of file diff --git a/public/js/i18n.js b/public/js/i18n.js index 070ad2d..112a72e 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -182,6 +182,20 @@ const translations = { "switch_to_light_mode": "Switch to light mode", "switch_to_dark_mode": "Switch to dark mode", + // Admin Panel + "header_settings": "Header Settings", + "shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)", + "max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads", + "manage_shared_links": "Manage Shared Links", + "folder_shares": "Folder Shares", + "file_shares": "File Shares", + "loading": "Loading…", + "error_loading_share_links": "Error loading share links", + "share_deleted_successfully": "Share deleted successfully", + "error_deleting_share": "Error deleting share", + "password_protected": "Password protected", + + // NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS: "admin_panel": "Admin Panel", "user_panel": "User Panel", diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php new file mode 100644 index 0000000..1e4bc9b --- /dev/null +++ b/src/controllers/AdminController.php @@ -0,0 +1,232 @@ + 'Unauthorized access.']); + exit; + } + + // Validate CSRF token. + $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; + } + + // Retrieve and decode JSON input. + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + if (!is_array($data)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid input.']); + exit; + } + + // Prepare existing settings + $headerTitle = isset($data['header_title']) ? trim($data['header_title']) : ""; + $oidc = isset($data['oidc']) ? $data['oidc'] : []; + $oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : ''; + $oidcClientId = isset($oidc['clientId']) ? trim($oidc['clientId']) : ''; + $oidcClientSecret = isset($oidc['clientSecret']) ? trim($oidc['clientSecret']) : ''; + $oidcRedirectUri = isset($oidc['redirectUri']) ? filter_var($oidc['redirectUri'], FILTER_SANITIZE_URL) : ''; + if (!$oidcProviderUrl || !$oidcClientId || !$oidcClientSecret || !$oidcRedirectUri) { + http_response_code(400); + echo json_encode(['error' => 'Incomplete OIDC configuration.']); + exit; + } + + $disableFormLogin = false; + if (isset($data['loginOptions']['disableFormLogin'])) { + $disableFormLogin = filter_var($data['loginOptions']['disableFormLogin'], FILTER_VALIDATE_BOOLEAN); + } elseif (isset($data['disableFormLogin'])) { + $disableFormLogin = filter_var($data['disableFormLogin'], FILTER_VALIDATE_BOOLEAN); + } + $disableBasicAuth = false; + if (isset($data['loginOptions']['disableBasicAuth'])) { + $disableBasicAuth = filter_var($data['loginOptions']['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN); + } elseif (isset($data['disableBasicAuth'])) { + $disableBasicAuth = filter_var($data['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN); + } + + $disableOIDCLogin = false; + if (isset($data['loginOptions']['disableOIDCLogin'])) { + $disableOIDCLogin = filter_var($data['loginOptions']['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN); + } elseif (isset($data['disableOIDCLogin'])) { + $disableOIDCLogin = filter_var($data['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN); + } + $globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : ""; + + // ── NEW: enableWebDAV flag ────────────────────────────────────── + $enableWebDAV = false; + if (array_key_exists('enableWebDAV', $data)) { + $enableWebDAV = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN); + } elseif (isset($data['features']['enableWebDAV'])) { + $enableWebDAV = filter_var($data['features']['enableWebDAV'], FILTER_VALIDATE_BOOLEAN); + } + + // ── NEW: sharedMaxUploadSize ────────────────────────────────────── + $sharedMaxUploadSize = null; + if (array_key_exists('sharedMaxUploadSize', $data)) { + $sharedMaxUploadSize = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT); + } elseif (isset($data['features']['sharedMaxUploadSize'])) { + $sharedMaxUploadSize = filter_var($data['features']['sharedMaxUploadSize'], FILTER_VALIDATE_INT); + } + + $configUpdate = [ + 'header_title' => $headerTitle, + 'oidc' => [ + 'providerUrl' => $oidcProviderUrl, + 'clientId' => $oidcClientId, + 'clientSecret' => $oidcClientSecret, + 'redirectUri' => $oidcRedirectUri, + ], + 'loginOptions' => [ + 'disableFormLogin' => $disableFormLogin, + 'disableBasicAuth' => $disableBasicAuth, + 'disableOIDCLogin' => $disableOIDCLogin, + ], + 'globalOtpauthUrl' => $globalOtpauthUrl, + 'enableWebDAV' => $enableWebDAV, + 'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW + ]; + + // Delegate to the model. + $result = AdminModel::updateConfig($configUpdate); + if (isset($result['error'])) { + http_response_code(500); + } + echo json_encode($result); + exit; + } +} \ No newline at end of file diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php new file mode 100644 index 0000000..5811cb8 --- /dev/null +++ b/src/controllers/AuthController.php @@ -0,0 +1,626 @@ +getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Internal Server Error']); + exit(); + }); + + // Decode any JSON payload + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $username = trim($data['username'] ?? ''); + $password = trim($data['password'] ?? ''); + $totpCode = trim($data['totp_code'] ?? ''); + $rememberMe = !empty($data['remember_me']); + + // + // 1) TOTP‑only step: user already passed credentials and we asked for TOTP, + // now they POST just totp_code. + // + if ($totpCode && isset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'])) { + $username = $_SESSION['pending_login_user']; + $secret = $_SESSION['pending_login_secret']; + $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; + $tfa = new TwoFactorAuth(new GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, Algorithm::Sha1); + if (! $tfa->verifyCode($secret, $totpCode)) { + echo json_encode(['error' => 'Invalid TOTP code']); + exit(); + } + // clear the pending markers + unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); + // now finish login + $this->finalizeLogin($username, $rememberMe); + } + + // + // 2) OIDC flow + // + $oidcAction = $_GET['oidc'] ?? null; + if (! $oidcAction && isset($_GET['code'])) { + $oidcAction = 'callback'; + } + if ($oidcAction) { + $cfg = AdminModel::getConfig(); + $oidc = new OpenIDConnectClient( + $cfg['oidc']['providerUrl'], + $cfg['oidc']['clientId'], + $cfg['oidc']['clientSecret'] + ); + $oidc->setRedirectURL($cfg['oidc']['redirectUri']); + + if ($oidcAction === 'callback') { + try { + $oidc->authenticate(); + $username = $oidc->requestUserInfo('preferred_username'); + + // check if this user has a TOTP secret + $totp_secret = null; + $usersFile = USERS_DIR . USERS_FILE; + if (file_exists($usersFile)) { + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $parts = explode(':', trim($line)); + if (count($parts) >= 4 && $parts[0] === $username && $parts[3] !== '') { + $totp_secret = decryptData($parts[3], $GLOBALS['encryptionKey']); + break; + } + } + } + if ($totp_secret) { + $_SESSION['pending_login_user'] = $username; + $_SESSION['pending_login_secret'] = $totp_secret; + header('Location: /index.html?totp_required=1'); + exit(); + } + + // no TOTP → finish immediately + $this->finishBrowserLogin($username); + } catch (\Exception $e) { + error_log("OIDC auth error: " . $e->getMessage()); + http_response_code(401); + echo json_encode(['error' => 'Authentication failed.']); + exit(); + } + } else { + // initial OIDC redirect + try { + $oidc->authenticate(); + exit(); + } catch (\Exception $e) { + error_log("OIDC initiation error: " . $e->getMessage()); + http_response_code(401); + echo json_encode(['error' => 'Authentication initiation failed.']); + exit(); + } + } + } + + // + // 3) Form‑based / AJAX login + // + if (! $username || ! $password) { + http_response_code(400); + echo json_encode(['error' => 'Username and password are required']); + exit(); + } + if (! preg_match(REGEX_USER, $username)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid username format']); + exit(); + } + + // rate‑limit + $ip = $_SERVER['REMOTE_ADDR']; + $attemptsFile = USERS_DIR . 'failed_logins.json'; + $failed = AuthModel::loadFailedAttempts($attemptsFile); + if ( + isset($failed[$ip]) && + $failed[$ip]['count'] >= 5 && + time() - $failed[$ip]['last_attempt'] < 30 * 60 + ) { + http_response_code(429); + echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']); + exit(); + } + + $user = AuthModel::authenticate($username, $password); + if ($user === false) { + // record failure + $failed[$ip] = [ + 'count' => ($failed[$ip]['count'] ?? 0) + 1, + 'last_attempt' => time() + ]; + AuthModel::saveFailedAttempts($attemptsFile, $failed); + http_response_code(401); + echo json_encode(['error' => 'Invalid credentials']); + exit(); + } + + // if this account has TOTP, ask for it + if (! empty($user['totp_secret'])) { + $_SESSION['pending_login_user'] = $username; + $_SESSION['pending_login_secret'] = $user['totp_secret']; + $_SESSION['pending_login_remember_me'] = $rememberMe; + echo json_encode(['totp_required' => true]); + exit(); + } + + // otherwise clear rate‑limit & finish + if (isset($failed[$ip])) { + unset($failed[$ip]); + AuthModel::saveFailedAttempts($attemptsFile, $failed); + } + $this->finalizeLogin($username, $rememberMe); + } + + /** + * Finalize an AJAX‐style login (form/basic/TOTP) by + * issuing the session, remember‑me cookie, and JSON payload. + */ + protected function finalizeLogin(string $username, bool $rememberMe): void + { + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1'); + + $perms = loadUserPermissions($username); + $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; + $_SESSION['readOnly'] = $perms['readOnly'] ?? false; + $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; + + // remember‑me + if ($rememberMe) { + $tokFile = USERS_DIR . 'persistent_tokens.json'; + $token = bin2hex(random_bytes(32)); + $expiry = time() + 30 * 24 * 60 * 60; + $all = []; + + if (file_exists($tokFile)) { + $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); + $all = json_decode($dec, true) ?: []; + } + + $all[$token] = [ + 'username' => $username, + 'expiry' => $expiry, + 'isAdmin' => $_SESSION['isAdmin'] + ]; + + file_put_contents( + $tokFile, + encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), + LOCK_EX + ); + + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + + setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); + + setcookie( + session_name(), + session_id(), + $expiry, + '/', + '', + $secure, + true + ); + + session_regenerate_id(true); + } + + echo json_encode([ + 'status' => 'ok', + 'success' => 'Login successful', + 'isAdmin' => $_SESSION['isAdmin'], + 'folderOnly' => $_SESSION['folderOnly'], + 'readOnly' => $_SESSION['readOnly'], + 'disableUpload' => $_SESSION['disableUpload'], + 'username' => $username + ]); + exit(); + } + + /** + * A version of finalizeLogin() that ends in a browser redirect + * (used for OIDC non‑AJAX flows). + */ + protected function finishBrowserLogin(string $username): void + { + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1'); + + $perms = loadUserPermissions($username); + $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; + $_SESSION['readOnly'] = $perms['readOnly'] ?? false; + $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; + + header('Location: /index.html'); + exit(); + } + + /** + * @OA\Get( + * path="/api/auth/checkAuth.php", + * summary="Check authentication status", + * description="Checks if the current session is authenticated. If the users file is missing or empty, returns a setup flag. Also returns information about admin privileges, TOTP status, and folder-only access.", + * operationId="checkAuth", + * tags={"Auth"}, + * @OA\Response( + * response=200, + * description="Returns authentication status and user details", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="authenticated", type="boolean", example=true), + * @OA\Property(property="isAdmin", type="boolean", example=true), + * @OA\Property(property="totp_enabled", type="boolean", example=false), + * @OA\Property(property="username", type="string", example="johndoe"), + * @OA\Property(property="folderOnly", type="boolean", example=false) + * ) + * ), + * @OA\Response( + * response=200, + * description="Setup mode (if the users file is missing or empty)", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="setup", type="boolean", example=true) + * ) + * ) + * ) + * + * Checks whether the user is authenticated or if the system is in setup mode. + * + * @return void Outputs a JSON response with authentication details. + */ + + public function checkAuth(): void + { + + // 1) Remember-me re-login + if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) { + $payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']); + if ($payload) { + $old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32)); + session_regenerate_id(true); + $_SESSION['csrf_token'] = $old; + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $payload['username']; + $_SESSION['isAdmin'] = !empty($payload['isAdmin']); + $_SESSION['folderOnly'] = $payload['folderOnly'] ?? false; + $_SESSION['readOnly'] = $payload['readOnly'] ?? false; + $_SESSION['disableUpload'] = $payload['disableUpload'] ?? false; + // regenerate CSRF if you use one + + + // TOTP enabled? (same logic as below) + $usersFile = USERS_DIR . USERS_FILE; + $totp = false; + if (file_exists($usersFile)) { + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $parts = explode(':', trim($line)); + if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) { + $totp = true; + break; + } + } + } + + echo json_encode([ + 'authenticated' => true, + 'csrf_token' => $_SESSION['csrf_token'], + 'isAdmin' => $_SESSION['isAdmin'], + 'totp_enabled' => $totp, + 'username' => $_SESSION['username'], + 'folderOnly' => $_SESSION['folderOnly'], + 'readOnly' => $_SESSION['readOnly'], + 'disableUpload' => $_SESSION['disableUpload'] + ]); + exit(); + } + } + + $usersFile = USERS_DIR . USERS_FILE; + + // 2) Setup mode? + if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') { + error_log("checkAuth: setup mode"); + echo json_encode(['setup' => true]); + exit(); + } + + // 3) Session-based auth + if (empty($_SESSION['authenticated'])) { + echo json_encode(['authenticated' => false]); + exit(); + } + + // 4) TOTP enabled? + $totp = false; + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $parts = explode(':', trim($line)); + if ($parts[0] === ($_SESSION['username'] ?? '') && !empty($parts[3])) { + $totp = true; + break; + } + } + + // 5) Final response + $resp = [ + 'authenticated' => true, + 'isAdmin' => !empty($_SESSION['isAdmin']), + 'totp_enabled' => $totp, + 'username' => $_SESSION['username'], + 'folderOnly' => $_SESSION['folderOnly'] ?? false, + 'readOnly' => $_SESSION['readOnly'] ?? false, + 'disableUpload' => $_SESSION['disableUpload'] ?? false + ]; + + echo json_encode($resp); + exit(); + } + + /** + * @OA\Get( + * path="/api/auth/token.php", + * summary="Retrieve CSRF token and share URL", + * description="Returns the current CSRF token along with the configured share URL.", + * operationId="getToken", + * tags={"Auth"}, + * @OA\Response( + * response=200, + * description="CSRF token and share URL", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="csrf_token", type="string", example="0123456789abcdef..."), + * @OA\Property(property="share_url", type="string", example="https://yourdomain.com/share.php") + * ) + * ) + * ) + * + * Returns the CSRF token and share URL. + * + * @return void Outputs the JSON response. + */ + public function getToken(): void + { + // 1) Ensure session and CSRF token exist + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + // 2) Emit headers + header('Content-Type: application/json'); + header('X-CSRF-Token: ' . $_SESSION['csrf_token']); + + // 3) Return JSON payload + echo json_encode([ + 'csrf_token' => $_SESSION['csrf_token'], + 'share_url' => SHARE_URL + ]); + exit; + } + + /** + * @OA\Get( + * path="/api/auth/login_basic.php", + * summary="Authenticate using HTTP Basic Authentication", + * description="Performs HTTP Basic authentication. If credentials are missing, sends a 401 response prompting for Basic auth. On valid credentials, optionally handles TOTP verification and finalizes session login.", + * operationId="loginBasic", + * tags={"Auth"}, + * @OA\Response( + * response=200, + * description="Login successful; redirects to index.html", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="string", example="Login successful") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized due to missing credentials or invalid credentials." + * ) + * ) + * + * Handles HTTP Basic authentication (with optional TOTP) and logs the user in. + * + * @return void Redirects on success or sends a 401 header. + */ + public function loginBasic(): void + { + // Set header for plain-text or JSON as needed. + header('Content-Type: application/json'); + + // Check for HTTP Basic auth credentials. + if (!isset($_SERVER['PHP_AUTH_USER'])) { + header('WWW-Authenticate: Basic realm="FileRise Login"'); + header('HTTP/1.0 401 Unauthorized'); + echo 'Authorization Required'; + exit; + } + + $username = trim($_SERVER['PHP_AUTH_USER']); + $password = trim($_SERVER['PHP_AUTH_PW']); + + // Validate username format. + if (!preg_match(REGEX_USER, $username)) { + header('WWW-Authenticate: Basic realm="FileRise Login"'); + header('HTTP/1.0 401 Unauthorized'); + echo 'Invalid username format'; + exit; + } + + // Attempt authentication. + $role = AuthModel::authenticate($username, $password); + if ($role !== false) { + // Check for TOTP secret. + $secret = AuthModel::getUserTOTPSecret($username); + if ($secret) { + // If TOTP is required, store pending values and redirect to prompt for TOTP. + $_SESSION['pending_login_user'] = $username; + $_SESSION['pending_login_secret'] = $secret; + header("Location: /index.html?totp_required=1"); + exit; + } + // Finalize login. + session_regenerate_id(true); + $_SESSION["authenticated"] = true; + $_SESSION["username"] = $username; + $_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1"); + // load _all_ the permissions + $userPerms = loadUserPermissions($username); + $_SESSION["folderOnly"] = $userPerms["folderOnly"] ?? false; + $_SESSION["readOnly"] = $userPerms["readOnly"] ?? false; + $_SESSION["disableUpload"] = $userPerms["disableUpload"] ?? false; + + header("Location: /index.html"); + exit; + } + // Invalid credentials; prompt again. + header('WWW-Authenticate: Basic realm="FileRise Login"'); + header('HTTP/1.0 401 Unauthorized'); + echo 'Invalid credentials'; + exit; + } + + /** + * @OA\Post( + * path="/api/auth/logout.php", + * summary="Logout user", + * description="Clears the session, removes persistent login tokens, and redirects the user to the login page.", + * operationId="logoutUser", + * tags={"Auth"}, + * @OA\Response( + * response=302, + * description="Redirects to the login page with a logout flag." + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ) + * ) + * + * Logs the user out by clearing session data, removing persistent tokens, and destroying the session. + * + * @return void Redirects to index.html with a logout flag. + */ + public function logout(): void + { + // Retrieve headers and check CSRF token. + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; + + // Log mismatch but do not prevent logout. + if (isset($_SESSION['csrf_token']) && $receivedToken !== $_SESSION['csrf_token']) { + error_log("CSRF token mismatch on logout. Proceeding with logout."); + } + + // Remove the "remember_me_token" from persistent tokens. + if (isset($_COOKIE['remember_me_token'])) { + $token = $_COOKIE['remember_me_token']; + $persistentTokensFile = USERS_DIR . 'persistent_tokens.json'; + if (file_exists($persistentTokensFile)) { + $encryptedContent = file_get_contents($persistentTokensFile); + $decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']); + $persistentTokens = json_decode($decryptedContent, true); + if (is_array($persistentTokens) && isset($persistentTokens[$token])) { + unset($persistentTokens[$token]); + $newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']); + file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX); + } + } + // Clear the cookie. + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true); + } + + // Clear session data. + $_SESSION = []; + + // Clear the session cookie. + if (ini_get("session.use_cookies")) { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params["path"], + $params["domain"], + $params["secure"], + $params["httponly"] + ); + } + + // Destroy the session. + session_destroy(); + + // Redirect the user to the login page (or index) with a logout flag. + header("Location: /index.html?logout=1"); + exit; + } +} diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php new file mode 100644 index 0000000..0c027b9 --- /dev/null +++ b/src/controllers/FileController.php @@ -0,0 +1,1604 @@ + "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; + } + + // 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 copy files."]); + exit; + } + + // Get JSON input data. + $data = json_decode(file_get_contents("php://input"), true); + if ( + !$data || + !isset($data['source']) || + !isset($data['destination']) || + !isset($data['files']) + ) { + http_response_code(400); + echo json_encode(["error" => "Invalid request"]); + exit; + } + + $sourceFolder = trim($data['source']); + $destinationFolder = trim($data['destination']); + $files = $data['files']; + + // Validate folder names. + if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { + echo json_encode(["error" => "Invalid source folder name."]); + exit; + } + if ($destinationFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $destinationFolder)) { + echo json_encode(["error" => "Invalid destination folder name."]); + exit; + } + + // Delegate to the model. + $result = FileModel::copyFiles($sourceFolder, $destinationFolder, $files); + echo json_encode($result); + } + + /** + * @OA\Post( + * path="/api/file/deleteFiles.php", + * summary="Delete files (move to trash)", + * description="Moves the specified files from the given folder to the trash and updates metadata accordingly.", + * operationId="deleteFiles", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"files"}, + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property( + * property="files", + * type="array", + * @OA\Items(type="string", example="example.pdf") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Files moved to Trash successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="Files moved to Trash: file1.pdf, file2.doc") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or permission denied" + * ) + * ) + * + * Handles deletion of files (moves them to Trash) by updating metadata. + * + * @return void Outputs JSON response. + */ + public function deleteFiles() + { + header('Content-Type: application/json'); + + // --- 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; + } + + // Load user's 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 delete files."]); + exit; + } + + // Get JSON input. + $data = json_decode(file_get_contents("php://input"), true); + if (!isset($data['files']) || !is_array($data['files'])) { + http_response_code(400); + echo json_encode(["error" => "No file names provided"]); + exit; + } + + // Determine folder; default to 'root'. + $folder = isset($data['folder']) ? trim($data['folder']) : 'root'; + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + $folder = trim($folder, "/\\ "); + + // Delegate to the FileModel. + $result = FileModel::deleteFiles($folder, $data['files']); + echo json_encode($result); + } + + /** + * @OA\Post( + * path="/api/file/moveFiles.php", + * summary="Move files between folders", + * description="Moves files from a source folder to a destination folder, updating metadata accordingly.", + * operationId="moveFiles", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"source", "destination", "files"}, + * @OA\Property(property="source", type="string", example="root"), + * @OA\Property(property="destination", type="string", example="Archives"), + * @OA\Property( + * property="files", + * type="array", + * @OA\Items(type="string", example="report.pdf") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Files moved successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="Files moved successfully") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request or input" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or permission denied" + * ) + * ) + * + * Handles moving files from a source folder to a destination folder. + * + * @return void Outputs JSON response. + */ + public function moveFiles() + { + header('Content-Type: application/json'); + + // --- 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 that the user is not read-only. + $username = $_SESSION['username'] ?? ''; + $userPermissions = loadUserPermissions($username); + if (!empty($userPermissions['readOnly'])) { + echo json_encode(["error" => "Read-only users are not allowed to move files."]); + exit; + } + + // Get JSON input. + $data = json_decode(file_get_contents("php://input"), true); + if ( + !$data || + !isset($data['source']) || + !isset($data['destination']) || + !isset($data['files']) + ) { + http_response_code(400); + echo json_encode(["error" => "Invalid request"]); + exit; + } + + $sourceFolder = trim($data['source']) ?: 'root'; + $destinationFolder = trim($data['destination']) ?: 'root'; + + // Validate folder names. + if ($sourceFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $sourceFolder)) { + echo json_encode(["error" => "Invalid source folder name."]); + exit; + } + if ($destinationFolder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $destinationFolder)) { + echo json_encode(["error" => "Invalid destination folder name."]); + exit; + } + + // Delegate to the model. + $result = FileModel::moveFiles($sourceFolder, $destinationFolder, $data['files']); + echo json_encode($result); + } + + /** + * @OA\Post( + * path="/api/file/renameFile.php", + * summary="Rename a file", + * description="Renames a file within a specified folder and updates folder metadata. If a file with the new name exists, a unique name is generated.", + * operationId="renameFile", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder", "oldName", "newName"}, + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property(property="oldName", type="string", example="oldfile.pdf"), + * @OA\Property(property="newName", type="string", example="newfile.pdf") + * ) + * ), + * @OA\Response( + * response=200, + * description="File renamed successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="File renamed successfully"), + * @OA\Property(property="newName", type="string", example="newfile.pdf") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or permission denied" + * ) + * ) + * + * Handles renaming a file by validating input and updating folder metadata. + * + * @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"); + + // --- 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; + } + + // 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; + } + + $folder = trim($data['folder']) ?: 'root'; + // Validate folder: allow letters, numbers, underscores, dashes, spaces, and forward slashes. + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + echo json_encode(["error" => "Invalid folder name"]); + exit; + } + + $oldName = basename(trim($data['oldName'])); + $newName = basename(trim($data['newName'])); + + // 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); + } + + /** + * @OA\Post( + * path="/api/file/saveFile.php", + * summary="Save a file", + * description="Saves file content to disk in a specified folder and updates metadata accordingly.", + * operationId="saveFile", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"fileName", "content"}, + * @OA\Property(property="fileName", type="string", example="document.txt"), + * @OA\Property(property="content", type="string", example="File content here"), + * @OA\Property(property="folder", type="string", example="Documents") + * ) + * ), + * @OA\Response( + * response=200, + * description="File saved successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="File saved successfully") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request data" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or read-only permission" + * ) + * ) + * + * Handles saving a file's content and updating the corresponding metadata. + * + * @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; + } + + $username = $_SESSION['username'] ?? ''; + // --- Read‑only check --- + $userPermissions = loadUserPermissions($username); + if ($username && !empty($userPermissions['readOnly'])) { + echo json_encode(["error" => "Read-only users are not allowed to save files."]); + exit; + } + + // --- Input parsing --- + $data = json_decode(file_get_contents("php://input"), true); + if (empty($data) || !isset($data["fileName"], $data["content"])) { + http_response_code(400); + echo json_encode(["error" => "Invalid request data", "received" => $data]); + exit; + } + + $fileName = basename($data["fileName"]); + $folder = isset($data["folder"]) ? trim($data["folder"]) : "root"; + + // --- Folder validation --- + if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) { + echo json_encode(["error" => "Invalid folder name"]); + exit; + } + $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 + ); + + echo json_encode($result); + } + + /** + * @OA\Get( + * path="/api/file/download.php", + * summary="Download a file", + * description="Downloads a file from a specified folder. The file is served inline for images or as an attachment for other types.", + * operationId="downloadFile", + * tags={"Files"}, + * @OA\Parameter( + * name="file", + * in="query", + * description="The name of the file to download", + * required=true, + * @OA\Schema(type="string", example="example.pdf") + * ), + * @OA\Parameter( + * name="folder", + * in="query", + * description="The folder in which the file is located. Defaults to root.", + * required=false, + * @OA\Schema(type="string", example="Documents") + * ), + * @OA\Response( + * response=200, + * description="File downloaded successfully" + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Access forbidden" + * ), + * @OA\Response( + * response=404, + * description="File not found" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * Downloads a file by validating parameters and serving its content. + * + * @return void Outputs file content with appropriate headers. + */ + public function downloadFile() + { + // Check if the user is authenticated. + if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(["error" => "Unauthorized"]); + exit; + } + + // Get GET parameters. + $file = isset($_GET['file']) ? basename($_GET['file']) : ''; + $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; + + // Validate the file name using REGEX_FILE_NAME. + if (!preg_match(REGEX_FILE_NAME, $file)) { + http_response_code(400); + echo json_encode(["error" => "Invalid file name."]); + exit; + } + + // Retrieve download info from the model. + $downloadInfo = FileModel::getDownloadInfo($folder, $file); + if (isset($downloadInfo['error'])) { + http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400); + echo json_encode(["error" => $downloadInfo['error']]); + exit; + } + + // Serve the file. + $realFilePath = $downloadInfo['filePath']; + $mimeType = $downloadInfo['mimeType']; + header("Content-Type: " . $mimeType); + + // For images, serve inline; for others, force download. + $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); + $inlineImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']; + if (in_array($ext, $inlineImageTypes)) { + header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); + } else { + header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); + } + header('Content-Length: ' . filesize($realFilePath)); + readfile($realFilePath); + exit; + } + + /** + * @OA\Post( + * path="/api/file/downloadZip.php", + * summary="Download a ZIP archive of selected files", + * description="Creates a ZIP archive of the specified files in a folder and serves it for download.", + * operationId="downloadZip", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder", "files"}, + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property( + * property="files", + * type="array", + * @OA\Items(type="string", example="example.pdf") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="ZIP archive created and served", + * @OA\MediaType( + * mediaType="application/zip" + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad request or invalid input" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + * + * Downloads a ZIP archive of the specified files. + * + * @return void Outputs the ZIP file for download. + */ + public function downloadZip() + { + // --- 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); + header('Content-Type: application/json'); + echo json_encode(["error" => "Invalid CSRF token"]); + exit; + } + + // Ensure user is authenticated. + if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(["error" => "Unauthorized"]); + 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'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Invalid input."]); + exit; + } + + $folder = $data['folder']; + $files = $data['files']; + + // Validate folder: if not "root", split and validate each segment. + if ($folder !== "root") { + $parts = explode('/', $folder); + foreach ($parts as $part) { + if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + } + } + + // Create ZIP archive using FileModel. + $result = FileModel::createZipArchive($folder, $files); + if (isset($result['error'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => $result['error']]); + exit; + } + + $zipPath = $result['zipPath']; + if (!file_exists($zipPath)) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(["error" => "ZIP archive not found."]); + exit; + } + + // Send headers to force download. + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="files.zip"'); + header('Content-Length: ' . filesize($zipPath)); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + + // Output the ZIP file. + readfile($zipPath); + unlink($zipPath); + exit; + } + + /** + * @OA\Post( + * path="/api/file/extractZip.php", + * summary="Extract ZIP files", + * description="Extracts ZIP archives from a specified folder and updates metadata. Returns a list of extracted files.", + * operationId="extractZip", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder", "files"}, + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property( + * property="files", + * type="array", + * @OA\Items(type="string", example="archive.zip") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="ZIP files extracted successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="extractedFiles", type="array", @OA\Items(type="string")) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + * + * Handles the extraction of ZIP files from a given folder. + * + * @return void Outputs JSON response. + */ + public function extractZip() + { + header('Content-Type: application/json'); + + // --- 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; + } + + // 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'])) { + http_response_code(400); + echo json_encode(["error" => "Invalid input."]); + exit; + } + + $folder = $data['folder']; + $files = $data['files']; + + // Validate folder name. + if ($folder !== "root") { + $parts = explode('/', trim($folder)); + foreach ($parts as $part) { + if (empty($part) || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + } + } + + // Delegate to the model. + $result = FileModel::extractZipArchive($folder, $files); + echo json_encode($result); + } + + /** + * @OA\Get( + * path="/api/file/share.php", + * summary="Access a shared file", + * description="Serves a shared file based on a share token. If the file is password protected and no password is provided, a password entry form is returned.", + * operationId="shareFile", + * tags={"Files"}, + * @OA\Parameter( + * name="token", + * in="query", + * description="The share token", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="pass", + * in="query", + * description="The password for the share if required", + * required=false, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=200, + * description="File served or password form rendered", + * @OA\MediaType(mediaType="application/octet-stream") + * ), + * @OA\Response( + * response=400, + * description="Missing token or invalid request" + * ), + * @OA\Response( + * response=403, + * description="Link expired, invalid password, or forbidden access" + * ), + * @OA\Response( + * response=404, + * description="Share link or file not found" + * ) + * ) + * + * Shares a file based on a share token. If the share record is password-protected and no password is provided, + * an HTML form prompting for the password is returned. + * + * @return void Outputs either HTML (password form) or serves the file. + */ + public function shareFile() + { + // Retrieve and sanitize GET parameters. + $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); + $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); + + if (empty($token)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing token."]); + exit; + } + + // Get share record from the model. + $record = FileModel::getShareRecord($token); + if (!$record) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(["error" => "Share link not found."]); + exit; + } + + // Check expiration. + if (time() > $record['expires']) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(["error" => "This link has expired."]); + exit; + } + + // If a password is required and not provided, show an HTML form. + if (!empty($record['password']) && empty($providedPass)) { + header("Content-Type: text/html; charset=utf-8"); +?> + + + + + + + Enter Password + + + + +

This file is protected by a password.

+
+ + + + +
+ + + + "Invalid password."]); + exit; + } + } + + // Build file path securely. + $folder = trim($record['folder'], "/\\ "); + $file = $record['file']; + $filePath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; + if (!empty($folder) && strtolower($folder) !== 'root') { + $filePath .= $folder . DIRECTORY_SEPARATOR; + } + $filePath .= $file; + + $realFilePath = realpath($filePath); + $uploadDirReal = realpath(UPLOAD_DIR); + if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(["error" => "File not found."]); + exit; + } + if (!file_exists($realFilePath)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(["error" => "File not found."]); + exit; + } + + // Serve the file. + $mimeType = mime_content_type($realFilePath); + header("Content-Type: " . $mimeType); + $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); + if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) { + header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); + } else { + header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); + } + header("Cache-Control: no-store, no-cache, must-revalidate"); + header("Pragma: no-cache"); + header('Content-Length: ' . filesize($realFilePath)); + + readfile($realFilePath); + exit; + } + + /** + * @OA\Post( + * path="/api/file/createShareLink.php", + * summary="Create a share link for a file", + * description="Generates a secure share link token for a specific file with optional password protection and a custom expiration time.", + * operationId="createShareLink", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder", "file", "expirationValue", "expirationUnit"}, + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property(property="file", type="string", example="report.pdf"), + * @OA\Property(property="expirationValue", type="integer", example=1), + * @OA\Property( + * property="expirationUnit", + * type="string", + * enum={"seconds","minutes","hours","days"}, + * example="minutes" + * ), + * @OA\Property(property="password", type="string", example="secret") + * ) + * ), + * @OA\Response( + * response=200, + * description="Share link created successfully", + * @OA\JsonContent( + * @OA\Property(property="token", type="string", example="a1b2c3d4e5f6..."), + * @OA\Property(property="expires", type="integer", example=1621234567) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request data" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Read-only users are not allowed to create share links" + * ) + * ) + * + * Creates a share link for a file. + * + * @return void Outputs JSON response. + */ + public function createShareLink() + { + 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; + } + + // Check user permissions. + $username = $_SESSION['username'] ?? ''; + $userPermissions = loadUserPermissions($username); + if ($username && !empty($userPermissions['readOnly'])) { + http_response_code(403); + echo json_encode(["error" => "Read-only users are not allowed to create share links."]); + exit; + } + + // Parse POST JSON input. + $input = json_decode(file_get_contents("php://input"), true); + if (!$input) { + http_response_code(400); + echo json_encode(["error" => "Invalid input."]); + exit; + } + + // Extract parameters. + $folder = isset($input['folder']) ? trim($input['folder']) : ""; + $file = isset($input['file']) ? basename($input['file']) : ""; + $value = isset($input['expirationValue']) ? intval($input['expirationValue']) : 60; + $unit = isset($input['expirationUnit']) ? $input['expirationUnit'] : 'minutes'; + $password = isset($input['password']) ? $input['password'] : ""; + + // Validate folder name. + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + + // Convert the provided value+unit into seconds + switch ($unit) { + case 'seconds': + $expirationSeconds = $value; + break; + case 'hours': + $expirationSeconds = $value * 3600; + break; + case 'days': + $expirationSeconds = $value * 86400; + break; + case 'minutes': + default: + $expirationSeconds = $value * 60; + break; + } + + // Delegate share link creation to the model. + $result = FileModel::createShareLink($folder, $file, $expirationSeconds, $password); + + echo json_encode($result); + } + + /** + * @OA\Get( + * path="/api/file/getTrashItems.php", + * summary="Get trash items", + * description="Retrieves a list of files that have been moved to Trash, enriched with metadata such as who deleted them and when.", + * operationId="getTrashItems", + * tags={"Files"}, + * @OA\Response( + * response=200, + * description="Trash items retrieved successfully", + * @OA\JsonContent(type="array", @OA\Items(type="object")) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ) + * ) + * + * Retrieves trash items from the trash metadata file. + * + * @return void Outputs JSON response with trash items. + */ + public function getTrashItems() + { + 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; + } + + // Delegate to the model. + $trashItems = FileModel::getTrashItems(); + echo json_encode($trashItems); + } + + /** + * @OA\Post( + * path="/api/file/restoreFiles.php", + * summary="Restore trashed files", + * description="Restores files from Trash based on provided trash file identifiers and updates metadata.", + * operationId="restoreFiles", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"files"}, + * @OA\Property(property="files", type="array", @OA\Items(type="string", example="trashedFile_1623456789.zip")) + * ) + * ), + * @OA\Response( + * response=200, + * description="Files restored successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="Items restored: file1, file2"), + * @OA\Property(property="restored", type="array", @OA\Items(type="string")) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + * + * Restores files from Trash based on provided trash file names. + * + * @return void Outputs JSON response. + */ + public function restoreFiles() + { + header('Content-Type: application/json'); + + // 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; + } + + // Read POST input. + $data = json_decode(file_get_contents("php://input"), true); + if (!isset($data['files']) || !is_array($data['files'])) { + http_response_code(400); + echo json_encode(["error" => "No file or folder identifiers provided"]); + exit; + } + + // Delegate restoration to the model. + $result = FileModel::restoreFiles($data['files']); + echo json_encode($result); + } + + /** + * @OA\Post( + * path="/api/file/deleteTrashFiles.php", + * summary="Delete trash files", + * description="Deletes trash items based on provided trash file identifiers from the trash metadata and removes the files from disk.", + * operationId="deleteTrashFiles", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * oneOf={ + * @OA\Schema( + * required={"deleteAll"}, + * @OA\Property(property="deleteAll", type="boolean", example=true) + * ), + * @OA\Schema( + * required={"files"}, + * @OA\Property( + * property="files", + * type="array", + * @OA\Items(type="string", example="trashedfile_1234567890") + * ) + * ) + * } + * ) + * ), + * @OA\Response( + * response=200, + * description="Trash items deleted successfully", + * @OA\JsonContent( + * @OA\Property(property="deleted", type="array", @OA\Items(type="string")) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + * + * Deletes trash files by processing provided trash file identifiers. + * + * @return void Outputs a JSON response. + */ + public function deleteTrashFiles() + { + header('Content-Type: application/json'); + + // 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; + } + + // Read and decode JSON input. + $data = json_decode(file_get_contents("php://input"), true); + if (!$data) { + http_response_code(400); + echo json_encode(["error" => "Invalid input"]); + exit; + } + + // Determine deletion mode. + $filesToDelete = []; + if (isset($data['deleteAll']) && $data['deleteAll'] === true) { + // In this case, we need to delete all trash items. + // Load current trash metadata. + $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; + $shareFile = $trashDir . "trash.json"; + if (file_exists($shareFile)) { + $json = file_get_contents($shareFile); + $tempData = json_decode($json, true); + if (is_array($tempData)) { + foreach ($tempData as $item) { + if (isset($item['trashName'])) { + $filesToDelete[] = $item['trashName']; + } + } + } + } + } elseif (isset($data['files']) && is_array($data['files'])) { + $filesToDelete = $data['files']; + } else { + http_response_code(400); + echo json_encode(["error" => "No trash file identifiers provided"]); + exit; + } + + // Delegate deletion to the model. + $result = FileModel::deleteTrashFiles($filesToDelete); + + // 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']); + echo json_encode(["success" => $msg]); + } elseif (!empty($result['error'])) { + echo json_encode(["error" => $result['error']]); + } else { + echo json_encode(["success" => "No items to delete."]); + } + exit; + } + + /** + * @OA\Get( + * path="/api/file/getFileTag.php", + * summary="Retrieve file tags", + * description="Retrieves tags from the createdTags.json metadata file.", + * operationId="getFileTags", + * tags={"Files"}, + * @OA\Response( + * response=200, + * description="File tags retrieved successfully", + * @OA\JsonContent( + * type="array", + * @OA\Items(type="object") + * ) + * ) + * ) + * + * Retrieves file tags from the createdTags.json metadata file. + * + * @return void Outputs JSON response with file tags. + */ + public function getFileTags(): void + { + header('Content-Type: application/json; charset=utf-8'); + + $tags = FileModel::getFileTags(); + echo json_encode($tags); + exit; + } + + /** + * @OA\Post( + * path="/api/file/saveFileTag.php", + * summary="Save file tags", + * description="Saves tag data for a specified file and updates global tag data. For folder-specific tags, saves to the folder's metadata file.", + * operationId="saveFileTag", + * tags={"Files"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"file", "tags"}, + * @OA\Property(property="file", type="string", example="document.txt"), + * @OA\Property(property="folder", type="string", example="Documents"), + * @OA\Property( + * property="tags", + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="name", type="string", example="Important"), + * @OA\Property(property="color", type="string", example="#FF0000") + * ) + * ), + * @OA\Property(property="deleteGlobal", type="boolean", example=false), + * @OA\Property(property="tagToDelete", type="string", example="OldTag") + * ) + * ), + * @OA\Response( + * response=200, + * description="Tag data saved successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="Tag data saved successfully."), + * @OA\Property(property="globalTags", type="array", @OA\Items(type="object")) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid request data" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or insufficient permissions" + * ) + * ) + * + * Saves tag data for a file and updates the global tag repository. + * + * @return void Outputs JSON response. + */ + public function saveFileTag(): void + { + header("Cache-Control: no-cache, no-store, must-revalidate"); + header("Pragma: no-cache"); + header("Expires: 0"); + header('Content-Type: application/json'); + + // CSRF Protection. + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $csrfHeader = $headersArr['x-csrf-token'] ?? ''; + if (!isset($_SESSION['csrf_token']) || trim($csrfHeader) !== $_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; + } + + // Check that the user is not read-only. + $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 file tags"]); + exit; + } + + // Retrieve and sanitize input. + $data = json_decode(file_get_contents('php://input'), true); + if (!$data) { + http_response_code(400); + echo json_encode(["error" => "No data received"]); + exit; + } + + $file = isset($data['file']) ? trim($data['file']) : ''; + $folder = isset($data['folder']) ? trim($data['folder']) : 'root'; + $tags = $data['tags'] ?? []; + $deleteGlobal = isset($data['deleteGlobal']) ? (bool)$data['deleteGlobal'] : false; + $tagToDelete = isset($data['tagToDelete']) ? trim($data['tagToDelete']) : null; + + if ($file === '') { + http_response_code(400); + echo json_encode(["error" => "No file specified."]); + exit; + } + + // Validate folder name. + if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + + // Delegate to the model. + $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); + echo json_encode($result); + } + + /** + * @OA\Get( + * path="/api/file/getFileList.php", + * summary="Get file list", + * description="Retrieves a list of files from a specified folder along with global tags and metadata.", + * operationId="getFileList", + * tags={"Files"}, + * @OA\Parameter( + * name="folder", + * in="query", + * description="Folder name (defaults to 'root')", + * required=false, + * @OA\Schema(type="string", example="Documents") + * ), + * @OA\Response( + * response=200, + * description="File list retrieved successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="files", type="array", @OA\Items(type="object")), + * @OA\Property(property="globalTags", type="array", @OA\Items(type="object")) + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ) + * ) + * + * Retrieves the file list and associated metadata for the specified folder. + * + * @return void Outputs JSON response. + */ + public function getFileList(): void + { + 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; + } + + // Retrieve the folder from GET; default to "root". + $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root'; + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + + // Delegate to the model. + $result = FileModel::getFileList($folder); + if (isset($result['error'])) { + http_response_code(400); + } + echo json_encode($result); + exit; + } + + /** + * GET /api/file/getShareLinks.php + */ + public function getShareLinks() + { + header('Content-Type: application/json'); + $shareFile = FileModel::getAllShareLinks(); + echo json_encode($shareFile, JSON_PRETTY_PRINT); + } + + /** + * POST /api/file/deleteShareLink.php + */ + public function deleteShareLink() + { + header('Content-Type: application/json'); + $token = $_POST['token'] ?? ''; + if (!$token) { + echo json_encode(['success' => false, 'error' => 'No token provided']); + return; + } + + $deleted = FileModel::deleteShareLink($token); + if ($deleted) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Not found']); + } + } +} diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php new file mode 100644 index 0000000..da99157 --- /dev/null +++ b/src/controllers/FolderController.php @@ -0,0 +1,1107 @@ + "Unauthorized"]); + exit; + } + + // Ensure the request method is POST. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['error' => 'Invalid request method.']); + exit; + } + + // CSRF check. + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $receivedToken = $headersArr['x-csrf-token'] ?? ''; + if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token.']); + exit; + } + + // Check 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 create folders."]); + exit; + } + + // Get and decode JSON input. + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['folderName'])) { + echo json_encode(['error' => 'Folder name not provided.']); + exit; + } + + $folderName = trim($input['folderName']); + $parent = isset($input['parent']) ? trim($input['parent']) : ""; + + // Basic sanitation for folderName. + if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { + echo json_encode(['error' => 'Invalid folder name.']); + exit; + } + + // Optionally sanitize the parent. + if ($parent && !preg_match(REGEX_FOLDER_NAME, $parent)) { + echo json_encode(['error' => 'Invalid parent folder name.']); + exit; + } + + // Delegate to FolderModel. + $result = FolderModel::createFolder($folderName, $parent); + echo json_encode($result); + exit; + } + + /** + * @OA\Post( + * path="/api/folder/deleteFolder.php", + * summary="Delete an empty folder", + * description="Deletes a specified folder if it is empty and not the root folder, and also removes its metadata file.", + * operationId="deleteFolder", + * tags={"Folders"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder"}, + * @OA\Property(property="folder", type="string", example="Documents/Subfolder") + * ) + * ), + * @OA\Response( + * response=200, + * description="Folder deleted successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true) + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request (e.g., invalid folder name or folder not empty)" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or permission denied" + * ) + * ) + * + * Deletes a folder if it is empty and not the root folder. + * + * @return void Outputs a JSON response. + */ + public function deleteFolder(): void + { + 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; + } + + // Ensure the request is a POST. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(["error" => "Invalid request method."]); + exit; + } + + // 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; + } + + // Check 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 delete folders."]); + exit; + } + + // Get and decode JSON input. + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['folder'])) { + echo json_encode(["error" => "Folder name not provided."]); + exit; + } + + $folder = trim($input['folder']); + // Prevent deletion of the root folder. + if (strtolower($folder) === 'root') { + echo json_encode(["error" => "Cannot delete root folder."]); + exit; + } + + // Delegate to the model. + $result = FolderModel::deleteFolder($folder); + echo json_encode($result); + exit; + } + + /** + * @OA\Post( + * path="/api/folder/renameFolder.php", + * summary="Rename a folder", + * description="Renames an existing folder and updates its associated metadata files.", + * operationId="renameFolder", + * tags={"Folders"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"oldFolder", "newFolder"}, + * @OA\Property(property="oldFolder", type="string", example="Documents/OldFolder"), + * @OA\Property(property="newFolder", type="string", example="Documents/NewFolder") + * ) + * ), + * @OA\Response( + * response=200, + * description="Folder renamed successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid folder names or folder does not exist" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or permission denied" + * ) + * ) + * + * Renames a folder by validating inputs and delegating to the model. + * + * @return void Outputs a JSON response. + */ + public function renameFolder(): void + { + 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; + } + + // Ensure the request method is POST. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['error' => 'Invalid request method.']); + exit; + } + + // 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; + } + + // Check that the user is not read-only. + $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 folders."]); + exit; + } + + // Get JSON input. + $input = json_decode(file_get_contents('php://input'), true); + if (!isset($input['oldFolder']) || !isset($input['newFolder'])) { + echo json_encode(['error' => 'Required folder names not provided.']); + exit; + } + + $oldFolder = trim($input['oldFolder']); + $newFolder = trim($input['newFolder']); + + // Validate folder names. + if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) { + echo json_encode(['error' => 'Invalid folder name(s).']); + exit; + } + + // Delegate to the model. + $result = FolderModel::renameFolder($oldFolder, $newFolder); + echo json_encode($result); + exit; + } + + /** + * @OA\Get( + * path="/api/folder/getFolderList.php", + * summary="Get list of folders", + * description="Retrieves the list of folders in the upload directory, including file counts and metadata file names for each folder.", + * operationId="getFolderList", + * tags={"Folders"}, + * @OA\Parameter( + * name="folder", + * in="query", + * description="Optional folder name to filter the listing", + * required=false, + * @OA\Schema(type="string", example="Documents") + * ), + * @OA\Response( + * response=200, + * description="Folder list retrieved successfully", + * @OA\JsonContent( + * type="array", + * @OA\Items(type="object") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=400, + * description="Bad request" + * ) + * ) + * + * Retrieves the folder list and associated metadata. + * + * @return void Outputs JSON response. + */ + public function getFolderList(): void + { + 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; + } + + // Optionally, you might add further input validation if necessary. + $folderList = FolderModel::getFolderList(); + echo json_encode($folderList); + exit; + } + + /** + * @OA\Get( + * path="/api/folder/shareFolder.php", + * summary="Display a shared folder", + * description="Renders an HTML view of a shared folder's contents. Supports password protection, file listing with pagination, and an upload container if uploads are allowed.", + * operationId="shareFolder", + * tags={"Folders"}, + * @OA\Parameter( + * name="token", + * in="query", + * description="The share token for the folder", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="pass", + * in="query", + * description="The password if the folder is protected", + * required=false, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="page", + * in="query", + * description="Page number for pagination", + * required=false, + * @OA\Schema(type="integer", example=1) + * ), + * @OA\Response( + * response=200, + * description="Shared folder displayed", + * @OA\MediaType(mediaType="text/html") + * ), + * @OA\Response( + * response=400, + * description="Invalid request" + * ), + * @OA\Response( + * response=403, + * description="Access forbidden (expired link or invalid password)" + * ), + * @OA\Response( + * response=404, + * description="Share folder not found" + * ) + * ) + * + * Displays a shared folder with file listings, pagination, and an upload container if allowed. + * + * @return void Outputs HTML content. + */ + + function formatBytes($bytes) + { + if ($bytes < 1024) { + return $bytes . " B"; + } elseif ($bytes < 1024 * 1024) { + return round($bytes / 1024, 2) . " KB"; + } elseif ($bytes < 1024 * 1024 * 1024) { + return round($bytes / (1024 * 1024), 2) . " MB"; + } else { + return round($bytes / (1024 * 1024 * 1024), 2) . " GB"; + } + } + + public function shareFolder(): void + { + // Retrieve GET parameters. + $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); + $providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING); + $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT); + if ($page === false || $page < 1) { + $page = 1; + } + + if (empty($token)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing token."]); + exit; + } + + // Delegate to the model. + $data = FolderModel::getSharedFolderData($token, $providedPass, $page); + + // If a password is needed, output an HTML form. + if (isset($data['needs_password']) && $data['needs_password'] === true) { + header("Content-Type: text/html; charset=utf-8"); +?> + + + + + + + Enter Password + + + + +
+

Folder Protected

+

This folder is protected by a password. Please enter the password to view its contents.

+
+ + + + +
+
+ + + + $data['error']]); + exit; + } + + // Load admin config so we can pull the sharedMaxUploadSize + require_once PROJECT_ROOT . '/src/models/AdminModel.php'; + $adminConfig = AdminModel::getConfig(); + $sharedMaxUploadSize = isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize']) + ? (int)$adminConfig['sharedMaxUploadSize'] + : null; + + // For human‐readable formatting + function formatBytes($bytes) + { + if ($bytes < 1024) { + return $bytes . " B"; + } elseif ($bytes < 1024 * 1024) { + return round($bytes / 1024, 2) . " KB"; + } elseif ($bytes < 1024 * 1024 * 1024) { + return round($bytes / (1024 * 1024), 2) . " MB"; + } else { + return round($bytes / (1024 * 1024 * 1024), 2) . " GB"; + } + } + + // Extract data for the HTML view. + $folderName = $data['folder']; + $files = $data['files']; + $currentPage = $data['currentPage']; + $totalPages = $data['totalPages']; + + // Build the HTML view. + header("Content-Type: text/html; charset=utf-8"); + ?> + + + + + + Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?> + + + + + +
+

Shared Folder:

+
+
+ + + + +
+ +

This folder is empty.

+ + + + + + + + + + + + + + + + +
FilenameSize
+ + + + +
+ +
+ + + + + + + + + +
+

Upload File + + ( max size) + +

+
+ + + +

+ +
+
+ +
+ + + + + + + + "Unauthorized"]); + exit; + } + + // Read-only check + $username = $_SESSION['username'] ?? ''; + $perms = loadUserPermissions($username); + if ($username && !empty($perms['readOnly'])) { + http_response_code(403); + echo json_encode(["error" => "Read-only users are not allowed to create share folders."]); + exit; + } + + // Input + $in = json_decode(file_get_contents("php://input"), true); + if (!$in || !isset($in['folder'])) { + http_response_code(400); + echo json_encode(["error" => "Invalid input."]); + exit; + } + + $folder = trim($in['folder']); + $value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60; + $unit = $in['expirationUnit'] ?? 'minutes'; + $password = $in['password'] ?? ''; + $allowUpload = intval($in['allowUpload'] ?? 0); + + // Folder name validation + if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { + http_response_code(400); + echo json_encode(["error" => "Invalid folder name."]); + exit; + } + + // Convert to seconds + switch ($unit) { + case 'seconds': + $seconds = $value; + break; + case 'hours': + $seconds = $value * 3600; + break; + case 'days': + $seconds = $value * 86400; + break; + case 'minutes': + default: + $seconds = $value * 60; + break; + } + + // Delegate + $res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload); + echo json_encode($res); + exit; + } + + /** + * @OA\Get( + * path="/api/folder/downloadSharedFile.php", + * summary="Download a file from a shared folder", + * description="Retrieves and serves a file from a shared folder based on a share token.", + * operationId="downloadSharedFile", + * tags={"Folders"}, + * @OA\Parameter( + * name="token", + * in="query", + * description="The share folder token", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="file", + * in="query", + * description="The filename to download", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=200, + * description="File served successfully", + * @OA\MediaType(mediaType="application/octet-stream") + * ), + * @OA\Response( + * response=400, + * description="Bad Request (missing parameters, invalid file name, etc.)" + * ), + * @OA\Response( + * response=403, + * description="Access forbidden (e.g., expired share link)" + * ), + * @OA\Response( + * response=404, + * description="File not found" + * ) + * ) + * + * Downloads a file from a shared folder based on a token. + * + * @return void Outputs the file with proper headers. + */ + public function downloadSharedFile(): void + { + // Retrieve and sanitize GET parameters. + $token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING); + $file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING); + + if (empty($token) || empty($file)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing token or file parameter."]); + exit; + } + + // Delegate to the model. + $result = FolderModel::getSharedFileInfo($token, $file); + if (isset($result['error'])) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(["error" => $result['error']]); + exit; + } + + $realFilePath = $result['realFilePath']; + $mimeType = $result['mimeType']; + + // Serve the file. + header("Content-Type: " . $mimeType); + $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); + if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) { + header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"'); + } else { + header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"'); + } + header('Content-Length: ' . filesize($realFilePath)); + readfile($realFilePath); + exit; + } + + /** + * @OA\Post( + * path="/api/folder/uploadToSharedFolder.php", + * summary="Upload a file to a shared folder", + * description="Handles file upload to a shared folder using a share token. Validates file size, extension, and uploads the file to the shared folder, updating metadata accordingly.", + * operationId="uploadToSharedFolder", + * tags={"Folders"}, + * @OA\RequestBody( + * required=true, + * description="Multipart form data containing the share token and file to upload.", + * @OA\MediaType( + * mediaType="multipart/form-data", + * @OA\Schema( + * required={"token", "fileToUpload"}, + * @OA\Property(property="token", type="string"), + * @OA\Property(property="fileToUpload", type="string", format="binary") + * ) + * ) + * ), + * @OA\Response( + * response=302, + * description="Redirects to the shared folder page on success." + * ), + * @OA\Response( + * response=400, + * description="Bad Request (missing token, file upload error, file type/size not allowed)" + * ), + * @OA\Response( + * response=403, + * description="Forbidden (share link expired or uploads not allowed)" + * ), + * @OA\Response( + * response=500, + * description="Server error during file move" + * ) + * ) + * + * Handles uploading a file to a shared folder. + * + * @return void Redirects upon successful upload or outputs JSON errors. + */ + public function uploadToSharedFolder(): void + { + // Ensure request is POST. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Content-Type: application/json'); + echo json_encode(["error" => "Method not allowed."]); + exit; + } + + // Ensure the share token is provided. + if (empty($_POST['token'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "Missing share token."]); + exit; + } + $token = trim($_POST['token']); + + // Delegate the upload to the model. + if (!isset($_FILES['fileToUpload'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(["error" => "No file was uploaded."]); + exit; + } + $fileUpload = $_FILES['fileToUpload']; + + $result = FolderModel::uploadToSharedFolder($token, $fileUpload); + if (isset($result['error'])) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode($result); + exit; + } + + // Optionally, set a flash message in session. + $_SESSION['upload_message'] = "File uploaded successfully."; + + // Redirect back to the shared folder view. + $redirectUrl = "/api/folder/shareFolder.php?token=" . urlencode($token); + header("Location: " . $redirectUrl); + exit; + } + + /** + * GET /api/folder/getShareFolderLinks.php + */ + public function getShareFolderLinks() + { + header('Content-Type: application/json'); + $links = FolderModel::getAllShareFolderLinks(); + echo json_encode($links, JSON_PRETTY_PRINT); + } + + /** + * POST /api/folder/deleteShareFolderLink.php + */ + public function deleteShareFolderLink() + { + header('Content-Type: application/json'); + $token = $_POST['token'] ?? ''; + if (!$token) { + echo json_encode(['success' => false, 'error' => 'No token provided']); + return; + } + + $deleted = FolderModel::deleteShareFolderLink($token); + if ($deleted) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Not found']); + } + } +} diff --git a/src/controllers/UploadController.php b/src/controllers/UploadController.php new file mode 100644 index 0000000..273b7b2 --- /dev/null +++ b/src/controllers/UploadController.php @@ -0,0 +1,199 @@ + true, + 'csrf_token' => $_SESSION['csrf_token'] + ]); + exit; + } + + // + // 2) Auth checks + // + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + echo json_encode(["error" => "Unauthorized"]); + exit; + } + $userPerms = loadUserPermissions($_SESSION['username']); + if (!empty($userPerms['disableUpload'])) { + http_response_code(403); + echo json_encode(["error" => "Upload disabled for this user."]); + exit; + } + + // + // 3) Delegate the actual file handling + // + $result = UploadModel::handleUpload($_POST, $_FILES); + + // + // 4) Respond + // + if (isset($result['error'])) { + http_response_code(400); + echo json_encode($result); + exit; + } + if (isset($result['status'])) { + echo json_encode($result); + exit; + } + + // full‐upload redirect + $_SESSION['upload_message'] = "File uploaded successfully."; + exit; + } + + /** + * @OA\Post( + * path="/api/upload/removeChunks.php", + * summary="Remove chunked upload temporary directory", + * description="Removes the temporary directory used for chunked uploads, given a folder name matching the expected resumable pattern.", + * operationId="removeChunks", + * tags={"Uploads"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"folder"}, + * @OA\Property(property="folder", type="string", example="resumable_myupload123") + * ) + * ), + * @OA\Response( + * response=200, + * description="Temporary folder removed successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="Temporary folder removed.") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input (e.g., missing folder or invalid folder name)" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + * + * Removes the temporary upload folder for chunked uploads. + * + * @return void Outputs a JSON response. + */ + public function removeChunks(): void { + header('Content-Type: application/json'); + + // CSRF Protection: Validate token from POST data. + $receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : ''; + if ($receivedToken !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(["error" => "Invalid CSRF token"]); + exit; + } + + // Check that the folder parameter is provided. + if (!isset($_POST['folder'])) { + http_response_code(400); + echo json_encode(["error" => "No folder specified"]); + exit; + } + + $folder = $_POST['folder']; + $result = UploadModel::removeChunks($folder); + echo json_encode($result); + exit; + } +} \ No newline at end of file diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php new file mode 100644 index 0000000..89e2b8d --- /dev/null +++ b/src/controllers/UserController.php @@ -0,0 +1,1014 @@ + "Unauthorized"]); + exit; + } + + // Retrieve users using the model + $users = userModel::getAllUsers(); + echo json_encode($users); + } + + /** + * @OA\Post( + * path="/api/addUser.php", + * summary="Add a new user", + * description="Adds a new user to the system. In setup mode, the new user is automatically made admin.", + * operationId="addUser", + * tags={"Users"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"username", "password"}, + * @OA\Property(property="username", type="string", example="johndoe"), + * @OA\Property(property="password", type="string", example="securepassword"), + * @OA\Property(property="isAdmin", type="boolean", example=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="User added successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="User added successfully") + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ) + * ) + */ + + public function addUser() + { + // 1) Ensure JSON output and session + header('Content-Type: application/json'); + + // 1a) Initialize CSRF token if missing + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + // 2) Determine setup mode (first-ever admin creation) + $usersFile = USERS_DIR . USERS_FILE; + $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); + $setupMode = false; + if ( + $isSetup && (! file_exists($usersFile) + || filesize($usersFile) === 0 + || 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'] ?? ''); + + // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token + if ($receivedToken !== $_SESSION['csrf_token']) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + header('X-CSRF-Token: ' . $_SESSION['csrf_token']); + echo json_encode([ + 'csrf_expired' => true, + 'csrf_token' => $_SESSION['csrf_token'] + ]); + 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; + } + } + + // 4) Parse input + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $newUsername = trim($data['username'] ?? ''); + $newPassword = trim($data['password'] ?? ''); + + // 5) Determine admin flag + if ($setupMode) { + $isAdmin = '1'; + } else { + $isAdmin = !empty($data['isAdmin']) ? '1' : '0'; + } + + // 6) Validate fields + if ($newUsername === '' || $newPassword === '') { + echo json_encode(["error" => "Username and password required"]); + exit; + } + if (!preg_match(REGEX_USER, $newUsername)) { + echo json_encode([ + "error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed." + ]); + exit; + } + + // 7) Delegate to model + $result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); + + // 8) Return model result + echo json_encode($result); + exit; + } + + /** + * @OA\Delete( + * path="/api/removeUser.php", + * summary="Remove a user", + * description="Removes the specified user from the system. Cannot remove the currently logged-in user.", + * operationId="removeUser", + * tags={"Users"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"username"}, + * @OA\Property(property="username", type="string", example="johndoe") + * ) + * ), + * @OA\Response( + * response=200, + * description="User removed successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="User removed successfully") + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + */ + + public function removeUser() + { + header('Content-Type: application/json'); + + // 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; + } + + // 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) { + 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) { + echo json_encode(["error" => "Cannot remove yourself"]); + exit; + } + + // Delegate the removal logic to the model. + $result = userModel::removeUser($usernameToRemove); + echo json_encode($result); + } + + /** + * @OA\Get( + * path="/api/getUserPermissions.php", + * summary="Retrieve user permissions", + * description="Returns the permissions for the current user, or all permissions if the user is an admin.", + * operationId="getUserPermissions", + * tags={"Users"}, + * @OA\Response( + * response=200, + * description="Successful response with user permissions", + * @OA\JsonContent(type="object") + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ) + * ) + */ + + public function getUserPermissions() + { + header('Content-Type: application/json'); + + // 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(); + echo json_encode($permissions); + } + + /** + * @OA\Put( + * path="/api/updateUserPermissions.php", + * summary="Update user permissions", + * description="Updates permissions for users. Only available to authenticated admin users.", + * operationId="updateUserPermissions", + * tags={"Users"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"permissions"}, + * @OA\Property( + * property="permissions", + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="username", type="string", example="johndoe"), + * @OA\Property(property="folderOnly", type="boolean", example=true), + * @OA\Property(property="readOnly", type="boolean", example=false), + * @OA\Property(property="disableUpload", type="boolean", example=false) + * ) + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="User permissions updated successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="User permissions updated successfully.") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ) + * ) + */ + + public function updateUserPermissions() + { + header('Content-Type: application/json'); + + // 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); + 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); + echo json_encode($result); + } + + /** + * @OA\Post( + * path="/api/changePassword.php", + * summary="Change user password", + * description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.", + * operationId="changePassword", + * tags={"Users"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"oldPassword", "newPassword", "confirmPassword"}, + * @OA\Property(property="oldPassword", type="string", example="oldpass123"), + * @OA\Property(property="newPassword", type="string", example="newpass456"), + * @OA\Property(property="confirmPassword", type="string", example="newpass456") + * ) + * ), + * @OA\Response( + * response=200, + * description="Password updated successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="Password updated successfully.") + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ) + * ) + */ + + 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; + } + + $username = $_SESSION['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"] ?? ""); + $confirmPassword = trim($data["confirmPassword"] ?? ""); + + // Validate input. + if (!$oldPassword || !$newPassword || !$confirmPassword) { + echo json_encode(["error" => "All fields are required."]); + exit; + } + if ($newPassword !== $confirmPassword) { + echo json_encode(["error" => "New passwords do not match."]); + exit; + } + + // Delegate password change logic to the model. + $result = userModel::changePassword($username, $oldPassword, $newPassword); + echo json_encode($result); + } + + /** + * @OA\Put( + * path="/api/updateUserPanel.php", + * summary="Update user panel settings", + * description="Updates user panel settings by disabling TOTP when not enabled. Accessible to authenticated users.", + * operationId="updateUserPanel", + * tags={"Users"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"totp_enabled"}, + * @OA\Property(property="totp_enabled", type="boolean", example=false) + * ) + * ), + * @OA\Response( + * response=200, + * description="User panel updated successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="User panel updated: TOTP disabled") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ) + * ) + */ + + public function updateUserPanel() + { + header('Content-Type: application/json'); + + // 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); + if (!is_array($data)) { + http_response_code(400); + echo json_encode(["error" => "Invalid input"]); + exit; + } + + $username = $_SESSION['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); + echo json_encode($result); + } + + /** + * @OA\Put( + * path="/api/totp_disable.php", + * summary="Disable TOTP for the authenticated user", + * description="Clears the TOTP secret from the users file for the current user.", + * operationId="disableTOTP", + * tags={"TOTP"}, + * @OA\Response( + * response=200, + * description="TOTP disabled successfully", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="TOTP disabled successfully.") + * ) + * ), + * @OA\Response( + * response=403, + * description="Not authenticated or invalid CSRF token" + * ), + * @OA\Response( + * response=500, + * description="Failed to disable TOTP" + * ) + * ) + */ + + 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; + } + + $username = $_SESSION['username'] ?? ''; + if (empty($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); + + if ($result) { + echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]); + } else { + http_response_code(500); + echo json_encode(["error" => "Failed to disable TOTP."]); + } + } + + /** + * @OA\Post( + * path="/api/totp_recover.php", + * summary="Recover TOTP", + * description="Verifies a recovery code to disable TOTP and finalize login.", + * operationId="recoverTOTP", + * tags={"TOTP"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"recovery_code"}, + * @OA\Property(property="recovery_code", type="string", example="ABC123DEF456") + * ) + * ), + * @OA\Response( + * response=200, + * description="Recovery successful", + * @OA\JsonContent( + * @OA\Property(property="status", type="string", example="ok") + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid input or recovery code" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token" + * ), + * @OA\Response( + * response=405, + * description="Method not allowed" + * ), + * @OA\Response( + * response=429, + * description="Too many attempts" + * ) + * ) + */ + + public function recoverTOTP() + { + header('Content-Type: application/json'); + + // 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; + if (!$userId) { + http_response_code(401); + exit(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); + } + + // 4) Validate userId format. + if (!preg_match(REGEX_USER, $userId)) { + http_response_code(400); + exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier'])); + } + + // 5) Get the recovery code from input. + $inputData = json_decode(file_get_contents("php://input"), true); + $recoveryCode = $inputData['recovery_code'] ?? ''; + + // 6) Delegate to the model. + $result = userModel::recoverTOTP($userId, $recoveryCode); + + if ($result['status'] === 'ok') { + // 7) 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.') { + http_response_code(429); + } else { + http_response_code(400); + } + echo json_encode($result); + } + } + + /** + * @OA\Post( + * path="/api/totp_saveCode.php", + * summary="Generate and save a new TOTP recovery code", + * description="Generates a new TOTP recovery code for the authenticated user, stores its hash, and returns the plain text recovery code.", + * operationId="totpSaveCode", + * tags={"TOTP"}, + * @OA\Response( + * response=200, + * description="Recovery code generated successfully", + * @OA\JsonContent( + * @OA\Property(property="status", type="string", example="ok"), + * @OA\Property(property="recoveryCode", type="string", example="ABC123DEF456") + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request" + * ), + * @OA\Response( + * response=403, + * description="Invalid CSRF token or unauthorized" + * ), + * @OA\Response( + * response=405, + * description="Method not allowed" + * ) + * ) + */ + + public function saveTOTPRecoveryCode() + { + header('Content-Type: application/json'); + + // 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'])); + } + + // 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'])); + } + + // 5) Delegate to the model. + $result = userModel::saveTOTPRecoveryCode($userId); + if ($result['status'] === 'ok') { + echo json_encode($result); + } else { + http_response_code(500); + echo json_encode($result); + } + } + + /** + * @OA\Get( + * path="/api/totp_setup.php", + * summary="Set up TOTP and generate a QR code", + * description="Generates (or retrieves) the TOTP secret for the user and builds a QR code image for scanning.", + * operationId="setupTOTP", + * tags={"TOTP"}, + * @OA\Response( + * response=200, + * description="QR code image for TOTP setup", + * @OA\MediaType( + * mediaType="image/png" + * ) + * ), + * @OA\Response( + * response=403, + * description="Unauthorized or invalid CSRF token" + * ), + * @OA\Response( + * response=500, + * description="Server error" + * ) + * ) + */ + + 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']))) { + 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"]); + exit; + } + + $username = $_SESSION['username'] ?? ''; + if (!$username) { + http_response_code(400); + exit; + } + + // Set header for PNG output. + header("Content-Type: image/png"); + + // Delegate the TOTP setup work to the model. + $result = userModel::setupTOTP($username); + if (isset($result['error'])) { + http_response_code(500); + echo json_encode(["error" => $result['error']]); + exit; + } + + // Output the QR code image. + echo $result['imageData']; + } + + /** + * @OA\Post( + * path="/api/totp_verify.php", + * summary="Verify TOTP code", + * description="Verifies a TOTP code and completes login for pending users or validates TOTP for setup verification.", + * operationId="verifyTOTP", + * tags={"TOTP"}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"totp_code"}, + * @OA\Property(property="totp_code", type="string", example="123456") + * ) + * ), + * @OA\Response( + * response=200, + * description="TOTP successfully verified", + * @OA\JsonContent( + * @OA\Property(property="status", type="string", example="ok"), + * @OA\Property(property="message", type="string", example="Login successful") + * ) + * ), + * @OA\Response( + * response=400, + * description="Bad Request (e.g., invalid input)" + * ), + * @OA\Response( + * response=403, + * description="Not authenticated or invalid CSRF token" + * ), + * @OA\Response( + * response=429, + * description="Too many attempts. Try again later." + * ) + * ) + */ + + public function verifyTOTP() + { + header('Content-Type: application/json'); + header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); + + // Rate-limit + if (!isset($_SESSION['totp_failures'])) { + $_SESSION['totp_failures'] = 0; + } + if ($_SESSION['totp_failures'] >= 5) { + http_response_code(429); + echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); + exit; + } + + // Must be authenticated OR pending login + if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); + exit; + } + + // 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; + } + + // Parse & validate input + $inputData = json_decode(file_get_contents("php://input"), true); + $code = trim($inputData['totp_code'] ?? ''); + if (!preg_match('/^\d{6}$/', $code)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); + exit; + } + + // TFA helper + $tfa = new \RobThree\Auth\TwoFactorAuth( + new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), + 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 + ); + + // === Pending-login flow (we just came from auth and need to finish login) === + if (isset($_SESSION['pending_login_user'])) { + $username = $_SESSION['pending_login_user']; + $pendingSecret = $_SESSION['pending_login_secret'] ?? null; + $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; + + if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { + $_SESSION['totp_failures']++; + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); + exit; + } + + // Issue “remember me” token if requested + if ($rememberMe) { + $tokFile = USERS_DIR . 'persistent_tokens.json'; + $token = bin2hex(random_bytes(32)); + $expiry = time() + 30 * 24 * 60 * 60; + $all = []; + if (file_exists($tokFile)) { + $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); + $all = json_decode($dec, true) ?: []; + } + $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 + ]; + file_put_contents( + $tokFile, + encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), + LOCK_EX + ); + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); + setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); + } + + // === Finalize login into session exactly as finalizeLogin() would === + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_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'], + $_SESSION['pending_login_remember_me'], + $_SESSION['totp_failures'] + ); + + // Send back full login payload + echo json_encode([ + 'status' => 'ok', + 'success' => 'Login successful', + 'isAdmin' => $_SESSION['isAdmin'], + 'folderOnly' => $_SESSION['folderOnly'], + 'readOnly' => $_SESSION['readOnly'], + 'disableUpload' => $_SESSION['disableUpload'], + 'username' => $_SESSION['username'] + ]); + exit; + } + + // Setup/verification flow (not pending) + $username = $_SESSION['username'] ?? ''; + if (!$username) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); + exit; + } + + $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.']); + exit; + } + + if (!$tfa->verifyCode($totpSecret, $code)) { + $_SESSION['totp_failures']++; + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); + exit; + } + + // Successful setup/verification + unset($_SESSION['totp_failures']); + echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); + } +} diff --git a/src/controllers/adminController.php b/src/controllers/adminController.php index 6f75d40..1e4bc9b 100644 --- a/src/controllers/adminController.php +++ b/src/controllers/adminController.php @@ -1,5 +1,5 @@ false, 'error' => 'No token provided']); + return; + } + + $deleted = FileModel::deleteShareLink($token); + if ($deleted) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Not found']); + } + } } diff --git a/src/controllers/folderController.php b/src/controllers/folderController.php index e45e725..da99157 100644 --- a/src/controllers/folderController.php +++ b/src/controllers/folderController.php @@ -1,5 +1,5 @@ false, 'error' => 'No token provided']); + return; + } + + $deleted = FolderModel::deleteShareFolderLink($token); + if ($deleted) { + echo json_encode(['success' => true]); + } else { + echo json_encode(['success' => false, 'error' => 'Not found']); + } + } } diff --git a/src/controllers/uploadController.php b/src/controllers/uploadController.php index f9bc2c8..273b7b2 100644 --- a/src/controllers/uploadController.php +++ b/src/controllers/uploadController.php @@ -1,5 +1,5 @@ $fileList, "globalTags" => $globalTags]; } + + public static function getAllShareLinks(): array + { + $shareFile = META_DIR . "share_links.json"; + if (!file_exists($shareFile)) { + return []; + } + $links = json_decode(file_get_contents($shareFile), true); + return is_array($links) ? $links : []; + } + + public static function deleteShareLink(string $token): bool + { + $shareFile = META_DIR . "share_links.json"; + if (!file_exists($shareFile)) { + return false; + } + $links = json_decode(file_get_contents($shareFile), true); + if (!is_array($links) || !isset($links[$token])) { + return false; + } + unset($links[$token]); + file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); + return true; + } } \ No newline at end of file diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php index d90d8d2..5689df1 100644 --- a/src/models/FolderModel.php +++ b/src/models/FolderModel.php @@ -570,4 +570,29 @@ class FolderModel return ["success" => "File uploaded successfully.", "newFilename" => $newFilename]; } + + public static function getAllShareFolderLinks(): array + { + $shareFile = META_DIR . "share_folder_links.json"; + if (!file_exists($shareFile)) { + return []; + } + $links = json_decode(file_get_contents($shareFile), true); + return is_array($links) ? $links : []; + } + + public static function deleteShareFolderLink(string $token): bool + { + $shareFile = META_DIR . "share_folder_links.json"; + if (!file_exists($shareFile)) { + return false; + } + $links = json_decode(file_get_contents($shareFile), true); + if (!is_array($links) || !isset($links[$token])) { + return false; + } + unset($links[$token]); + file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); + return true; + } }