1
0
forked from Thunix/www

added terminal interface as default. old website as no-js fallback

This commit is contained in:
root
2026-01-21 09:58:53 -07:00
parent 81d9ddfd03
commit 6f709886ed
14 changed files with 1569 additions and 19 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$rootDir = dirname(__DIR__, 2);
if (!is_dir($rootDir)) {
http_response_code(500);
echo json_encode(['error' => 'Invalid root directory'], JSON_UNESCAPED_SLASHES);
exit;
}
function json_out(array $data, int $status = 200): void
{
http_response_code($status);
try {
echo json_encode(
$data,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
} catch (Throwable $e) {
http_response_code(500);
echo '{"error":"JSON encoding failed"}';
}
exit;
}

58
terminal/api/menu.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require __DIR__ . '/terminal/_bootstrap.php';
$file = $rootDir . '/includes/sidebar.md';
if (!is_file($file)) {
json_out(['sections' => []]);
}
$md = file_get_contents($file);
if ($md === false) {
json_out(['sections' => []]);
}
$lines = preg_split('/\r\n|\n|\r/', $md) ?: [];
$sections = [];
$current = null;
foreach ($lines as $line) {
$raw = rtrim($line);
// Section header: "- Title"
if (preg_match('/^\-\s{2,}(.+)$/', $raw, $m)) {
if ($current !== null) {
$sections[] = $current;
}
$current = [
'title' => trim($m[1]),
'items' => [],
];
continue;
}
// Menu item: " - [Text](Href)"
if ($current !== null && preg_match('/^\s+\-\s{2,}\[(.+?)\]\((.+?)\)\s*$/', $raw, $m)) {
$text = trim($m[1]);
$href = trim($m[2]);
$internal = str_starts_with($href, '/');
$slug = $internal ? ltrim(parse_url($href, PHP_URL_PATH) ?? '', '/') : '';
$current['items'][] = [
'text' => $text,
'href' => $href,
'internal' => $internal,
'slug' => $slug,
];
continue;
}
}
if ($current !== null) {
$sections[] = $current;
}
json_out(['sections' => $sections]);

53
terminal/api/page.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_bootstrap.php';
$p = $_GET['p'] ?? '';
if (!is_string($p)) {
json_out(['error' => 'Invalid page'], 400);
}
$slug = trim($p);
$slug = ltrim($slug, '/');
$slug = preg_replace('/\?.*$/', '', $slug) ?? $slug;
if ($slug === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9_-]*$/', $slug)) {
json_out(['error' => 'Invalid page'], 400);
}
if (preg_match('/^success\d+$/', $slug) === 1) {
json_out(['error' => 'Not found'], 404);
}
$file = $rootDir . '/articles/' . $slug . '.md';
if (!is_file($file)) {
json_out(['error' => 'Not found'], 404);
}
$md = file_get_contents($file);
if ($md === false) {
json_out(['error' => 'Failed to read page'], 500);
}
$parsedownPath = $rootDir . '/parsedown-1.7.3/Parsedown.php';
$extraPath = $rootDir . '/parsedown-extra-0.7.1/ParsedownExtra.php';
if (is_file($parsedownPath)) {
require_once $parsedownPath;
}
if (is_file($extraPath)) {
require_once $extraPath;
}
$html = '';
if (class_exists('ParsedownExtra')) {
$pd = new ParsedownExtra();
$html = $pd->text($md);
}
json_out([
'slug' => $slug,
'markdown' => $md,
'html' => $html,
]);

51
terminal/api/pages.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_bootstrap.php';
$articlesDir = $rootDir . '/articles';
if (!is_dir($articlesDir)) {
json_out(['pages' => []]);
}
$pages = [];
$files = glob($articlesDir . '/*.md') ?: [];
sort($files);
foreach ($files as $file) {
$slug = basename($file, '.md');
if (preg_match('/^success\d+$/', $slug) === 1) {
continue;
}
$title = $slug;
$fh = fopen($file, 'rb');
if ($fh !== false) {
for ($i = 0; $i < 20; $i++) {
$line = fgets($fh);
if ($line === false) {
break;
}
$line = trim($line);
if ($line === '') {
continue;
}
if (preg_match('/^#\s+(.+)$/', $line, $m)) {
$title = trim($m[1]);
break;
}
$title = mb_substr($line, 0, 80);
break;
}
fclose($fh);
}
$pages[] = [
'slug' => $slug,
'title' => $title,
];
}
json_out(['pages' => $pages]);

