需要在伺服器上快速編輯程式碼、查看日誌、修改設定?本文提供一個單一 PHP 檔案即可運行的線上編輯器,整合 ACE Editor(語法高亮、主題、自動完成)、密碼認證(Session + Cookie)、檔案樹瀏覽、新建/刪除/重命名/預覽、儲存快捷鍵,適合開發者快速部署於測試/正式環境(務必設定強密碼、限制存取 IP、僅 HTTPS 使用)。
ace/ 資料夾(需包含 ace.js、mode-*.js、theme-*.js、worker-*.js)。alertify.min.js、alertify.core.min.css、alertify.default.min.css、jquery.min.js)。editor.php(或任意名稱),上傳至伺服器。https://your-domain.com/editor.php,依提示設定強密碼(建議 16+ 字元、含大小寫數字符號)。<?php
// ========== 設定區 ==========
session_start();
define('ROOT_DIR', __DIR__); // 限制存取根目錄
$acePath = 'ace/ace.js'; // ACE 主檔相對路徑
$assets = [
'jquery' => 'jquery.min.js',
'alertify_js' => 'alertify.min.js',
'alertify_css' => 'alertify.default.min.css',
'alertify_core' => 'alertify.core.min.css',
];
// ========== 工具函數 ==========
function isLogged(): bool { return isset($_SESSION['auth']) && $_SESSION['auth'] === true; }
function jsonResponse(array $data): void { header('Content-Type: application/json'); echo json_encode($data); exit; }
function safePath(string $relPath): string {
$abs = realpath(ROOT_DIR . DIRECTORY_SEPARATOR . $relPath);
$root = realpath(ROOT_DIR);
if ($abs === false || !str_starts_with($abs, $root)) {
throw new Exception('路徑超出允許範圍');
}
return $abs;
}
function listDir(string $relPath): array {
$abs = safePath($relPath);
$items = [];
foreach (new DirectoryIterator($abs) as $info) {
if ($info->isDot()) continue;
$name = $info->getFilename();
$path = $relPath ? $relPath . '/' . $name : $name;
$items[] = [
'name' => $name,
'path' => $path,
'type' => $info->isDir() ? 'dir' : 'file',
'size' => $info->getSize(),
'mtime' => $info->getMTime(),
];
}
usort($items, fn($a, $b) => ($a['type'] === 'dir' ? 0 : 1) - ($b['type'] === 'dir' ? 0 : 1) ?: strcmp($a['name'], $b['name']));
return $items;
}
// ========== AJAX 處理器 ==========
if (isLogged() && isset($_SERVER['HTTP_X_REQUESTED_WITH'])) {
header('Content-Type: application/json');
$action = $_POST['action'] ?? ($_GET['action'] ?? '');
try {
switch ($action) {
case 'list': jsonResponse(['items' => listDir($_POST['path'] ?? '')]); break;
case 'read':
$content = file_get_contents(safePath($_POST['path']));
$ext = pathinfo($_POST['path'], PATHINFO_EXTENSION);
$modes = ['php'=>'php','js'=>'javascript','css'=>'css','html'=>'html','json'=>'json','sql'=>'sql','xml'=>'xml','md'=>'markdown','py'=>'python','sh'=>'sh','yml'=>'yaml','txt'=>'text'];
jsonResponse(['content' => $content, 'mode' => $modes[$ext] ?? 'text']); break;
case 'save': file_put_contents(safePath($_POST['path']), $_POST['content']); jsonResponse(['ok'=>true]); break;
case 'create':
$target = safePath($_POST['target']);
$name = basename($_POST['name']);
$newPath = $target . DIRECTORY_SEPARATOR . $name;
if (str_contains($name, '.') && isset($modes[pathinfo($name, PATHINFO_EXTENSION)])) {
file_put_contents($newPath, '');
} else {
mkdir($newPath, 0755, true);
}
jsonResponse(['ok'=>true, 'path' => $_POST['target'] . '/' . $name]); break;
case 'delete':
$target = safePath($_POST['path']);
if (is_dir($target)) {
new RecursiveIteratorIterator(new RecursiveDirectoryIterator($target, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
// 簡化:實際需遞歸刪除
function rrmdir($d) { foreach(glob($d.'/*') as $f) is_dir($f)?rrmdir($f):unlink($f); rmdir($d); }
rrmdir($target);
} else { unlink($target); }
jsonResponse(['ok'=>true]); break;
case 'rename':
$old = safePath($_POST['old']);
$new = safePath(dirname($_POST['old']) . '/' . basename($_POST['new']));
rename($old, $new);
jsonResponse(['ok'=>true]); break;
default: jsonResponse(['ok'=>false, 'error'=>'未知操作']);
}
} catch (Throwable $e) {
jsonResponse(['ok'=>false, 'error'=>$e->getMessage()]);
}
}
// ========== 認證流程 ==========
if (!isLogged()) {
if (isset($_POST['password'])) {
// 首次設定或驗證
$hashFile = ROOT_DIR . '/.editor_pass';
if (!file_exists($hashFile)) {
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);
file_put_contents($hashFile, $hash);
}
if (password_verify($_POST['password'], file_get_contents($hashFile))) {
$_SESSION['auth'] = true;
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
$loginError = '密碼錯誤';
}
// 輸出登入頁面(含 Alertify、jQuery)
// ... 省略 HTML,實際部署請使用完整版本 ...
exit;
}
// ========== 主編輯器頁面 ==========
$modeMap = ['php'=>'php','js'=>'javascript','css'=>'css','html'=>'html','json'=>'json','sql'=>'sql','xml'=>'xml','md'=>'markdown','py'=>'python','sh'=>'sh','yml'=>'yaml','txt'=>'text'];
?>
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>PHP 線上編輯器</title>
<link rel="stylesheet" href="<?=$assets['alertify_core']?>" />
<link rel="stylesheet" href="<?=$assets['alertify_css']?>" />
<style>
body {margin:0;background:#1e1e1e;color:#ddd;font:13px/1.5 Monaco,Menlo,Consolas,monospace;height:100vh;display:flex;}
#sidebar {width:280px;border-right:1px solid #333;overflow:auto;padding:10px;background:#252526;}
#tree {list-style:none;padding:0;margin:0;}
#tree li {padding:4px 8px;cursor:pointer;background:url(data:image/svg+xml;base64,...) no-repeat 4px center;padding-left:24px;}
#tree li.dir {background-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBmaWxsPSIjZmZjMDAwIiBkPSJNMTMgNkgzYTEgMSAwIDAgMS0xLTFWNGExIDEgMCAwIDEgMS0xaDEwYTEgMSAwIDAgMSAxIDF2MmExIDEgMCAwIDEtMSAxeiIvPjwvc3ZnPg==);}
#tree li.file {background-image:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQgMmgtNGwtNCA0SDZhMiAyIDAgMCAwLTIgMnYxMmEyIDIgMCAwIDAgMiAyaDEyYTIgMiAwIDAgMCAyLTJWNGEyIDIgMCAwIDAtMi0yeiIvPjwvc3ZnPg==);}
#tree li:hover {background-color:#37373d;}
#tree li.active {color:#4ec9b0;}
#editor {flex:1;position:relative;}
#toolbar {padding:8px;background:#2d2d2d;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;}
#toolbar button {background:#0e639c;color:#fff;border:none;padding:4px 12px;border-radius:3px;cursor:pointer;}
#toolbar button:hover {background:#1177bb;}
#toolbar span {color:#888;font-size:12px;}
</style>
</head><body>
<div id="sidebar">
<div style="margin-bottom:10px;display:flex;gap:4px;">
<button onclick="loadTree('')">刷新樹</button>
<button onclick="logout()">登出</button>
<button onclick="resetEditor()">重置(改檔名)</button>
</div>
<ul id="tree"></ul>
</div>
<div id="editor">
<div id="toolbar">
<button onclick="saveFile()">儲存 (Ctrl+S)</button>
<button onclick="refreshFile()">重新載入</button>
<span id="currentPath">請從左側選擇檔案</span>
</div>
<pre id="aceEditor" style="position:absolute;top:40px;bottom:0;left:0;right:0;"></pre>
</div>
<script src="<?=$assets['jquery']?>"></script>
<script src="<?=$acePath?>"></script>
<script src="<?=$assets['alertify_js']?>"></script>
<script>
const MODE_MAP = <?=json_encode($modeMap)?>;
let editor = ace.edit('aceEditor');
editor.setTheme('ace/theme/monokai');
editor.session.setUseWrapMode(true);
editor.renderer.setShowPrintMargin(false);
editor.commands.addCommand({name:'save', bindKey:{win:'Ctrl-S', mac:'Command-S'}, exec:saveFile});
let currentPath = '';
function loadTree(path) {
$('#tree li.active').removeClass('active');
$.post('', {action:'list', path}, function(res){
const $ul = path ? $('#tree li[data-path="'+path+'"]') : $('#tree');
$ul.find('> ul').remove();
if (res.items.length) {
const $newUl = $('
');
res.items.forEach(item => {
const $li = $(' { e.stopPropagation(); loadTree(item.path); });
} else {
$li.on('click', e => { e.stopPropagation(); openFile(item.path); });
}
// 右鍵選單簡化:實際可用 contextmenu.js
$newUl.append($li);
});
$ul.append($newUl);
}
}, 'json');
}
function openFile(path) {
currentPath = path;
$('#tree li.active').removeClass('active');
$('#tree li[data-path="'+path+'"]').addClass('active');
$('#currentPath').text(path);
$.post('', {action:'read', path}, function(res){
editor.session.setValue(res.content);
editor.session.setMode('ace/mode/' + (res.mode || 'text'));
editor.clearSelection();
editor.focus();
}, 'json');
}
function saveFile() {
if (!currentPath) return alertify.error('未選擇檔案');
$.post('', {action:'save', path:currentPath, content:editor.getValue()}, function(res){
res.ok ? alertify.success('儲存成功') : alertify.error(res.error);
}, 'json');
}
function refreshFile() { if (currentPath) openFile(currentPath); }
function logout() { $.post('', {action:'logout'}, () => location.reload()); }
function resetEditor() { if(confirm('確定要重命名編輯器檔案嗎?')) { $.post('', {action:'rename', old:location.pathname, new:prompt('新檔名:')}, res=>res.ok?location.reload():alertify.error(res.error)); }}
// 初始載入樹
loadTree('');
</script>
</body></html>
password_hash()(bcrypt)而非 MD5。disable_functions 包含 exec,shell_exec,system,passthru,proc_open 等,防編輯器被植入後門利用。www-data 讀寫,其他目錄不可寫入。git status/diff、支援 Commit/Push。xterm.js + WebSocket 實現瀏覽器終端機。這個單檔編輯器雖輕量,但功能完整,是開發者「隨時隨地修修改改」的得力工具——前提是鎖好大門。