$err]; list($destDir, $err) = self::resolveFolderPath($destinationFolder, true); if ($err) return ["error" => $err]; $sourceDir .= DIRECTORY_SEPARATOR; $destDir .= DIRECTORY_SEPARATOR; // Metadata paths $srcMetaFile = self::getMetadataFilePath($sourceFolder); $destMetaFile = self::getMetadataFilePath($destinationFolder); $srcMetadata = file_exists($srcMetaFile) ? (json_decode(file_get_contents($srcMetaFile), true) ?: []) : []; $destMetadata = file_exists($destMetaFile) ? (json_decode(file_get_contents($destMetaFile), true) ?: []) : []; $safeFileNamePattern = REGEX_FILE_NAME; $actor = $_SESSION['username'] ?? 'Unknown'; $now = date(DATE_TIME_FORMAT); foreach ($files as $fileName) { $originalName = basename(trim($fileName)); $basename = $originalName; if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } $srcPath = $sourceDir . $originalName; $destPath = $destDir . $basename; clearstatcache(); if (!file_exists($srcPath)) { $errors[] = "$originalName does not exist in source."; continue; } // Avoid overwrite: pick unique name if (file_exists($destPath)) { $basename = self::getUniqueFileName($destDir, $basename); $destPath = $destDir . $basename; } if (!copy($srcPath, $destPath)) { $errors[] = "Failed to copy $basename."; continue; } // Carry over non-ownership fields (e.g., tags), but stamp new ownership/timestamps $tags = []; if (isset($srcMetadata[$originalName]['tags']) && is_array($srcMetadata[$originalName]['tags'])) { $tags = $srcMetadata[$originalName]['tags']; } $destMetadata[$basename] = [ 'uploaded' => $now, 'modified' => $now, 'uploader' => $actor, 'tags' => $tags ]; } if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update destination metadata."; } return empty($errors) ? ["success" => "Files copied successfully"] : ["error" => implode("; ", $errors)]; } /** * Generates the metadata file path for a given folder. * * @param string $folder * @return string */ private static function getMetadataFilePath($folder) { if (strtolower($folder) === 'root' || trim($folder) === '') { return META_DIR . "root_metadata.json"; } return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; } /** * Generates a unique file name if a file with the same name exists in the destination directory. * * @param string $destDir * @param string $fileName * @return string */ private static function getUniqueFileName($destDir, $fileName) { $fullPath = $destDir . $fileName; clearstatcache(true, $fullPath); if (!file_exists($fullPath)) { return $fileName; } $basename = pathinfo($fileName, PATHINFO_FILENAME); $extension = pathinfo($fileName, PATHINFO_EXTENSION); $counter = 1; do { $newName = $basename . " (" . $counter . ")" . ($extension ? "." . $extension : ""); $newFullPath = $destDir . $newName; clearstatcache(true, $newFullPath); $counter++; } while (file_exists($destDir . $newName)); return $newName; } /** * Deletes (i.e. moves to Trash) the specified files from a given folder * and updates metadata accordingly. * * @param string $folder The folder (or "root") from which files are deleted. * @param array $files The array of file names to delete. * @return array An associative array with a "success" or "error" message. */ public static function deleteFiles($folder, $files) { $errors = []; list($uploadDir, $err) = self::resolveFolderPath($folder, false); if ($err) return ["error" => $err]; $uploadDir .= DIRECTORY_SEPARATOR; // Setup the Trash folder and metadata. $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!file_exists($trashDir)) { mkdir($trashDir, 0755, true); } $trashMetadataFile = $trashDir . "trash.json"; $trashData = file_exists($trashMetadataFile) ? json_decode(file_get_contents($trashMetadataFile), true) : []; if (!is_array($trashData)) { $trashData = []; } // Load folder metadata if available. $metadataFile = self::getMetadataFilePath($folder); $folderMetadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; if (!is_array($folderMetadata)) { $folderMetadata = []; } $movedFiles = []; $safeFileNamePattern = REGEX_FILE_NAME; foreach ($files as $fileName) { $basename = basename(trim($fileName)); // Validate the file name. if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } $filePath = $uploadDir . $basename; // Check if file exists. if (file_exists($filePath)) { // Unique trash name (timestamp + random) $trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4)); if (rename($filePath, $trashDir . $trashFileName)) { $movedFiles[] = $basename; // Record trash metadata for possible restoration. $trashData[] = [ 'type' => 'file', 'originalFolder' => $uploadDir, 'originalName' => $basename, 'trashName' => $trashFileName, 'trashedAt' => time(), 'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown", 'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown", 'deletedBy' => $_SESSION['username'] ?? "Unknown" ]; } else { $errors[] = "Failed to move $basename to Trash."; continue; } } else { // If file does not exist, consider it already removed. $movedFiles[] = $basename; } } // Save updated trash metadata. file_put_contents($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT), LOCK_EX); // Remove deleted file entries from folder metadata. if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); if (is_array($metadata)) { foreach ($movedFiles as $delFile) { if (isset($metadata[$delFile])) { unset($metadata[$delFile]); } } file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); } } if (empty($errors)) { return ["success" => "Files moved to Trash: " . implode(", ", $movedFiles)]; } else { return ["error" => implode("; ", $errors) . ". Files moved to Trash: " . implode(", ", $movedFiles)]; } } /** * Moves files from a source folder to a destination folder and updates metadata. * * @param string $sourceFolder The source folder (e.g., "root" or a subfolder). * @param string $destinationFolder The destination folder. * @param array $files An array of file names to move. * @return array An associative array with either a "success" key or an "error" key. */ public static function moveFiles($sourceFolder, $destinationFolder, $files) { $errors = []; list($sourceDir, $err) = self::resolveFolderPath($sourceFolder, false); if ($err) return ["error" => $err]; list($destDir, $err) = self::resolveFolderPath($destinationFolder, true); if ($err) return ["error" => $err]; $sourceDir .= DIRECTORY_SEPARATOR; $destDir .= DIRECTORY_SEPARATOR; // Get metadata file paths. $srcMetaFile = self::getMetadataFilePath($sourceFolder); $destMetaFile = self::getMetadataFilePath($destinationFolder); $srcMetadata = file_exists($srcMetaFile) ? json_decode(file_get_contents($srcMetaFile), true) : []; $destMetadata = file_exists($destMetaFile) ? json_decode(file_get_contents($destMetaFile), true) : []; if (!is_array($srcMetadata)) { $srcMetadata = []; } if (!is_array($destMetadata)) { $destMetadata = []; } $movedFiles = []; $safeFileNamePattern = REGEX_FILE_NAME; foreach ($files as $fileName) { // Save the original file name for metadata lookup. $originalName = basename(trim($fileName)); $basename = $originalName; // Validate the file name. if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has invalid characters."; continue; } $srcPath = $sourceDir . $originalName; $destPath = $destDir . $basename; clearstatcache(); if (!file_exists($srcPath)) { $errors[] = "$originalName does not exist in source."; continue; } // If a file with the same name exists in destination, generate a unique name. if (file_exists($destPath)) { $uniqueName = self::getUniqueFileName($destDir, $basename); $basename = $uniqueName; $destPath = $destDir . $uniqueName; } if (!rename($srcPath, $destPath)) { $errors[] = "Failed to move $basename."; continue; } $movedFiles[] = $originalName; // Update destination metadata: if metadata for the original file exists in source, move it under the new name. if (isset($srcMetadata[$originalName])) { $destMetadata[$basename] = $srcMetadata[$originalName]; unset($srcMetadata[$originalName]); } } // Write back updated metadata. if (file_put_contents($srcMetaFile, json_encode($srcMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update source metadata."; } if (file_put_contents($destMetaFile, json_encode($destMetadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update destination metadata."; } if (empty($errors)) { return ["success" => "Files moved successfully"]; } else { return ["error" => implode("; ", $errors)]; } } /** * Renames a file within a given folder and updates folder metadata. * * @param string $folder The folder where the file is located (or "root" for the base directory). * @param string $oldName The current name of the file. * @param string $newName The new name for the file. * @return array An associative array with either "success" (and newName) or "error" message. */ public static function renameFile($folder, $oldName, $newName) { list($directory, $err) = self::resolveFolderPath($folder, false); if ($err) return ["error" => $err]; $directory .= DIRECTORY_SEPARATOR; // Sanitize file names. $oldName = basename(trim($oldName)); $newName = basename(trim($newName)); // Validate file names using REGEX_FILE_NAME. if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) { return ["error" => "Invalid file name."]; } $oldPath = $directory . $oldName; $newPath = $directory . $newName; // Helper: Generate a unique file name if the new name already exists. if (file_exists($newPath)) { $newName = self::getUniqueFileName($directory, $newName); $newPath = $directory . $newName; } // Check that the old file exists. if (!file_exists($oldPath)) { return ["error" => "File does not exist"]; } // Perform the rename. if (rename($oldPath, $newPath)) { // Update the metadata file. $metadataKey = ($folder === 'root') ? "root" : $folder; $metadataFile = META_DIR . str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); if (isset($metadata[$oldName])) { $metadata[$newName] = $metadata[$oldName]; unset($metadata[$oldName]); file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); } } return ["success" => "File renamed successfully", "newName" => $newName]; } else { return ["error" => "Error renaming file"]; } } /* * Save a file’s contents *and* record its metadata, including who uploaded it. * * @param string $folder Folder key (e.g. "root" or "invoices/2025") * @param string $fileName Basename of the file * @param resource|string $content File contents (stream or string) * @param string|null $uploader Username of uploader (if null, falls back to session) * @return array ["success"=>"…"] or ["error"=>"…"] */ public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array { $folder = trim($folder) ?: 'root'; $fileName = basename(trim($fileName)); if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name"]; } if (!preg_match(REGEX_FILE_NAME, $fileName)) { return ["error" => "Invalid file name"]; } $baseDirReal = realpath(UPLOAD_DIR); if ($baseDirReal === false) { return ["error" => "Server misconfiguration"]; } $targetDir = (strtolower($folder) === 'root') ? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR : rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR; // Ensure directory exists *before* realpath + containment check if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { return ["error" => "Failed to create destination folder"]; } $targetDirReal = realpath($targetDir); if ($targetDirReal === false || strpos($targetDirReal, $baseDirReal) !== 0) { return ["error" => "Invalid folder path"]; } $filePath = $targetDirReal . DIRECTORY_SEPARATOR . $fileName; if (is_resource($content)) { $out = fopen($filePath, 'wb'); if ($out === false) return ["error" => "Unable to open file for writing"]; stream_copy_to_stream($content, $out); fclose($out); } else { if (file_put_contents($filePath, (string)$content, LOCK_EX) === false) { return ["error" => "Error saving file"]; } } // Metadata $metadataKey = strtolower($folder) === "root" ? "root" : $folder; $metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json'; $metadataFilePath = META_DIR . $metadataFileName; $metadata = file_exists($metadataFilePath) ? (json_decode(file_get_contents($metadataFilePath), true) ?: []) : []; $currentTime = date(DATE_TIME_FORMAT); $uploader = $uploader ?? ($_SESSION['username'] ?? "Unknown"); if (isset($metadata[$fileName])) { $metadata[$fileName]['modified'] = $currentTime; $metadata[$fileName]['uploader'] = $uploader; } else { $metadata[$fileName] = [ "uploaded" => $currentTime, "modified" => $currentTime, "uploader" => $uploader ]; } if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Failed to update metadata"]; } return ["success" => "File saved successfully"]; } /** * Validates and retrieves information needed to download a file. * * @param string $folder The folder from which to download (e.g., "root" or a subfolder). * @param string $file The file name. * @return array An associative array with "error" key on failure, * or "filePath" and "mimeType" keys on success. */ public static function getDownloadInfo($folder, $file) { // Validate file name using REGEX_FILE_NAME. $file = basename(trim($file)); if (!preg_match(REGEX_FILE_NAME, $file)) { return ["error" => "Invalid file name."]; } // Determine the real upload directory. $uploadDirReal = realpath(UPLOAD_DIR); if ($uploadDirReal === false) { return ["error" => "Server misconfiguration."]; } // Determine directory based on folder. if (strtolower($folder) === 'root' || trim($folder) === '') { $directory = $uploadDirReal; } else { // Prevent path traversal. if (strpos($folder, '..') !== false) { return ["error" => "Invalid folder name."]; } $directoryPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . trim($folder, "/\\ "); $directory = realpath($directoryPath); if ($directory === false || strpos($directory, $uploadDirReal) !== 0) { return ["error" => "Invalid folder path."]; } } // Build the file path. $filePath = $directory . DIRECTORY_SEPARATOR . $file; $realFilePath = realpath($filePath); // Ensure the file exists and is within the allowed directory. if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) { return ["error" => "Access forbidden."]; } if (!file_exists($realFilePath)) { return ["error" => "File not found."]; } // Get the MIME type with safe fallback. $mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null; if (!$mimeType) { $mimeType = 'application/octet-stream'; } // OPTIONAL: normalize SVG MIME $ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION)); if ($ext === 'svg') { $mimeType = 'image/svg+xml'; } return [ "filePath" => $realFilePath, "mimeType" => $mimeType ]; } public static function deleteFilesPermanent(string $folder, array $files): array { $errors = []; $deleted = []; list($uploadDir, $err) = self::resolveFolderPath($folder, false); if ($err) return ['error' => $err]; $uploadDir = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR; $safeFileNamePattern = REGEX_FILE_NAME; foreach ($files as $fileName) { $originalName = basename(trim((string)$fileName)); $basename = $originalName; if ($basename === '') { $errors[] = 'Empty file name.'; continue; } if (!preg_match($safeFileNamePattern, $basename)) { $errors[] = "$basename has an invalid name."; continue; } $filePath = $uploadDir . $basename; if (file_exists($filePath)) { if (!@unlink($filePath)) { $errors[] = "Failed to delete {$basename}."; continue; } } $deleted[] = $basename; // Remove from folder metadata if present $metadataFile = self::getMetadataFilePath($folder); if (file_exists($metadataFile)) { $meta = json_decode(file_get_contents($metadataFile), true); if (is_array($meta) && isset($meta[$basename])) { unset($meta[$basename]); @file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX); } } } if ($errors && !$deleted) { return ['error' => implode('; ', $errors)]; } if ($errors) { return [ 'error' => implode('; ', $errors), 'success' => 'Deleted: ' . implode(', ', $deleted), ]; } return ['success' => 'Deleted: ' . implode(', ', $deleted)]; } /** * Creates a ZIP archive of the specified files from a given folder. * * @param string $folder The folder from which to zip the files (e.g., "root" or a subfolder). * @param array $files An array of file names to include in the ZIP. * @return array An associative array with either an "error" key or a "zipPath" key. */ public static function createZipArchive($folder, $files) { // Purge old temp zips > 6h (best-effort) $zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp'; $now = time(); foreach ((glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) { if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) { @unlink($zp); } } // Normalize and validate target folder $folder = trim((string)$folder) ?: 'root'; $baseDir = realpath(UPLOAD_DIR); if ($baseDir === false) { return ["error" => "Uploads directory not configured correctly."]; } if (strtolower($folder) === 'root' || $folder === "") { $folderPathReal = $baseDir; } else { if (strpos($folder, '..') !== false) { return ["error" => "Invalid folder name."]; } $parts = explode('/', trim($folder, "/\\ ")); foreach ($parts as $part) { if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) { return ["error" => "Invalid folder name."]; } } $folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts); $folderPathReal = realpath($folderPath); if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) { return ["error" => "Folder not found."]; } } // Collect files to zip (only regular files in the chosen folder) $filesToZip = []; foreach ($files as $fileName) { $fileName = basename(trim((string)$fileName)); if (!preg_match(REGEX_FILE_NAME, $fileName)) { continue; } $fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName; // Skip symlinks (avoid archiving outside targets via links) if (is_link($fullPath)) { continue; } if (is_file($fullPath)) { $filesToZip[] = $fullPath; } } if (empty($filesToZip)) { return ["error" => "No valid files found to zip."]; } // Workspace on the big disk: META_DIR/ziptmp $work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp'; if (!is_dir($work)) { @mkdir($work, 0775, true); } if (!is_dir($work) || !is_writable($work)) { return ["error" => "ZIP temp dir not writable: " . $work]; } // Optional sanity: ensure there is roughly enough free space $totalSize = 0; foreach ($filesToZip as $fp) { $sz = @filesize($fp); if ($sz !== false) $totalSize += (int)$sz; } $free = @disk_free_space($work); // Add ~20MB overhead and a 5% cushion if ($free !== false && $totalSize > 0) { $needed = (int)ceil($totalSize * 1.05) + (20 * 1024 * 1024); if ($free < $needed) { return ["error" => "Insufficient free space in ZIP workspace."]; } } @set_time_limit(0); // Create the ZIP path inside META_DIR/ziptmp (libzip temp stays on same FS) $zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip'; $zipPath = $work . DIRECTORY_SEPARATOR . $zipName; $zip = new \ZipArchive(); if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { return ["error" => "Could not create zip archive."]; } foreach ($filesToZip as $filePath) { // Add using basename at the root of the zip (matches current behavior) $zip->addFile($filePath, basename($filePath)); } if (!$zip->close()) { // Commonly indicates disk full at finalize return ["error" => "Failed to finalize ZIP (disk full?)."]; } // Success: controller will readfile() and unlink() return ["zipPath" => $zipPath]; } /** * Extracts ZIP archives from the specified folder. * * @param string $folder The folder from which ZIP files will be extracted (e.g., "root" or a subfolder). * @param array $files An array of ZIP file names to extract. * @return array An associative array with keys "success" (boolean), and either "extractedFiles" (array) on success or "error" (string) on failure. */ public static function extractZipArchive($folder, $files) { $errors = []; $allSuccess = true; $extractedFiles = []; // Config toggles $SKIP_DOTFILES = defined('SKIP_DOTFILES_ON_EXTRACT') ? (bool)SKIP_DOTFILES_ON_EXTRACT : true; // Hard limits to mitigate zip-bombs (tweak via defines if you like) $MAX_UNZIP_BYTES = defined('MAX_UNZIP_BYTES') ? (int)MAX_UNZIP_BYTES : (200 * 1024 * 1024 * 1024); // 200 GiB $MAX_UNZIP_FILES = defined('MAX_UNZIP_FILES') ? (int)MAX_UNZIP_FILES : 20000; $baseDir = realpath(UPLOAD_DIR); if ($baseDir === false) { return ["error" => "Uploads directory not configured correctly."]; } // Build target dir if (strtolower(trim($folder) ?: '') === "root") { $relativePath = ""; $folderNorm = "root"; } else { $parts = explode('/', trim($folder, "/\\")); foreach ($parts as $part) { if ($part === '' || $part === '.' || $part === '..' || !preg_match(REGEX_FOLDER_NAME, $part)) { return ["error" => "Invalid folder name."]; } } $relativePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; $folderNorm = implode('/', $parts); // normalized with forward slashes for metadata helpers } $folderPath = $baseDir . DIRECTORY_SEPARATOR . $relativePath; if (!is_dir($folderPath) && !mkdir($folderPath, 0775, true)) { return ["error" => "Folder not found and cannot be created."]; } $folderPathReal = realpath($folderPath); if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) { return ["error" => "Folder not found."]; } // Metadata cache per folder to avoid many reads/writes $metaCache = []; $getMeta = function(string $folderStr) use (&$metaCache) { if (!isset($metaCache[$folderStr])) { $mf = self::getMetadataFilePath($folderStr); $metaCache[$folderStr] = file_exists($mf) ? (json_decode(file_get_contents($mf), true) ?: []) : []; } return $metaCache[$folderStr]; }; $putMeta = function(string $folderStr, array $meta) use (&$metaCache) { $metaCache[$folderStr] = $meta; }; $safeFileNamePattern = REGEX_FILE_NAME; $actor = $_SESSION['username'] ?? 'Unknown'; $now = date(DATE_TIME_FORMAT); // --- Helpers --- // Reject absolute paths, traversal, drive letters $isUnsafeEntryPath = function(string $entry) : bool { $e = str_replace('\\', '/', $entry); if ($e === '' || str_contains($e, "\0")) return true; if (str_starts_with($e, '/')) return true; // absolute nix path if (preg_match('/^[A-Za-z]:[\\/]/', $e)) return true; // Windows drive if (str_contains($e, '../') || str_contains($e, '..\\')) return true; return false; }; // Validate each subfolder name in the path using REGEX_FOLDER_NAME $validEntrySubdirs = function(string $entry) : bool { $e = trim(str_replace('\\', '/', $entry), '/'); if ($e === '') return true; $dirs = explode('/', $e); array_pop($dirs); // remove basename; we only validate directories here foreach ($dirs as $d) { if ($d === '' || !preg_match(REGEX_FOLDER_NAME, $d)) return false; } return true; }; // NEW: hidden path detector — true if ANY segment starts with '.' $isHiddenDotPath = function(string $entry) : bool { $e = trim(str_replace('\\', '/', $entry), '/'); if ($e === '') return false; foreach (explode('/', $e) as $seg) { if ($seg !== '' && $seg[0] === '.') return true; } return false; }; // Generalized metadata stamper: writes to the specified folder's metadata.json $stampMeta = function(string $folderStr, string $basename) use (&$getMeta, &$putMeta, $actor, $now) { $meta = $getMeta($folderStr); $meta[$basename] = [ 'uploaded' => $now, 'modified' => $now, 'uploader' => $actor, ]; $putMeta($folderStr, $meta); }; // No PHP execution time limit during heavy work @set_time_limit(0); foreach ($files as $zipFileName) { $zipBase = basename(trim((string)$zipFileName)); if (strtolower(substr($zipBase, -4)) !== '.zip') { continue; } if (!preg_match($safeFileNamePattern, $zipBase)) { $errors[] = "$zipBase has an invalid name."; $allSuccess = false; continue; } $zipFilePath = $folderPathReal . DIRECTORY_SEPARATOR . $zipBase; if (!file_exists($zipFilePath)) { $errors[] = "$zipBase does not exist in folder."; $allSuccess = false; continue; } $zip = new \ZipArchive(); if ($zip->open($zipFilePath) !== true) { $errors[] = "Could not open $zipBase as a zip file."; $allSuccess = false; continue; } // ---- Pre-scan: safety and size limits + build allow-list (skip dotfiles) ---- $unsafe = false; $totalUncompressed = 0; $fileCount = 0; $allowedEntries = []; // names to extract (files and/or directories) $allowedFiles = []; // only files (for metadata stamping) for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $name = $zip->getNameIndex($i); if ($name === false || !$stat) { $unsafe = true; break; } $isDir = str_ends_with($name, '/'); // Basic path checks if ($isUnsafeEntryPath($name) || !$validEntrySubdirs($name)) { $unsafe = true; break; } // Skip hidden entries (any segment starts with '.') if ($SKIP_DOTFILES && $isHiddenDotPath($name)) { continue; // just ignore; do not treat as unsafe } // Detect symlinks via external attributes (best-effort) $mode = (isset($stat['external_attributes']) ? (($stat['external_attributes'] >> 16) & 0xF000) : 0); if ($mode === 0120000) { // S_IFLNK $unsafe = true; break; } // Track limits only for files we're going to extract if (!$isDir) { $fileCount++; $sz = isset($stat['size']) ? (int)$stat['size'] : 0; $totalUncompressed += $sz; if ($fileCount > $MAX_UNZIP_FILES || $totalUncompressed > $MAX_UNZIP_BYTES) { $unsafe = true; break; } $allowedFiles[] = $name; } $allowedEntries[] = $name; } if ($unsafe) { $zip->close(); $errors[] = "$zipBase contains unsafe or oversized contents; extraction aborted."; $allSuccess = false; continue; } // Nothing to extract after filtering? if (empty($allowedEntries)) { $zip->close(); // Treat as success (nothing visible to extract), but informatively note it $errors[] = "$zipBase contained only hidden or unsupported entries."; $allSuccess = false; // or keep true if you'd rather not mark as failure continue; } // ---- Extract ONLY the allowed entries ---- if (!$zip->extractTo($folderPathReal, $allowedEntries)) { $errors[] = "Failed to extract $zipBase."; $allSuccess = false; $zip->close(); continue; } // ---- Stamp metadata for files in the target folder AND nested subfolders (allowed files only) ---- foreach ($allowedFiles as $entryName) { // Normalize entry path for filesystem checks $entryFsRel = str_replace(['\\'], '/', $entryName); $entryFsRel = ltrim($entryFsRel, '/'); // ensure relative // Skip any directories (shouldn't be listed here, but defend anyway) if ($entryFsRel === '' || str_ends_with($entryFsRel, '/')) continue; $basename = basename($entryFsRel); if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) continue; // Decide which folder's metadata to update: // - top-level files -> $folderNorm // - nested files -> corresponding "/" (or "sub/dir" if folderNorm is 'root') $relDir = str_replace('\\', '/', trim(dirname($entryFsRel), '.')); $relDir = ($relDir === '.' ? '' : trim($relDir, '/')); $targetFolderNorm = ($relDir === '' || $relDir === '.') ? $folderNorm : (($folderNorm === 'root') ? $relDir : ($folderNorm . '/' . $relDir)); // Only stamp if the file actually exists on disk after extraction $targetAbs = $folderPathReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entryFsRel); if (is_file($targetAbs)) { // Preserve list behavior: only include top-level extracted names if ($relDir === '' || $relDir === '.') { $extractedFiles[] = $basename; } $stampMeta($targetFolderNorm, $basename); } } $zip->close(); } // Persist metadata for any touched folder(s) foreach ($metaCache as $folderStr => $meta) { $metadataFile = self::getMetadataFilePath($folderStr); if (!is_dir(dirname($metadataFile))) { @mkdir(dirname($metadataFile), 0775, true); } if (file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX) === false) { $errors[] = "Failed to update metadata for {$folderStr}."; $allSuccess = false; } } return $allSuccess ? ["success" => true, "extractedFiles" => $extractedFiles] : ["success" => false, "error" => implode(" ", $errors)]; } /** * Retrieves the share record for a given token. * * @param string $token The share token. * @return array|null Returns the share record as an associative array, or null if not found. */ public static function getShareRecord($token) { $shareFile = META_DIR . "share_links.json"; if (!file_exists($shareFile)) { return null; } $shareLinks = json_decode(file_get_contents($shareFile), true); if (!is_array($shareLinks) || !isset($shareLinks[$token])) { return null; } return $shareLinks[$token]; } /** * Creates a share link for a file. * * @param string $folder The folder containing the shared file (or "root"). * @param string $file The name of the file being shared. * @param int $expirationSeconds The number of seconds until expiration. * @param string $password Optional password protecting the share. * @return array Returns an associative array with keys "token" and "expires" on success, * or "error" on failure. */ public static function createShareLink($folder, $file, $expirationSeconds = 3600, $password = "") { // Validate folder if necessary (this can also be done in the controller). if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name."]; } // Validate file name. $file = basename(trim($file)); if (!preg_match(REGEX_FILE_NAME, $file)) { return ["error" => "Invalid file name."]; } // Generate a secure token (32 hex characters). $token = bin2hex(random_bytes(16)); // Calculate expiration (Unix timestamp). $expires = time() + $expirationSeconds; // Hash the password if provided. $hashedPassword = !empty($password) ? password_hash($password, PASSWORD_DEFAULT) : ""; // File to store share links. $shareFile = META_DIR . "share_links.json"; $shareLinks = []; if (file_exists($shareFile)) { $data = file_get_contents($shareFile); $shareLinks = json_decode($data, true); if (!is_array($shareLinks)) { $shareLinks = []; } } // Clean up expired share links. $currentTime = time(); foreach ($shareLinks as $key => $link) { if ($link["expires"] < $currentTime) { unset($shareLinks[$key]); } } // Add new share record. $shareLinks[$token] = [ "folder" => $folder, "file" => $file, "expires" => $expires, "password" => $hashedPassword ]; // Save the updated share links. if (file_put_contents($shareFile, json_encode($shareLinks, JSON_PRETTY_PRINT), LOCK_EX)) { return ["token" => $token, "expires" => $expires]; } else { return ["error" => "Could not save share link."]; } } /** * Retrieves and enriches trash records from the trash metadata file. * * @return array An array of trash items. */ public static function getTrashItems() { $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; $trashMetadataFile = $trashDir . "trash.json"; $trashItems = []; if (file_exists($trashMetadataFile)) { $json = file_get_contents($trashMetadataFile); $trashItems = json_decode($json, true); if (!is_array($trashItems)) { $trashItems = []; } } // Enrich each trash record. foreach ($trashItems as &$item) { if (empty($item['deletedBy'])) { $item['deletedBy'] = "Unknown"; } if (empty($item['uploaded']) || empty($item['uploader'])) { if (isset($item['originalFolder']) && isset($item['originalName'])) { $metadataFile = self::getMetadataFilePath($item['originalFolder']); if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); if (is_array($metadata) && isset($metadata[$item['originalName']])) { $item['uploaded'] = !empty($metadata[$item['originalName']]['uploaded']) ? $metadata[$item['originalName']]['uploaded'] : "Unknown"; $item['uploader'] = !empty($metadata[$item['originalName']]['uploader']) ? $metadata[$item['originalName']]['uploader'] : "Unknown"; } else { $item['uploaded'] = "Unknown"; $item['uploader'] = "Unknown"; } } else { $item['uploaded'] = "Unknown"; $item['uploader'] = "Unknown"; } } else { $item['uploaded'] = "Unknown"; $item['uploader'] = "Unknown"; } } } unset($item); return $trashItems; } /** * Restores files from Trash based on an array of trash file identifiers. * * @param array $trashFiles An array of trash file names (i.e. the 'trashName' fields). * @return array An associative array with keys "restored" (an array of successfully restored items) * and optionally an "error" message if any issues occurred. */ public static function restoreFiles(array $trashFiles) { $errors = []; $restoredItems = []; // Setup Trash directory and trash metadata file. $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!file_exists($trashDir)) { mkdir($trashDir, 0755, true); } $trashMetadataFile = $trashDir . "trash.json"; $trashData = []; if (file_exists($trashMetadataFile)) { $json = file_get_contents($trashMetadataFile); $trashData = json_decode($json, true); if (!is_array($trashData)) { $trashData = []; } } // Helper to get metadata file path for a folder. $getMetadataFilePath = function($folder) { if (strtolower($folder) === 'root' || trim($folder) === '') { return META_DIR . "root_metadata.json"; } return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; }; // Process each provided trash file name. foreach ($trashFiles as $trashFileName) { $trashFileName = trim($trashFileName); // Validate file name with REGEX_FILE_NAME. if (!preg_match(REGEX_FILE_NAME, $trashFileName)) { $errors[] = "$trashFileName has an invalid format."; continue; } // Locate the matching trash record. $recordKey = null; foreach ($trashData as $key => $record) { if (isset($record['trashName']) && $record['trashName'] === $trashFileName) { $recordKey = $key; break; } } if ($recordKey === null) { $errors[] = "No trash record found for $trashFileName."; continue; } $record = $trashData[$recordKey]; if (!isset($record['originalFolder']) || !isset($record['originalName'])) { $errors[] = "Incomplete trash record for $trashFileName."; continue; } $originalFolder = $record['originalFolder']; $originalName = $record['originalName']; // Convert absolute original folder to relative folder. $relativeFolder = 'root'; if (strpos($originalFolder, UPLOAD_DIR) === 0) { $relativeFolder = trim(substr($originalFolder, strlen(UPLOAD_DIR)), '/\\'); if ($relativeFolder === '') { $relativeFolder = 'root'; } } // Build destination path. $destinationPath = (strtolower($relativeFolder) !== 'root') ? rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $relativeFolder . DIRECTORY_SEPARATOR . $originalName : rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $originalName; // Handle folder-type records if necessary. if (isset($record['type']) && $record['type'] === 'folder') { if (!file_exists($destinationPath)) { if (mkdir($destinationPath, 0755, true)) { $restoredItems[] = $originalName . " (folder restored)"; } else { $errors[] = "Failed to restore folder $originalName."; continue; } } else { $errors[] = "Folder already exists at destination: $originalName."; continue; } unset($trashData[$recordKey]); continue; } // For files: Ensure destination directory exists. $destinationDir = dirname($destinationPath); if (!file_exists($destinationDir)) { if (!mkdir($destinationDir, 0755, true)) { $errors[] = "Failed to create destination folder for $originalName."; continue; } } if (file_exists($destinationPath)) { $errors[] = "File already exists at destination: $originalName."; continue; } // Move the file from trash to its original location. $sourcePath = $trashDir . $trashFileName; if (file_exists($sourcePath)) { if (rename($sourcePath, $destinationPath)) { $restoredItems[] = $originalName; // Update metadata: Restore metadata for this file. $metadataFile = $getMetadataFilePath($relativeFolder); $metadata = []; if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true); if (!is_array($metadata)) { $metadata = []; } } $restoredMeta = [ "uploaded" => isset($record['uploaded']) ? $record['uploaded'] : date(DATE_TIME_FORMAT), "uploader" => isset($record['uploader']) ? $record['uploader'] : "Unknown" ]; $metadata[$originalName] = $restoredMeta; file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX); unset($trashData[$recordKey]); } else { $errors[] = "Failed to restore $originalName."; } } else { $errors[] = "Trash file not found: $trashFileName."; } } // Write back updated trash metadata. file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX); if (empty($errors)) { return ["success" => "Items restored: " . implode(", ", $restoredItems), "restored" => $restoredItems]; } else { return ["success" => false, "error" => implode("; ", $errors), "restored" => $restoredItems]; } } /** * Deletes trash items based on an array of trash file identifiers. * * @param array $filesToDelete An array of trash file names (identifiers). * @return array An associative array containing "deleted" (array of deleted items) and optionally "error" (error message). */ public static function deleteTrashFiles(array $filesToDelete) { // Setup trash directory and metadata file. $trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR; if (!file_exists($trashDir)) { mkdir($trashDir, 0755, true); } $trashMetadataFile = $trashDir . "trash.json"; // Load trash metadata into an associative array keyed by trashName. $trashData = []; if (file_exists($trashMetadataFile)) { $json = file_get_contents($trashMetadataFile); $tempData = json_decode($json, true); if (is_array($tempData)) { foreach ($tempData as $item) { if (isset($item['trashName'])) { $trashData[$item['trashName']] = $item; } } } } $deletedFiles = []; $errors = []; // Define a safe file name pattern. $safeFileNamePattern = REGEX_FILE_NAME; // Process each file identifier in the $filesToDelete array. foreach ($filesToDelete as $trashName) { $trashName = trim($trashName); if (!preg_match($safeFileNamePattern, $trashName)) { $errors[] = "$trashName has an invalid format."; continue; } if (!isset($trashData[$trashName])) { $errors[] = "Trash item $trashName not found."; continue; } // Build the full path to the trash file. $filePath = $trashDir . $trashName; if (file_exists($filePath)) { if (unlink($filePath)) { $deletedFiles[] = $trashName; unset($trashData[$trashName]); } else { $errors[] = "Failed to delete $trashName."; } } else { // If the file doesn't exist, remove its metadata. unset($trashData[$trashName]); $deletedFiles[] = $trashName; } } // Save the updated trash metadata back as an indexed array. file_put_contents($trashMetadataFile, json_encode(array_values($trashData), JSON_PRETTY_PRINT), LOCK_EX); if (empty($errors)) { return ["deleted" => $deletedFiles]; } else { return ["deleted" => $deletedFiles, "error" => implode("; ", $errors)]; } } /** * Retrieves file tags from the createdTags.json metadata file. * * @return array An array of tags. Returns an empty array if the file doesn't exist or is not readable. */ public static function getFileTags(): array { $metadataPath = META_DIR . 'createdTags.json'; // Check if the metadata file exists and is readable. if (!file_exists($metadataPath) || !is_readable($metadataPath)) { error_log('Metadata file does not exist or is not readable: ' . $metadataPath); return []; } $data = file_get_contents($metadataPath); if ($data === false) { error_log('Failed to read metadata file: ' . $metadataPath); // Return an empty array for a graceful fallback. return []; } $jsonData = json_decode($data, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log('Invalid JSON in metadata file: ' . $metadataPath . ' Error: ' . json_last_error_msg()); return []; } return $jsonData; } /** * Saves tag data for a specified file and updates the global tags. * * @param string $folder The folder where the file is located (e.g., "root" or a subfolder). * @param string $file The name of the file for which tags are being saved. * @param array $tags An array of tag definitions, each being an associative array (e.g. ['name' => 'Tag1', 'color' => '#FF0000']). * @param bool $deleteGlobal Optional flag; if true and 'tagToDelete' is provided, remove that tag from the global tags. * @param string|null $tagToDelete Optional tag name to delete from global tags when $deleteGlobal is true. * @return array Returns an associative array with a "success" key and updated "globalTags", or an "error" key on failure. */ public static function saveFileTag(string $folder, string $file, array $tags, bool $deleteGlobal = false, ?string $tagToDelete = null): array { // Validate the file name and folder $folder = trim($folder) ?: 'root'; $file = basename(trim($file)); if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name."]; } if (!preg_match(REGEX_FILE_NAME, $file)) { return ["error" => "Invalid file name."]; } // Determine the folder metadata file. $metadataFile = (strtolower($folder) === "root") ? META_DIR . "root_metadata.json" : META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; // Load existing metadata for this folder. $metadata = []; if (file_exists($metadataFile)) { $metadata = json_decode(file_get_contents($metadataFile), true) ?? []; } // Update the metadata for the specified file. if (!isset($metadata[$file])) { $metadata[$file] = []; } $metadata[$file]['tags'] = $tags; if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Failed to save tag data for file metadata."]; } // Now update the global tags file. $globalTagsFile = META_DIR . "createdTags.json"; $globalTags = []; if (file_exists($globalTagsFile)) { $globalTags = json_decode(file_get_contents($globalTagsFile), true) ?? []; if (!is_array($globalTags)) { $globalTags = []; } } // If deleteGlobal is true and tagToDelete is provided, remove that tag. if ($deleteGlobal && !empty($tagToDelete)) { $tagToDeleteLower = strtolower($tagToDelete); $globalTags = array_values(array_filter($globalTags, function($globalTag) use ($tagToDeleteLower) { return strtolower($globalTag['name']) !== $tagToDeleteLower; })); } else { // Otherwise, merge (update or add) new tags into the global tags. foreach ($tags as $tag) { $found = false; foreach ($globalTags as &$globalTag) { if (strtolower($globalTag['name']) === strtolower($tag['name'])) { $globalTag['color'] = $tag['color']; $found = true; break; } } if (!$found) { $globalTags[] = $tag; } } unset($globalTag); } if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT), LOCK_EX) === false) { return ["error" => "Failed to save global tags."]; } return ["success" => "Tag data saved successfully.", "globalTags" => $globalTags]; } /** * Retrieves the list of files in a given folder, enriched with metadata, along with global tags. * * @param string $folder The folder name (e.g., "root" or a subfolder). * @return array Returns an associative array with keys "files" and "globalTags". */ public static function getFileList(string $folder): array { // --- caps for safe inlining --- if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB $folder = trim($folder) ?: 'root'; // Determine the target directory. if (strtolower($folder) !== 'root') { $directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder; } else { $directory = UPLOAD_DIR; } // Validate folder. if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) { return ["error" => "Invalid folder name."]; } // Helper: Build the metadata file path. $getMetadataFilePath = function(string $folder): string { if (strtolower($folder) === 'root' || trim($folder) === '') { return META_DIR . "root_metadata.json"; } return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json'; }; $metadataFile = $getMetadataFilePath($folder); $metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : []; if (!is_dir($directory)) { return ["error" => "Directory not found."]; } $allFiles = array_values(array_diff(scandir($directory), array('.', '..'))); $fileList = []; // Define a safe file name pattern. $safeFileNamePattern = REGEX_FILE_NAME; // Prepare finfo (if available) for MIME sniffing. $finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false; foreach ($allFiles as $file) { if ($file === '' || $file[0] === '.') { continue; // Skip hidden/invalid entries. } $filePath = $directory . DIRECTORY_SEPARATOR . $file; if (!is_file($filePath)) { continue; // Only process files. } if (!preg_match($safeFileNamePattern, $file)) { continue; } // Meta $mtime = @filemtime($filePath); $fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown"; $metaKey = $file; $fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown"; $fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown"; // Size $fileSizeBytes = @filesize($filePath); if (!is_int($fileSizeBytes)) $fileSizeBytes = 0; if ($fileSizeBytes >= 1073741824) { $fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824); } elseif ($fileSizeBytes >= 1048576) { $fileSizeFormatted = sprintf("%.1f MB", $fileSizeBytes / 1048576); } elseif ($fileSizeBytes >= 1024) { $fileSizeFormatted = sprintf("%.1f KB", $fileSizeBytes / 1024); } else { $fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes)); } // MIME + text detection (fallback to extension) $mime = 'application/octet-stream'; if ($finfo) { $det = @finfo_file($finfo, $filePath); if (is_string($det) && $det !== '') $mime = $det; } $isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml'; $isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file); $isText = $isTextByMime || $isTextByExt; // Build entry $fileEntry = [ 'name' => $file, 'modified' => $fileDateModified, 'uploaded' => $fileUploadedDate, 'size' => $fileSizeFormatted, 'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic 'uploader' => $fileUploader, 'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [], 'mime' => $mime, ]; // Small, safe snippet for text files only (never full content) $fileEntry['content'] = ''; $fileEntry['contentTruncated'] = false; if ($isText && $fileSizeBytes > 0) { if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) { $fh = @fopen($filePath, 'rb'); if ($fh) { $snippet = @fread($fh, LISTING_CONTENT_BYTES_MAX); @fclose($fh); if ($snippet !== false) { // ensure UTF-8 for JSON if (function_exists('mb_check_encoding') && !mb_check_encoding($snippet, 'UTF-8')) { if (function_exists('mb_convert_encoding')) { $snippet = @mb_convert_encoding($snippet, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252'); } } $fileEntry['content'] = $snippet; $fileEntry['contentTruncated'] = ($fileSizeBytes > LISTING_CONTENT_BYTES_MAX); } } } else { // too large to sample: mark truncated so UI/search knows $fileEntry['contentTruncated'] = true; } } $fileList[] = $fileEntry; } if ($finfo) { @finfo_close($finfo); } // Load global tags. $globalTagsFile = META_DIR . "createdTags.json"; $globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : []; return ["files" => $fileList, "globalTags" => $globalTags]; } public static function getAllShareLinks(): array { $shareFile = META_DIR . "share_links.json"; if (!file_exists($shareFile)) { return []; } $links = json_decode(file_get_contents($shareFile), true); return is_array($links) ? $links : []; } public static function deleteShareLink(string $token): bool { $shareFile = META_DIR . "share_links.json"; if (!file_exists($shareFile)) { return false; } $links = json_decode(file_get_contents($shareFile), true); if (!is_array($links) || !isset($links[$token])) { return false; } unset($links[$token]); file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX); return true; } /** * Create an empty file plus metadata entry. * * @param string $folder * @param string $filename * @param string $uploader * @return array ['success'=>bool, 'error'=>string, 'code'=>int] */ public static function createFile(string $folder, string $filename, string $uploader): array { // 1) basic validation $filename = basename(trim($filename)); if (!preg_match(REGEX_FILE_NAME, $filename)) { return ['success'=>false,'error'=>'Invalid filename','code'=>400]; } // 2) resolve target folder list($baseDir, $err) = self::resolveFolderPath($folder, true); if ($err) { return ['success'=>false, 'error'=>$err, 'code'=>($err === 'Invalid folder name.' ? 400 : 500)]; } $path = $baseDir . DIRECTORY_SEPARATOR . $filename; // 3) no overwrite if (file_exists($path)) { return ['success'=>false,'error'=>'File already exists','code'=>400]; } // 4) touch the file if (false === @file_put_contents($path, '', LOCK_EX)) { return ['success'=>false,'error'=>'Could not create file','code'=>500]; } // 5) write metadata $metaKey = (strtolower($folder) === 'root' || trim($folder) === '') ? 'root' : $folder; $metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json'; $metaPath = META_DIR . $metaName; $collection = []; if (file_exists($metaPath)) { $json = file_get_contents($metaPath); $collection = json_decode($json, true) ?: []; } $now = date(DATE_TIME_FORMAT); $collection[$filename] = [ 'uploaded' => $now, 'modified' => $now, 'uploader' => $uploader ]; if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT), LOCK_EX)) { return ['success'=>false,'error'=>'Failed to update metadata','code'=>500]; } return ['success'=>true]; } }