49
terminal/api/server.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_bootstrap.php';
$candidates = [
$rootDir . '/report',
$rootDir . '/includes/report',
];
$report = null;
foreach ($candidates as $cand) {
if (is_file($cand)) {
$report = $cand;
break;
}
}
if ($report === null) {
json_out(['rows' => [], 'lastUpdated' => null]);
}
$rows = [];
$fh = fopen($report, 'rb');
if ($fh === false) {
json_out(['rows' => [], 'lastUpdated' => null]);
}
while (($line = fgets($fh)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$parts = str_getcsv($line);
if (count($parts) < 3) {
continue;
}
$rows[] = [
'host' => (string)$parts[0],
'check' => (string)$parts[1],
'status' => (string)$parts[2],
];
}
fclose($fh);
$mtime = @filemtime($report);
$last = $mtime ? gmdate('c', (int)$mtime) : null;
json_out(['rows' => $rows, 'lastUpdated' => $last]);

42
terminal/api/users.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_bootstrap.php';
$siteRoot = '//' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$skelIndex = '/etc/skel/public_html/index.html';
$skelIndexCksum = is_file($skelIndex) ? @md5_file($skelIndex) : null;
$users = [];
$homes = glob('/home/*', GLOB_ONLYDIR) ?: [];
foreach ($homes as $homeDir) {
$user = basename($homeDir);
if ($user === '' || $user === 'lost+found') {
continue;
}
$userIndex = $homeDir . '/public_html/index.html';
$userPub = $homeDir . '/public_html';
if (!is_dir($userPub)) {
continue;
}
$hasCustomIndex = false;
if (is_file($userIndex) && $skelIndexCksum !== null) {
$userCksum = @md5_file($userIndex);
$hasCustomIndex = ($userCksum !== false && $userCksum !== $skelIndexCksum);
} elseif (is_file($userIndex)) {
$hasCustomIndex = true;
}
$users[] = [
'username' => $user,
'url' => $siteRoot . '/~' . rawurlencode($user) . '/',
'hasContent' => $hasCustomIndex,
];
}
json_out(['users' => $users]);

159
terminal/index.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>thunix terminal</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<noscript><meta http-equiv="refresh" content="0;url=/main"></noscript>
<!-- xterm core styles -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
<style>
:root{
--mono: "Departure Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bg: #111;
--amber: #edb200;
--amber-txt: #ffc828;
--panel: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.08);
}
*{ box-sizing:border-box; }
html, body{
margin:0;
padding:0;
height:100%;
width:100%;
overflow:hidden;
background: var(--bg);
color: var(--amber);
font-family: var(--mono);
}
body::before{
content:'';
position:fixed;
inset:0;
pointer-events:none;
z-index:2;
background: linear-gradient(to bottom, transparent 50%, rgba(0,0,0,0.32) 51%);
background-size: 100% 4px;
}
#wrap{
position:relative;
z-index:1;
height:100%;
display:flex;
flex-direction:column;
padding: 6vh 0 6vh 0;
gap: 10px;
}
#head{
width: min(92vw, 1400px);
margin: 0 auto;
font-size: clamp(18px, 3vw, 44px);
text-shadow: 0 0 1.75rem rgba(237,178,0,0.55);
line-height:1.1;
}
#head small{
display:block;
font-size: 0.8rem;
opacity: 0.85;
margin-top: 8px;
text-shadow:none;
}
#main{
width: min(92vw, 1400px);
margin: 0 auto;
flex: 1;
display: grid;
grid-template-columns: 1.05fr 1.25fr;
gap: 12px;
align-items: stretch;
}
#terminalHost,
#contentHost{
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 24px rgba(237,178,0,0.09);
min-height: 0;
}
#terminal{ height: 100%; width: 100%; }
#contentFrame{
width: 100%;
height: 100%;
border: 0;
background: var(--bg);
}
@media (max-width: 1050px){
#main{ grid-template-columns: 1fr; grid-template-rows: 48vh 1fr; }
}
#terminalHost .xterm-rows a,
#terminalHost .xterm-screen a{
color: var(--amber-txt) !important;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
text-shadow: 0 0 0.85rem rgba(237,178,0,0.30);
}
#terminalHost .xterm-rows a:hover,
#terminalHost .xterm-screen a:hover{
filter: brightness(1.08);
text-shadow: 0 0 1.25rem rgba(237,178,0,0.55);
}
</style>
</head>
<body>
<noscript>
<div style="max-width: 900px; margin: 2rem auto; padding: 1.25rem 1.5rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 14px; background: rgba(0,0,0,0.55); color: #e6e6e6; font-family: system-ui, -apple-system, Segoe UI, sans-serif;">
<h1 style="margin: 0 0 0.6rem 0; font-size: 1.35rem; font-weight: 700;">Terminal mode needs JavaScript</h1>
<p style="margin: 0; opacity: 0.9; line-height: 1.4;">JavaScript is disabled, so the terminal UI cant run. Redirecting you to the classic site… If youre not redirected, use <a href="/main" style="color: #6ec5ff; text-decoration: underline;">the classic site</a>.</p>
</div>
</noscript>
<div id="wrap">
<div id="head">
🌻 thunix
<small>Type <strong>help</strong>. Click inside the terminal to focus.</small>
</div>
<div id="main">
<div id="terminalHost">
<div id="terminal" aria-label="Terminal"></div>
</div>
<div id="contentHost" aria-label="Content">
<iframe
id="contentFrame"
src="/terminal/view.php?page=main"
title="thunix content"
referrerpolicy="no-referrer"
loading="eager"
></iframe>
</div>
</div>
</div>
<script type="module" src="/terminal/terminal.js"></script>
</body>
</html>

