| Name | Actions | |||
|---|---|---|---|---|
| Parent folder | ||||
| This folder is empty. | ||||
'$2y$10$REPLACE_WITH_A_PASSWORD_HASH', ]; $readonly_users = []; // e.g. ['viewer'] $user_directories = []; // e.g. ['client-a' => '/srv/client-a'] // SAFETY: Keep true until at least one real password hash is configured. $block_placeholder_credentials = true; // Mutating operations. Recommended production defaults are conservative. $allow_upload = true; $allow_delete = true; $allow_create_folder = true; $allow_rename = true; $allow_copy_move = true; $allow_edit = true; $allow_archive_download = true; $allow_direct_link = false; // false forces downloads instead of direct links. // Upload policy. Empty allowed MIME list accepts all non-blocked files. $max_upload_size_bytes = 100 * 1024 * 1024; // 100 MiB per file; also configure PHP/nginx limits. $allowed_upload_extensions = []; // e.g. ['jpg', 'png', 'pdf', 'zip']; empty = no allow-list. $blocked_upload_extensions = [ 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'cgi', 'pl', 'py', 'asp', 'aspx', 'jsp', 'sh', 'bash', 'zsh', 'exe', 'dll', 'com', 'bat', 'cmd', ]; $allowed_upload_mime_types = []; // e.g. ['image/jpeg', 'image/png', 'application/pdf']; empty = no allow-list. // Listing and access policy. $show_hidden_files = false; $hidden_patterns = ['.git', '.env', '*.php']; // Applied only to the directory listing. $disallowed_download_patterns = ['*.php', '*.env', '.git*']; // Optional IP gate. Set mode to 'allow' and add trusted IPs / CIDRs for a private deployment. $ip_access_mode = 'off'; // 'off', 'allow', or 'deny' $ip_rules = []; // e.g. ['127.0.0.1', '192.168.1.0/24'] // Require HTTPS for authentication and modifying requests in production. $require_https = false; // Keep false in production. Errors are logged through PHP's normal error log. $debug = false; // UI defaults. $default_theme = 'light'; // 'light' or 'dark' $default_timezone = 'Asia/Kuala_Lumpur'; /* ======================================================================== * END CONFIGURATION * ====================================================================== */ const SFM_VERSION = '2.0.0'; const SFM_SESSION_NAME = 'simple_file_manager'; ini_set('display_errors', $debug ? '1' : '0'); error_reporting($debug ? E_ALL : E_ALL & ~E_NOTICE & ~E_DEPRECATED); date_default_timezone_set($default_timezone); $is_https = sfm_is_https(); if ($require_https && !$is_https) { sfm_fail(403, 'HTTPS is required for this file manager.'); } if (!sfm_ip_is_allowed(sfm_client_ip(), $ip_access_mode, $ip_rules)) { sfm_fail(403, 'Access denied.'); } $root_path = sfm_normalize_root($root_path); if ($root_path === null) { sfm_fail(500, 'Configured root path does not exist or is not readable.'); } session_name(SFM_SESSION_NAME); session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'secure' => $is_https, 'httponly' => true, 'samesite' => 'Strict', ]); session_start(); if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); } if (empty($_SESSION['theme'])) { $_SESSION['theme'] = $default_theme === 'dark' ? 'dark' : 'light'; } // No default backdoor: empty or placeholder auth configuration is refused. if ($use_auth && ($auth_users === [] || sfm_auth_has_placeholder($auth_users)) && $block_placeholder_credentials) { sfm_show_setup_error($app_title); exit; } if (isset($_GET['logout'])) { $_SESSION = []; session_regenerate_id(true); header('Location: ' . sfm_self_url()); exit; } if ($use_auth) { sfm_handle_login($auth_users); } $current_user = $use_auth ? (string) ($_SESSION['sfm_user'] ?? '') : 'anonymous'; if ($use_auth && !isset($auth_users[$current_user])) { sfm_show_login($app_title); exit; } if ($use_auth && isset($user_directories[$current_user])) { $user_root = sfm_normalize_root((string) $user_directories[$current_user]); if ($user_root === null) { sfm_fail(500, 'The configured directory for this user is unavailable.'); } $root_path = $user_root; } $readonly = in_array($current_user, $readonly_users, true); // Security headers. Scripts and styles are served locally, but a nonce supports this page's small bootstrap script. $nonce = base64_encode(random_bytes(18)); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('Referrer-Policy: same-origin'); header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'nonce-{$nonce}'; script-src 'self' 'nonce-{$nonce}'; connect-src 'self'"); header('Cache-Control: no-store, private'); $requested_path = (string) ($_REQUEST['path'] ?? ''); $current_path = sfm_resolve_path($root_path, $requested_path, true); if ($current_path === null) { sfm_fail(403, 'Invalid or inaccessible path.'); } $action = (string) ($_REQUEST['action'] ?? ''); if ($action !== '') { sfm_handle_action( $action, $root_path, $current_path, $requested_path, $readonly, [ 'upload' => $allow_upload, 'delete' => $allow_delete, 'mkdir' => $allow_create_folder, 'rename' => $allow_rename, 'copy_move' => $allow_copy_move, 'edit' => $allow_edit, 'archive' => $allow_archive_download, 'direct_link' => $allow_direct_link, 'max_upload_size' => $max_upload_size_bytes, 'allowed_extensions' => $allowed_upload_extensions, 'blocked_extensions' => $blocked_upload_extensions, 'allowed_mimes' => $allowed_upload_mime_types, 'show_hidden' => $show_hidden_files, 'hidden_patterns' => $hidden_patterns, 'download_patterns' => $disallowed_download_patterns, ] ); } sfm_render_page($app_title, $root_path, $current_path, $requested_path, $current_user, $readonly, $nonce, $_SESSION['theme']); /* ======================================================================== * REQUEST HANDLERS * ====================================================================== */ function sfm_handle_login(array $auth_users): void { if (isset($_SESSION['sfm_user']) && isset($auth_users[$_SESSION['sfm_user']])) { return; } if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_POST['login'])) { sfm_show_login('Simple File Manager'); exit; } if (!sfm_verify_csrf((string) ($_POST['csrf'] ?? ''))) { sfm_show_login('Simple File Manager', 'Your session expired. Please try again.'); exit; } $username = (string) ($_POST['username'] ?? ''); $password = (string) ($_POST['password'] ?? ''); // Always take a small fixed delay to make online guessing less practical. usleep(500000); $hash = $auth_users[$username] ?? null; if (!is_string($hash) || !password_verify($password, $hash)) { sfm_show_login('Simple File Manager', 'Invalid username or password.'); exit; } session_regenerate_id(true); $_SESSION['sfm_user'] = $username; header('Location: ' . sfm_self_url()); exit; } function sfm_handle_action(string $action, string $root, string $current_path, string $requested_path, bool $readonly, array $config): void { if (in_array($action, ['download', 'archive'], true)) { // Download links are GET so they work in browsers; protect them by session authentication. if ($action === 'download') { sfm_action_download($root, $current_path, $config['download_patterns']); } if ($readonly || !$config['archive']) { sfm_fail(403, 'This action is disabled.'); } sfm_action_archive($root, $current_path, $requested_path); } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sfm_fail(405, 'POST is required for this action.'); } if (!sfm_verify_csrf((string) ($_POST['csrf'] ?? ''))) { sfm_fail(403, 'Invalid CSRF token.'); } if ($readonly && !in_array($action, ['theme'], true)) { sfm_fail(403, 'This account is read-only.'); } switch ($action) { case 'upload': if (!$config['upload']) sfm_fail(403, 'Uploads are disabled.'); sfm_action_upload($root, $current_path, $requested_path, $config); break; case 'mkdir': if (!$config['mkdir']) sfm_fail(403, 'Folder creation is disabled.'); sfm_action_mkdir($current_path, $requested_path); break; case 'delete': if (!$config['delete']) sfm_fail(403, 'Deletion is disabled.'); sfm_action_delete($root, $current_path, $requested_path); break; case 'rename': if (!$config['rename']) sfm_fail(403, 'Rename is disabled.'); sfm_action_rename($root, $current_path, $requested_path); break; case 'copy_move': if (!$config['copy_move']) sfm_fail(403, 'Copy and move are disabled.'); sfm_action_copy_move($root, $current_path, $requested_path); break; case 'save': if (!$config['edit']) sfm_fail(403, 'Editing is disabled.'); sfm_action_save($root, $current_path, $requested_path); break; case 'theme': $theme = (string) ($_POST['theme'] ?? 'light'); $_SESSION['theme'] = $theme === 'dark' ? 'dark' : 'light'; sfm_redirect($requested_path, 'Theme updated.'); break; default: sfm_fail(400, 'Unknown action.'); } } function sfm_action_upload(string $root, string $directory, string $requested_path, array $config): void { if (!is_dir($directory) || !is_writable($directory)) sfm_fail(403, 'Destination is not writable.'); if (empty($_FILES['files'])) sfm_fail(400, 'No files were received.'); $files = $_FILES['files']; $count = is_array($files['name']) ? count($files['name']) : 0; $finfo = new finfo(FILEINFO_MIME_TYPE); $uploaded = 0; for ($i = 0; $i < $count; $i++) { $error = (int) $files['error'][$i]; if ($error !== UPLOAD_ERR_OK) { sfm_fail(400, 'Upload failed: ' . sfm_upload_error_message($error)); } $name = sfm_safe_filename((string) $files['name'][$i]); if ($name === null) sfm_fail(400, 'An uploaded filename is invalid.'); $tmp = (string) $files['tmp_name'][$i]; $size = (int) $files['size'][$i]; if (!is_uploaded_file($tmp)) sfm_fail(400, 'Invalid upload source.'); if ($size < 1 || $size > (int) $config['max_upload_size']) sfm_fail(413, 'One uploaded file exceeds the configured size limit.'); $extension = strtolower((string) pathinfo($name, PATHINFO_EXTENSION)); if (in_array($extension, $config['blocked_extensions'], true)) sfm_fail(403, 'This file extension is blocked.'); if ($config['allowed_extensions'] !== [] && !in_array($extension, $config['allowed_extensions'], true)) sfm_fail(403, 'This file extension is not allowed.'); $mime = $finfo->file($tmp) ?: 'application/octet-stream'; if ($config['allowed_mimes'] !== [] && !in_array($mime, $config['allowed_mimes'], true)) sfm_fail(403, 'This file type is not allowed.'); $destination = $directory . DIRECTORY_SEPARATOR . $name; if (file_exists($destination)) sfm_fail(409, 'A file with this name already exists: ' . $name); if (!move_uploaded_file($tmp, $destination)) sfm_fail(500, 'The uploaded file could not be saved.'); @chmod($destination, 0640); $uploaded++; } sfm_redirect($requested_path, "Uploaded {$uploaded} file(s)."); } function sfm_action_mkdir(string $directory, string $requested_path): void { $name = sfm_safe_filename((string) ($_POST['name'] ?? '')); if ($name === null) sfm_fail(400, 'Invalid folder name.'); $target = $directory . DIRECTORY_SEPARATOR . $name; if (file_exists($target)) sfm_fail(409, 'That name is already in use.'); if (!mkdir($target, 0750)) sfm_fail(500, 'Folder creation failed.'); sfm_redirect($requested_path, 'Folder created.'); } function sfm_action_delete(string $root, string $target, string $requested_path): void { if ($target === $root) sfm_fail(403, 'The configured root cannot be deleted.'); if (!file_exists($target) && !is_link($target)) sfm_fail(404, 'Target not found.'); if (!sfm_delete_tree($target)) sfm_fail(500, 'Could not completely delete the selected item.'); sfm_redirect(sfm_parent_relative_path($requested_path), 'Item deleted.'); } function sfm_action_rename(string $root, string $source, string $requested_path): void { if ($source === $root) sfm_fail(403, 'The configured root cannot be renamed.'); $name = sfm_safe_filename((string) ($_POST['name'] ?? '')); if ($name === null) sfm_fail(400, 'Invalid new name.'); $target = dirname($source) . DIRECTORY_SEPARATOR . $name; if (file_exists($target) || is_link($target)) sfm_fail(409, 'That name is already in use.'); if (!rename($source, $target)) sfm_fail(500, 'Rename failed.'); sfm_redirect(sfm_parent_relative_path($requested_path), 'Item renamed.'); } function sfm_action_copy_move(string $root, string $source, string $requested_path): void { if ($source === $root) sfm_fail(403, 'The configured root cannot be moved or copied.'); $target_relative = (string) ($_POST['destination'] ?? ''); $destination_directory = sfm_resolve_path($root, $target_relative, true); if ($destination_directory === null || !is_dir($destination_directory) || !is_writable($destination_directory)) sfm_fail(400, 'Invalid destination folder.'); $target = $destination_directory . DIRECTORY_SEPARATOR . basename($source); if (file_exists($target) || is_link($target)) sfm_fail(409, 'The destination already has an item with this name.'); if (sfm_path_is_within($source, $target)) sfm_fail(400, 'Cannot place a folder inside itself.'); $mode = (string) ($_POST['mode'] ?? 'copy'); if ($mode === 'move') { if (!rename($source, $target)) sfm_fail(500, 'Move failed.'); sfm_redirect($target_relative, 'Item moved.'); } if (!sfm_copy_tree($source, $target)) sfm_fail(500, 'Copy failed.'); sfm_redirect($target_relative, 'Item copied.'); } function sfm_action_save(string $root, string $file, string $requested_path): void { if (!is_file($file) || is_link($file)) sfm_fail(400, 'Only regular files can be edited.'); $max_edit_size = 2 * 1024 * 1024; if (filesize($file) > $max_edit_size) sfm_fail(413, 'Files larger than 2 MiB cannot be edited in the browser.'); $content = (string) ($_POST['content'] ?? ''); if (strlen($content) > $max_edit_size) sfm_fail(413, 'Saved content exceeds the editing limit.'); $temp = tempnam(dirname($file), '.sfm-'); if ($temp === false || file_put_contents($temp, $content, LOCK_EX) === false || !rename($temp, $file)) { @unlink($temp ?: ''); sfm_fail(500, 'Could not save the file.'); } @chmod($file, 0640); sfm_redirect($requested_path, 'File saved.'); } function sfm_action_download(string $root, string $file, array $disallowed_patterns): void { if (!is_file($file) || is_link($file)) sfm_fail(404, 'File not found.'); if (sfm_matches_patterns(basename($file), $disallowed_patterns)) sfm_fail(403, 'Downloads of this file type are disabled.'); $name = basename($file); $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file) ?: 'application/octet-stream'; header('Content-Type: ' . $mime); header('Content-Length: ' . (string) filesize($file)); header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"'); header('X-Content-Type-Options: nosniff'); while (ob_get_level() > 0) ob_end_clean(); readfile($file); exit; } function sfm_action_archive(string $root, string $directory, string $requested_path): void { if (!class_exists('ZipArchive')) sfm_fail(501, 'The PHP ZipArchive extension is unavailable.'); if (!is_dir($directory)) sfm_fail(400, 'Only folders can be archived.'); $temp = tempnam(sys_get_temp_dir(), 'sfm-archive-'); if ($temp === false) sfm_fail(500, 'Could not create the archive.'); $zip = new ZipArchive(); if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) sfm_fail(500, 'Could not create the archive.'); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY); foreach ($iterator as $item) { if ($item->isLink()) continue; $path = $item->getPathname(); $relative = substr($path, strlen($directory) + 1); if ($item->isFile()) $zip->addFile($path, $relative); } $zip->close(); $archive_name = (basename($directory) ?: 'archive') . '.zip'; header('Content-Type: application/zip'); header('Content-Length: ' . (string) filesize($temp)); header('Content-Disposition: attachment; filename="' . rawurlencode($archive_name) . '"'); readfile($temp); @unlink($temp); exit; } /* ======================================================================== * PAGE * ====================================================================== */ function sfm_render_page(string $title, string $root, string $current, string $requested_path, string $user, bool $readonly, string $nonce, string $theme): void { $message = (string) ($_SESSION['flash'] ?? ''); unset($_SESSION['flash']); $relative_current = sfm_relative_path($root, $current); $entries = sfm_list_entries($root, $current, $requested_path, $GLOBALS['show_hidden_files'], $GLOBALS['hidden_patterns']); $edit_target = isset($_GET['edit']) ? sfm_resolve_path($root, (string) $_GET['edit'], true) : null; $edit_content = null; $edit_relative = ''; if ($edit_target !== null && is_file($edit_target) && !is_link($edit_target) && filesize($edit_target) <= 2 * 1024 * 1024) { $edit_content = file_get_contents($edit_target); $edit_relative = sfm_relative_path($root, $edit_target); } $root_json = json_encode($relative_current, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $csrf_json = json_encode($_SESSION['csrf'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>
| Name | Actions | |||
|---|---|---|---|---|
| Parent folder | ||||
| This folder is empty. | ||||
Authentication is enabled but no usable password hash exists.
Edit the CONFIGURATION section at the top of index.php, then add a password hash to $auth_users.
php -r "echo password_hash('your-strong-password', PASSWORD_DEFAULT), PHP_EOL;"Example: 'admin' => '$2y$...'