feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend (closes #53)
This commit is contained in:
@@ -6,25 +6,60 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
class AdminModel
|
||||
{
|
||||
/**
|
||||
* Parse a shorthand size value (e.g. "5G", "500M", "123K") into bytes.
|
||||
* Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes.
|
||||
* Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc.
|
||||
*
|
||||
* @param string $val
|
||||
* @return int
|
||||
* @return int Bytes (rounded)
|
||||
*/
|
||||
private static function parseSize(string $val): int
|
||||
{
|
||||
$unit = strtolower(substr($val, -1));
|
||||
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
return $num * 1024 ** 3;
|
||||
case 'm':
|
||||
return $num * 1024 ** 2;
|
||||
case 'k':
|
||||
return $num * 1024;
|
||||
default:
|
||||
return $num;
|
||||
$val = trim($val);
|
||||
if ($val === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...)
|
||||
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) {
|
||||
$num = (float)$m[1];
|
||||
$unit = strtolower($m[2] ?? '');
|
||||
|
||||
switch ($unit) {
|
||||
case 'k': case 'kb': case 'kib':
|
||||
$num *= 1024;
|
||||
break;
|
||||
case 'm': case 'mb': case 'mib':
|
||||
$num *= 1024 ** 2;
|
||||
break;
|
||||
case 'g': case 'gb': case 'gib':
|
||||
$num *= 1024 ** 3;
|
||||
break;
|
||||
case 't': case 'tb': case 'tib':
|
||||
$num *= 1024 ** 4;
|
||||
break;
|
||||
case 'p': case 'pb': case 'pib':
|
||||
$num *= 1024 ** 5;
|
||||
break;
|
||||
case 'e': case 'eb': case 'eib':
|
||||
$num *= 1024 ** 6;
|
||||
break;
|
||||
case 'z': case 'zb': case 'zib':
|
||||
$num *= 1024 ** 7;
|
||||
break;
|
||||
case 'y': case 'yb': case 'yib':
|
||||
$num *= 1024 ** 8;
|
||||
break;
|
||||
// case 'b' or empty => bytes; do nothing
|
||||
default:
|
||||
// If unit is just 'b' or empty, treat as bytes.
|
||||
// For unknown units fall back to bytes.
|
||||
break;
|
||||
}
|
||||
return (int) round($num);
|
||||
}
|
||||
|
||||
// Fallback: cast any unrecognized input to int (bytes)
|
||||
return (int)$val;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,17 +70,22 @@ class AdminModel
|
||||
*/
|
||||
public static function updateConfig(array $configUpdate): array
|
||||
{
|
||||
// New: only enforce OIDC fields when OIDC is enabled
|
||||
// Ensure encryption key exists
|
||||
if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) {
|
||||
return ["error" => "Server encryption key is not configured."];
|
||||
}
|
||||
|
||||
// Only enforce OIDC fields when OIDC is enabled
|
||||
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
|
||||
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
|
||||
: true; // default to disabled when not present
|
||||
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
|
||||
: true; // default to disabled when not present
|
||||
|
||||
if (!$oidcDisabled) {
|
||||
$oidc = $configUpdate['oidc'] ?? [];
|
||||
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
|
||||
foreach ($required as $k) {
|
||||
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
|
||||
$oidc = $configUpdate['oidc'] ?? [];
|
||||
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
|
||||
foreach ($required as $k) {
|
||||
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +112,7 @@ class AdminModel
|
||||
$configUpdate['sharedMaxUploadSize'] = $sms;
|
||||
}
|
||||
|
||||
// ── NEW: normalize authBypass & authHeaderName ─────────────────────────
|
||||
// Normalize authBypass & authHeaderName
|
||||
if (!isset($configUpdate['loginOptions']['authBypass'])) {
|
||||
$configUpdate['loginOptions']['authBypass'] = false;
|
||||
}
|
||||
@@ -85,10 +125,8 @@ class AdminModel
|
||||
) {
|
||||
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
|
||||
} else {
|
||||
$configUpdate['loginOptions']['authHeaderName'] =
|
||||
trim($configUpdate['loginOptions']['authHeaderName']);
|
||||
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
@@ -109,7 +147,7 @@ class AdminModel
|
||||
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);
|
||||
@unlink($configFile);
|
||||
}
|
||||
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
|
||||
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
|
||||
@@ -130,13 +168,15 @@ class AdminModel
|
||||
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);
|
||||
// Do not set HTTP status here; let the controller decide.
|
||||
return ["error" => "Failed to decrypt configuration."];
|
||||
}
|
||||
|
||||
$config = json_decode($decryptedContent, true);
|
||||
if (!is_array($config)) {
|
||||
$config = [];
|
||||
@@ -144,7 +184,7 @@ class AdminModel
|
||||
|
||||
// Normalize login options if missing
|
||||
if (!isset($config['loginOptions'])) {
|
||||
// migrate legacy top-level flags; default OIDC to true (disabled)
|
||||
// Migrate legacy top-level flags; default OIDC to true (disabled)
|
||||
$config['loginOptions'] = [
|
||||
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
|
||||
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
|
||||
@@ -152,13 +192,14 @@ class AdminModel
|
||||
];
|
||||
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
|
||||
} else {
|
||||
// normalize booleans; default OIDC to true (disabled) if missing
|
||||
// Normalize booleans; default OIDC to true (disabled) if missing
|
||||
$lo = &$config['loginOptions'];
|
||||
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
|
||||
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
|
||||
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
|
||||
}
|
||||
|
||||
// Ensure OIDC structure exists
|
||||
if (!isset($config['oidc']) || !is_array($config['oidc'])) {
|
||||
$config['oidc'] = [
|
||||
'providerUrl' => '',
|
||||
@@ -174,6 +215,7 @@ class AdminModel
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize authBypass & authHeaderName
|
||||
if (!array_key_exists('authBypass', $config['loginOptions'])) {
|
||||
$config['loginOptions']['authBypass'] = false;
|
||||
} else {
|
||||
@@ -191,38 +233,41 @@ class AdminModel
|
||||
if (!isset($config['globalOtpauthUrl'])) {
|
||||
$config['globalOtpauthUrl'] = "";
|
||||
}
|
||||
if (!isset($config['header_title']) || empty($config['header_title'])) {
|
||||
if (!isset($config['header_title']) || $config['header_title'] === '') {
|
||||
$config['header_title'] = "FileRise";
|
||||
}
|
||||
if (!isset($config['enableWebDAV'])) {
|
||||
$config['enableWebDAV'] = false;
|
||||
}
|
||||
// Default sharedMaxUploadSize to 50MB or TOTAL_UPLOAD_SIZE if smaller
|
||||
if (!isset($config['sharedMaxUploadSize'])) {
|
||||
$defaultSms = min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE));
|
||||
$config['sharedMaxUploadSize'] = $defaultSms;
|
||||
|
||||
// sharedMaxUploadSize: default if missing; clamp if present
|
||||
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
|
||||
if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) {
|
||||
$config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes);
|
||||
} else {
|
||||
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
|
||||
}
|
||||
|
||||
return $config;
|
||||
} else {
|
||||
// Return defaults.
|
||||
return [
|
||||
'header_title' => "FileRise",
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => true
|
||||
],
|
||||
'globalOtpauthUrl' => "",
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||
];
|
||||
}
|
||||
|
||||
// No config on disk; return defaults.
|
||||
return [
|
||||
'header_title' => "FileRise",
|
||||
'oidc' => [
|
||||
'providerUrl' => 'https://your-oidc-provider.com',
|
||||
'clientId' => '',
|
||||
'clientSecret' => '',
|
||||
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
|
||||
],
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => false,
|
||||
'disableBasicAuth' => false,
|
||||
'disableOIDCLogin' => true
|
||||
],
|
||||
'globalOtpauthUrl' => "",
|
||||
'enableWebDAV' => false,
|
||||
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE))
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,58 +6,61 @@ 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.
|
||||
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
|
||||
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||||
* containment, and (optionally) creates the folder.
|
||||
*
|
||||
* @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.
|
||||
* @param string $folder Relative folder or "root"
|
||||
* @param bool $create Create the folder if missing
|
||||
* @return array [string|null $realPath, string $relative, string|null $error]
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = ""): array
|
||||
private static function resolveFolderPath(string $folder, bool $create = false): array
|
||||
{
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
$folder = trim($folder) ?: 'root';
|
||||
$relative = 'root';
|
||||
|
||||
// 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."];
|
||||
$base = realpath(UPLOAD_DIR);
|
||||
if ($base === false) {
|
||||
return [null, 'root', "Uploads directory not configured correctly."];
|
||||
}
|
||||
|
||||
$baseDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
if ($parent !== "" && strtolower($parent) !== "root") {
|
||||
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $parent . DIRECTORY_SEPARATOR . $folderName;
|
||||
$relativePath = $parent . "/" . $folderName;
|
||||
if (strtolower($folder) === 'root') {
|
||||
$dir = $base;
|
||||
} 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."];
|
||||
// validate each segment against REGEX_FOLDER_NAME
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
|
||||
if (empty($parts)) {
|
||||
return [null, 'root', "Invalid folder name."];
|
||||
}
|
||||
return ["success" => true];
|
||||
} else {
|
||||
return ["error" => "Failed to create folder."];
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
return [null, 'root', "Invalid folder name."];
|
||||
}
|
||||
}
|
||||
$relative = implode('/', $parts);
|
||||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||
}
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if ($create) {
|
||||
if (!mkdir($dir, 0775, true)) {
|
||||
return [null, $relative, "Failed to create folder."];
|
||||
}
|
||||
} else {
|
||||
return [null, $relative, "Folder does not exist."];
|
||||
}
|
||||
}
|
||||
|
||||
$real = realpath($dir);
|
||||
if ($real === false || strpos($real, $base) !== 0) {
|
||||
return [null, $relative, "Invalid folder path."];
|
||||
}
|
||||
|
||||
return [$real, $relative, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the metadata file path for a given folder.
|
||||
*
|
||||
* @param string $folder The relative folder path.
|
||||
* @return string The metadata file path.
|
||||
* Build metadata file path for a given (relative) folder.
|
||||
*/
|
||||
private static function getMetadataFilePath(string $folder): string
|
||||
{
|
||||
@@ -67,134 +70,146 @@ class FolderModel
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a folder under the specified parent (or in root) and creates an empty metadata file.
|
||||
*/
|
||||
public static function createFolder(string $folderName, string $parent = ""): array
|
||||
{
|
||||
$folderName = trim($folderName);
|
||||
$parent = trim($parent);
|
||||
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
|
||||
// Resolve parent path (root ok; nested ok)
|
||||
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName);
|
||||
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName;
|
||||
|
||||
if (file_exists($targetDir)) {
|
||||
return ["error" => "Folder already exists."];
|
||||
}
|
||||
|
||||
if (!mkdir($targetDir, 0775, true)) {
|
||||
return ["error" => "Failed to create folder."];
|
||||
}
|
||||
|
||||
// Create an empty metadata file for the new folder.
|
||||
$metadataFile = self::getMetadataFilePath($targetRel);
|
||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ["error" => "Folder created but failed to create metadata file."];
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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."];
|
||||
}
|
||||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// 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('.', '..'));
|
||||
// Prevent deletion if not empty.
|
||||
$items = array_diff(scandir($real), 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 {
|
||||
if (!rmdir($real)) {
|
||||
return ["error" => "Failed to delete folder."];
|
||||
}
|
||||
|
||||
// Remove metadata file (best-effort).
|
||||
$metadataFile = self::getMetadataFilePath($relative);
|
||||
if (file_exists($metadataFile)) {
|
||||
@unlink($metadataFile);
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Renames a folder and updates related metadata files (by renaming their filenames).
|
||||
*/
|
||||
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)."];
|
||||
// Validate names (per-segment)
|
||||
foreach ([$oldFolder, $newFolder] as $f) {
|
||||
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
|
||||
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
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;
|
||||
[$oldReal, $oldRel, $err] = self::resolveFolderPath($oldFolder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// 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
|
||||
) {
|
||||
$base = realpath(UPLOAD_DIR);
|
||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||
|
||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||
|
||||
// Parent of new path must exist
|
||||
$newParent = dirname($newPath);
|
||||
if (!is_dir($newParent) || strpos(realpath($newParent), $base) !== 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 {
|
||||
if (!rename($oldReal, $newPath)) {
|
||||
return ["error" => "Failed to rename folder."];
|
||||
}
|
||||
|
||||
// Update metadata filenames (prefix-rename)
|
||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||||
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts));
|
||||
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||||
$metadataFiles = glob($globPat) ?: [];
|
||||
|
||||
foreach ($metadataFiles as $oldMetaFile) {
|
||||
$baseName = basename($oldMetaFile);
|
||||
$newBase = preg_replace('/^' . preg_quote($oldPrefix, '/') . '/', $newPrefix, $baseName);
|
||||
$newMeta = META_DIR . $newBase;
|
||||
@rename($oldMetaFile, $newMeta);
|
||||
}
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Recursively scans a directory for subfolders (relative paths).
|
||||
*/
|
||||
private static function getSubfolders(string $dir, string $relative = ''): array
|
||||
{
|
||||
$folders = [];
|
||||
$items = scandir($dir);
|
||||
$safeFolderNamePattern = REGEX_FOLDER_NAME;
|
||||
$items = @scandir($dir) ?: [];
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match($safeFolderNamePattern, $item)) {
|
||||
continue;
|
||||
}
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $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);
|
||||
$folders[] = $folderPath;
|
||||
$folders = array_merge($folders, self::getSubfolders($path, $folderPath));
|
||||
}
|
||||
}
|
||||
return $folders;
|
||||
@@ -202,35 +217,31 @@ class FolderModel
|
||||
|
||||
/**
|
||||
* 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, '/\\');
|
||||
$baseDir = realpath(UPLOAD_DIR);
|
||||
if ($baseDir === false) {
|
||||
return []; // or ["error" => "..."]
|
||||
}
|
||||
|
||||
$folderInfoList = [];
|
||||
|
||||
// Process the "root" folder.
|
||||
$rootMetaFile = self::getMetadataFilePath('root');
|
||||
$rootFileCount = 0;
|
||||
// root
|
||||
$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,
|
||||
"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.
|
||||
// subfolders
|
||||
$subfolders = is_dir($baseDir) ? self::getSubfolders($baseDir) : [];
|
||||
foreach ($subfolders as $folder) {
|
||||
$metaFile = self::getMetadataFilePath($folder);
|
||||
$fileCount = 0;
|
||||
@@ -239,8 +250,8 @@ class FolderModel
|
||||
$fileCount = is_array($metadata) ? count($metadata) : 0;
|
||||
}
|
||||
$folderInfoList[] = [
|
||||
"folder" => $folder,
|
||||
"fileCount" => $fileCount,
|
||||
"folder" => $folder,
|
||||
"fileCount" => $fileCount,
|
||||
"metadataFile" => basename($metaFile)
|
||||
];
|
||||
}
|
||||
@@ -250,136 +261,101 @@ class FolderModel
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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];
|
||||
return (is_array($shareLinks) && isset($shareLinks[$token])) ? $shareLinks[$token] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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."];
|
||||
}
|
||||
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']) {
|
||||
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
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)) {
|
||||
|
||||
// Resolve shared folder
|
||||
$folder = trim((string)$record['folder'], "/\\ ");
|
||||
[$realFolderPath, $relative, $err] = self::resolveFolderPath($folder === '' ? 'root' : $folder, false);
|
||||
if ($err || !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;
|
||||
|
||||
// List files (safe names only; skip hidden)
|
||||
$all = @scandir($realFolderPath) ?: [];
|
||||
$allFiles = [];
|
||||
foreach ($all as $it) {
|
||||
if ($it === '.' || $it === '..') continue;
|
||||
if ($it[0] === '.') continue;
|
||||
if (!preg_match(REGEX_FILE_NAME, $it)) continue;
|
||||
if (is_file($realFolderPath . DIRECTORY_SEPARATOR . $it)) {
|
||||
$allFiles[] = $it;
|
||||
}
|
||||
}
|
||||
sort($allFiles, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
$totalFiles = count($allFiles);
|
||||
$totalPages = max(1, (int)ceil($totalFiles / max(1, $itemsPerPage)));
|
||||
$currentPage = min(max(1, $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
|
||||
"record" => $record,
|
||||
"folder" => $relative,
|
||||
"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 $expirationSeconds How many seconds until expiry.
|
||||
* @param string $password Optional password.
|
||||
* @param int $allowUpload 0 or 1 whether uploads are allowed.
|
||||
* @return array ["token","expires","link"] on success, or ["error"].
|
||||
*/
|
||||
public static function createShareFolderLink(string $folder, int $expirationSeconds = 3600, string $password = "", int $allowUpload = 0): array
|
||||
{
|
||||
// Validate folder
|
||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
// Validate folder (and ensure it exists)
|
||||
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Token
|
||||
try {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
return ["error" => "Could not generate token."];
|
||||
}
|
||||
|
||||
// Expiry
|
||||
$expires = time() + $expirationSeconds;
|
||||
$expires = time() + max(1, $expirationSeconds);
|
||||
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// Password hash
|
||||
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
|
||||
|
||||
// Load existing
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
$links = file_exists($shareFile)
|
||||
? json_decode(file_get_contents($shareFile), true) ?? []
|
||||
? (json_decode(file_get_contents($shareFile), true) ?? [])
|
||||
: [];
|
||||
|
||||
// Cleanup
|
||||
// cleanup expired
|
||||
$now = time();
|
||||
foreach ($links as $k => $v) {
|
||||
if (!empty($v['expires']) && $v['expires'] < $now) {
|
||||
@@ -387,107 +363,78 @@ class FolderModel
|
||||
}
|
||||
}
|
||||
|
||||
// Add new
|
||||
$links[$token] = [
|
||||
"folder" => $folder,
|
||||
"folder" => $relative,
|
||||
"expires" => $expires,
|
||||
"password" => $hashedPassword,
|
||||
"allowUpload" => $allowUpload
|
||||
"allowUpload" => $allowUpload ? 1 : 0
|
||||
];
|
||||
|
||||
// Save
|
||||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)) === false) {
|
||||
if (file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ["error" => "Could not save share link."];
|
||||
}
|
||||
|
||||
// Build URL
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||
$baseUrl = $protocol . '://' . rtrim($host, '/');
|
||||
$link = $baseUrl . "/api/folder/shareFolder.php?token=" . urlencode($token);
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
|
||||
$baseUrl = $scheme . '://' . rtrim($host, '/');
|
||||
$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."];
|
||||
}
|
||||
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']) {
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
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)) {
|
||||
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
|
||||
if ($err || !is_dir($realFolderPath)) {
|
||||
return ["error" => "Shared folder not found."];
|
||||
}
|
||||
|
||||
// Sanitize the file name to prevent path traversal.
|
||||
if (strpos($file, "/") !== false || strpos($file, "\\") !== false) {
|
||||
$file = basename(trim($file));
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
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)) {
|
||||
$full = $realFolderPath . DIRECTORY_SEPARATOR . $file;
|
||||
$real = realpath($full);
|
||||
if ($real === false || strpos($real, $realFolderPath) !== 0 || !is_file($real)) {
|
||||
return ["error" => "File not found."];
|
||||
}
|
||||
|
||||
$mimeType = mime_content_type($realFilePath);
|
||||
return [
|
||||
"realFilePath" => $realFilePath,
|
||||
"mimeType" => $mimeType
|
||||
];
|
||||
$mime = function_exists('mime_content_type') ? mime_content_type($real) : 'application/octet-stream';
|
||||
return ["realFilePath" => $real, "mimeType" => $mime];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
// Max size & allowed extensions (mirror FileModel’s common types)
|
||||
$maxSize = 50 * 1024 * 1024; // 50 MB
|
||||
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv'];
|
||||
$allowedExtensions = [
|
||||
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
|
||||
'mp4','webm','mp3','mkv','csv','json','xml','md'
|
||||
];
|
||||
|
||||
// Load the share folder record.
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return ["error" => "Share record not found."];
|
||||
@@ -498,75 +445,50 @@ class FolderModel
|
||||
}
|
||||
$record = $shareLinks[$token];
|
||||
|
||||
// Check expiration.
|
||||
if (time() > $record['expires']) {
|
||||
if (time() > ($record['expires'] ?? 0)) {
|
||||
return ["error" => "This share link has expired."];
|
||||
}
|
||||
|
||||
// Check whether uploads are allowed.
|
||||
if (empty($record['allowUpload']) || $record['allowUpload'] != 1) {
|
||||
if (empty($record['allowUpload']) || (int)$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['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ["error" => "File upload error. Code: " . (int)$fileUpload['error']];
|
||||
}
|
||||
|
||||
if ($fileUpload['size'] > $maxSize) {
|
||||
if (($fileUpload['size'] ?? 0) > $maxSize) {
|
||||
return ["error" => "File size exceeds allowed limit."];
|
||||
}
|
||||
|
||||
$uploadedName = basename($fileUpload['name']);
|
||||
$uploadedName = basename((string)($fileUpload['name'] ?? ''));
|
||||
$ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExtensions)) {
|
||||
if (!in_array($ext, $allowedExtensions, true)) {
|
||||
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;
|
||||
}
|
||||
// Resolve target folder
|
||||
[$targetDir, $relative, $err] = self::resolveFolderPath((string)$record['folder'], true);
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// 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."];
|
||||
}
|
||||
// New safe filename
|
||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
|
||||
$newFilename= uniqid('', true) . "_" . $safeBase;
|
||||
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
|
||||
|
||||
// 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
|
||||
// Update metadata (uploaded + modified + uploader)
|
||||
$metadataFile = self::getMetadataFilePath($relative);
|
||||
$meta = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
$meta[$newFilename] = [
|
||||
"uploaded" => $now,
|
||||
"modified" => $now,
|
||||
"uploader" => "Outside Share"
|
||||
];
|
||||
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
|
||||
file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
|
||||
return ["success" => "File uploaded successfully.", "newFilename" => $newFilename];
|
||||
}
|
||||
@@ -574,9 +496,7 @@ class FolderModel
|
||||
public static function getAllShareFolderLinks(): array
|
||||
{
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return [];
|
||||
}
|
||||
if (!file_exists($shareFile)) return [];
|
||||
$links = json_decode(file_get_contents($shareFile), true);
|
||||
return is_array($links) ? $links : [];
|
||||
}
|
||||
@@ -584,15 +504,13 @@ class FolderModel
|
||||
public static function deleteShareFolderLink(string $token): bool
|
||||
{
|
||||
$shareFile = META_DIR . "share_folder_links.json";
|
||||
if (!file_exists($shareFile)) {
|
||||
return false;
|
||||
}
|
||||
if (!file_exists($shareFile)) return false;
|
||||
|
||||
$links = json_decode(file_get_contents($shareFile), true);
|
||||
if (!is_array($links) || !isset($links[$token])) {
|
||||
return false;
|
||||
}
|
||||
if (!is_array($links) || !isset($links[$token])) return false;
|
||||
|
||||
unset($links[$token]);
|
||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
|
||||
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ require_once PROJECT_ROOT . '/config/config.php';
|
||||
class userModel
|
||||
{
|
||||
/**
|
||||
* Retrieves all users from the users file.
|
||||
*
|
||||
* @return array Returns an array of users.
|
||||
* Retrieve all users (username + role).
|
||||
*/
|
||||
public static function getAllUsers()
|
||||
{
|
||||
@@ -30,67 +28,75 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new user.
|
||||
* Add a 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.
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $isAdmin "1" or "0"
|
||||
* @param bool $setupMode overwrite file if true
|
||||
*/
|
||||
public static function addUser($username, $password, $isAdmin, $setupMode)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
// Ensure users.txt exists.
|
||||
// Defense in depth
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
if (!is_string($password) || $password === '') {
|
||||
return ["error" => "Password required"];
|
||||
}
|
||||
$isAdmin = $isAdmin === '1' ? '1' : '0';
|
||||
|
||||
if (!file_exists($usersFile)) {
|
||||
file_put_contents($usersFile, '');
|
||||
@file_put_contents($usersFile, '', LOCK_EX);
|
||||
}
|
||||
|
||||
// Check if username already exists.
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
// Check duplicates
|
||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($existingUsers as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if ($username === $parts[0]) {
|
||||
if (isset($parts[0]) && $username === $parts[0]) {
|
||||
return ["error" => "User already exists"];
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||
|
||||
// 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);
|
||||
if (file_put_contents($usersFile, $newUserLine, LOCK_EX) === false) {
|
||||
return ["error" => "Failed to write users file"];
|
||||
}
|
||||
} else {
|
||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
||||
if (file_put_contents($usersFile, $newUserLine, FILE_APPEND | LOCK_EX) === false) {
|
||||
return ["error" => "Failed to write users file"];
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
* Remove a user and update encrypted userPermissions.json.
|
||||
*/
|
||||
public static function removeUser($usernameToRemove)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
global $encryptionKey;
|
||||
|
||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$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);
|
||||
$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) {
|
||||
@@ -98,7 +104,7 @@ class userModel
|
||||
}
|
||||
if ($parts[0] === $usernameToRemove) {
|
||||
$userFound = true;
|
||||
continue; // Do not add this user to the new array.
|
||||
continue; // skip
|
||||
}
|
||||
$newUsers[] = $line;
|
||||
}
|
||||
@@ -107,17 +113,25 @@ class userModel
|
||||
return ["error" => "User not found"];
|
||||
}
|
||||
|
||||
// Write the updated user list back to the file.
|
||||
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
||||
$newContent = $newUsers ? (implode(PHP_EOL, $newUsers) . PHP_EOL) : '';
|
||||
if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) {
|
||||
return ["error" => "Failed to update users file"];
|
||||
}
|
||||
|
||||
// Update the userPermissions.json file.
|
||||
// Update *encrypted* userPermissions.json consistently
|
||||
$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));
|
||||
$raw = file_get_contents($permissionsFile);
|
||||
$decrypted = decryptData($raw, $encryptionKey);
|
||||
$permissionsArray = $decrypted !== false
|
||||
? json_decode($decrypted, true)
|
||||
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext
|
||||
|
||||
if (is_array($permissionsArray)) {
|
||||
unset($permissionsArray[strtolower($usernameToRemove)]);
|
||||
$plain = json_encode($permissionsArray, JSON_PRETTY_PRINT);
|
||||
$enc = encryptData($plain, $encryptionKey);
|
||||
file_put_contents($permissionsFile, $enc, LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,11 +139,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Get permissions for current user (or all, if admin).
|
||||
*/
|
||||
public static function getUserPermissions()
|
||||
{
|
||||
@@ -137,28 +147,24 @@ class userModel
|
||||
$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.
|
||||
$decrypted = decryptData($content, $encryptionKey);
|
||||
if ($decrypted === false) {
|
||||
// tolerate legacy plaintext
|
||||
$permissionsArray = json_decode($content, true);
|
||||
} else {
|
||||
$permissionsArray = json_decode($decryptedContent, true);
|
||||
$permissionsArray = json_decode($decrypted, true);
|
||||
}
|
||||
if (!is_array($permissionsArray)) {
|
||||
$permissionsArray = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is an admin, return all permissions.
|
||||
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
|
||||
if (!empty($_SESSION['isAdmin'])) {
|
||||
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) {
|
||||
@@ -166,129 +172,103 @@ class userModel
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
* Update permissions (encrypted on disk). Skips admins.
|
||||
*/
|
||||
public static function updateUserPermissions($permissions)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||
$existingPermissions = [];
|
||||
{
|
||||
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."];
|
||||
// Load existing (decrypt if needed)
|
||||
if (file_exists($permissionsFile)) {
|
||||
$encryptedContent = file_get_contents($permissionsFile);
|
||||
$json = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($json === false) $json = $encryptedContent; // plain JSON fallback
|
||||
$existingPermissions = json_decode($json, true) ?: [];
|
||||
}
|
||||
|
||||
// Load roles to skip admins
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$userRoles = [];
|
||||
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 && preg_match(REGEX_USER, $parts[0])) {
|
||||
$userRoles[strtolower($parts[0])] = trim($parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$knownKeys = [
|
||||
'folderOnly','readOnly','disableUpload',
|
||||
'bypassOwnership','canShare','canZip','viewOwnOnly'
|
||||
];
|
||||
|
||||
foreach ($permissions as $perm) {
|
||||
if (empty($perm['username'])) continue;
|
||||
$uname = strtolower($perm['username']);
|
||||
$role = $userRoles[$uname] ?? null;
|
||||
if ($role === "1") continue; // skip admins
|
||||
|
||||
$current = $existingPermissions[$uname] ?? [];
|
||||
foreach ($knownKeys as $k) {
|
||||
if (array_key_exists($k, $perm)) {
|
||||
$current[$k] = (bool)$perm[$k];
|
||||
} elseif (!isset($current[$k])) {
|
||||
// default missing keys to false (preserve existing if set)
|
||||
$current[$k] = false;
|
||||
}
|
||||
}
|
||||
$existingPermissions[$uname] = $current;
|
||||
}
|
||||
|
||||
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||
$encrypted = encryptData($plain, $encryptionKey);
|
||||
if (file_put_contents($permissionsFile, $encrypted) === 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.
|
||||
* Change password (preserve TOTP + extra fields).
|
||||
*/
|
||||
public static function changePassword($username, $oldPassword, $newPassword)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$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);
|
||||
$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;
|
||||
}
|
||||
$parts[1] = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||
$newLines[] = implode(':', $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
@@ -298,148 +278,128 @@ class userModel
|
||||
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 {
|
||||
$payload = implode(PHP_EOL, $newLines) . PHP_EOL;
|
||||
if (file_put_contents($usersFile, $payload, LOCK_EX) === false) {
|
||||
return ["error" => "Could not update password."];
|
||||
}
|
||||
|
||||
return ["success" => "Password updated successfully."];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Update panel: if TOTP disabled, clear secret.
|
||||
*/
|
||||
public static function updateUserPanel($username, $totp_enabled)
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (!preg_match(REGEX_USER, $username)) {
|
||||
return ["error" => "Invalid username"];
|
||||
}
|
||||
|
||||
$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);
|
||||
$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 {
|
||||
while (count($parts) < 4) {
|
||||
$parts[] = "";
|
||||
}
|
||||
$parts[3] = "";
|
||||
$newLines[] = implode(':', $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||
if ($result === false) {
|
||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) === 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.
|
||||
* Clear TOTP secret.
|
||||
*/
|
||||
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);
|
||||
$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 {
|
||||
while (count($parts) < 4) {
|
||||
$parts[] = "";
|
||||
}
|
||||
$parts[3] = "";
|
||||
$modified = true;
|
||||
$newLines[] = implode(":", $parts);
|
||||
} else {
|
||||
$newLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($modified) {
|
||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||
return file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX) !== false;
|
||||
}
|
||||
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'.
|
||||
* Recover via recovery code.
|
||||
*/
|
||||
public static function recoverTOTP($userId, $recoveryCode)
|
||||
{
|
||||
// --- Rate‑limit recovery attempts ---
|
||||
// Rate limit storage
|
||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
||||
$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId;
|
||||
$attempts = is_file($attemptsFile) ? (json_decode(@file_get_contents($attemptsFile), true) ?: []) : [];
|
||||
$key = ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|' . $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;
|
||||
});
|
||||
$attempts[$key] = array_values(array_filter($attempts[$key], fn($ts) => $ts > $now - 900));
|
||||
}
|
||||
if (count($attempts[$key] ?? []) >= 5) {
|
||||
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
||||
}
|
||||
|
||||
// --- Load user metadata file ---
|
||||
// User JSON 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)) {
|
||||
if ($fp) fclose($fp);
|
||||
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);
|
||||
@@ -448,19 +408,19 @@ class userModel
|
||||
|
||||
$storedHash = $data['totp_recovery_code'] ?? null;
|
||||
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
||||
// Record failed attempt.
|
||||
// record failed attempt
|
||||
$attempts[$key][] = $now;
|
||||
file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX);
|
||||
@file_put_contents($attemptsFile, json_encode($attempts, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
||||
}
|
||||
|
||||
// --- Invalidate recovery code ---
|
||||
// Invalidate code
|
||||
$data['totp_recovery_code'] = null;
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data));
|
||||
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@@ -469,10 +429,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random recovery code.
|
||||
*
|
||||
* @param int $length Length of the recovery code.
|
||||
* @return string
|
||||
* Generate random recovery code.
|
||||
*/
|
||||
private static function generateRecoveryCode($length = 12)
|
||||
{
|
||||
@@ -486,45 +443,34 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Save new TOTP recovery code (hash on disk) and return plaintext to caller.
|
||||
*/
|
||||
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) {
|
||||
if (file_put_contents($userFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === 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)) {
|
||||
if ($fp) fclose($fp);
|
||||
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.
|
||||
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@@ -533,11 +479,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'.
|
||||
* Setup TOTP & build QR PNG.
|
||||
*/
|
||||
public static function setupTOTP($username)
|
||||
{
|
||||
@@ -548,9 +490,9 @@ class userModel
|
||||
return ['error' => 'Users file not found'];
|
||||
}
|
||||
|
||||
// Look for an existing TOTP secret.
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$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])) {
|
||||
@@ -559,19 +501,18 @@ class userModel
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||
'FileRise',
|
||||
6,
|
||||
30,
|
||||
\RobThree\Auth\Algorithm::Sha1
|
||||
);
|
||||
|
||||
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));
|
||||
@@ -589,8 +530,7 @@ class userModel
|
||||
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.
|
||||
// Prefer admin-configured otpauth template if present
|
||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||
$globalOtpauthUrl = "";
|
||||
if (file_exists($adminConfigFile)) {
|
||||
@@ -598,7 +538,7 @@ class userModel
|
||||
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($decryptedContent !== false) {
|
||||
$config = json_decode($decryptedContent, true);
|
||||
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) {
|
||||
if (!empty($config['globalOtpauthUrl'])) {
|
||||
$globalOtpauthUrl = $config['globalOtpauthUrl'];
|
||||
}
|
||||
}
|
||||
@@ -606,14 +546,17 @@ class userModel
|
||||
|
||||
if (!empty($globalOtpauthUrl)) {
|
||||
$label = "FileRise:" . $username;
|
||||
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
||||
$otpauthUrl = str_replace(
|
||||
["{label}", "{secret}"],
|
||||
[urlencode($label), $totpSecret],
|
||||
$globalOtpauthUrl
|
||||
);
|
||||
} else {
|
||||
$label = urlencode("FileRise:" . $username);
|
||||
$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)
|
||||
@@ -626,10 +569,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Get decrypted TOTP secret.
|
||||
*/
|
||||
public static function getTOTPSecret($username)
|
||||
{
|
||||
@@ -638,10 +578,9 @@ class userModel
|
||||
if (!file_exists($usersFile)) {
|
||||
return null;
|
||||
}
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$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);
|
||||
}
|
||||
@@ -650,10 +589,7 @@ class userModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a user's role from users.txt.
|
||||
*
|
||||
* @param string $username
|
||||
* @return string|null
|
||||
* Get role ('1' admin, '0' user) or null.
|
||||
*/
|
||||
public static function getUserRole($username)
|
||||
{
|
||||
@@ -670,27 +606,30 @@ class userModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user’s info (admin flag, TOTP status, profile picture).
|
||||
*/
|
||||
public static function getUser(string $username): array
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (! file_exists($usersFile)) {
|
||||
if (!file_exists($usersFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
// split *all* the fields
|
||||
$parts = explode(':', $line);
|
||||
|
||||
if ($parts[0] !== $username) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// determine admin & totp
|
||||
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
|
||||
$totpEnabled = !empty($parts[3]);
|
||||
// profile_picture is the 5th field if present
|
||||
$pic = isset($parts[4]) ? $parts[4] : '';
|
||||
|
||||
|
||||
// Normalize to a leading slash (UI expects /uploads/…)
|
||||
if ($pic !== '' && $pic[0] !== '/') {
|
||||
$pic = '/' . $pic;
|
||||
}
|
||||
|
||||
return [
|
||||
'username' => $parts[0],
|
||||
'isAdmin' => $isAdmin,
|
||||
@@ -698,49 +637,44 @@ class userModel
|
||||
'profile_picture' => $pic,
|
||||
];
|
||||
}
|
||||
|
||||
return []; // user not found
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistently set the profile picture URL for a given user,
|
||||
* storing it in the 5th field so we leave the 4th (TOTP secret) untouched.
|
||||
* Persist profile picture URL as 5th field (keeps TOTP secret intact).
|
||||
*
|
||||
* users.txt format:
|
||||
* username:hash:isAdmin:totp_secret:profile_picture
|
||||
*
|
||||
* @param string $username
|
||||
* @param string $url The public URL (e.g. "/uploads/profile_pics/…")
|
||||
* @return array ['success'=>true] or ['success'=>false,'error'=>'…']
|
||||
* users.txt: username:hash:isAdmin:totp_secret:profile_picture
|
||||
*/
|
||||
public static function setProfilePicture(string $username, string $url): array
|
||||
{
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
if (! file_exists($usersFile)) {
|
||||
if (!file_exists($usersFile)) {
|
||||
return ['success' => false, 'error' => 'Users file not found'];
|
||||
}
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES);
|
||||
// Ensure leading slash (consistent with controller response)
|
||||
$url = '/' . ltrim($url, '/');
|
||||
|
||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$out = [];
|
||||
$found = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') { $out[] = $line; continue; }
|
||||
$parts = explode(':', $line);
|
||||
if ($parts[0] === $username) {
|
||||
$found = true;
|
||||
// Ensure we have at least 5 fields
|
||||
while (count($parts) < 5) {
|
||||
$parts[] = '';
|
||||
}
|
||||
// Write profile_picture into the 5th field (index 4)
|
||||
$parts[4] = ltrim($url, '/'); // or $url if leading slash is desired
|
||||
// Re-assemble (this preserves parts[3] completely)
|
||||
$parts[4] = $url;
|
||||
$line = implode(':', $parts);
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
if (!$found) {
|
||||
return ['success' => false, 'error' => 'User not found'];
|
||||
}
|
||||
|
||||
@@ -751,4 +685,4 @@ class userModel
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user