545
terminal/terminal.js Normal file
View File

@@ -0,0 +1,545 @@
// thunix terminal UI powered by xterm.js (ESM via jsDelivr).
import { Terminal } from "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/+esm";
import { FitAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/+esm";
import { WebLinksAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.12.0/+esm";
const MODULE_BASE = new URL(".", import.meta.url);
const urlFromBase = (path) => new URL(path, MODULE_BASE).toString();
const DEFAULT_WEBMAIL_PATH = "/webmail/";
const ESC = "\x1b";
const CSI = `${ESC}[`;
const OSC = `${ESC}]`;
const ANSI = {
reset: `${CSI}0m`,
bold: `${CSI}1m`,
dim: `${CSI}2m`,
underline: `${CSI}4m`,
noUnderline: `${CSI}24m`,
clrLine: `${CSI}2K`,
fgBrightYellow: `${CSI}93m`,
fgWhite: `${CSI}37m`,
};
function osc8(url, text) {
const BEL = "\x07";
return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}`;
}
function absolutizeUrl(href) {
if (!href) return href;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return href;
if (href.startsWith("//")) return `${location.protocol}${href}`;
if (href.startsWith("/")) return `${location.origin}${href}`;
return new URL(href, location.href).toString();
}
function renderInlineLinks(line) {
return line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => {
const url = absolutizeUrl(String(href).trim());
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${text}${ANSI.reset}`;
return osc8(url, label);
});
}
function stripHtmlToText(html) {
let s = String(html || "");
s = s.replace(/<\s*br\s*\/?\s*>/gi, "\n");
s = s.replace(/<\s*\/(p|div|tr|li|table|form|h1|h2|h3|ul|ol)\s*>/gi, "\n");
s = s.replace(/<\s*(p|div|tr|li|table|form|h1|h2|h3|ul|ol)(\s[^>]*)?>/gi, "\n");
s = s.replace(/<a\s+[^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/gi, (_m, href, text) => {
const cleanText = String(text).replace(/<[^>]+>/g, "").trim() || href;
return `[${cleanText}](${href})`;
});
s = s.replace(/<[^>]+>/g, "");
s = s.replace(/&nbsp;/g, " ");
s = s.replace(/&amp;/g, "&");
s = s.replace(/&lt;/g, "<");
s = s.replace(/&gt;/g, ">");
s = s.replace(/&quot;/g, '"');
s = s.replace(/&#39;/g, "'");
return s;
}
function renderMarkdown(md) {
const input = stripHtmlToText(md);
const out = [];
const lines = input.replace(/\r\n/g, "\n").split("\n");
let inCode = false;
for (const raw of lines) {
let line = raw;
if (/^\s*```/.test(line)) {
inCode = !inCode;
out.push(inCode ? `${ANSI.dim}--- code ---${ANSI.reset}` : `${ANSI.dim}--- end ---${ANSI.reset}`);
continue;
}
if (inCode) {
out.push(line);
continue;
}
if (/^\s*#\s+/.test(line)) {
const title = line.replace(/^\s*#\s+/, "").trim();
out.push(`${ANSI.bold}${title}${ANSI.reset}`);
out.push(`${ANSI.dim}${"=".repeat(Math.min(78, title.length || 1))}${ANSI.reset}`);
continue;
}
if (/^\s*##\s+/.test(line)) {
const title = line.replace(/^\s*##\s+/, "").trim();
out.push(`${ANSI.bold}${title}${ANSI.reset}`);
out.push(`${ANSI.dim}${"-".repeat(Math.min(78, title.length || 1))}${ANSI.reset}`);
continue;
}
const mBullet = line.match(/^\s*[-*]\s+(.*)$/);
if (mBullet) {
line = ` - ${mBullet[1]}`;
}
line = line.replace(/\s+$/g, "");
line = renderInlineLinks(line);
out.push(line);
}
return out.join("\r\n");
}
class ThunixTerminal {
constructor(hostEl, frameEl) {
this.el = hostEl;
this.frame = frameEl;
this.term = null;
this.fit = new FitAddon();
this.buffer = "";
this.history = [];
this.historyIdx = -1;
this.pages = new Map();
this.menu = null;
this.webLinks = new WebLinksAddon((ev, uri) => {
try {
ev?.preventDefault?.();
} catch {
}
this.handleLinkActivate(uri);
});
this.init();
}
init() {
this.term = new Terminal({
cursorBlink: true,
convertEol: true,
scrollback: 4000,
fontFamily:
'"Departure Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 18,
theme: {
background: "#111111",
foreground: "#edb200",
cursor: "#ffc828",
selection: "rgba(255, 200, 40, 0.25)",
black: "#000000",
brightYellow: "#ffc828",
},
});
this.term.loadAddon(this.fit);
this.term.loadAddon(this.webLinks);
this.term.open(this.el);
this.fit.fit();
window.addEventListener("resize", () => this.fit.fit());
this.el.addEventListener("mousedown", () => this.term.focus());
this.term.focus();
this.term.onKey((e) => this.onKey(e));
this.boot();
}
writeln(s = "") {
this.term.writeln(s);
}
write(s = "") {
this.term.write(s);
}
prompt() {
this.write(`\r\n${ANSI.bold}guest@thunix${ANSI.reset}:${ANSI.fgWhite}~${ANSI.reset}$ `);
}
redrawInput(newValue) {
this.write(`\r${ANSI.clrLine}${ANSI.bold}guest@thunix${ANSI.reset}:${ANSI.fgWhite}~${ANSI.reset}$ ${newValue}`);
}
async boot() {
this.writeln(`${ANSI.bold}thunix terminal${ANSI.reset}`);
this.writeln(`${ANSI.dim}Terminal commands + real HTML panel so forms actually work.${ANSI.reset}`);
this.writeln("");
await Promise.allSettled([this.loadPages(), this.loadMenu()]);
const hash = (location.hash || "").replace(/^#/, "").trim();
const initial = hash && this.normalizeSlug(hash);
if (initial) {
this.openInPanel(initial);
} else {
this.openInPanel("main");
}
this.writeln(`${ANSI.dim}Type ${ANSI.reset}${ANSI.bold}help${ANSI.reset}${ANSI.dim} for commands.${ANSI.reset}`);
this.prompt();
}
async loadPages() {
const res = await fetch(urlFromBase("api/pages.php"), { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (!data?.pages) return;
for (const p of data.pages) {
this.pages.set(p.slug, p.title || p.slug);
}
}
async loadMenu() {
const res = await fetch(urlFromBase("api/menu.php"), { cache: "no-store" });
if (!res.ok) return;
this.menu = await res.json();
}
handleLinkActivate(uri) {
const u = (() => {
try {
return new URL(uri, location.origin);
} catch {
return null;
}
})();
if (!u) {
window.open(uri, "_blank", "noopener");
return;
}
if (u.pathname.endsWith("/terminal/view.php")) {
const p = u.searchParams.get("page") || "";
if (p) {
this.openInPanel(p);
return;
}
}
if (u.origin === location.origin && u.pathname.startsWith("/")) {
const slug = this.normalizeSlug(u.pathname.replace(/^\/+/, ""));
if (slug && this.pages.has(slug)) {
this.openInPanel(slug);
return;
}
}
window.open(u.toString(), "_blank", "noopener");
}
onKey({ key, domEvent }) {
const ev = domEvent;
if (ev.ctrlKey && ev.key.toLowerCase() === "l") {
ev.preventDefault();
this.cmdClear();
return;
}
if (ev.ctrlKey && ev.key.toLowerCase() === "c") {
ev.preventDefault();
this.write("^C");
this.buffer = "";
this.historyIdx = -1;
this.prompt();
return;
}
if (ev.key === "Enter") {
const line = this.buffer.trim();
this.buffer = "";
this.historyIdx = -1;
this.write("\r\n");
if (line) this.history.unshift(line);
this.run(line);
return;
}
if (ev.key === "Backspace") {
if (this.buffer.length > 0) {
this.buffer = this.buffer.slice(0, -1);
this.write("\b \b");
}
return;
}
if (ev.key === "ArrowUp") {
if (this.history.length === 0) return;
if (this.historyIdx + 1 < this.history.length) this.historyIdx++;
const next = this.history[this.historyIdx] ?? "";
this.buffer = next;
this.redrawInput(this.buffer);
return;
}
if (ev.key === "ArrowDown") {
if (this.history.length === 0) return;
if (this.historyIdx > 0) this.historyIdx--;
else this.historyIdx = -1;
const next = this.historyIdx >= 0 ? (this.history[this.historyIdx] ?? "") : "";
this.buffer = next;
this.redrawInput(this.buffer);
return;
}
if (!ev.altKey && !ev.ctrlKey && !ev.metaKey && key && key.length === 1) {
this.buffer += key;
this.write(key);
}
}
async run(line) {
if (!line) {
this.prompt();
return;
}
const [cmdRaw, ...args] = line.split(/\s+/);
const cmd = cmdRaw.toLowerCase();
switch (cmd) {
case "help":
this.cmdHelp();
break;
case "clear":
this.cmdClear();
break;
case "menu":
await this.cmdMenu();
break;
case "pages":
case "ls":
await this.cmdPages();
break;
case "open":
await this.cmdOpen(args.join(" "));
break;
case "cat":
await this.cmdCat(args.join(" "));
break;
case "web":
this.cmdWeb(args.join(" "));
break;
case "webmail":
case "mail":
this.cmdWebmail(args.join(" "));
break;
case "users":
await this.cmdOpen("users");
break;
case "server":
await this.cmdOpen("server");
break;
case "news":
await this.cmdOpen("news");
break;
case "main":
case "home":
await this.cmdOpen("main");
break;
default:
this.writeln(`${ANSI.dim}Unknown command:${ANSI.reset} ${cmdRaw}`);
this.writeln(`${ANSI.dim}Try:${ANSI.reset} ${ANSI.bold}help${ANSI.reset}`);
break;
}
this.prompt();
}
cmdHelp() {
this.writeln(`${ANSI.bold}Commands${ANSI.reset}`);
this.writeln(`${ANSI.dim}help${ANSI.reset} Show this help`);
this.writeln(`${ANSI.dim}pages | ls${ANSI.reset} List available content pages`);
this.writeln(`${ANSI.dim}open <page>${ANSI.reset} Load a page in the panel`);
this.writeln(`${ANSI.dim}web <page>${ANSI.reset} Print web URLs for a page`);
this.writeln(`${ANSI.dim}webmail [url]${ANSI.reset} Open webmail in a new tab (alias: mail)`);
this.writeln(`${ANSI.dim}clear${ANSI.reset} Clear the terminal (Ctrl+L)`);
this.writeln("");
this.writeln(`${ANSI.dim}Aliases:${ANSI.reset} home, main, users, server, news, mail`);
}
cmdClear() {
this.term.clear();
this.term.reset();
this.writeln(`${ANSI.bold}thunix terminal${ANSI.reset}`);
}
async cmdMenu() {
if (!this.menu) await this.loadMenu();
const menu = this.menu;
if (!menu?.sections?.length) {
this.writeln(`${ANSI.dim}Menu not available.${ANSI.reset}`);
return;
}
for (const section of menu.sections) {
this.writeln("");
this.writeln(`${ANSI.bold}${section.title}${ANSI.reset}`);
this.writeln(`${ANSI.dim}${"-".repeat(Math.min(78, section.title.length || 1))}${ANSI.reset}`);
for (const item of section.items) {
const href = item.internal ? `${location.origin}/${item.slug}` : absolutizeUrl(item.href);
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${item.text}${ANSI.reset}`;
const link = osc8(href, label);
const hint = item.internal ? `${ANSI.dim} (open ${item.slug})${ANSI.reset}` : "";
this.writeln(`${link}${hint}`);
}
}
}
async cmdPages() {
if (this.pages.size === 0) await this.loadPages();
if (this.pages.size === 0) {
this.writeln(`${ANSI.dim}No pages found.${ANSI.reset}`);
return;
}
this.writeln(`${ANSI.bold}Pages${ANSI.reset}`);
const slugs = [...this.pages.keys()].sort();
this.writeln(slugs.map((s) => ` - ${s}`).join("\r\n"));
}
cmdWeb(arg) {
const slug = this.normalizeSlug(arg);
if (!slug) {
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} web <page>`);
return;
}
const classic = `${location.origin}/${slug}`;
const panelUrl = new URL("view.php", MODULE_BASE);
panelUrl.searchParams.set("page", slug);
const panel = panelUrl.toString();
this.writeln(`${ANSI.dim}Classic:${ANSI.reset} ${osc8(classic, `${ANSI.underline}${ANSI.fgBrightYellow}${classic}${ANSI.reset}`)}`);
this.writeln(`${ANSI.dim}Panel:${ANSI.reset} ${osc8(panel, `${ANSI.underline}${ANSI.fgBrightYellow}${panel}${ANSI.reset}`)}`);
}
cmdWebmail(arg) {
const raw = String(arg || "").trim();
const configured = (() => {
try {
const v = window.THUNIX_WEBMAIL_URL;
return typeof v === "string" ? v.trim() : "";
} catch {
return "";
}
})();
const target = raw || configured || DEFAULT_WEBMAIL_PATH;
const url = absolutizeUrl(target);
window.open(url, "_blank", "noopener");
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${url}${ANSI.reset}`;
this.writeln(`${ANSI.dim}Opened webmail:${ANSI.reset} ${osc8(url, label)}`);
if (!raw && !configured && DEFAULT_WEBMAIL_PATH !== "/webmail/") {
this.writeln(`${ANSI.dim}Hint:${ANSI.reset} set window.THUNIX_WEBMAIL_URL if your webmail lives elsewhere.`);
} else if (!raw && !configured) {
this.writeln(
`${ANSI.dim}Hint:${ANSI.reset} if your webmail isn't at ${ANSI.bold}${DEFAULT_WEBMAIL_PATH}${ANSI.reset}${ANSI.dim}, set window.THUNIX_WEBMAIL_URL (or run: webmail <url>).${ANSI.reset}`
);
}
}
normalizeSlug(arg) {
if (!arg) return "";
let s = String(arg).trim();
if (s.startsWith("/")) s = s.replace(/^\/+/, "");
if (s.includes("?")) s = s.split("?")[0];
if (s === "") return "";
return s;
}
openInPanel(slug) {
if (!this.frame) return;
const clean = this.normalizeSlug(slug);
this.frame.src = `${urlFromBase("view.php")}?page=${encodeURIComponent(clean)}`;
try {
history.replaceState(null, "", `#${encodeURIComponent(clean)}`);
} catch {
}
}
async cmdOpen(arg) {
const slug = this.normalizeSlug(arg);
if (!slug) {
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} open <page>`);
return;
}
if (/^https?:\/\//i.test(slug) || slug.startsWith("//")) {
const url = absolutizeUrl(slug);
window.open(url, "_blank", "noopener");
this.writeln(`${ANSI.dim}Opened externally:${ANSI.reset} ${osc8(url, `${ANSI.underline}${ANSI.fgBrightYellow}${url}${ANSI.reset}`)}`);
return;
}
if (this.pages.size === 0) await this.loadPages();
if (this.pages.size && !this.pages.has(slug) && !/^success\d+$/.test(slug)) {
this.writeln(`${ANSI.dim}No such page:${ANSI.reset} ${slug}`);
this.writeln(`${ANSI.dim}Try:${ANSI.reset} pages`);
return;
}
this.openInPanel(slug);
this.writeln(`${ANSI.dim}Loaded in panel:${ANSI.reset} ${ANSI.bold}${slug}${ANSI.reset}`);
}
async cmdCat(arg) {
const slug = this.normalizeSlug(arg);
if (!slug) {
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} cat <page>`);
return;
}
const res = await fetch(`${urlFromBase("api/page.php")}?p=${encodeURIComponent(slug)}`, { cache: "no-store" });
if (!res.ok) {
this.writeln(`${ANSI.dim}No such page:${ANSI.reset} ${slug}`);
return;
}
const data = await res.json();
const md = String(data?.markdown ?? "");
this.writeln("");
this.writeln(renderMarkdown(md));
}
}
document.addEventListener("DOMContentLoaded", () => {
const host = document.getElementById("terminal");
const frame = document.getElementById("contentFrame");
if (!host) return;
new ThunixTerminal(host, frame);
});

