diff --git a/.htaccess b/.htaccess index 86582bf..e7aa532 100644 --- a/.htaccess +++ b/.htaccess @@ -1,5 +1,23 @@ - RewriteEngine On - RewriteRule ^$ main [QSA] - RewriteRule ^index\.php$ wiki.php?page=main [QSA] - RewriteCond %{REQUEST_URI} !(/includes/|/media/|tilde.json|humans.txt|/webmail/|/favicon.ico|/~|githook|sitemap.xml) - RewriteRule ^([^\d]+)/?$ wiki.php?page=$1 [QSA] +RewriteEngine On + +# Classic query-style links like /?page=main should keep working. +RewriteCond %{QUERY_STRING} (^|&)page= [NC] +RewriteRule ^$ wiki.php [QSA,L] + +# Default experience: terminal UI. +RewriteRule ^$ terminal/ [QSA,L] + +# If someone explicitly requests index.php with a page query, keep classic behavior. +RewriteCond %{QUERY_STRING} (^|&)page= [NC] +RewriteRule ^index\.php$ wiki.php [QSA,L] + +# Otherwise, index.php also goes to the terminal UI. +RewriteRule ^index\.php$ terminal/ [QSA,L] + +# Let real files and directories through untouched. +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +# Pretty URLs for wiki pages (no leading digit). +RewriteRule ^([^0-9][A-Za-z0-9_-]*)/?$ wiki.php?page=$1 [QSA,L] diff --git a/articles/faq.md b/articles/faq.md index 6d79be9..fe10d8c 100644 --- a/articles/faq.md +++ b/articles/faq.md @@ -2,38 +2,65 @@ **How do I sign up for an account?** -- Simply by going to our [signup page](/signup) and filling in the form. You can ask for help in \#thunix on newnet.net, or you can [contact us](contact), if you run into any difficulties. +- Go to the [signup page](/signup) and fill out the form. +- If you get stuck, ask in **#thunix** on **newnet.net**, or [contact us](/contact). **How can I request an account recovery or public key replacement?** -- Just send the request from the email you used to register and we'll poke a new key in for you. +- Email us **from the address you used to register** and tell us: + - your username + - what you need (recovery / key replacement) + - your **new public key** (paste it in the email) +- We’ll swap the key and let you know when it’s done. **Who is running thunix?** -- The current system administrators are [deepend](/~deepend), [Naglfar](/~naglfar). +- Current system administrators: [deepend](/~deepend), [Naglfar](/~naglfar) **What happened to the old thunix? Why the name change?** -- The original machine and founder dissappeared without any warning to anyone, including server staff. For this reason, most things were not backed up, and we needed to obtain a new domain name, and a new set of machines. +- The original machine and founder disappeared without warning (including to staff). +- Most things weren’t backed up, so we rebuilt on new machines and moved to a new domain. **I want a new package installed, or I want something changed on Thunix!** -- Excellent! We're looking to make this system useful for the community! You can ask for help in \#thunix on newnet.net, or you can [contact us](contact), to request the system change. +- Good. That’s how systems become useful instead of decorative. +- Ask in **#thunix** on **newnet.net**, or [contact us](/contact) with: + - what you want + - why you want it + - whether it needs to be available to everyone or just you **Can I get password-based login? Old thunix had it!** -- No. Sorry. Not for shell access. For other integrated services, password auth will be enabled, but not for your ssh connection. We use key based authentication, as it's more secure, and more convienent for you, to be honest. +- No. Not for **shell access**. +- SSH is **key-based** because it’s more secure and, honestly, less annoying once you’re set up. +- Other services (like email) use passwords because that’s how the world works. -**That's too hard! Can you just open the port up for this service I have running?** +**That’s too hard! Can you just open the port up for this service I have running?** -- No. Due to security issues, we cannot. HOWEVER! You can certainly use an [SSH tunnel](https://duckduckgo.com/?q=ssh+tunnnel) to access it. +- No. +- If you need access to something you’re running, use an **SSH tunnel**. + - Example (adjust ports as needed): + - `ssh -L 8080:127.0.0.1:8080 youruser@YOUR_SSH_HOSTNAME` **Old thunix did {fill in the blank}, and now it doesn't. Make it work like it used to!** -- There was a huge changeover. Maybe we can get something going old thunix had, and maybe not. You can mention it in the IRC channel, and we'll see what we can do. +- There was a big changeover. Some old stuff can come back, some can’t. +- Mention it in **#thunix** and we’ll see what’s realistic. **How can I access my thunix email?** -- You can use the following for your mail settings (This is Thunderbird's setting screen, but the settings are the same): +- Use these settings in Thunderbird, Apple Mail, Outlook, mutt, a ham radio, whatever. -[![](/media/mail.png)](/media/mail.png) +## Incoming Mail (IMAP) +- **Server:** `thunix.net` +- **Username:** `yourusername` +- **Password:** your **mail/service** password (not your SSH key) +- **Security:** SSL/TLS +- **Port:** `993` + +## Outgoing Mail (SMTP) +- **Server:** `thunix.net` +- **Authentication:** Yes (same username/password as above) +- **Security:** STARTTLS +- **Port:** `587` diff --git a/includes/contact.php b/includes/contact.php index 59d5570..80561b7 100644 --- a/includes/contact.php +++ b/includes/contact.php @@ -2,6 +2,12 @@ include "../config.php"; // This code is licensed under the AGPL 3 or later by ubergeek (https://tildegit.org/ubergeek) +// Optional: keep the terminal UI flow inside /terminal/ without changing the classic site. +// +// Prefer an explicit flag (terminal=1). As a fallback, detect a terminal embed +// by referrer so the classic site behavior stays unchanged. +$terminalMode = (isset($_REQUEST['terminal']) && (string) $_REQUEST['terminal'] === '1') + || (isset($_SERVER['HTTP_REFERER']) && strpos((string) $_SERVER['HTTP_REFERER'], '/terminal/') !== false); $name = $_GET['contact_name']; $return_addr = $_GET['email_address']; $type = $_GET['type']; @@ -19,7 +25,10 @@ Message: $body"; if ( $tv != "tildeverse" ) { print "Spam attempt"; - header("Location: $site_root/?page=success1"); + $redirect = $terminalMode + ? $site_root . "/terminal/view.php?page=success1" + : $site_root . "/?page=success1"; + header("Location: $redirect"); die(); } @@ -28,7 +37,10 @@ shell_exec("echo '$mailbody' | /usr/bin/mail -s '$subject' -r '$return_addr' $de // In the future, here, we *should* be able to build a process that // auto opens an issue in the tildegit project -header("Location: $site_root/?page=success2"); +$redirect = $terminalMode + ? $site_root . "/terminal/view.php?page=success2" + : $site_root . "/?page=success2"; +header("Location: $redirect"); die() ?> diff --git a/includes/signup.php b/includes/signup.php index 9033aa2..5da6f19 100644 --- a/includes/signup.php +++ b/includes/signup.php @@ -1,6 +1,12 @@ (terminal content panel), so it must +style full documents reliably without depending on any outer page CSS. +*/ + +:root { + color-scheme: dark; + + /* Keep these in sync with terminal/index.php. */ + --mono: "Departure Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --bg: #111111; + --panel: rgba(255, 255, 255, 0.03); + --amber: #edb200; + --amber-txt: #ffc828; + --border: rgba(255, 255, 255, 0.08); + --glow: rgba(237, 178, 0, 0.55); + --glow-soft: rgba(237, 178, 0, 0.25); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; +} + +body { + background: var(--bg); + color: var(--amber); + font-family: var(--mono); + font-size: 18px; /* Match xterm.js default in terminal.js */ + line-height: 1.55; + overflow-x: hidden; +} + +/* CRT-ish scanlines, because humans love pretending it’s 1996. */ +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.28) 51%); + background-size: 100% 4px; +} + +/* Keep content above page background but below scanlines. */ +#content { + position: relative; + z-index: 1; + width: 100%; + padding: 18px 20px; + background: transparent; +} + +/* Hide classic wiki chrome if a fragment includes it for any reason. */ +#header, +#sidebar, +#footer { + display: none; +} + +/* Typographic polish */ +h1, +h2, +h3 { + margin: 0.9em 0 0.35em; + line-height: 1.15; + color: var(--amber-txt); + text-shadow: 0 0 1.4rem var(--glow-soft); +} + +h1 { + font-size: 1.55rem; +} + +h2 { + font-size: 1.25rem; +} + +h3 { + font-size: 1.1rem; +} + +p { + margin: 0.75em 0; +} + +strong { + color: var(--amber-txt); +} + +hr { + border: 0; + border-top: 1px solid var(--border); + margin: 14px 0; +} + +/* Keep images sane inside the panel. */ +img { + max-width: 100%; + height: auto; +} + +/* Lists */ +ul, +ol { + margin: 0.65em 0 0.9em 1.25em; + padding: 0; +} + +li { + margin: 0.2em 0; +} + +/* Links */ +a, +a:visited { + color: var(--amber-txt); + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; + text-shadow: 0 0 0.85rem rgba(237, 178, 0, 0.3); +} + +a:hover { + filter: brightness(1.08); + text-shadow: 0 0 1.25rem rgba(237, 178, 0, 0.55); +} + +/* Code */ +code, +pre { + font-family: var(--mono); +} + +code { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.08em 0.3em; + border-radius: 6px; +} + +pre { + padding: 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + overflow: auto; + line-height: 1.45; +} + +pre code { + background: transparent; + border: 0; + padding: 0; +} + +/* Tables (contact/signup forms, lists). */ +table { + border-collapse: collapse; + width: 100%; + max-width: 980px; +} + +th, +td { + padding: 8px 10px; + vertical-align: top; +} + +tr + tr td, +tr + tr th { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +/* Form controls should look like they belong here. */ +input, +select, +textarea { + width: 100%; + max-width: 100%; + background: var(--panel); + color: var(--amber); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-family: var(--mono); + font-size: 18px; + outline: none; +} + +textarea { + resize: vertical; +} + +input:focus, +select:focus, +textarea:focus { + border-color: rgba(255, 200, 40, 0.55); + box-shadow: 0 0 0 2px rgba(255, 200, 40, 0.15); +} + +input[type='submit'], +button { + width: auto; + cursor: pointer; + background: rgba(255, 200, 40, 0.14); + border-color: rgba(255, 200, 40, 0.32); + padding: 10px 14px; +} + +input[type='submit']:hover, +button:hover { + filter: brightness(1.1); +} + +/* Blockquotes */ +blockquote { + margin: 1em 0; + padding: 0.2em 0.9em; + border-left: 3px solid rgba(255, 200, 40, 0.28); + background: rgba(255, 255, 255, 0.02); +} + +/* Selection */ +::selection { + background: rgba(255, 200, 40, 0.22); + color: var(--amber-txt); +} + +/* Scrollbars (best-effort, doesn’t break anything if unsupported). */ +html { + scrollbar-color: rgba(255, 200, 40, 0.55) rgba(0, 0, 0, 0.2); + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.22); +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 200, 40, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 200, 40, 0.5); +} diff --git a/terminal/api/_bootstrap.php b/terminal/api/_bootstrap.php new file mode 100644 index 0000000..8dd10f0 --- /dev/null +++ b/terminal/api/_bootstrap.php @@ -0,0 +1,27 @@ + '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; +} diff --git a/terminal/api/menu.php b/terminal/api/menu.php new file mode 100644 index 0000000..5954f67 --- /dev/null +++ b/terminal/api/menu.php @@ -0,0 +1,58 @@ + []]); +} + +$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]); diff --git a/terminal/api/page.php b/terminal/api/page.php new file mode 100644 index 0000000..5a0a091 --- /dev/null +++ b/terminal/api/page.php @@ -0,0 +1,53 @@ + '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, +]); diff --git a/terminal/api/pages.php b/terminal/api/pages.php new file mode 100644 index 0000000..87395de --- /dev/null +++ b/terminal/api/pages.php @@ -0,0 +1,51 @@ + []]); +} + +$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]); diff --git a/terminal/api/server.php b/terminal/api/server.php new file mode 100644 index 0000000..c867a8c --- /dev/null +++ b/terminal/api/server.php @@ -0,0 +1,49 @@ + [], '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]); diff --git a/terminal/api/users.php b/terminal/api/users.php new file mode 100644 index 0000000..3cad251 --- /dev/null +++ b/terminal/api/users.php @@ -0,0 +1,42 @@ + $user, + 'url' => $siteRoot . '/~' . rawurlencode($user) . '/', + 'hasContent' => $hasCustomIndex, + ]; +} + +json_out(['users' => $users]); diff --git a/terminal/index.php b/terminal/index.php new file mode 100644 index 0000000..ac26f3b --- /dev/null +++ b/terminal/index.php @@ -0,0 +1,159 @@ + + + + + thunix terminal + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+ +
+
+
+ + + + diff --git a/terminal/terminal.js b/terminal/terminal.js new file mode 100644 index 0000000..b768276 --- /dev/null +++ b/terminal/terminal.js @@ -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(/]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/gi, (_m, href, text) => { + const cleanText = String(text).replace(/<[^>]+>/g, "").trim() || href; + return `[${cleanText}](${href})`; + }); + + s = s.replace(/<[^>]+>/g, ""); + + s = s.replace(/ /g, " "); + s = s.replace(/&/g, "&"); + s = s.replace(/</g, "<"); + s = s.replace(/>/g, ">"); + s = s.replace(/"/g, '"'); + s = s.replace(/'/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 ${ANSI.reset} Load a page in the panel`); + this.writeln(`${ANSI.dim}web ${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 `); + 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 ).${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 `); + 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 `); + 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); +}); diff --git a/terminal/view.php b/terminal/view.php new file mode 100644 index 0000000..25b2019 --- /dev/null +++ b/terminal/view.php @@ -0,0 +1,234 @@ +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( + '~(]*\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( + '~(]*>)~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" . ''; + }, + $html +); + +libxml_use_internal_errors(true); +$dom = new DOMDocument(); +$dom->loadHTML( + '
' . $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'); + +?> + + + + + <?php echo htmlspecialchars($site_name . ' - ' . $page, ENT_QUOTES, 'UTF-8'); ?> + + + +loadHTML($finalHtml); +libxml_clear_errors(); +$content = $dom2->getElementById('content'); +if ($content === null) { + echo $finalHtml; +} else { + echo $dom2->saveHTML($content); +} +?> + +