Refactor API endpoints and modularize controllers and models

This commit is contained in:
Ryan
2025-04-16 11:40:17 -04:00
committed by GitHub
parent 853d8835d9
commit ec5c3fc452
128 changed files with 8189 additions and 5318 deletions

118
src/models/AdminModel.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
// src/models/AdminModel.php
require_once PROJECT_ROOT . '/config/config.php';
class AdminModel
{
/**
* Updates the admin configuration file.
*
* @param array $configUpdate The configuration to update.
* @return array Returns an array with "success" on success or "error" on failure.
*/
public static function updateConfig(array $configUpdate): array
{
// Validate required OIDC configuration keys.
if (
empty($configUpdate['oidc']['providerUrl']) ||
empty($configUpdate['oidc']['clientId']) ||
empty($configUpdate['oidc']['clientSecret']) ||
empty($configUpdate['oidc']['redirectUri'])
) {
return ["error" => "Incomplete OIDC configuration."];
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) {
return ["error" => "Failed to encode configuration to JSON."];
}
// Encrypt configuration.
$encryptedContent = encryptData($plainTextConfig, $GLOBALS['encryptionKey']);
if ($encryptedContent === false) {
return ["error" => "Failed to encrypt configuration."];
}
// Define the configuration file path.
$configFile = USERS_DIR . 'adminConfig.json';
// Attempt to write the new configuration.
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
// Attempt a cleanup: delete the old file and try again.
if (file_exists($configFile)) {
unlink($configFile);
}
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
return ["error" => "Failed to update configuration even after cleanup."];
}
}
return ["success" => "Configuration updated successfully."];
}
/**
* Retrieves the current configuration.
*
* @return array The configuration array, or defaults if not found.
*/
public static function getConfig(): array {
$configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
if ($decryptedContent === false) {
http_response_code(500);
return ["error" => "Failed to decrypt configuration."];
}
$config = json_decode($decryptedContent, true);
if (!is_array($config)) {
$config = [];
}
// Normalize login options.
if (!isset($config['loginOptions'])) {
// Create loginOptions array from top-level keys if missing.
$config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : false,
];
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
} else {
// Ensure proper boolean types
$config['loginOptions']['disableFormLogin'] = (bool)$config['loginOptions']['disableFormLogin'];
$config['loginOptions']['disableBasicAuth'] = (bool)$config['loginOptions']['disableBasicAuth'];
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
}
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
if (!isset($config['header_title']) || empty($config['header_title'])) {
$config['header_title'] = "FileRise";
}
return $config;
} else {
// Return defaults.
return [
'header_title' => "FileRise",
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => 'YOUR_CLIENT_ID',
'clientSecret' => 'YOUR_CLIENT_SECRET',
'redirectUri' => 'https://yourdomain.com/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => false
],
'globalOtpauthUrl' => ""
];
}
}
}

124
src/models/AuthModel.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
// src/models/AuthModel.php
require_once PROJECT_ROOT . '/config/config.php';
class AuthModel {
/**
* Retrieves the user's role from the users file.
*
* @param string $username
* @return string|null The role string (e.g. "1" for admin) or null if not found.
*/
public static function getUserRole(string $username): ?string {
$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) >= 3 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
/**
* Authenticates the user using form-based credentials.
*
* @param string $username
* @param string $password
* @return array|false Returns an associative array with user data (role, totp_secret) on success or false on failure.
*/
public static function authenticate(string $username, string $password) {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) continue;
if ($username === $parts[0] && password_verify($password, $parts[1])) {
return [
'role' => $parts[2],
'totp_secret' => (isset($parts[3]) && !empty($parts[3]))
? decryptData($parts[3], $GLOBALS['encryptionKey'])
: null
];
}
}
return false;
}
/**
* Loads failed login attempts from a file.
*
* @param string $file
* @return array
*/
public static function loadFailedAttempts(string $file): array {
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
if (is_array($data)) {
return $data;
}
}
return [];
}
/**
* Saves failed login attempts into a file.
*
* @param string $file
* @param array $data
* @return void
*/
public static function saveFailedAttempts(string $file, array $data): void {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
}
/**
* Retrieves a user's TOTP secret from the users file.
*
* @param string $username
* @return string|null Returns the decrypted TOTP secret or null if not set.
*/
public static function getUserTOTPSecret(string $username): ?string {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return null;
}
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
return decryptData($parts[3], $GLOBALS['encryptionKey']);
}
}
return null;
}
/**
* Loads the folder-only permission for a given user.
*
* @param string $username
* @return bool
*/
public static function loadFolderPermission(string $username): bool {
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
$decrypted = decryptData($content, $GLOBALS['encryptionKey']);
$permissions = $decrypted !== false ? json_decode($decrypted, true) : json_decode($content, true);
if (is_array($permissions)) {
foreach ($permissions as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0 && isset($data['folderOnly'])) {
return (bool)$data['folderOnly'];
}
}
}
}
return false;
}
}

