Refactor API endpoints and modularize controllers and models
This commit is contained in:
210
src/controllers/adminController.php
Normal file
210
src/controllers/adminController.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
// src/controllers/adminController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
|
||||
class AdminController
|
||||
{
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/admin/getConfig.php",
|
||||
* summary="Retrieve admin configuration",
|
||||
* description="Returns the admin configuration settings, decrypting the configuration file and providing default values if not set.",
|
||||
* operationId="getAdminConfig",
|
||||
* tags={"Admin"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Configuration retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="header_title", type="string", example="FileRise"),
|
||||
* @OA\Property(
|
||||
* property="oidc",
|
||||
* type="object",
|
||||
* @OA\Property(property="providerUrl", type="string", example="https://your-oidc-provider.com"),
|
||||
* @OA\Property(property="clientId", type="string", example="YOUR_CLIENT_ID"),
|
||||
* @OA\Property(property="clientSecret", type="string", example="YOUR_CLIENT_SECRET"),
|
||||
* @OA\Property(property="redirectUri", type="string", example="https://yourdomain.com/auth.php?oidc=callback")
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="loginOptions",
|
||||
* type="object",
|
||||
* @OA\Property(property="disableFormLogin", type="boolean", example=false),
|
||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
||||
* ),
|
||||
* @OA\Property(property="globalOtpauthUrl", type="string", example="")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=500,
|
||||
* description="Failed to decrypt configuration or server error"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Retrieves the admin configuration settings.
|
||||
*
|
||||
* @return void Outputs a JSON response with configuration data.
|
||||
*/
|
||||
public function getConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
$config = AdminModel::getConfig();
|
||||
|
||||
// If an error was encountered, send a 500 status.
|
||||
if (isset($config['error'])) {
|
||||
http_response_code(500);
|
||||
}
|
||||
echo json_encode($config);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/admin/updateConfig.php",
|
||||
* summary="Update admin configuration",
|
||||
* description="Updates the admin configuration settings. Requires admin privileges and a valid CSRF token.",
|
||||
* operationId="updateAdminConfig",
|
||||
* tags={"Admin"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"header_title", "oidc", "loginOptions"},
|
||||
* @OA\Property(property="header_title", type="string", example="FileRise"),
|
||||
* @OA\Property(
|
||||
* property="oidc",
|
||||
* type="object",
|
||||
* @OA\Property(property="providerUrl", type="string", example="https://your-oidc-provider.com"),
|
||||
* @OA\Property(property="clientId", type="string", example="YOUR_CLIENT_ID"),
|
||||
* @OA\Property(property="clientSecret", type="string", example="YOUR_CLIENT_SECRET"),
|
||||
* @OA\Property(property="redirectUri", type="string", example="https://yourdomain.com/api/auth/auth.php?oidc=callback")
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="loginOptions",
|
||||
* type="object",
|
||||
* @OA\Property(property="disableFormLogin", type="boolean", example=false),
|
||||
* @OA\Property(property="disableBasicAuth", type="boolean", example=false),
|
||||
* @OA\Property(property="disableOIDCLogin", type="boolean", example=false)
|
||||
* ),
|
||||
* @OA\Property(property="globalOtpauthUrl", type="string", example="")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Configuration updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="success", type="string", example="Configuration updated successfully.")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request (e.g., invalid input, incomplete OIDC configuration)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="Unauthorized (user not admin or invalid CSRF token)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=500,
|
||||
* description="Server error (failed to write configuration file)"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Updates the admin configuration settings.
|
||||
*
|
||||
* @return void Outputs a JSON response indicating success or failure.
|
||||
*/
|
||||
public function updateConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure the user is authenticated and is an admin.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||
) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => '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 configuration array.
|
||||
$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']) : "";
|
||||
|
||||
$configUpdate = [
|
||||
'header_title' => $headerTitle,
|
||||
'oidc' => [
|
||||
'providerUrl' => $oidcProviderUrl,
|
||||
'clientId' => $oidcClientId,
|
||||
'clientSecret' => $oidcClientSecret,
|
||||
'redirectUri' => $oidcRedirectUri,
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => $disableFormLogin,
|
||||
'disableBasicAuth' => $disableBasicAuth,
|
||||
'disableOIDCLogin' => $disableOIDCLogin,
|
||||
],
|
||||
'globalOtpauthUrl' => $globalOtpauthUrl
|
||||
];
|
||||
|
||||
// Delegate to the model.
|
||||
$result = AdminModel::updateConfig($configUpdate);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
}
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
524
src/controllers/authController.php
Normal file
524
src/controllers/authController.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
// src/controllers/authController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
|
||||
use RobThree\Auth\Algorithm;
|
||||
use RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider;
|
||||
use Jumbojett\OpenIDConnectClient;
|
||||
|
||||
class AuthController {
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/auth/auth.php",
|
||||
* summary="Authenticate user",
|
||||
* description="Handles user authentication via OIDC or form-based credentials. For OIDC flows, processes callbacks; otherwise, performs standard authentication with optional TOTP verification.",
|
||||
* operationId="authUser",
|
||||
* tags={"Auth"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"username", "password"},
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="password", type="string", example="secretpassword"),
|
||||
* @OA\Property(property="remember_me", type="boolean", example=true),
|
||||
* @OA\Property(property="totp_code", type="string", example="123456")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Login successful; returns user info and status",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="string", example="ok"),
|
||||
* @OA\Property(property="success", type="string", example="Login successful"),
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request (e.g., missing credentials)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized (e.g., invalid credentials, too many attempts)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=429,
|
||||
* description="Too many failed login attempts"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Handles user authentication via OIDC or form-based login.
|
||||
*
|
||||
* @return void Redirects on success or outputs JSON error.
|
||||
*/
|
||||
public function auth(): void {
|
||||
// Global exception handler.
|
||||
set_exception_handler(function ($e) {
|
||||
error_log("Unhandled exception: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Internal Server Error"]);
|
||||
exit();
|
||||
});
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// If OIDC parameters are present, initiate OIDC flow.
|
||||
$oidcAction = $_GET['oidc'] ?? null;
|
||||
if (!$oidcAction && isset($_GET['code'])) {
|
||||
$oidcAction = 'callback';
|
||||
}
|
||||
if ($oidcAction) {
|
||||
// Load admin configuration for OIDC.
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
if (file_exists($adminConfigFile)) {
|
||||
$enc = file_get_contents($adminConfigFile);
|
||||
$dec = decryptData($enc, $encryptionKey);
|
||||
$cfg = ($dec !== false) ? json_decode($dec, true) : [];
|
||||
} else {
|
||||
$cfg = [];
|
||||
}
|
||||
$oidc_provider_url = $cfg['oidc']['providerUrl'] ?? 'https://your-oidc-provider.com';
|
||||
$oidc_client_id = $cfg['oidc']['clientId'] ?? 'YOUR_CLIENT_ID';
|
||||
$oidc_client_secret = $cfg['oidc']['clientSecret'] ?? 'YOUR_CLIENT_SECRET';
|
||||
$oidc_redirect_uri = $cfg['oidc']['redirectUri'] ?? 'https://yourdomain.com/api/auth/auth.php?oidc=callback';
|
||||
|
||||
$oidc = new OpenIDConnectClient($oidc_provider_url, $oidc_client_id, $oidc_client_secret);
|
||||
$oidc->setRedirectURL($oidc_redirect_uri);
|
||||
|
||||
if ($oidcAction === 'callback') {
|
||||
try {
|
||||
$oidc->authenticate();
|
||||
$username = $oidc->requestUserInfo('preferred_username');
|
||||
|
||||
// Check for 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 && !empty($parts[3])) {
|
||||
$totp_secret = decryptData($parts[3], $encryptionKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($totp_secret) {
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $totp_secret;
|
||||
header("Location: index.html?totp_required=1");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Finalize login (no TOTP)
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1");
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
header("Location: index.html");
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
error_log("OIDC authentication error: " . $e->getMessage());
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Authentication failed."]);
|
||||
exit();
|
||||
}
|
||||
} else {
|
||||
// Initiate OIDC authentication.
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Form-based Authentication.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$username = trim($data["username"] ?? "");
|
||||
$password = trim($data["password"] ?? "");
|
||||
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
|
||||
|
||||
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. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$currentTime = time();
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||||
$failedAttempts = AuthModel::loadFailedAttempts($attemptsFile);
|
||||
$maxAttempts = 5;
|
||||
$lockoutTime = 30 * 60; // 30 minutes
|
||||
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$attemptData = $failedAttempts[$ip];
|
||||
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
|
||||
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) {
|
||||
// Handle TOTP if required.
|
||||
if (!empty($user['totp_secret'])) {
|
||||
if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) {
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $user['totp_secret'];
|
||||
echo json_encode([
|
||||
"totp_required" => true,
|
||||
"message" => "TOTP code required"
|
||||
]);
|
||||
exit();
|
||||
} else {
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new GoogleChartsQrCodeProvider(),
|
||||
'FileRise',
|
||||
6,
|
||||
30,
|
||||
Algorithm::Sha1
|
||||
);
|
||||
$providedCode = trim($data['totp_code']);
|
||||
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
|
||||
echo json_encode(["error" => "Invalid TOTP code"]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear failed attempts.
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
unset($failedAttempts[$ip]);
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $username;
|
||||
$_SESSION["isAdmin"] = ($user['role'] === "1");
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($username);
|
||||
|
||||
// Handle "remember me"
|
||||
if ($rememberMe) {
|
||||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$tokenPersistent = bin2hex(random_bytes(32));
|
||||
$expiry = time() + (30 * 24 * 60 * 60);
|
||||
$persistentTokens = [];
|
||||
if (file_exists($persistentTokensFile)) {
|
||||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||||
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
|
||||
$persistentTokens = json_decode($decryptedContent, true);
|
||||
if (!is_array($persistentTokens)) {
|
||||
$persistentTokens = [];
|
||||
}
|
||||
}
|
||||
$persistentTokens[$tokenPersistent] = [
|
||||
"username" => $username,
|
||||
"expiry" => $expiry,
|
||||
"isAdmin" => ($_SESSION["isAdmin"] === true)
|
||||
];
|
||||
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']);
|
||||
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
setcookie('remember_me_token', $tokenPersistent, $expiry, '/', '', $secure, true);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"status" => "ok",
|
||||
"success" => "Login successful",
|
||||
"isAdmin" => $_SESSION["isAdmin"],
|
||||
"folderOnly" => $_SESSION["folderOnly"],
|
||||
"username" => $_SESSION["username"]
|
||||
]);
|
||||
} else {
|
||||
// Record failed login attempt.
|
||||
if (isset($failedAttempts[$ip])) {
|
||||
$failedAttempts[$ip]['count']++;
|
||||
$failedAttempts[$ip]['last_attempt'] = $currentTime;
|
||||
} else {
|
||||
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
|
||||
}
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts);
|
||||
$failedLogFile = USERS_DIR . 'failed_login.log';
|
||||
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
|
||||
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Invalid credentials"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
// If the users file does not exist or is empty, signal setup mode.
|
||||
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
|
||||
error_log("checkAuth: users file not found or empty; entering setup mode.");
|
||||
echo json_encode(["setup" => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// If the session is not authenticated, output false.
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
echo json_encode(["authenticated" => false]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Retrieve the username from the session.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
// Determine TOTP enabled by checking the users file.
|
||||
$totp_enabled = false;
|
||||
if ($username) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if ($parts[0] === $username && isset($parts[3]) && trim($parts[3]) !== "") {
|
||||
$totp_enabled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine admin status using AuthModel::getUserRole()
|
||||
$userRole = AuthModel::getUserRole($username);
|
||||
$isAdmin = ((int)$userRole === 1);
|
||||
|
||||
$response = [
|
||||
"authenticated" => true,
|
||||
"isAdmin" => $isAdmin,
|
||||
"totp_enabled" => $totp_enabled,
|
||||
"username" => $username,
|
||||
"folderOnly" => $_SESSION["folderOnly"] ?? false
|
||||
];
|
||||
echo json_encode($response);
|
||||
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 {
|
||||
header('Content-Type: application/json');
|
||||
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");
|
||||
$_SESSION["folderOnly"] = AuthModel::loadFolderPermission($username);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1513
src/controllers/fileController.php
Normal file
1513
src/controllers/fileController.php
Normal file
File diff suppressed because it is too large
Load Diff
907
src/controllers/folderController.php
Normal file
907
src/controllers/folderController.php
Normal file
@@ -0,0 +1,907 @@
|
||||
<?php
|
||||
// src/controllers/folderController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
|
||||
class FolderController {
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/folder/createFolder.php",
|
||||
* summary="Create a new folder",
|
||||
* description="Creates a new folder in the upload directory (under an optional parent) and creates an associated empty metadata file.",
|
||||
* operationId="createFolder",
|
||||
* tags={"Folders"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folderName"},
|
||||
* @OA\Property(property="folderName", type="string", example="NewFolder"),
|
||||
* @OA\Property(property="parent", type="string", example="Documents")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Folder created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request (e.g., invalid folder name)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="Invalid CSRF token or permission denied"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Creates a new folder in the upload directory, optionally under a parent folder.
|
||||
*
|
||||
* @return void Outputs a JSON response.
|
||||
*/
|
||||
public function createFolder(): 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 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.
|
||||
*/
|
||||
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");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Enter Password</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; background-color: #f7f7f7; }
|
||||
.container { max-width: 400px; margin: 80px auto; background: #fff; padding: 20px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
input[type="password"], button { width: 100%; padding: 10px; margin: 10px 0; font-size: 1rem; }
|
||||
button { background-color: #007BFF; border: none; color: #fff; cursor: pointer; }
|
||||
button:hover { background-color: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Folder Protected</h2>
|
||||
<p>This folder is protected by a password. Please enter the password to view its contents.</p>
|
||||
<form method="get" action="api/folder/shareFolder.php">
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" name="pass" id="pass" required>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
// If the model returned an error, output JSON error.
|
||||
if (isset($data['error'])) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(["error" => $data['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Extract data for the HTML view.
|
||||
$folderName = $data['folder'];
|
||||
$files = $data['files'];
|
||||
$currentPage = $data['currentPage'];
|
||||
$totalPages = $data['totalPages'];
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// Build the HTML view.
|
||||
header("Content-Type: text/html; charset=utf-8");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { background: #f2f2f2; font-family: Arial, sans-serif; padding: 20px; color: #333; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: #fff; border-radius: 4px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { padding: 12px; border-bottom: 1px solid #ddd; text-align: left; }
|
||||
th { background: #007BFF; color: #fff; }
|
||||
.pagination { text-align: center; margin-top: 20px; }
|
||||
.pagination a, .pagination span { margin: 0 5px; padding: 8px 12px; background: #007BFF; color: #fff; border-radius: 4px; text-decoration: none; }
|
||||
.pagination span.current { background: #0056b3; }
|
||||
/* Gallery view styles if needed */
|
||||
.shared-gallery-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; padding: 10px 0; }
|
||||
.shared-gallery-card { border: 1px solid #ccc; padding: 5px; text-align: center; }
|
||||
.shared-gallery-card img { max-width: 100%; display: block; margin: 0 auto; }
|
||||
/* Upload container */
|
||||
.upload-container { margin-top: 30px; text-align: center; }
|
||||
.upload-container h3 { font-size: 1.4rem; margin-bottom: 10px; }
|
||||
.upload-container form { display: inline-block; margin-top: 10px; }
|
||||
.upload-container button { background-color: #28a745; border: none; color: #fff; padding: 10px 20px; font-size: 1rem; border-radius: 4px; cursor: pointer; }
|
||||
.upload-container button:hover { background-color: #218838; }
|
||||
.footer { text-align: center; margin-top: 40px; font-size: 0.9rem; color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?></h1>
|
||||
</div>
|
||||
<div class="container">
|
||||
<!-- Toggle Button -->
|
||||
<button id="toggleBtn" class="toggle-btn" onclick="toggleViewMode()">Switch to Gallery View</button>
|
||||
|
||||
<!-- List View Container -->
|
||||
<div id="listViewContainer">
|
||||
<?php if (empty($files)): ?>
|
||||
<p style="text-align:center;">This folder is empty.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
// For each file, build a download link using your downloadSharedFile endpoint.
|
||||
foreach ($files as $file):
|
||||
$filePath = $data['realFolderPath'] . DIRECTORY_SEPARATOR . $file;
|
||||
$fileSize = file_exists($filePath) ? formatBytes(filesize($filePath)) : "Unknown";
|
||||
$downloadLink = "api/folder/downloadSharedFile.php?token=" . urlencode($token) . "&file=" . urlencode($file);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo htmlspecialchars($downloadLink, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php echo htmlspecialchars($file, ENT_QUOTES, 'UTF-8'); ?>
|
||||
<span class="download-icon">⇩</span>
|
||||
</a>
|
||||
</td>
|
||||
<td><?php echo $fileSize; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View Container (hidden by default) -->
|
||||
<div id="galleryViewContainer" style="display:none;"></div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination">
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage - 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Prev</a>
|
||||
<?php else: ?>
|
||||
<span>Prev</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$startPage = max(1, $currentPage - 2);
|
||||
$endPage = min($totalPages, $currentPage + 2);
|
||||
for ($i = $startPage; $i <= $endPage; $i++): ?>
|
||||
<?php if ($i == $currentPage): ?>
|
||||
<span class="current"><?php echo $i; ?></span>
|
||||
<?php else: ?>
|
||||
<a href="api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $i; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>"><?php echo $i; ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage + 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Next</a>
|
||||
<?php else: ?>
|
||||
<span>Next</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Upload Container (if uploads are allowed by the share record) -->
|
||||
<?php if (isset($data['record']['allowUpload']) && $data['record']['allowUpload'] == 1): ?>
|
||||
<div class="upload-container">
|
||||
<h3>Upload File (50mb max size)</h3>
|
||||
<form action="api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
|
||||
<!-- Pass the share token so the upload endpoint can verify -->
|
||||
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<input type="file" name="fileToUpload" required>
|
||||
<br><br>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© <?php echo date("Y"); ?> FileRise. All rights reserved.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// (Optional) JavaScript for toggling view modes (list/gallery).
|
||||
var viewMode = 'list';
|
||||
window.imageCache = window.imageCache || {};
|
||||
|
||||
var filesData = <?php echo json_encode($files); ?>;
|
||||
|
||||
function cacheImage(imgElem, key) {
|
||||
window.imageCache[key] = imgElem.src;
|
||||
}
|
||||
|
||||
function renderGalleryView() {
|
||||
var galleryContainer = document.getElementById("galleryViewContainer");
|
||||
var html = '<div class="shared-gallery-container">';
|
||||
filesData.forEach(function(file) {
|
||||
var fileUrl = "uploads/<?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?>/" + encodeURIComponent(file);
|
||||
var ext = file.split('.').pop().toLowerCase();
|
||||
var thumbnail = "";
|
||||
if (['jpg','jpeg','png','gif','bmp','webp','svg','ico'].indexOf(ext) >= 0) {
|
||||
var cacheKey = fileUrl;
|
||||
if (window.imageCache[cacheKey]) {
|
||||
thumbnail = '<img src="'+window.imageCache[cacheKey]+'" alt="'+file+'">';
|
||||
} else {
|
||||
var imageUrl = fileUrl + '?t=' + new Date().getTime();
|
||||
thumbnail = '<img src="'+imageUrl+'" onload="cacheImage(this, \''+cacheKey+'\')" alt="'+file+'">';
|
||||
}
|
||||
} else {
|
||||
thumbnail = '<span class="material-icons">insert_drive_file</span>';
|
||||
}
|
||||
html += '<div class="shared-gallery-card">';
|
||||
html += '<div class="gallery-preview" onclick="window.location.href=\''+fileUrl+'\'" style="cursor:pointer;">'+ thumbnail +'</div>';
|
||||
html += '<div class="gallery-info"><span class="gallery-file-name">'+file+'</span></div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
galleryContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
if (viewMode === 'list') {
|
||||
viewMode = 'gallery';
|
||||
document.getElementById("listViewContainer").style.display = "none";
|
||||
renderGalleryView();
|
||||
document.getElementById("galleryViewContainer").style.display = "block";
|
||||
document.getElementById("toggleBtn").textContent = "Switch to List View";
|
||||
} else {
|
||||
viewMode = 'list';
|
||||
document.getElementById("galleryViewContainer").style.display = "none";
|
||||
document.getElementById("listViewContainer").style.display = "block";
|
||||
document.getElementById("toggleBtn").textContent = "Switch to Gallery View";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/folder/createShareFolderLink.php",
|
||||
* summary="Create a share link for a folder",
|
||||
* description="Generates a secure share link for a folder along with optional password protection and upload settings.",
|
||||
* operationId="createShareFolderLink",
|
||||
* tags={"Folders"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"folder"},
|
||||
* @OA\Property(property="folder", type="string", example="Documents"),
|
||||
* @OA\Property(property="expirationMinutes", type="integer", example=60),
|
||||
* @OA\Property(property="password", type="string", example="secret"),
|
||||
* @OA\Property(property="allowUpload", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Share link created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="token", type="string", example="a1b2c3d4..."),
|
||||
* @OA\Property(property="expires", type="integer", example=1623456789),
|
||||
* @OA\Property(property="link", type="string", example="https://yourdomain.com/api/folder/shareFolder.php?token=...")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Invalid input"
|
||||
* ),
|
||||
* @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 folder by validating input and delegating to the FolderModel.
|
||||
*
|
||||
* @return void Outputs a JSON response.
|
||||
*/
|
||||
public function createShareFolderLink(): 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;
|
||||
}
|
||||
|
||||
// Check that the user is not read-only.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Read-only users are not allowed to create share folders."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Retrieve and decode POST input.
|
||||
$input = json_decode(file_get_contents("php://input"), true);
|
||||
if (!$input || !isset($input['folder'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Invalid input."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$folder = trim($input['folder']);
|
||||
$expirationMinutes = isset($input['expirationMinutes']) ? intval($input['expirationMinutes']) : 60;
|
||||
$password = isset($input['password']) ? $input['password'] : "";
|
||||
$allowUpload = isset($input['allowUpload']) ? intval($input['allowUpload']) : 0;
|
||||
|
||||
// Delegate to the model.
|
||||
$result = FolderModel::createShareFolderLink($folder, $expirationMinutes, $password, $allowUpload);
|
||||
echo json_encode($result);
|
||||
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;
|
||||
}
|
||||
}
|
||||
177
src/controllers/uploadController.php
Normal file
177
src/controllers/uploadController.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
// src/controllers/uploadController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
||||
|
||||
class UploadController {
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/upload/upload.php",
|
||||
* summary="Handle file upload",
|
||||
* description="Handles file uploads for both chunked and non-chunked (full) uploads. Validates CSRF, user authentication, and permissions, and processes file uploads accordingly. On success, returns a JSON status for chunked uploads or redirects for full uploads.",
|
||||
* operationId="handleUpload",
|
||||
* tags={"Uploads"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* description="Multipart form data for file upload. For chunked uploads, include fields like 'resumableChunkNumber', 'resumableTotalChunks', 'resumableIdentifier', 'resumableFilename', etc.",
|
||||
* @OA\MediaType(
|
||||
* mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"token", "fileToUpload"},
|
||||
* @OA\Property(property="token", type="string", description="Share token or upload token."),
|
||||
* @OA\Property(
|
||||
* property="fileToUpload",
|
||||
* type="string",
|
||||
* format="binary",
|
||||
* description="The file to upload."
|
||||
* ),
|
||||
* @OA\Property(property="resumableChunkNumber", type="integer", description="Chunk number for chunked uploads."),
|
||||
* @OA\Property(property="resumableTotalChunks", type="integer", description="Total number of chunks."),
|
||||
* @OA\Property(property="resumableFilename", type="string", description="Original filename."),
|
||||
* @OA\Property(property="folder", type="string", description="Target folder (default 'root').")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="File uploaded successfully (or chunk uploaded status).",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* @OA\Property(property="success", type="string", example="File uploaded successfully"),
|
||||
* @OA\Property(property="newFilename", type="string", example="5f2d7c123a_example.png"),
|
||||
* @OA\Property(property="status", type="string", example="chunk uploaded")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=302,
|
||||
* description="Redirection on full upload success."
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=400,
|
||||
* description="Bad Request (e.g., missing file, invalid parameters)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=403,
|
||||
* description="Forbidden (e.g., invalid CSRF token, upload disabled)"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=500,
|
||||
* description="Server error during file processing"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Handles file uploads, both chunked and full, and redirects upon success.
|
||||
*
|
||||
* @return void Outputs JSON response (for chunked uploads) or redirects on successful full upload.
|
||||
*/
|
||||
public function handleUpload(): void {
|
||||
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']) || trim($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;
|
||||
}
|
||||
// Check user permissions.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
$userPermissions = loadUserPermissions($username);
|
||||
if ($username && !empty($userPermissions['disableUpload'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Upload disabled for this user."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate to the model.
|
||||
$result = UploadModel::handleUpload($_POST, $_FILES);
|
||||
|
||||
// For chunked uploads, output JSON (e.g., "chunk uploaded" status).
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
if (isset($result['status'])) {
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Otherwise, for full upload success, set a flash message and 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;
|
||||
}
|
||||
}
|
||||
950
src/controllers/userController.php
Normal file
950
src/controllers/userController.php
Normal file
@@ -0,0 +1,950 @@
|
||||
<?php
|
||||
// userController.php located in src/controllers/
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
|
||||
class UserController
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/getUsers.php",
|
||||
* summary="Retrieve a list of users",
|
||||
* description="Returns a JSON array of users. Only available to authenticated admin users.",
|
||||
* operationId="getUsers",
|
||||
* tags={"Users"},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Successful response with an array of users",
|
||||
* @OA\JsonContent(
|
||||
* type="array",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* @OA\Property(property="username", type="string", example="johndoe"),
|
||||
* @OA\Property(property="role", type="string", example="admin")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized: the user is not authenticated or is not an admin"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
|
||||
public function getUsers()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication and admin privileges.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Determine if we're in setup mode.
|
||||
// Setup mode means the "setup" query parameter is passed
|
||||
// and users.txt is missing, empty, or contains only whitespace.
|
||||
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
||||
if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) {
|
||||
// Allow initial admin creation without session or CSRF checks.
|
||||
$setupMode = true;
|
||||
} else {
|
||||
$setupMode = false;
|
||||
// In non-setup mode, perform CSRF token and authentication checks.
|
||||
$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;
|
||||
}
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
||||
) {
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the JSON input data.
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
$newUsername = trim($data["username"] ?? "");
|
||||
$newPassword = trim($data["password"] ?? "");
|
||||
|
||||
// In setup mode, force the new user to be an admin.
|
||||
if ($setupMode) {
|
||||
$isAdmin = "1";
|
||||
} else {
|
||||
$isAdmin = !empty($data["isAdmin"]) ? "1" : "0";
|
||||
}
|
||||
|
||||
// Validate that a username and password are provided.
|
||||
if (!$newUsername || !$newPassword) {
|
||||
echo json_encode(["error" => "Username and password required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate username format.
|
||||
if (!preg_match(REGEX_USER, $newUsername)) {
|
||||
echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delegate the business logic to the model.
|
||||
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
||||
echo json_encode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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');
|
||||
// Set CSP headers if desired:
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||
|
||||
// Rate‑limit: initialize totp_failures if not set.
|
||||
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 have a pending login.
|
||||
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || 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 = 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(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Parse 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;
|
||||
}
|
||||
|
||||
// Create TFA object.
|
||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||
'FileRise',
|
||||
6,
|
||||
30,
|
||||
\RobThree\Auth\Algorithm::Sha1
|
||||
);
|
||||
|
||||
// Check if we are in pending login flow.
|
||||
if (isset($_SESSION['pending_login_user'])) {
|
||||
$username = $_SESSION['pending_login_user'];
|
||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||
$_SESSION['totp_failures']++;
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
||||
exit;
|
||||
}
|
||||
// Successful pending login: finalize login.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $username;
|
||||
// Set isAdmin based on user role.
|
||||
$_SESSION['isAdmin'] = (userModel::getUserRole($username) === "1");
|
||||
// Load additional permissions (e.g., folderOnly) as needed.
|
||||
$_SESSION['folderOnly'] = loadUserPermissions($username);
|
||||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'], $_SESSION['totp_failures']);
|
||||
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Otherwise, we are in setup/verification flow.
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if (!$username) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Retrieve the user's TOTP secret from the model.
|
||||
$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 verification.
|
||||
unset($_SESSION['totp_failures']);
|
||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
||||
}
|
||||
}
|
||||
118
src/models/AdminModel.php
Normal file
118
src/models/AdminModel.php
Normal 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
124
src/models/AuthModel.php
Normal 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
1249
src/models/FileModel.php
Normal file
File diff suppressed because it is too large
Load Diff
570
src/models/FolderModel.php
Normal file
570
src/models/FolderModel.php
Normal 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
266
src/models/UploadModel.php
Normal 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
657
src/models/UserModel.php
Normal 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) {
|
||||
// --- Rate‑limit 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 user’s 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user