234
terminal/view.php Normal file
View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
// Render wiki content using the *same* parser stack as wiki.php,
require __DIR__ . '/../config.php';
require __DIR__ . '/../parsedown-1.7.3/Parsedown.php';
require __DIR__ . '/../parsedown-extra-0.7.1/ParsedownExtra.php';
$page = isset($_GET['page']) ? (string) $_GET['page'] : 'main';
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/', $page)) {
http_response_code(400);
header('Content-Type: text/plain; charset=UTF-8');
echo "Bad page name.";
exit;
}
$contentPath = $doc_root . '/articles/' . $page . '.md';
if (!is_file($contentPath)) {
http_response_code(404);
header('Content-Type: text/plain; charset=UTF-8');
echo "Not found.";
exit;
}
$ParsedownExtra = new ParsedownExtra();
$md = file_get_contents($contentPath);
if ($md === false) {
http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
echo "Failed to read page.";
exit;
}
$html = $ParsedownExtra->text($md);
if ($page === 'users' || $page === 'server') {
$inc = $doc_root . '/includes/' . $page . '.php';
if (is_file($inc)) {
ob_start();
require $inc;
$html .= (string) ob_get_clean();
}
}
$knownPages = [];
foreach (glob($doc_root . '/articles/*.md') ?: [] as $file) {
$slug = basename($file, '.md');
$knownPages[$slug] = true;
}
$rewriteFormActions = function (string $action): string {
$path = parse_url($action, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
$path = $action;
}
$pathLower = strtolower($path);
if (!preg_match('~(^|/)(includes/(contact|signup)\.php)$~', $pathLower)) {
return $action;
}
$parts = parse_url($action);
if ($parts === false) {
if (strpos($action, 'terminal=1') !== false) {
return $action;
}
return (strpos($action, '?') !== false) ? ($action . '&terminal=1') : ($action . '?terminal=1');
}
$query = [];
if (isset($parts['query'])) {
parse_str((string) $parts['query'], $query);
}
$query['terminal'] = '1';
$queryString = http_build_query($query);
$rebuilt = '';
if (isset($parts['scheme'])) {
$rebuilt .= $parts['scheme'] . '://';
} elseif (str_starts_with($action, '//')) {
$rebuilt .= '//';
}
if (isset($parts['user'])) {
$rebuilt .= $parts['user'];
if (isset($parts['pass'])) {
$rebuilt .= ':' . $parts['pass'];
}
$rebuilt .= '@';
}
if (isset($parts['host'])) {
$rebuilt .= $parts['host'];
}
if (isset($parts['port'])) {
$rebuilt .= ':' . $parts['port'];
}
$rebuilt .= $parts['path'] ?? '';
if ($queryString !== '') {
$rebuilt .= '?' . $queryString;
}
if (isset($parts['fragment'])) {
$rebuilt .= '#' . $parts['fragment'];
}
return $rebuilt;
};
$isTerminalSuccessFormTarget = function (string $action): bool {
$path = parse_url($action, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
$path = $action;
}
$pathLower = strtolower($path);
return preg_match('~(^|/)(includes/(contact|signup)\.php)$~', $pathLower) === 1;
};
$html = preg_replace_callback(
'~(<form\b[^>]*\baction\s*=\s*)(["\'])([^"\']+)(\2)~i',
function (array $m) use ($rewriteFormActions): string {
$new = $rewriteFormActions((string) $m[3]);
return $m[1] . $m[2] . $new . $m[4];
},
$html
);
$html = preg_replace_callback(
'~(<form\b[^>]*>)~i',
function (array $m) use ($rewriteFormActions, $isTerminalSuccessFormTarget): string {
$tag = $m[1];
if (preg_match('~\baction\s*=\s*(["\'])([^"\']+)\1~i', $tag, $am) !== 1) {
return $tag;
}
$action = (string) $am[2];
if ($isTerminalSuccessFormTarget($action) === false) {
return $tag;
}
$newAction = $rewriteFormActions($action);
$tag = preg_replace(
'~\baction\s*=\s*(["\'])([^"\']+)\1~i',
'action=' . $am[1] . $newAction . $am[1],
$tag,
1
);
return $tag . "\n" . '<input type="hidden" name="terminal" value="1">';
},
$html
);
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML(
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="content">' . $html . '</div></body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
foreach ($dom->getElementsByTagName('a') as $a) {
$href = $a->getAttribute('href');
if ($href === '' || str_starts_with($href, '#')) {
continue;
}
if (preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $href) === 1) {
$a->setAttribute('target', '_blank');
$a->setAttribute('rel', 'noopener');
continue;
}
if (str_starts_with($href, '/')) {
$path = parse_url($href, PHP_URL_PATH) ?? '';
$slug = ltrim($path, '/');
if ($slug !== '' && isset($knownPages[$slug])) {
$a->setAttribute('href', './view.php?page=' . rawurlencode($slug));
continue;
}
$a->setAttribute('target', '_blank');
$a->setAttribute('rel', 'noopener');
continue;
}
$a->setAttribute('target', '_blank');
$a->setAttribute('rel', 'noopener');
}
foreach ($dom->getElementsByTagName('form') as $form) {
$action = $form->getAttribute('action');
if ($action !== '') {
$form->setAttribute('action', $rewriteFormActions($action));
}
}
$finalHtml = $dom->saveHTML();
libxml_clear_errors();
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Content-Type: text/html; charset=UTF-8');
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo htmlspecialchars($site_name . ' - ' . $page, ENT_QUOTES, 'UTF-8'); ?></title>
<link rel="stylesheet" type="text/css" href="<?php echo htmlspecialchars($site_root . '/includes/terminal.css', ENT_QUOTES, 'UTF-8'); ?>">
</head>
<body>
<?php
$dom2 = new DOMDocument();
libxml_use_internal_errors(true);
$dom2->loadHTML($finalHtml);
libxml_clear_errors();
$content = $dom2->getElementById('content');
if ($content === null) {
echo $finalHtml;
} else {
echo $dom2->saveHTML($content);
}
?>
</body>
</html>