Created
March 27, 2026 18:48
-
-
Save JensTech/4c7a19e7b83999e7edc709f7373a8fb7 to your computer and use it in GitHub Desktop.
PHP server side terminal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| // Simple PHP Terminal | |
| // I would recommend you add a form of auth if you deploy this | |
| // This publicly allows access to your sever without auth so | |
| // lock it down on your server | |
| if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| header('Content-Type: text/plain'); | |
| $cmd = trim($_POST['cmd'] ?? ''); | |
| if ($cmd === '') { | |
| echo ''; | |
| exit; | |
| } | |
| $output = shell_exec($cmd . ' 2>&1'); | |
| echo $output ?? '(no output)'; | |
| exit; | |
| } | |
| ?> | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>terminal</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap'); | |
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| :root { | |
| --bg: #0d0d0d; | |
| --surface: #111; | |
| --border: #1f1f1f; | |
| --green: #00ff88; | |
| --dim: #3a3a3a; | |
| --text: #c8c8c8; | |
| --error: #ff4f4f; | |
| --tab-h: 32px; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 13px; | |
| overflow: hidden; | |
| } | |
| body { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| #titlebar { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 16px; | |
| background: var(--surface); | |
| border-bottom: 1px solid var(--border); | |
| user-select: none; | |
| flex-shrink: 0; | |
| } | |
| .dot { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .dot.r { | |
| background: #ff5f56; | |
| cursor: pointer; | |
| } | |
| .dot.y { | |
| background: #ffbd2e; | |
| } | |
| .dot.g { | |
| background: #27c93f; | |
| } | |
| #titlebar-label { | |
| margin-left: 8px; | |
| color: var(--dim); | |
| font-size: 11px; | |
| letter-spacing: 0.08em; | |
| } | |
| #tabbar { | |
| display: flex; | |
| align-items: stretch; | |
| background: var(--surface); | |
| border-bottom: 1px solid var(--border); | |
| height: var(--tab-h); | |
| flex-shrink: 0; | |
| overflow-x: auto; | |
| } | |
| #tabbar::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .tab { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 0 12px; | |
| cursor: pointer; | |
| color: var(--dim); | |
| font-size: 11px; | |
| border-right: 1px solid var(--border); | |
| white-space: nowrap; | |
| transition: color 0.15s; | |
| user-select: none; | |
| } | |
| .tab:hover { | |
| color: var(--text); | |
| } | |
| .tab.active { | |
| color: var(--green); | |
| background: var(--bg); | |
| } | |
| .tab .tab-close { | |
| opacity: 0; | |
| font-size: 14px; | |
| line-height: 1; | |
| color: var(--dim); | |
| transition: opacity 0.15s, color 0.15s; | |
| padding: 0 2px; | |
| } | |
| .tab:hover .tab-close { | |
| opacity: 1; | |
| } | |
| .tab .tab-close:hover { | |
| color: #ff5f56; | |
| } | |
| #btn-new-tab { | |
| display: flex; | |
| align-items: center; | |
| padding: 0 12px; | |
| cursor: pointer; | |
| color: var(--dim); | |
| font-size: 18px; | |
| line-height: 1; | |
| transition: color 0.15s; | |
| user-select: none; | |
| flex-shrink: 0; | |
| } | |
| #btn-new-tab:hover { | |
| color: var(--green); | |
| } | |
| #btn-new-tab.disabled { | |
| color: var(--border); | |
| cursor: not-allowed; | |
| } | |
| #split-container { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| .pane { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 1; | |
| overflow: hidden; | |
| min-width: 0; | |
| } | |
| .pane+.pane { | |
| border-left: 1px solid var(--border); | |
| } | |
| .pane-output { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| line-height: 1.7; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .pane-output::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .pane-output::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .pane-output::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .entry { | |
| margin-bottom: 6px; | |
| } | |
| .prompt-line { | |
| color: var(--green); | |
| } | |
| .prompt-line::before { | |
| content: '$ '; | |
| opacity: 0.5; | |
| } | |
| .result { | |
| color: var(--text); | |
| } | |
| .result.empty { | |
| color: var(--dim); | |
| font-style: italic; | |
| } | |
| .disconnected-msg { | |
| color: var(--dim); | |
| padding: 8px 0; | |
| } | |
| .btn-reconnect { | |
| background: none; | |
| border: 1px solid var(--dim); | |
| color: var(--green); | |
| font-family: inherit; | |
| font-size: 12px; | |
| padding: 2px 10px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| margin-left: 8px; | |
| } | |
| .resizer { | |
| width: 4px; | |
| background: var(--border); | |
| cursor: col-resize; | |
| flex-shrink: 0; | |
| transition: background 0.15s; | |
| } | |
| .resizer:hover, | |
| .resizer.dragging { | |
| background: var(--dim); | |
| } | |
| .pane-inputbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 16px; | |
| border-top: 1px solid var(--border); | |
| background: var(--surface); | |
| flex-shrink: 0; | |
| } | |
| .pane-inputbar span { | |
| color: var(--green); | |
| opacity: 0.7; | |
| white-space: nowrap; | |
| } | |
| .pane-cmd { | |
| flex: 1; | |
| background: transparent; | |
| border: none; | |
| outline: none; | |
| color: var(--green); | |
| font-family: inherit; | |
| font-size: 13px; | |
| caret-color: var(--green); | |
| } | |
| .pane-cmd::placeholder { | |
| color: var(--dim); | |
| } | |
| #btn-split { | |
| margin-left: auto; | |
| color: var(--dim); | |
| font-size: 11px; | |
| cursor: pointer; | |
| padding: 2px 8px; | |
| border: 1px solid var(--border); | |
| border-radius: 3px; | |
| transition: color 0.15s, border-color 0.15s; | |
| user-select: none; | |
| } | |
| #btn-split:hover { | |
| color: var(--green); | |
| border-color: var(--dim); | |
| } | |
| #btn-split.disabled { | |
| color: var(--border); | |
| border-color: var(--border); | |
| cursor: not-allowed; | |
| } | |
| #btn-popout { | |
| color: var(--dim); | |
| font-size: 11px; | |
| cursor: pointer; | |
| padding: 2px 8px; | |
| border: 1px solid var(--border); | |
| border-radius: 3px; | |
| transition: color 0.15s, border-color 0.15s; | |
| user-select: none; | |
| margin-left: 6px; | |
| } | |
| #btn-popout:hover { | |
| color: var(--green); | |
| border-color: var(--dim); | |
| } | |
| body.popout #titlebar, | |
| body.popout #tabbar { | |
| display: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="titlebar"> | |
| <div class="dot r" id="btn-kill" title="Disconnect active pane"></div> | |
| <div class="dot y"></div> | |
| <div class="dot g"></div> | |
| <span id="titlebar-label"> <?= htmlspecialchars(php_uname('n')) ?> — bash </span> | |
| <div id="btn-split" title="Split pane">⊞ split</div> | |
| <div id="btn-popout" title="Pop out tab">⤢ popout</div> | |
| </div> | |
| <div id="tabbar"> | |
| <div id="btn-new-tab" title="New tab">+</div> | |
| </div> | |
| <div id="split-container"></div> | |
| <script> | |
| const MAX_TABS = 5; | |
| const hostname = <?= json_encode(php_uname('n')) ?>; | |
| const osinfo = <?= json_encode(php_uname('s') . ' ' . php_uname('r')) ?>; | |
| let tabs = []; // { id, label, panes: [paneObj, ...] } | |
| let activeTabId = null; | |
| let tabCounter = 0; | |
| let paneCounter = 0; | |
| const tabbar = document.getElementById('tabbar'); | |
| const btnNewTab = document.getElementById('btn-new-tab'); | |
| const splitContainer = document.getElementById('split-container'); | |
| const btnSplit = document.getElementById('btn-split'); | |
| const btnKill = document.getElementById('btn-kill'); | |
| // window panes | |
| function createPane() { | |
| const id = ++paneCounter; | |
| const history = []; | |
| let histIdx = -1; | |
| let killed = false; | |
| const el = document.createElement('div'); | |
| el.className = 'pane'; | |
| el.dataset.paneId = id; | |
| const output = document.createElement('div'); | |
| output.className = 'pane-output'; | |
| // welcome line | |
| const welcome = document.createElement('div'); | |
| welcome.className = 'entry'; | |
| welcome.innerHTML = ` | |
| <div class="result">Connected to | |
| <strong style="color:var(--green)">${hostname}</strong> · ${osinfo} | |
| </div>`; | |
| output.appendChild(welcome); | |
| const inputbar = document.createElement('div'); | |
| inputbar.className = 'pane-inputbar'; | |
| const prompt = document.createElement('span'); | |
| prompt.textContent = '$'; | |
| const input = document.createElement('input'); | |
| input.className = 'pane-cmd'; | |
| input.type = 'text'; | |
| input.autocomplete = 'off'; | |
| input.spellcheck = false; | |
| input.placeholder = 'type a command…'; | |
| inputbar.appendChild(prompt); | |
| inputbar.appendChild(input); | |
| el.appendChild(output); | |
| el.appendChild(inputbar); | |
| // disconnected msg (hidden by default) | |
| const discMsg = document.createElement('div'); | |
| discMsg.className = 'disconnected-msg'; | |
| discMsg.style.display = 'none'; | |
| discMsg.innerHTML = `Session terminated. | |
| <button class="btn-reconnect">Connect</button>`; | |
| output.appendChild(discMsg); | |
| discMsg.querySelector('.btn-reconnect').addEventListener('click', () => { | |
| killed = false; | |
| btnKill.style.background = '#ff5f56'; | |
| inputbar.style.display = 'flex'; | |
| discMsg.style.display = 'none'; | |
| input.disabled = false; | |
| input.focus(); | |
| }); | |
| input.addEventListener('keydown', async e => { | |
| if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| if (histIdx < history.length - 1) histIdx++; | |
| input.value = history[history.length - 1 - histIdx] ?? ''; | |
| return; | |
| } | |
| if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| if (histIdx > 0) histIdx--; | |
| else { | |
| histIdx = -1; | |
| input.value = ''; | |
| return; | |
| } | |
| input.value = history[history.length - 1 - histIdx] ?? ''; | |
| return; | |
| } | |
| if (e.key !== 'Enter') return; | |
| const cmd = input.value.trim(); | |
| input.value = ''; | |
| histIdx = -1; | |
| if (!cmd) return; | |
| history.push(cmd); | |
| const entry = document.createElement('div'); | |
| entry.className = 'entry'; | |
| const promptLine = document.createElement('div'); | |
| promptLine.className = 'prompt-line'; | |
| promptLine.textContent = cmd; | |
| entry.appendChild(promptLine); | |
| if (cmd === 'clear' || cmd === 'cls') { | |
| // clear all entries but keep welcome + discMsg | |
| output.querySelectorAll('.entry').forEach(n => n.remove()); | |
| return; | |
| } | |
| const resultLine = document.createElement('div'); | |
| resultLine.className = 'result'; | |
| resultLine.textContent = '…'; | |
| entry.appendChild(resultLine); | |
| output.insertBefore(entry, discMsg); | |
| output.scrollTop = output.scrollHeight; | |
| try { | |
| const fd = new FormData(); | |
| fd.append('cmd', cmd); | |
| const res = await fetch(window.location.pathname, { | |
| method: 'POST', | |
| body: fd | |
| }); | |
| const text = await res.text(); | |
| resultLine.textContent = text || ''; | |
| if (!text.trim()) { | |
| resultLine.textContent = '(no output)'; | |
| resultLine.classList.add('empty'); | |
| } | |
| } catch (err) { | |
| resultLine.textContent = 'Request failed: ' + err.message; | |
| resultLine.style.color = 'var(--error)'; | |
| } | |
| output.scrollTop = output.scrollHeight; | |
| }); | |
| return { | |
| id, | |
| el, | |
| output, | |
| inputbar, | |
| input, | |
| discMsg, | |
| kill() { | |
| killed = true; | |
| inputbar.style.display = 'none'; | |
| discMsg.style.display = 'block'; | |
| input.disabled = true; | |
| output.scrollTop = output.scrollHeight; | |
| }, | |
| focus() { | |
| input.focus(); | |
| } | |
| }; | |
| } | |
| // tabs | |
| function createTab(label) { | |
| const id = ++tabCounter; | |
| const pane = createPane(); | |
| // tab element | |
| const tabEl = document.createElement('div'); | |
| tabEl.className = 'tab'; | |
| tabEl.dataset.tabId = id; | |
| const labelEl = document.createElement('span'); | |
| labelEl.textContent = label || `bash ${id}`; | |
| const closeEl = document.createElement('span'); | |
| closeEl.className = 'tab-close'; | |
| closeEl.textContent = '×'; | |
| closeEl.title = 'Close tab'; | |
| closeEl.addEventListener('click', e => { | |
| e.stopPropagation(); | |
| closeTab(id); | |
| }); | |
| tabEl.appendChild(labelEl); | |
| tabEl.appendChild(closeEl); | |
| tabEl.addEventListener('click', () => activateTab(id)); | |
| // insert before + button | |
| tabbar.insertBefore(tabEl, btnNewTab); | |
| const tab = { | |
| id, | |
| label: label || `bash ${id}`, | |
| tabEl, | |
| panes: [pane] | |
| }; | |
| tabs.push(tab); | |
| return tab; | |
| } | |
| function closeTab(id) { | |
| if (tabs.length === 1) return; // keep at least one | |
| const idx = tabs.findIndex(t => t.id === id); | |
| if (idx === -1) return; | |
| tabs[idx].tabEl.remove(); | |
| tabs.splice(idx, 1); | |
| if (activeTabId === id) { | |
| activateTab(tabs[Math.min(idx, tabs.length - 1)].id); | |
| } | |
| updateNewTabBtn(); | |
| } | |
| function activateTab(id) { | |
| activeTabId = id; | |
| tabs.forEach(t => t.tabEl.classList.toggle('active', t.id === id)); | |
| renderSplitContainer(); | |
| } | |
| function updateNewTabBtn() { | |
| const full = tabs.length >= MAX_TABS; | |
| btnNewTab.classList.toggle('disabled', full); | |
| btnNewTab.title = full ? `Max ${MAX_TABS} tabs` : 'New tab'; | |
| } | |
| // split terminal | |
| function getActiveTab() { | |
| return tabs.find(t => t.id === activeTabId); | |
| } | |
| function renderSplitContainer() { | |
| splitContainer.innerHTML = ''; | |
| const tab = getActiveTab(); | |
| if (!tab) return; | |
| tab.panes.forEach((pane, i) => { | |
| if (i > 0) { | |
| const resizer = document.createElement('div'); | |
| resizer.className = 'resizer'; | |
| initResizer(resizer, tab, i); | |
| splitContainer.appendChild(resizer); | |
| } | |
| splitContainer.appendChild(pane.el); | |
| }); | |
| updateSplitBtn(); | |
| // focus last pane | |
| tab.panes[tab.panes.length - 1].focus(); | |
| } | |
| function updateSplitBtn() { | |
| const tab = getActiveTab(); | |
| const full = tab && tab.panes.length >= 3; | |
| btnSplit.classList.toggle('disabled', full); | |
| btnSplit.title = full ? 'Max 3 splits' : 'Split pane'; | |
| } | |
| function addSplit() { | |
| const tab = getActiveTab(); | |
| if (!tab || tab.panes.length >= 3) return; | |
| const pane = createPane(); | |
| tab.panes.push(pane); | |
| renderSplitContainer(); | |
| } | |
| // resizer functions | |
| function initResizer(resizer, tab, paneIdx) { | |
| let startX, startWidths; | |
| resizer.addEventListener('mousedown', e => { | |
| e.preventDefault(); | |
| resizer.classList.add('dragging'); | |
| startX = e.clientX; | |
| startWidths = tab.panes.map(p => p.el.getBoundingClientRect().width); | |
| const onMove = e => { | |
| const dx = e.clientX - startX; | |
| const total = startWidths.reduce((a, b) => a + b, 0); | |
| const leftW = Math.max(80, startWidths[paneIdx - 1] + dx); | |
| const rightW = Math.max(80, startWidths[paneIdx] - dx); | |
| const leftPct = (leftW / total * 100).toFixed(2); | |
| const rightPct = (rightW / total * 100).toFixed(2); | |
| tab.panes[paneIdx - 1].el.style.flex = `0 0 ${leftPct}%`; | |
| tab.panes[paneIdx].el.style.flex = `0 0 ${rightPct}%`; | |
| }; | |
| const onUp = () => { | |
| resizer.classList.remove('dragging'); | |
| window.removeEventListener('mousemove', onMove); | |
| window.removeEventListener('mouseup', onUp); | |
| }; | |
| window.addEventListener('mousemove', onMove); | |
| window.addEventListener('mouseup', onUp); | |
| }); | |
| } | |
| // kill the active pain | |
| btnKill.addEventListener('click', () => { | |
| const tab = getActiveTab(); | |
| if (!tab) return; | |
| // kill the focused/last pane | |
| tab.panes[tab.panes.length - 1].kill(); | |
| btnKill.style.background = '#555'; | |
| }); | |
| // spawn a new tab | |
| btnNewTab.addEventListener('click', () => { | |
| if (tabs.length >= MAX_TABS) return; | |
| const tab = createTab(); | |
| activateTab(tab.id); | |
| updateNewTabBtn(); | |
| }); | |
| // Split btn | |
| btnSplit.addEventListener('click', () => addSplit()); | |
| // popout btn | |
| const btnPopout = document.getElementById('btn-popout'); | |
| const isPopout = new URLSearchParams(window.location.search).has('popout'); | |
| if (isPopout) { | |
| document.body.classList.add('popout'); | |
| document.title = 'terminal — ' + hostname; | |
| btnPopout.style.display = 'none'; | |
| } | |
| let popoutWin = null; | |
| btnPopout.addEventListener('click', () => { | |
| if (popoutWin && !popoutWin.closed) { | |
| popoutWin.focus(); | |
| return; | |
| } | |
| const w = 800, | |
| h = 520; | |
| const left = Math.round(screen.width / 2 - w / 2); | |
| const top = Math.round(screen.height / 2 - h / 2); | |
| popoutWin = window.open(window.location.pathname + '?popout', `terminal_popout_${Date.now()}`, `width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no`); | |
| if (!popoutWin) { | |
| alert('Pop-up blocked! Allow pop-ups for this site.'); | |
| return; | |
| } | |
| btnPopout.textContent = '⤢ focus'; | |
| btnPopout.title = 'Bring popout to front'; | |
| const checkClosed = setInterval(() => { | |
| if (popoutWin.closed) { | |
| clearInterval(checkClosed); | |
| btnPopout.textContent = '⤢ popout'; | |
| btnPopout.title = 'Pop out tab'; | |
| popoutWin = null; | |
| } | |
| }, 500); | |
| }); | |
| // Init | |
| const first = createTab('bash 1'); | |
| activateTab(first.id); | |
| updateNewTabBtn(); | |
| </script> | |
| </body> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment