1
0
forked from Thunix/www

6 Commits

Author SHA1 Message Date
root
6f709886ed added terminal interface as default. old website as no-js fallback 2026-01-21 09:58:53 -07:00
deepend-tildeclub
81d9ddfd03 Merge pull request #2 from l0v3ris/update-code-links
update code links to point to github instead of tildegit
2026-01-13 11:15:05 -07:00
c74cd91ab3 update code links to point to github instead of tildegit 2026-01-13 10:34:26 -07:00
deepend-tildeclub
ca2f3df587 Merge pull request #1 from l0v3ris/master
fix donate link and minor copy update
2026-01-13 10:19:23 -07:00
d02152e2b8 fix donate link and minor copy update 2026-01-10 07:17:39 -07:00
root
9d38dd8ede minor fixes/updates 2026-01-09 10:39:50 -07:00
20 changed files with 1696 additions and 87 deletions

View File

@@ -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]

View File

@@ -1,12 +1,13 @@
# Donations
As this server and our projects are all a labor of love and goodwill for the community, operating the thunix server costs money. We love what we do and we love sharing what we do for free, but over time, operating expenses can have a big impact.
While this server and our projects are a labor of love and goodwill for the community, operating the thunix server still costs money. We love what we do and we love sharing what we do for free, but over time, operating expenses can have a big impact. In order to keep going, we rely on the good nature of generous people who are willing to donate to us. The price breakdown right now is €80/month.
That being said, we also rely on the good nature of generous people, who are willing to donate to us. The price breakdown right now is €80/month. So to help with server costs and time spent, you can donate the following ways:
If you'd like to assist with server costs and help ensure we can spend time on the projects, you can donate in the following ways:
<div style="text-align:center;">
<p>You can donate via Liberapay here: <a href="https://liberapay.com/deepend/donate"><img src="https://liberapay.com/assets/widgets/donate.svg"></a></p>
<p>You can donate via fosspay here: <a href="https://donate.tilde.club/?project=2"><img src="https://www.gravatar.com/avatar/08ba2126a0dd0cb2efa30b854c7b4252?s=129"></a></p>
</p>
</div>
_Be sure to select the thunix.net project in the dropdown and please add a comment to tell us your thoughts! Thank you!_

View File

@@ -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)
- Well swap the key and let you know when its 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 werent 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. Thats 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 its more secure and, honestly, less annoying once youre set up.
- Other services (like email) use passwords because thats how the world works.
**That's too hard! Can you just open the port up for this service I have running?**
**Thats 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 youre 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 cant.
- Mention it in **#thunix** and well see whats 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`

View File

@@ -1,3 +1,11 @@
# Seasons Greetings - December 2025
As the year winds down, a sincere thank-you to everyone whos made Thunix feel like a real community and not just “a server with accounts.” Whether youre here for SSH, web hosting, email, or the other UNIX-y odds and ends, were glad youre part of this little corner of the internet.
If youre taking some quiet time over the holidays, consider doing something wonderfully low-pressure: update your `~/public_html`, publish something on Gopher, poke at the Gemini capsule (`gemini://thunix.net`), or just hang out with us on IRC (`irc.newnet.net:6697` in `#thunix`). No algorithms, no shouting, just people making things.
However you spend the season: take care of yourself, be decent to each other, and well keep doing our best to keep Thunix stable, secure, and fun.
# Changes to Terms of Service and Service Updates - April 2025
We've updated our Terms of Service to clarify rules around running servers—now explicitly prohibited without prior approval. Please take a moment to review these important changes.
@@ -23,20 +31,3 @@ own infrastructure. Sign ups that come in will be kept in queue until the syst
for more users.
More to come very soon.
# Gemini is Live on Thunix! - Nov 2024
Hey everyone, exciting news—Thunix now supports Gemini! 🎉
You can check out our Gemini capsule at gemini://thunix.net. It's simple, fast, and perfect for sharing cool stuff without all the web bloat.
Got ideas for Gemini content? Let us know! And if youre new to Gemini, dive in—its like the web, but chill. 😎
Catch you in the capsule! 🚀
# State of the Thunix - July 2023
We are on the mend. deepend from tilde.club has taken on running Thunix and has started to build it up on his own infrastructure. Sign-ups that come in will be kept in queue until the system is ready for more users.
More to come very soon.

View File

@@ -3,7 +3,7 @@
$site_name="🌻 thunix 🌻";
//Root for the site, in a browser
$site_root="https://".$_SERVER['HTTP_HOST'];
$site_root="//".$_SERVER['HTTP_HOST'];
//Local base root for app files
$doc_root="/var/www/thunix.cf";

View File

@@ -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()
?>

View File

@@ -64,3 +64,84 @@ a:visited {
clear: both;
font-size: smaller;
}
#new
/* 1) Keep terminal feel, but make reading humane */
body {
background: #050505; /* not pure black */
color: #f1a67a; /* slightly softer than #F79862 */
font-size: 16px; /* consistent baseline */
line-height: 1.65; /* biggest readability win */
text-rendering: optimizeLegibility;
}
/* 2) Use the dot font for headings/branding, but not for paragraphs */
#header,
#body h1, #body h2, #body h3 {
font-family: "dot", Courier, monospace;
letter-spacing: 0.5px;
}
#content,
#sidebar,
#footer {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* still terminal, but readable */
}
/* 3) Limit line length so the eyes don't have to travel across Alberta */
#content {
max-width: 980px;
font-size: 1rem;
}
/* 4) Make links look like links, not “random orange words” */
a {
color: #f1a67a;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:hover {
color: #ffb58f;
}
a:visited {
color: #e6a042; /* still orange, but distinct */
}
/* 5) Softer headings and better spacing */
#body h1, #body h2, #body h3,
#sidebar h1, #sidebar h2 {
color: #ffb347; /* softer orange */
margin-top: 1.2em;
margin-bottom: 0.6em;
}
/* 6) Sidebar: keep the panel look, make it calmer */
#sidebar {
background-color: #0c0c0c; /* slightly lighter */
border: 1px solid #222;
border-radius: 6px; /* tiny, still “retro UI” */
}
/* 7) Fix your lineitem border (currently border:1px does nothing) */
.lineitem {
border: 1px solid #222;
}
/* 8) Optional: code blocks that actually read nicely */
pre, code {
background: #0b0b0b;
border: 1px solid #1f1f1f;
border-radius: 6px;
padding: 0.15em 0.35em;
}
pre {
padding: 12px;
overflow-x: auto;
}