1249
src/models/FileModel.php Normal file

File diff suppressed because it is too large Load Diff

570
src/models/FolderModel.php Normal file
View File

@@ -0,0 +1,570 @@
<?php
// src/models/FolderModel.php
require_once PROJECT_ROOT . '/config/config.php';
class FolderModel {
/**
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
*
* @param string $folderName The name of the folder to create.
* @param string $parent (Optional) The parent folder name. Defaults to empty.
* @return array Returns an array with a "success" key if the folder was created,
* or an "error" key if an error occurred.
*/
public static function createFolder(string $folderName, string $parent = ""): array {
$folderName = trim($folderName);
$parent = trim($parent);
// Validate folder name (only letters, numbers, underscores, dashes, and spaces allowed).
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ["error" => "Invalid folder name."];
}
if ($parent !== "" && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ["error" => "Invalid parent folder name."];
}
$baseDir = rtrim(UPLOAD_DIR, '/\\');
if ($parent !== "" && strtolower($parent) !== "root") {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $parent . "/" . $folderName;
} else {
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $folderName;
$relativePath = $folderName;
}
// Check if the folder already exists.
if (file_exists($fullPath)) {
return ["error" => "Folder already exists."];
}
// Attempt to create the folder.
if (mkdir($fullPath, 0755, true)) {
// Create an empty metadata file for the new folder.
$metadataFile = self::getMetadataFilePath($relativePath);
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
return ["error" => "Folder created but failed to create metadata file."];
}
return ["success" => true];
} else {
return ["error" => "Failed to create folder."];
}
}
/**
* Generates the metadata file path for a given folder.
*
* @param string $folder The relative folder path.
* @return string The metadata file path.
*/
private static function getMetadataFilePath(string $folder): string {
if (strtolower($folder) === 'root' || trim($folder) === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
}
/**
* Deletes a folder if it is empty and removes its corresponding metadata.
*
* @param string $folder The folder name (relative to the upload directory).
* @return array An associative array with "success" on success or "error" on failure.
*/
public static function deleteFolder(string $folder): array {
// Prevent deletion of "root".
if (strtolower($folder) === 'root') {
return ["error" => "Cannot delete root folder."];
}
// Validate folder name.
if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
// Build the full folder path.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
// Check if the folder exists and is a directory.
if (!file_exists($folderPath) || !is_dir($folderPath)) {
return ["error" => "Folder does not exist."];
}
// Prevent deletion if the folder is not empty.
$items = array_diff(scandir($folderPath), array('.', '..'));
if (count($items) > 0) {
return ["error" => "Folder is not empty."];
}
// Attempt to delete the folder.
if (rmdir($folderPath)) {
// Remove corresponding metadata file.
$metadataFile = self::getMetadataFilePath($folder);
if (file_exists($metadataFile)) {
unlink($metadataFile);
}
return ["success" => true];
} else {
return ["error" => "Failed to delete folder."];
}
}
/**
* Renames a folder and updates related metadata files.
*
* @param string $oldFolder The current folder name (relative to UPLOAD_DIR).
* @param string $newFolder The new folder name.
* @return array Returns an associative array with "success" on success or "error" on failure.
*/
public static function renameFolder(string $oldFolder, string $newFolder): array {
// Sanitize and trim folder names.
$oldFolder = trim($oldFolder, "/\\ ");
$newFolder = trim($newFolder, "/\\ ");
// Validate folder names.
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
return ["error" => "Invalid folder name(s)."];
}
// Build the full folder paths.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$oldPath = $baseDir . DIRECTORY_SEPARATOR . $oldFolder;
$newPath = $baseDir . DIRECTORY_SEPARATOR . $newFolder;
// Validate that the old folder exists and new folder does not.
if ((realpath($oldPath) === false) || (realpath(dirname($newPath)) === false) ||
strpos(realpath($oldPath), realpath($baseDir)) !== 0 ||
strpos(realpath(dirname($newPath)), realpath($baseDir)) !== 0) {
return ["error" => "Invalid folder path."];
}
if (!file_exists($oldPath) || !is_dir($oldPath)) {
return ["error" => "Folder to rename does not exist."];
}
if (file_exists($newPath)) {
return ["error" => "New folder name already exists."];
}
// Attempt to rename the folder.
if (rename($oldPath, $newPath)) {
// Update metadata: Rename all metadata files that have the old folder prefix.
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldFolder);
$newPrefix = str_replace(['/', '\\', ' '], '-', $newFolder);
$metadataFiles = glob(META_DIR . $oldPrefix . '*_metadata.json');
foreach ($metadataFiles as $oldMetaFile) {
$baseName = basename($oldMetaFile);
$newBaseName = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
$newMetaFile = META_DIR . $newBaseName;
rename($oldMetaFile, $newMetaFile);
}
return ["success" => true];
} else {
return ["error" => "Failed to rename folder."];
}
}
/**
* Recursively scans a directory for subfolders.
*
* @param string $dir The full path to the directory.
* @param string $relative The relative path from the base directory.
* @return array An array of folder paths (relative to the base).
*/
private static function getSubfolders(string $dir, string $relative = ''): array {
$folders = [];
$items = scandir($dir);
$safeFolderNamePattern = REGEX_FOLDER_NAME;
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
if (!preg_match($safeFolderNamePattern, $item)) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
$folderPath = ($relative ? $relative . '/' : '') . $item;
$folders[] = $folderPath;
$subFolders = self::getSubfolders($path, $folderPath);
$folders = array_merge($folders, $subFolders);
}
}
return $folders;
}
/**
* Retrieves the list of folders (including "root") along with file count metadata.
*
* @return array An array of folder information arrays.
*/
public static function getFolderList(): array {
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$folderInfoList = [];
// Process the "root" folder.
$rootMetaFile = self::getMetadataFilePath('root');
$rootFileCount = 0;
if (file_exists($rootMetaFile)) {
$rootMetadata = json_decode(file_get_contents($rootMetaFile), true);
$rootFileCount = is_array($rootMetadata) ? count($rootMetadata) : 0;
}
$folderInfoList[] = [
"folder" => "root",
"fileCount" => $rootFileCount,
"metadataFile" => basename($rootMetaFile)
];
// Recursively scan for subfolders.
if (is_dir($baseDir)) {
$subfolders = self::getSubfolders($baseDir);
} else {
$subfolders = [];
}
// For each subfolder, load metadata to get file counts.
foreach ($subfolders as $folder) {
$metaFile = self::getMetadataFilePath($folder);
$fileCount = 0;
if (file_exists($metaFile)) {
$metadata = json_decode(file_get_contents($metaFile), true);
$fileCount = is_array($metadata) ? count($metadata) : 0;
}
$folderInfoList[] = [
"folder" => $folder,
"fileCount" => $fileCount,
"metadataFile" => basename($metaFile)
];
}
return $folderInfoList;
}
/**
* Retrieves the share folder record for a given token.
*
* @param string $token The share folder token.
* @return array|null The share folder record, or null if not found.
*/
public static function getShareFolderRecord(string $token): ?array {
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return null;
}
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return null;
}
return $shareLinks[$token];
}
/**
* Retrieves shared folder data based on a share token.
*
* @param string $token The share folder token.
* @param string|null $providedPass The provided password (if any).
* @param int $page The page number for pagination.
* @param int $itemsPerPage The number of files to display per page.
* @return array Associative array with keys:
* - 'record': the share record,
* - 'folder': the shared folder (relative),
* - 'realFolderPath': absolute folder path,
* - 'files': array of filenames for the current page,
* - 'currentPage': current page number,
* - 'totalPages': total pages,
* or an 'error' key on failure.
*/
public static function getSharedFolderData(string $token, ?string $providedPass, int $page = 1, int $itemsPerPage = 10): array {
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share link not found."];
}
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."];
}
$record = $shareLinks[$token];
// Check expiration.
if (time() > $record['expires']) {
return ["error" => "This share link has expired."];
}
// If password protection is enabled and no password is provided, signal that.
if (!empty($record['password']) && empty($providedPass)) {
return ["needs_password" => true];
}
if (!empty($record['password']) && !password_verify($providedPass, $record['password'])) {
return ["error" => "Invalid password."];
}
// Determine the shared folder.
$folder = trim($record['folder'], "/\\ ");
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."];
}
if (!empty($folder) && strtolower($folder) !== 'root') {
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
} else {
$folder = "root";
$folderPath = $baseDir;
}
$realFolderPath = realpath($folderPath);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
// Scan for files (only files).
$allFiles = array_values(array_filter(scandir($realFolderPath), function($item) use ($realFolderPath) {
return is_file($realFolderPath . DIRECTORY_SEPARATOR . $item);
}));
sort($allFiles);
$totalFiles = count($allFiles);
$totalPages = max(1, ceil($totalFiles / $itemsPerPage));
$currentPage = min($page, $totalPages);
$startIndex = ($currentPage - 1) * $itemsPerPage;
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
return [
"record" => $record,
"folder" => $folder,
"realFolderPath" => $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
];
}
/**
* Creates a share link for a folder.
*
* @param string $folder The folder to share (relative to UPLOAD_DIR).
* @param int $expirationMinutes The duration (in minutes) until the link expires.
* @param string $password Optional password for the share.
* @param int $allowUpload Optional flag (0 or 1) indicating whether uploads are allowed.
* @return array An associative array with "token", "expires", and "link" on success, or "error" on failure.
*/
public static function createShareFolderLink(string $folder, int $expirationMinutes = 60, string $password = "", int $allowUpload = 0): array {
// Validate folder name.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
// Generate secure token.
try {
$token = bin2hex(random_bytes(16)); // 32 hex characters.
} catch (Exception $e) {
return ["error" => "Could not generate token."];
}
// Calculate expiration time.
$expires = time() + ($expirationMinutes * 60);
// Hash the password if provided.
$hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : "";
// Define the share folder links file.
$shareFile = META_DIR . "share_folder_links.json";
$shareLinks = [];
if (file_exists($shareFile)) {
$data = file_get_contents($shareFile);
$shareLinks = json_decode($data, true);
if (!is_array($shareLinks)) {
$shareLinks = [];
}
}
// Clean up expired share links.
$currentTime = time();
foreach ($shareLinks as $key => $link) {
if (isset($link["expires"]) && $link["expires"] < $currentTime) {
unset($shareLinks[$key]);
}
}
// Add new share record.
$shareLinks[$token] = [
"folder" => $folder,
"expires" => $expires,
"password" => $hashedPassword,
"allowUpload" => $allowUpload
];
// Save the updated share links.
if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Could not save share link."];
}
// Determine the base URL.
if (defined('BASE_URL') && !empty(BASE_URL) && strpos(BASE_URL, 'yourwebsite') === false) {
$baseUrl = rtrim(BASE_URL, '/');
} else {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$host = !empty($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : gethostbyname($_SERVER['SERVER_ADDR'] ?? 'localhost');
$baseUrl = $protocol . "://" . $host;
}
// The share URL points to the shared folder page.
$link = $baseUrl . "api/folder/shareFolder.php?token=" . urlencode($token);
return ["token" => $token, "expires" => $expires, "link" => $link];
}
/**
* Retrieves information for a shared file from a shared folder link.
*
* @param string $token The share folder token.
* @param string $file The requested file name.
* @return array An associative array with keys:
* - "error": error message, if any,
* - "realFilePath": the absolute path to the file,
* - "mimeType": the detected MIME type.
*/
public static function getSharedFileInfo(string $token, string $file): array {
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share link not found."];
}
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Share link not found."];
}
$record = $shareLinks[$token];
// Check if the link has expired.
if (time() > $record['expires']) {
return ["error" => "This share link has expired."];
}
// Determine the shared folder.
$folder = trim($record['folder'], "/\\ ");
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
return ["error" => "Uploads directory not configured correctly."];
}
if (!empty($folder) && strtolower($folder) !== 'root') {
$folderPath = $baseDir . DIRECTORY_SEPARATOR . $folder;
} else {
$folderPath = $baseDir;
}
$realFolderPath = realpath($folderPath);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realFolderPath === false || strpos($realFolderPath, $uploadDirReal) !== 0 || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
// Sanitize the file name to prevent path traversal.
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
return ["error" => "Invalid file name."];
}
$file = basename($file);
// Build the full file path.
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$realFilePath = realpath($filePath);
if ($realFilePath === false || strpos($realFilePath, $realFolderPath) !== 0 || !is_file($realFilePath)) {
return ["error" => "File not found."];
}
$mimeType = mime_content_type($realFilePath);
return [
"realFilePath" => $realFilePath,
"mimeType" => $mimeType
];
}
/**
* Handles uploading a file to a shared folder.
*
* @param string $token The share folder token.
* @param array $fileUpload The $_FILES['fileToUpload'] array.
* @return array An associative array with "success" on success or "error" on failure.
*/
public static function uploadToSharedFolder(string $token, array $fileUpload): array {
// Define maximum file size and allowed extensions.
$maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = ['jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx','mp4','webm','mp3','mkv'];
// Load the share folder record.
$shareFile = META_DIR . "share_folder_links.json";
if (!file_exists($shareFile)) {
return ["error" => "Share record not found."];
}
$shareLinks = json_decode(file_get_contents($shareFile), true);
if (!is_array($shareLinks) || !isset($shareLinks[$token])) {
return ["error" => "Invalid share token."];
}
$record = $shareLinks[$token];
// Check expiration.
if (time() > $record['expires']) {
return ["error" => "This share link has expired."];
}
// Check whether uploads are allowed.
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
return ["error" => "File uploads are not allowed for this share."];
}
// Validate file upload presence.
if ($fileUpload['error'] !== UPLOAD_ERR_OK) {
return ["error" => "File upload error. Code: " . $fileUpload['error']];
}
if ($fileUpload['size'] > $maxSize) {
return ["error" => "File size exceeds allowed limit."];
}
$uploadedName = basename($fileUpload['name']);
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) {
return ["error" => "File type not allowed."];
}
// Determine the target folder from the share record.
$folderName = trim($record['folder'], "/\\");
$targetFolder = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!empty($folderName) && strtolower($folderName) !== 'root') {
$targetFolder .= $folderName;
}
// Verify target folder exists.
$realTargetFolder = realpath($targetFolder);
$uploadDirReal = realpath(UPLOAD_DIR);
if ($realTargetFolder === false || strpos($realTargetFolder, $uploadDirReal) !== 0 || !is_dir($realTargetFolder)) {
return ["error" => "Shared folder not found."];
}
// Generate a new filename (using uniqid and sanitizing the original name).
$newFilename = uniqid() . "_" . preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$targetPath = $realTargetFolder . DIRECTORY_SEPARATOR . $newFilename;
// Move the uploaded file.
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
return ["error" => "Failed to move the uploaded file."];
}
// --- Metadata Update ---
// Determine metadata file.
$metadataKey = (empty($folderName) || strtolower($folderName) === "root") ? "root" : $folderName;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$metadataCollection = [];
if (file_exists($metadataFile)) {
$data = file_get_contents($metadataFile);
$metadataCollection = json_decode($data, true);
if (!is_array($metadataCollection)) {
$metadataCollection = [];
}
}
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = "Outside Share"; // As per your original implementation.
// Update metadata with the new file's info.
$metadataCollection[$newFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
}
}

