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

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

View File

@@ -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 FileModels 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;
}
}
}