Security hardening: - bcrypt password auth via password_hash()/password_verify(), no default creds - Session CSRF token verified with hash_equals() - Secure session cookies: HttpOnly, SameSite=Strict, Secure over HTTPS - Nonce-based Content-Security-Policy, frame/clickjack + nosniff headers - realpath() root containment + symlink exclusion to block traversal - Server-side upload size + MIME(finfo) + extension validation - Configurable IP allow/deny with CIDR, optional HTTPS enforcement - Fixed 500ms login delay to slow brute-force guessing Features (single PHP file, no CDN): - New Bootstrap 5 responsive UI with light/dark theme toggle - Upload (drag+drop), download-only links, delete, rename, copy/move, mkdir - Folder ZIP download, in-browser text editor (<=2MiB) - Multi-user, per-user read-only + per-user root directories - All frontend assets vendored locally under assets/ (349K) Docs: full config reference, password hash guide, Tor hidden-service guide.
692 lines
42 KiB
PHP
692 lines
42 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Simple File Manager — hardened, self-hosted edition
|
|
*
|
|
* Inspired by jcampbell1/simple-file-manager and Tiny File Manager v2.6.
|
|
* This application is intentionally a single PHP entry point. Its frontend
|
|
* dependencies are stored locally in ./assets; no CDN requests are used.
|
|
*
|
|
* Requirements: PHP 7.4+ (PHP 8.x recommended), fileinfo extension.
|
|
*/
|
|
|
|
/* ========================================================================
|
|
* CONFIGURATION — EDIT THIS SECTION ONLY
|
|
* ====================================================================== */
|
|
|
|
// Display name shown in the browser.
|
|
$app_title = 'Simple File Manager';
|
|
|
|
// Directory that users may manage. Use __DIR__ to manage this project folder.
|
|
// Set an explicit absolute directory for production, e.g. '/srv/uploads'.
|
|
$root_path = __DIR__;
|
|
|
|
// Authentication is enabled by default. Generate a hash with:
|
|
// php -r "echo password_hash('change-this-password', PASSWORD_DEFAULT), PHP_EOL;"
|
|
$use_auth = true;
|
|
$auth_users = [
|
|
// 'admin' => '$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);
|
|
?>
|
|
<!doctype html>
|
|
<html lang="en" data-bs-theme="<?= sfm_h($theme) ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?= sfm_h($title) ?></title>
|
|
<link rel="stylesheet" href="assets/css/bootstrap.min.css">
|
|
<link rel="stylesheet" href="assets/css/font-awesome.min.css">
|
|
<link rel="stylesheet" href="assets/css/highlight.min.css">
|
|
<style nonce="<?= sfm_h($nonce) ?>">
|
|
body { min-height:100vh; background:var(--bs-tertiary-bg); }
|
|
.sfm-navbar { box-shadow:0 .125rem .25rem rgba(0,0,0,.08); }
|
|
.file-icon { width:2.1rem; text-align:center; font-size:1.2rem; }
|
|
.file-row:hover { background:var(--bs-secondary-bg); }
|
|
.file-name { overflow-wrap:anywhere; }
|
|
.drop-zone { border:2px dashed var(--bs-border-color); border-radius:.5rem; padding:1.25rem; text-align:center; background:var(--bs-body-bg); transition:.2s; }
|
|
.drop-zone.dragover { border-color:var(--bs-primary); background:var(--bs-primary-bg-subtle); }
|
|
.path-bar { overflow-x:auto; white-space:nowrap; }
|
|
.editor { width:100%; min-height:65vh; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; tab-size:4; }
|
|
.table td, .table th { vertical-align:middle; }
|
|
@media (max-width: 767px) { .hide-mobile { display:none; } .file-icon { width:1.6rem; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar navbar-expand-lg bg-body sfm-navbar sticky-top">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand fw-semibold" href="<?= sfm_h(sfm_url('')) ?>"><i class="fa fa-folder-open text-warning me-1"></i><?= sfm_h($title) ?></a>
|
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
|
<span class="small text-body-secondary d-none d-md-inline"><i class="fa fa-user me-1"></i><?= sfm_h($user) ?><?= $readonly ? ' (read-only)' : '' ?></span>
|
|
<form method="post" class="m-0">
|
|
<input type="hidden" name="action" value="theme"><input type="hidden" name="path" value="<?= sfm_h($relative_current) ?>"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>">
|
|
<input type="hidden" name="theme" value="<?= $theme === 'dark' ? 'light' : 'dark' ?>">
|
|
<button class="btn btn-sm btn-outline-secondary" title="Toggle theme"><i class="fa fa-<?= $theme === 'dark' ? 'sun-o' : 'moon-o' ?>"></i></button>
|
|
</form>
|
|
<?php if ($GLOBALS['use_auth']): ?><a class="btn btn-sm btn-outline-danger" href="?logout=1">Logout</a><?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="container-fluid py-3">
|
|
<?php if ($message !== ''): ?><div class="alert alert-success alert-dismissible fade show" role="alert"><?= sfm_h($message) ?><button class="btn-close" data-bs-dismiss="alert"></button></div><?php endif; ?>
|
|
|
|
<div class="card shadow-sm mb-3"><div class="card-body py-2 path-bar">
|
|
<a href="<?= sfm_h(sfm_url('')) ?>" class="text-decoration-none"><i class="fa fa-home"></i> Home</a>
|
|
<?= sfm_breadcrumbs($relative_current) ?>
|
|
</div></div>
|
|
|
|
<?php if (!$readonly): ?>
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-lg-7">
|
|
<div class="card shadow-sm"><div class="card-body">
|
|
<div id="drop-zone" class="drop-zone"><i class="fa fa-cloud-upload fa-2x text-primary mb-2"></i><div class="fw-medium">Drop files here to upload</div><div class="small text-body-secondary mb-2">Maximum <?= sfm_h(sfm_format_bytes((int) $GLOBALS['max_upload_size_bytes'])) ?> per file</div>
|
|
<form method="post" enctype="multipart/form-data" id="upload-form"><input type="hidden" name="action" value="upload"><input type="hidden" name="path" value="<?= sfm_h($relative_current) ?>"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><input id="file-input" name="files[]" type="file" multiple class="form-control"></form>
|
|
</div><div id="upload-status" class="small mt-2"></div>
|
|
</div></div>
|
|
</div>
|
|
<div class="col-lg-5"><div class="card shadow-sm"><div class="card-body">
|
|
<div class="row g-2">
|
|
<div class="col-md-6"><form method="post" class="input-group"><input type="hidden" name="action" value="mkdir"><input type="hidden" name="path" value="<?= sfm_h($relative_current) ?>"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><input class="form-control" name="name" maxlength="255" placeholder="New folder" required><button class="btn btn-primary" title="Create folder"><i class="fa fa-folder-o"></i></button></form></div>
|
|
<div class="col-md-6"><input id="file-filter" class="form-control" placeholder="Filter this folder…" aria-label="Filter files"></div>
|
|
</div>
|
|
</div></div></div>
|
|
</div>
|
|
<?php else: ?><div class="mb-3"><input id="file-filter" class="form-control" placeholder="Filter this folder…" aria-label="Filter files"></div><?php endif; ?>
|
|
|
|
<div class="card shadow-sm"><div class="table-responsive"><table class="table table-hover mb-0" id="file-table">
|
|
<thead class="table-light"><tr><th>Name</th><th class="hide-mobile text-end">Size</th><th class="hide-mobile">Modified</th><th class="hide-mobile">Permissions</th><th class="text-end">Actions</th></tr></thead>
|
|
<tbody>
|
|
<?php if ($relative_current !== ''): ?><tr><td colspan="5"><a href="<?= sfm_h(sfm_url(sfm_parent_relative_path($relative_current))) ?>" class="text-decoration-none"><i class="fa fa-level-up me-2"></i>Parent folder</a></td></tr><?php endif; ?>
|
|
<?php foreach ($entries as $entry): ?>
|
|
<tr class="file-row" data-name="<?= sfm_h(strtolower($entry['name'])) ?>">
|
|
<td><div class="d-flex align-items-center"><span class="file-icon text-<?= $entry['is_dir'] ? 'warning' : 'secondary' ?>"><i class="fa fa-<?= $entry['is_dir'] ? 'folder' : sfm_icon_for_name($entry['name']) ?>"></i></span><div class="file-name"><a class="text-decoration-none fw-<?= $entry['is_dir'] ? 'semibold' : 'normal' ?>" href="<?= sfm_h($entry['href']) ?>"><?= sfm_h($entry['name']) ?></a></div></div></td>
|
|
<td class="hide-mobile text-end text-body-secondary small"><?= $entry['is_dir'] ? '—' : sfm_h(sfm_format_bytes($entry['size'])) ?></td>
|
|
<td class="hide-mobile text-body-secondary small"><?= sfm_h(date('Y-m-d H:i', $entry['mtime'])) ?></td>
|
|
<td class="hide-mobile text-body-secondary small"><code><?= sfm_h($entry['perms']) ?></code></td>
|
|
<td class="text-end text-nowrap">
|
|
<?php if (!$entry['is_dir']): ?><a class="btn btn-sm btn-outline-secondary" title="Download" href="<?= sfm_h(sfm_action_url('download', $entry['relative'])) ?>"><i class="fa fa-download"></i></a><?php endif; ?>
|
|
<?php if ($entry['is_dir'] && !$readonly && $GLOBALS['allow_archive_download']): ?><a class="btn btn-sm btn-outline-secondary" title="Download ZIP" href="<?= sfm_h(sfm_action_url('archive', $entry['relative'])) ?>"><i class="fa fa-file-archive-o"></i></a><?php endif; ?>
|
|
<?php if (!$readonly && !$entry['is_dir'] && $GLOBALS['allow_edit'] && $entry['size'] <= 2*1024*1024): ?><a class="btn btn-sm btn-outline-primary" title="Edit" href="<?= sfm_h(sfm_url($relative_current, ['edit' => $entry['relative']])) ?>"><i class="fa fa-pencil"></i></a><?php endif; ?>
|
|
<?php if (!$readonly): ?><button type="button" class="btn btn-sm btn-outline-secondary action-modal" data-bs-toggle="modal" data-bs-target="#actionModal" data-path="<?= sfm_h($entry['relative']) ?>" data-name="<?= sfm_h($entry['name']) ?>" data-dir="<?= $entry['is_dir'] ? '1' : '0' ?>" title="More actions"><i class="fa fa-ellipsis-h"></i></button><?php endif; ?>
|
|
</td></tr>
|
|
<?php endforeach; ?>
|
|
<?php if ($entries === []): ?><tr><td colspan="5" class="text-center text-body-secondary py-5">This folder is empty.</td></tr><?php endif; ?>
|
|
</tbody></table></div></div>
|
|
</main>
|
|
|
|
<?php if (!$readonly): ?>
|
|
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">Manage <span id="modal-item-name"></span></h5><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body">
|
|
<div class="d-grid gap-2 mb-3"><button id="show-rename" class="btn btn-outline-primary">Rename</button><button id="show-copy-move" class="btn btn-outline-secondary">Copy or move</button><button id="show-delete" class="btn btn-outline-danger">Delete permanently</button></div>
|
|
<form id="rename-form" method="post" class="d-none"><input type="hidden" name="action" value="rename"><input type="hidden" name="path" id="rename-path"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><label class="form-label">New name</label><input id="rename-name" class="form-control mb-2" name="name" required><button class="btn btn-primary">Rename</button></form>
|
|
<form id="copy-move-form" method="post" class="d-none"><input type="hidden" name="action" value="copy_move"><input type="hidden" name="path" id="copy-move-path"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><label class="form-label">Destination relative to Home</label><input class="form-control mb-2" name="destination" placeholder="e.g. uploads/2026" required><select class="form-select mb-2" name="mode"><option value="copy">Copy</option><option value="move">Move</option></select><button class="btn btn-primary">Continue</button></form>
|
|
<form id="delete-form" method="post" class="d-none"><input type="hidden" name="action" value="delete"><input type="hidden" name="path" id="delete-path"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><p class="text-danger">This action cannot be undone.</p><button class="btn btn-danger">Delete permanently</button></form>
|
|
</div></div></div></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($edit_content !== null && !$readonly): ?>
|
|
<div class="modal fade" id="editorModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><form method="post"><div class="modal-header"><h5 class="modal-title"><i class="fa fa-pencil me-2"></i><?= sfm_h($edit_relative) ?></h5><a class="btn-close" href="<?= sfm_h(sfm_url($relative_current)) ?>"></a></div><div class="modal-body"><input type="hidden" name="action" value="save"><input type="hidden" name="path" value="<?= sfm_h($edit_relative) ?>"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf']) ?>"><textarea class="form-control editor" name="content" spellcheck="false"><?= sfm_h((string) $edit_content) ?></textarea></div><div class="modal-footer"><a class="btn btn-outline-secondary" href="<?= sfm_h(sfm_url($relative_current)) ?>">Cancel</a><button class="btn btn-primary"><i class="fa fa-save me-1"></i>Save</button></div></form></div></div></div>
|
|
<?php endif; ?>
|
|
|
|
<script src="assets/js/bootstrap.bundle.min.js"></script>
|
|
<script nonce="<?= sfm_h($nonce) ?>">
|
|
(() => {
|
|
const csrf = <?= $csrf_json ?>, currentPath = <?= $root_json ?>;
|
|
const filter = document.getElementById('file-filter');
|
|
if (filter) filter.addEventListener('input', () => { const q = filter.value.toLowerCase(); document.querySelectorAll('#file-table tbody tr[data-name]').forEach(row => row.hidden = !row.dataset.name.includes(q)); });
|
|
const drop = document.getElementById('drop-zone'), input = document.getElementById('file-input'), form = document.getElementById('upload-form');
|
|
if (drop && input && form) {
|
|
['dragenter','dragover'].forEach(type => drop.addEventListener(type, e => { e.preventDefault(); drop.classList.add('dragover'); }));
|
|
['dragleave','drop'].forEach(type => drop.addEventListener(type, e => { e.preventDefault(); drop.classList.remove('dragover'); }));
|
|
drop.addEventListener('drop', e => { input.files = e.dataTransfer.files; form.submit(); });
|
|
input.addEventListener('change', () => { if (input.files.length) form.submit(); });
|
|
}
|
|
const modal = document.getElementById('actionModal');
|
|
if (modal) {
|
|
modal.addEventListener('show.bs.modal', e => { const b = e.relatedTarget; const path = b.dataset.path, name = b.dataset.name; document.getElementById('modal-item-name').textContent = name; document.getElementById('rename-path').value = path; document.getElementById('rename-name').value = name; document.getElementById('copy-move-path').value = path; document.getElementById('delete-path').value = path; ['rename-form','copy-move-form','delete-form'].forEach(id => document.getElementById(id).classList.add('d-none')); });
|
|
document.getElementById('show-rename').onclick = () => document.getElementById('rename-form').classList.remove('d-none');
|
|
document.getElementById('show-copy-move').onclick = () => document.getElementById('copy-move-form').classList.remove('d-none');
|
|
document.getElementById('show-delete').onclick = () => document.getElementById('delete-form').classList.remove('d-none');
|
|
}
|
|
const editor = document.getElementById('editorModal'); if (editor) new bootstrap.Modal(editor).show();
|
|
})();
|
|
</script>
|
|
</body></html>
|
|
<?php
|
|
}
|
|
|
|
function sfm_show_login(string $title, string $error = ''): void
|
|
{
|
|
$nonce = base64_encode(random_bytes(18));
|
|
header('Content-Security-Policy: default-src \'self\'; style-src \'self\' \'nonce-' . $nonce . '\'; script-src \'self\'; img-src \'self\' data:; base-uri \'self\'; form-action \'self\'; frame-ancestors \'none\'');
|
|
?>
|
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= sfm_h($title) ?> — Login</title><link rel="stylesheet" href="assets/css/bootstrap.min.css"><link rel="stylesheet" href="assets/css/font-awesome.min.css"><style nonce="<?= sfm_h($nonce) ?>">body{min-height:100vh;background:#f4f6f9}.login{max-width:420px;margin:auto}.card{box-shadow:0 .5rem 1rem rgba(0,0,0,.12)}</style></head><body class="d-flex align-items-center"><main class="login w-100 p-3"><div class="card"><div class="card-body p-4"><h1 class="h3 text-center mb-4"><i class="fa fa-folder-open text-warning me-2"></i><?= sfm_h($title) ?></h1><?php if ($error): ?><div class="alert alert-danger"><?= sfm_h($error) ?></div><?php endif; ?><form method="post" autocomplete="off"><input type="hidden" name="login" value="1"><input type="hidden" name="csrf" value="<?= sfm_h($_SESSION['csrf'] ?? '') ?>"><div class="mb-3"><label class="form-label" for="username">Username</label><input id="username" class="form-control" name="username" required autofocus></div><div class="mb-3"><label class="form-label" for="password">Password</label><input id="password" class="form-control" type="password" name="password" required></div><button class="btn btn-primary w-100">Sign in</button></form></div></div></main></body></html>
|
|
<?php
|
|
}
|
|
|
|
function sfm_show_setup_error(string $title): void
|
|
{
|
|
http_response_code(503);
|
|
?>
|
|
<!doctype html><html><head><meta charset="utf-8"><title>Configuration required</title></head><body><h1><?= sfm_h($title) ?> is not configured</h1><p>Authentication is enabled but no usable password hash exists.</p><p>Edit the <strong>CONFIGURATION</strong> section at the top of <code>index.php</code>, then add a password hash to <code>$auth_users</code>.</p><pre>php -r "echo password_hash('your-strong-password', PASSWORD_DEFAULT), PHP_EOL;"</pre><p>Example: <code>'admin' => '$2y$...'</code></p></body></html>
|
|
<?php
|
|
}
|
|
|
|
/* ========================================================================
|
|
* FILESYSTEM / SECURITY HELPERS
|
|
* ====================================================================== */
|
|
|
|
function sfm_is_https(): bool { return (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); }
|
|
function sfm_self_url(): string { return strtok($_SERVER['REQUEST_URI'] ?? $_SERVER['PHP_SELF'], '?') ?: '/'; }
|
|
function sfm_h(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
|
function sfm_verify_csrf(string $token): bool { return isset($_SESSION['csrf']) && $token !== '' && hash_equals((string) $_SESSION['csrf'], $token); }
|
|
function sfm_auth_has_placeholder(array $users): bool { foreach ($users as $hash) if (!is_string($hash) || str_contains($hash, 'REPLACE_WITH')) return true; return false; }
|
|
function sfm_normalize_root(string $root): ?string { $real = realpath($root); return ($real !== false && is_dir($real) && is_readable($real)) ? rtrim($real, DIRECTORY_SEPARATOR) : null; }
|
|
|
|
function sfm_resolve_path(string $root, string $relative, bool $must_exist): ?string
|
|
{
|
|
$relative = str_replace('\\', '/', trim($relative));
|
|
if ($relative === '' || $relative === '.') return $root;
|
|
if (str_contains($relative, "\0") || preg_match('#(^/|^[a-zA-Z]:|://)#', $relative)) return null;
|
|
$segments = [];
|
|
foreach (explode('/', $relative) as $segment) {
|
|
if ($segment === '' || $segment === '.') continue;
|
|
if ($segment === '..') { if ($segments === []) return null; array_pop($segments); continue; }
|
|
$segments[] = $segment;
|
|
}
|
|
$candidate = $root . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
|
if ($must_exist) {
|
|
$real = realpath($candidate);
|
|
if ($real === false || !sfm_path_is_within($root, $real)) return null;
|
|
return $real;
|
|
}
|
|
return sfm_path_is_within($root, $candidate) ? $candidate : null;
|
|
}
|
|
|
|
function sfm_path_is_within(string $root, string $path): bool
|
|
{
|
|
$root = rtrim(str_replace('\\', '/', $root), '/') . '/';
|
|
$path = rtrim(str_replace('\\', '/', $path), '/');
|
|
return strncmp(strtolower($path . '/'), strtolower($root), strlen($root)) === 0;
|
|
}
|
|
function sfm_relative_path(string $root, string $path): string { $relative = ltrim(substr(str_replace('\\', '/', $path), strlen(rtrim(str_replace('\\', '/', $root), '/'))), '/'); return $relative; }
|
|
function sfm_parent_relative_path(string $path): string { $path = trim(str_replace('\\', '/', $path), '/'); return $path === '' ? '' : trim(str_replace('\\', '/', dirname($path)), '.'); }
|
|
function sfm_safe_filename(string $name): ?string { $name = trim($name); if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0") || preg_match('#[\\/:*?"<>|]#', $name)) return null; return $name; }
|
|
function sfm_matches_patterns(string $name, array $patterns): bool { foreach ($patterns as $pattern) if (fnmatch($pattern, $name, FNM_CASEFOLD)) return true; return false; }
|
|
|
|
function sfm_list_entries(string $root, string $directory, string $requested_path, bool $show_hidden, array $hidden_patterns): array
|
|
{
|
|
if (!is_dir($directory) || !is_readable($directory)) return [];
|
|
$entries = [];
|
|
foreach (new DirectoryIterator($directory) as $item) {
|
|
if ($item->isDot() || $item->isLink()) continue; // Never expose symlinks through the manager.
|
|
$name = $item->getFilename();
|
|
if ((!$show_hidden && str_starts_with($name, '.')) || sfm_matches_patterns($name, $hidden_patterns)) continue;
|
|
$real = $item->getRealPath();
|
|
if ($real === false || !sfm_path_is_within($root, $real)) continue;
|
|
$is_dir = $item->isDir();
|
|
$relative = sfm_relative_path($root, $real);
|
|
$entries[] = [
|
|
'name' => $name, 'is_dir' => $is_dir, 'size' => $is_dir ? 0 : $item->getSize(), 'mtime' => $item->getMTime(),
|
|
'perms' => substr(sprintf('%o', $item->getPerms()), -4), 'relative' => $relative,
|
|
'href' => $is_dir ? sfm_url($relative) : ($GLOBALS['allow_direct_link'] ? sfm_url($relative) : sfm_action_url('download', $relative)),
|
|
];
|
|
}
|
|
usort($entries, static fn(array $a, array $b): int => ($a['is_dir'] === $b['is_dir']) ? strnatcasecmp($a['name'], $b['name']) : ($a['is_dir'] ? -1 : 1));
|
|
return $entries;
|
|
}
|
|
|
|
function sfm_delete_tree(string $path): bool
|
|
{
|
|
if (is_link($path)) return unlink($path);
|
|
if (is_file($path)) return unlink($path);
|
|
if (!is_dir($path)) return false;
|
|
foreach (new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS) as $item) {
|
|
if ($item->isLink() || !sfm_delete_tree($item->getPathname())) return false;
|
|
}
|
|
return rmdir($path);
|
|
}
|
|
function sfm_copy_tree(string $source, string $destination): bool
|
|
{
|
|
if (is_link($source)) return false;
|
|
if (is_file($source)) return copy($source, $destination);
|
|
if (!mkdir($destination, 0750)) return false;
|
|
foreach (new FilesystemIterator($source, FilesystemIterator::SKIP_DOTS) as $item) {
|
|
if (!sfm_copy_tree($item->getPathname(), $destination . DIRECTORY_SEPARATOR . $item->getFilename())) return false;
|
|
}
|
|
return true;
|
|
}
|
|
function sfm_upload_error_message(int $code): string { return [UPLOAD_ERR_INI_SIZE => 'server upload limit exceeded', UPLOAD_ERR_FORM_SIZE => 'form upload limit exceeded', UPLOAD_ERR_PARTIAL => 'partial upload', UPLOAD_ERR_NO_FILE => 'no file supplied', UPLOAD_ERR_NO_TMP_DIR => 'temporary directory missing', UPLOAD_ERR_CANT_WRITE => 'failed writing file', UPLOAD_ERR_EXTENSION => 'blocked by PHP extension'][$code] ?? 'unknown error'; }
|
|
function sfm_format_bytes(int $bytes): string { $units = ['B','KiB','MiB','GiB','TiB']; $i = 0; $value = (float) $bytes; while ($value >= 1024 && $i < count($units) - 1) { $value /= 1024; $i++; } return ($i === 0 ? (string) (int) $value : number_format($value, 1)) . ' ' . $units[$i]; }
|
|
function sfm_icon_for_name(string $name): string { $ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION)); return match ($ext) { 'jpg','jpeg','png','gif','webp','svg' => 'file-image-o', 'pdf' => 'file-pdf-o', 'zip','gz','rar','7z','tar' => 'file-archive-o', 'mp3','wav','ogg','m4a' => 'file-audio-o', 'mp4','mkv','webm','avi' => 'file-video-o', 'php','js','ts','css','html','htm','json','xml','yml','yaml','md','txt','log' => 'file-code-o', default => 'file-o' }; }
|
|
|
|
function sfm_url(string $path = '', array $extra = []): string { $query = array_merge(['path' => $path], $extra); if ($path === '') unset($query['path']); return sfm_self_url() . ($query ? '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) : ''); }
|
|
function sfm_action_url(string $action, string $path): string { return sfm_self_url() . '?' . http_build_query(['action' => $action, 'path' => $path], '', '&', PHP_QUERY_RFC3986); }
|
|
function sfm_redirect(string $path, string $message): void { $_SESSION['flash'] = $message; header('Location: ' . sfm_url($path)); exit; }
|
|
function sfm_breadcrumbs(string $path): string { if ($path === '') return ''; $html = ''; $build = []; foreach (explode('/', $path) as $part) { $build[] = $part; $html .= ' <span class="text-body-secondary mx-1">/</span> <a class="text-decoration-none" href="' . sfm_h(sfm_url(implode('/', $build))) . '">' . sfm_h($part) . '</a>'; } return $html; }
|
|
function sfm_fail(int $status, string $message): void { http_response_code($status); header('Content-Type: text/plain; charset=utf-8'); echo $message; exit; }
|
|
|
|
function sfm_client_ip(): string { return (string) ($_SERVER['REMOTE_ADDR'] ?? ''); }
|
|
function sfm_ip_is_allowed(string $ip, string $mode, array $rules): bool { if ($mode === 'off') return true; $matched = false; foreach ($rules as $rule) { if (sfm_ip_matches_rule($ip, $rule)) { $matched = true; break; } } return $mode === 'allow' ? $matched : !$matched; }
|
|
function sfm_ip_matches_rule(string $ip, string $rule): bool { if (!str_contains($rule, '/')) return hash_equals($rule, $ip); [$network, $prefix] = explode('/', $rule, 2); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false || filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false || !ctype_digit($prefix) || (int) $prefix > 32) return false; $mask = -1 << (32 - (int) $prefix); return ((ip2long($ip) & $mask) === (ip2long($network) & $mask)); }
|
|
?>
|