266
src/models/UploadModel.php Normal file
View File

@@ -0,0 +1,266 @@
<?php
// src/models/UploadModel.php
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
/**
* Handles file uploads supports both chunked uploads and full (non-chunked) uploads.
*
* @param array $post The $_POST array.
* @param array $files The $_FILES array.
* @return array Returns an associative array with "success" on success or "error" on failure.
*/
public static function handleUpload(array $post, array $files): array {
// If this is a GET request for testing chunk existence.
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
$chunkNumber = intval($post['resumableChunkNumber']);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
}
// Handle chunked uploads.
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = intval($post['resumableChunkNumber']);
$totalChunks = intval($post['resumableTotalChunks']);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableFilename = urldecode(basename($post['resumableFilename']));
// Validate file name.
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ["error" => "Invalid file name: $resumableFilename"];
}
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
return ["error" => "Failed to create temporary chunk directory"];
}
if (!isset($files["file"]) || $files["file"]["error"] !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
if (!move_uploaded_file($files["file"]["tmp_name"], $chunkFile)) {
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
}
// Check if all chunks are present.
$allChunksPresent = true;
for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) {
$allChunksPresent = false;
break;
}
}
if (!$allChunksPresent) {
return ["status" => "chunk uploaded"];
}
// Merge chunks.
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
return ["error" => "Failed to open target file for writing"];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) {
fclose($out);
return ["error" => "Chunk $i missing during merge"];
}
if (!$in = fopen($chunkPath, "rb")) {
fclose($out);
return ["error" => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in);
}
fclose($out);
// Update metadata.
$relativeFolder = $folder;
$metadataKey = ($relativeFolder === '' || strtolower($relativeFolder) === 'root') ? "root" : $relativeFolder;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection)) {
$metadataCollection = [];
}
if (!isset($metadataCollection[$resumableFilename])) {
$metadataCollection[$resumableFilename] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
}
// Cleanup temporary directory.
$rrmdir = function($dir) use (&$rrmdir) {
if (!is_dir($dir)) return;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getRealPath()) : unlink($item->getRealPath());
}
rmdir($dir);
};
$rrmdir($tempDir);
return ["success" => "File uploaded successfully"];
} else {
// Handle full upload (non-chunked).
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
}
$uploadDir = $baseUploadDir;
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder"];
}
$targetPath = $uploadDir . $safeFileName;
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
$folderPath = $folder;
$metadataKey = ($folderPath === '' || strtolower($folderPath) === 'root') ? "root" : $folderPath;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = [
"uploaded" => $uploadedDate,
"uploader" => $uploader
];
$metadataChanged[$metadataKey] = true;
}
} else {
return ["error" => "Error uploading file"];
}
}
foreach ($metadataCollection as $folderKey => $data) {
if ($metadataChanged[$folderKey]) {
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
}
}
return ["success" => "Files uploaded successfully"];
}
}
/**
* Recursively removes a directory and its contents.
*
* @param string $dir The directory to remove.
* @return void
*/
private static function rrmdir(string $dir): void {
if (!is_dir($dir)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) {
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
rmdir($dir);
}
/**
* Removes the temporary chunk directory for resumable uploads.
*
* The folder name is expected to exactly match the "resumable_" pattern.
*
* @param string $folder The folder name provided (URL-decoded).
* @return array Returns a status array indicating success or error.
*/
public static function removeChunks(string $folder): array {
$folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) {
return ["error" => "Invalid folder name"];
}
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder already removed."];
}
self::rrmdir($tempDir);
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder removed."];
} else {
return ["error" => "Failed to remove temporary folder."];
}
}
}

