Files
FileRise/src/models/FileModel.php

1673 lines
67 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// src/models/FileModel.php
require_once PROJECT_ROOT . '/config/config.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
class FileModel {
/**
* Resolve a logical folder key (e.g. "root", "invoices/2025") to a
* real path under UPLOAD_DIR, enforce REGEX_FOLDER_NAME, and ensure
* optional creation.
*
* @param string $folder
* @param bool $create
* @return array [string|null $realPath, string|null $error]
*/
private static function resolveFolderPath(string $folder, bool $create = true): array {
$folder = trim($folder) ?: 'root';
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return [null, "Invalid folder name."];
}
$base = realpath(UPLOAD_DIR);
if ($base === false) {
return [null, "Server misconfiguration."];
}
$dir = (strtolower($folder) === 'root')
? $base
: $base . DIRECTORY_SEPARATOR . trim($folder, "/\\ ");
if ($create && !is_dir($dir) && !mkdir($dir, 0775, true)) {
return [null, "Cannot create destination folder"];
}
$real = realpath($dir);
if ($real === false || strpos($real, $base) !== 0) {
return [null, "Invalid folder path."];
}
return [$real, null];
}
/**
* Copies files from a source folder to a destination folder, updating metadata if available.
*
* @param string $sourceFolder The source folder (e.g. "root" or a subfolder)
* @param string $destinationFolder The destination folder.
* @param array $files Array of file names to copy.
* @return array Result with either "success" or "error" key.
*/
public static function copyFiles($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;
// 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 files 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 "<folderNorm>/<sub/dir>" (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];
}
}