View File

@@ -6,19 +6,19 @@
- [Terms of Service](/tos)
- [Privacy Policy](/privacy)
- [Contact Us](/contact)
- [Donations](/donate)
- [Donations](https://donate.tilde.club)
- Resources and User Content
---------------------------
- [Wiki](https://wiki.thunix.net/)
- [User Web Directories](/users)
- [User Gopher Directories](https://gopher.tildeverse.org/thunix.net)
- [User Gemini Directories](https://gemini.tildeverse.org/?gemini://thunix.net/)
- Services and Status
--------------------
- [Status and Information](/server)
- [Service News](/news)
- [thunix Mirror Services](https://ftp.thunix.net/)
- [Web Server Stats](https://stats.thunix.net/)
- [Web Mail](/webmail/)
- [ZNC Service](https://thunix.net:1356/)

View File

@@ -1,23 +1,25 @@
<?php
// This code is licensed under the AGPL 3 or later by ubergeek (https://tildegit.org/ubergeek)
include "../config.php";
$name = $_GET['contact_name'];
$email = $_GET['email_address'];
$username = $_GET['username'];
$interest = $_GET['interest'];
$pubkey = $_GET['pubkey'];
$tv = $_GET['tv'];
// 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'];
$email = $_GET['email_address'];
$username = $_GET['username'];
$interest = $_GET['interest'];
$pubkey = $_GET['pubkey'];
$tv = $_GET['tv'];
// username passed lowercased
$username = strtolower($username);
$pubkey = trim($pubkey);
// strip new line characters from the end
$pubkey = trim($pubkey);
$from = 'From: www-data <www-data@thunix.net>';
$destination_addr = "newuser@thunix.net";
$subject = "New User Registration";
$from = 'From: www-data <www-data@thunix.net>';
$destination_addr = 'newuser@thunix.net';
$subject = 'New User Registration';
$mailbody = "A new user has tried to register.
Username: $username
Real Name: $name
@@ -25,39 +27,33 @@ Email Address: $email
Interest: $interest
Pubkey: $pubkey";
// In the future, here, we *should* be able to build a process that
// somehow auto-verifies the user, and instead of email, it'll kick off the new user process here
$user_queue = '/dev/shm/userqueue';
$user_queue = '/dev/shm/userqueue';
// Spam attempt
$success = 'success1';
if ( $tv == "tildeverse" )
{
// Success!
$success = 'success2';
// Check if username already taken
if (posix_getpwnam($username)) {
$success = 'success3';
if ($tv == 'tildeverse') {
$success = 'success2';
if (posix_getpwnam($username)) {
$success = 'success3';
}
$valid_key_starts = ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2', 'ssh-ed25519'];
$key_parts = explode(' ', $pubkey, 3);
if (!in_array($key_parts[0], $valid_key_starts) || count($key_parts) < 2) {
$success = 'success4';
}
if ($success === 'success2') {
mail($destination_addr, $subject, $mailbody, $from);
$fp = fopen($user_queue, 'a');
fwrite($fp, "'$username','$email','$pubkey'\n");
fclose($fp);
$fp2 = fopen('/var/signups', 'a');
fwrite($fp2, 'makeuser ' . $username . ' ' . $email . ' "' . addslashes($pubkey) . "\"\n");
fclose($fp2);
}
}
// Simple SSH public key format check
$valid_key_starts = ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2', 'ssh-ed25519'];
$key_parts = explode(' ', $pubkey, 3);
if (!in_array($key_parts[0], $valid_key_starts) || count($key_parts) < 2) {
$success = 'success4';
if ($terminalMode) {
header("Location: $site_root/terminal/view.php?page=$success");
} else {
header("Location: $site_root/?page=$success");
}
if ($success == "success2") {
mail($destination_addr, $subject, $mailbody, $from);
$fp = fopen($user_queue, 'a');
fwrite($fp, "'$username','$email','$pubkey'\n");
fclose($fp);
}
}
header("Location: $site_root/?page=$success");
die();
?>

265
includes/terminal.css Normal file
View File

@@ -0,0 +1,265 @@
/*
Terminal theme for /terminal/view.php.
Keeps the same wiki.php content pipeline (Parsedown/ParsedownExtra),
but makes it look like the amber CRT terminal UI.
This file is loaded inside an <iframe> (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 its 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, doesnt 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);
}

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>

View File

@@ -82,7 +82,7 @@ print " </div>
echo $Parsedown->text($footer);
print " <a href=\"https://tildegit.org/thunix/www\">Site Source</a> | <a href=\"https://tildegit.org/thunix/www/src/branch/master/articles/$page.md\">Page Source</a>
print " <a href=\"https://github.com/ThunixdotNet/www\">Site Source</a> | <a href=\"https://github.com/ThunixdotNet/www/tree/master/articles/$page.md\">Page Source</a>
</div>
<!-- End Footer -->