657
src/models/UserModel.php Normal file
View File

@@ -0,0 +1,657 @@
<?php
// src/models/userModel.php
require_once PROJECT_ROOT . '/config/config.php';
class userModel {
/**
* Retrieves all users from the users file.
*
* @return array Returns an array of users.
*/
public static function getAllUsers() {
$usersFile = USERS_DIR . USERS_FILE;
$users = [];
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
$users[] = [
"username" => $parts[0],
"role" => trim($parts[2])
];
}
}
}
return $users;
}
/**
* Adds a new user.
*
* @param string $username The new username.
* @param string $password The plain-text password.
* @param string $isAdmin "1" if admin; "0" otherwise.
* @param bool $setupMode If true, overwrite the users file.
* @return array Response containing either an error or a success message.
*/
public static function addUser($username, $password, $isAdmin, $setupMode) {
$usersFile = USERS_DIR . USERS_FILE;
// Ensure users.txt exists.
if (!file_exists($usersFile)) {
file_put_contents($usersFile, '');
}
// Check if username already exists.
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($existingUsers as $line) {
$parts = explode(':', trim($line));
if ($username === $parts[0]) {
return ["error" => "User already exists"];
}
}
// Hash the password.
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
// Prepare the new line.
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
// If setup mode, overwrite the file; otherwise, append.
if ($setupMode) {
file_put_contents($usersFile, $newUserLine);
} else {
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
}
return ["success" => "User added successfully"];
}
/**
* Removes the specified user from the users file and updates the userPermissions file.
*
* @param string $usernameToRemove The username to remove.
* @return array An array with either an error message or a success message.
*/
public static function removeUser($usernameToRemove) {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return ["error" => "Users file not found"];
}
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newUsers = [];
$userFound = false;
// Loop through users; skip (remove) the specified user.
foreach ($existingUsers as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) {
continue;
}
if ($parts[0] === $usernameToRemove) {
$userFound = true;
continue; // Do not add this user to the new array.
}
$newUsers[] = $line;
}
if (!$userFound) {
return ["error" => "User not found"];
}
// Write the updated user list back to the file.
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
// Update the userPermissions.json file.
$permissionsFile = USERS_DIR . "userPermissions.json";
if (file_exists($permissionsFile)) {
$permissionsJson = file_get_contents($permissionsFile);
$permissionsArray = json_decode($permissionsJson, true);
if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) {
unset($permissionsArray[$usernameToRemove]);
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
}
}
return ["success" => "User removed successfully"];
}
/**
* Retrieves permissions from the userPermissions.json file.
* If the current user is an admin, returns all permissions.
* Otherwise, returns only the permissions for the current user.
*
* @return array|object Returns an associative array of permissions or an empty object if none are found.
*/
public static function getUserPermissions() {
global $encryptionKey;
$permissionsFile = USERS_DIR . "userPermissions.json";
$permissionsArray = [];
// Load permissions if the file exists.
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Attempt to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent === false) {
// If decryption fails, assume the content is plain JSON.
$permissionsArray = json_decode($content, true);
} else {
$permissionsArray = json_decode($decryptedContent, true);
}
if (!is_array($permissionsArray)) {
$permissionsArray = [];
}
}
// If the user is an admin, return all permissions.
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
return $permissionsArray;
}
// Otherwise, return only the permissions for the currently logged-in user.
$username = $_SESSION['username'] ?? '';
foreach ($permissionsArray as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0) {
return $data;
}
}
// If no permissions are found, return an empty object.
return new stdClass();
}
/**
* Updates user permissions in the userPermissions.json file.
*
* @param array $permissions An array of permission updates.
* @return array An associative array with a success or error message.
*/
public static function updateUserPermissions($permissions) {
global $encryptionKey;
$permissionsFile = USERS_DIR . "userPermissions.json";
$existingPermissions = [];
// Load existing permissions if available and decrypt.
if (file_exists($permissionsFile)) {
$encryptedContent = file_get_contents($permissionsFile);
$json = decryptData($encryptedContent, $encryptionKey);
$existingPermissions = json_decode($json, true);
if (!is_array($existingPermissions)) {
$existingPermissions = [];
}
}
// Load user roles from the users file.
$usersFile = USERS_DIR . USERS_FILE;
$userRoles = [];
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 3 && preg_match(REGEX_USER, $parts[0])) {
// Use lowercase keys for consistency.
$userRoles[strtolower($parts[0])] = trim($parts[2]);
}
}
}
// Process each permission update.
foreach ($permissions as $perm) {
if (!isset($perm['username'])) {
continue;
}
$username = $perm['username'];
// Look up the user's role.
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
// Skip updating permissions for admin users.
if ($role === "1") {
continue;
}
// Update permissions: default any missing value to false.
$existingPermissions[strtolower($username)] = [
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false,
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
];
}
// Convert the updated permissions array to JSON.
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
// Encrypt the JSON.
$encryptedData = encryptData($plainText, $encryptionKey);
// Save encrypted permissions back to the file.
$result = file_put_contents($permissionsFile, $encryptedData);
if ($result === false) {
return ["error" => "Failed to save user permissions."];
}
return ["success" => "User permissions updated successfully."];
}
/**
* Changes the password for the given user.
*
* @param string $username The username whose password is to be changed.
* @param string $oldPassword The old (current) password.
* @param string $newPassword The new password.
* @return array An array with either a success or error message.
*/
public static function changePassword($username, $oldPassword, $newPassword) {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return ["error" => "Users file not found"];
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$userFound = false;
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// Expect at least 3 parts: username, hashed password, and role.
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
$storedUser = $parts[0];
$storedHash = $parts[1];
$storedRole = $parts[2];
// Preserve TOTP secret if it exists.
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
if ($storedUser === $username) {
$userFound = true;
// Verify the old password.
if (!password_verify($oldPassword, $storedHash)) {
return ["error" => "Old password is incorrect."];
}
// Hash the new password.
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
// Rebuild the line, preserving TOTP secret if it exists.
if ($totpSecret !== "") {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
} else {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
}
} else {
$newLines[] = $line;
}
}
if (!$userFound) {
return ["error" => "User not found."];
}
// Save the updated users file.
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
return ["success" => "Password updated successfully."];
} else {
return ["error" => "Could not update password."];
}
}
/**
* Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled.
*
* @param string $username The username whose panel settings are being updated.
* @param bool $totp_enabled Whether TOTP is enabled.
* @return array An array indicating success or failure.
*/
public static function updateUserPanel($username, $totp_enabled) {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return ["error" => "Users file not found"];
}
// If TOTP is disabled, update the file to clear the TOTP secret.
if (!$totp_enabled) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// Leave lines with fewer than three parts unchanged.
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
if ($parts[0] === $username) {
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
if (count($parts) >= 4) {
$parts[3] = "";
} else {
$parts[] = "";
}
$newLines[] = implode(':', $parts);
} else {
$newLines[] = $line;
}
}
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
if ($result === false) {
return ["error" => "Failed to disable TOTP secret"];
}
return ["success" => "User panel updated: TOTP disabled"];
}
// If TOTP is enabled, do nothing.
return ["success" => "User panel updated: TOTP remains enabled"];
}
/**
* Disables the TOTP secret for the specified user.
*
* @param string $username The user for whom TOTP should be disabled.
* @return bool True if the secret was cleared; false otherwise.
*/
public static function disableTOTPSecret($username) {
global $encryptionKey; // In case it's used in this model context.
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$modified = false;
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// If the line doesn't have at least three parts, leave it unchanged.
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
if ($parts[0] === $username) {
// If a fourth field exists, clear it; otherwise, append an empty field.
if (count($parts) >= 4) {
$parts[3] = "";
} else {
$parts[] = "";
}
$modified = true;
$newLines[] = implode(":", $parts);
} else {
$newLines[] = $line;
}
}
if ($modified) {
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
}
return $modified;
}
/**
* Attempts to recover TOTP for a user using the supplied recovery code.
*
* @param string $userId The user identifier.
* @param string $recoveryCode The recovery code provided by the user.
* @return array An associative array with keys 'status' and 'message'.
*/
public static function recoverTOTP($userId, $recoveryCode) {
// --- Ratelimit recovery attempts ---
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId;
$now = time();
if (isset($attempts[$key])) {
// Prune attempts older than 15 minutes.
$attempts[$key] = array_filter($attempts[$key], function($ts) use ($now) {
return $ts > $now - 900;
});
}
if (count($attempts[$key] ?? []) >= 5) {
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
}
// --- Load user metadata file ---
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
if (!file_exists($userFile)) {
return ['status' => 'error', 'message' => 'User not found'];
}
// --- Open and lock file ---
$fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) {
return ['status' => 'error', 'message' => 'Server error'];
}
$fileContents = stream_get_contents($fp);
$data = json_decode($fileContents, true) ?: [];
// --- Check recovery code ---
if (empty($recoveryCode)) {
flock($fp, LOCK_UN);
fclose($fp);
return ['status' => 'error', 'message' => 'Recovery code required'];
}
$storedHash = $data['totp_recovery_code'] ?? null;
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
// Record failed attempt.
$attempts[$key][] = $now;
file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX);
flock($fp, LOCK_UN);
fclose($fp);
return ['status' => 'error', 'message' => 'Invalid recovery code'];
}
// --- Invalidate recovery code ---
$data['totp_recovery_code'] = null;
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return ['status' => 'ok'];
}
/**
* Generates a random recovery code.
*
* @param int $length Length of the recovery code.
* @return string
*/
private static function generateRecoveryCode($length = 12) {
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$max = strlen($chars) - 1;
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, $max)];
}
return $code;
}
/**
* Saves a new TOTP recovery code for the specified user.
*
* @param string $userId The username of the user.
* @return array An associative array with the status and recovery code (if successful).
*/
public static function saveTOTPRecoveryCode($userId) {
// Determine the user file path.
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
// Ensure the file exists; if not, create it with default data.
if (!file_exists($userFile)) {
$defaultData = [];
if (file_put_contents($userFile, json_encode($defaultData)) === false) {
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
}
}
// Generate a new recovery code.
$recoveryCode = self::generateRecoveryCode();
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
// Open the file, lock it, and update the totp_recovery_code field.
$fp = fopen($userFile, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) {
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
}
// Read and decode the existing JSON.
$contents = stream_get_contents($fp);
$data = json_decode($contents, true) ?: [];
// Update the totp_recovery_code field.
$data['totp_recovery_code'] = $recoveryHash;
// Write the new data.
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, json_encode($data)); // Plain JSON in production.
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
}
/**
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
* then builds and returns a QR code image for the OTPAuth URL.
*
* @param string $username The username for which to set up TOTP.
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
*/
public static function setupTOTP($username) {
global $encryptionKey;
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return ['error' => 'Users file not found'];
}
// Look for an existing TOTP secret.
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totpSecret = null;
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
$totpSecret = decryptData($parts[3], $encryptionKey);
break;
}
}
// Use the TwoFactorAuth library to create a new secret if none found.
$tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
'FileRise', // issuer
6, // number of digits
30, // period (seconds)
\RobThree\Auth\Algorithm::Sha1 // algorithm
);
if (!$totpSecret) {
$totpSecret = $tfa->createSecret();
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
// Update the users line with the new encrypted secret.
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 3 && $parts[0] === $username) {
if (count($parts) >= 4) {
$parts[3] = $encryptedSecret;
} else {
$parts[] = $encryptedSecret;
}
$newLines[] = implode(':', $parts);
} else {
$newLines[] = $line;
}
}
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
}
// Determine the OTPAuth URL.
// Try to load a global OTPAuth URL template from admin configuration.
$adminConfigFile = USERS_DIR . 'adminConfig.json';
$globalOtpauthUrl = "";
if (file_exists($adminConfigFile)) {
$encryptedContent = file_get_contents($adminConfigFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent !== false) {
$config = json_decode($decryptedContent, true);
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) {
$globalOtpauthUrl = $config['globalOtpauthUrl'];
}
}
}
if (!empty($globalOtpauthUrl)) {
$label = "FileRise:" . $username;
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
} else {
$label = urlencode("FileRise:" . $username);
$issuer = urlencode("FileRise");
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
}
// Build the QR code image using the Endroid QR Code Builder.
$result = \Endroid\QrCode\Builder\Builder::create()
->writer(new \Endroid\QrCode\Writer\PngWriter())
->data($otpauthUrl)
->build();
return [
'imageData' => $result->getString(),
'mimeType' => $result->getMimeType()
];
}
/**
* Retrieves the decrypted TOTP secret for a given user.
*
* @param string $username
* @return string|null Returns the TOTP secret if found, or null if not.
*/
public static function getTOTPSecret($username) {
global $encryptionKey;
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return null;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// Expect at least 4 parts: username, hash, role, and TOTP secret.
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
return decryptData($parts[3], $encryptionKey);
}
}
return null;
}
/**
* Helper to get a user's role from users.txt.
*
* @param string $username
* @return string|null
*/
public static function getUserRole($username) {
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return null;
}
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 3 && $parts[0] === $username) {
return trim($parts[2]);
}
}
return null;
}
}