forked from Thunix/www
Compare commits
6 Commits
donate-upd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f709886ed | ||
|
|
81d9ddfd03 | ||
| c74cd91ab3 | |||
|
|
ca2f3df587 | ||
| d02152e2b8 | |||
|
|
9d38dd8ede |
28
.htaccess
28
.htaccess
@@ -1,5 +1,23 @@
|
|||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteRule ^$ main [QSA]
|
|
||||||
RewriteRule ^index\.php$ wiki.php?page=main [QSA]
|
# Classic query-style links like /?page=main should keep working.
|
||||||
RewriteCond %{REQUEST_URI} !(/includes/|/media/|tilde.json|humans.txt|/webmail/|/favicon.ico|/~|githook|sitemap.xml)
|
RewriteCond %{QUERY_STRING} (^|&)page= [NC]
|
||||||
RewriteRule ^([^\d]+)/?$ wiki.php?page=$1 [QSA]
|
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]
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Donations
|
# 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;">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
_Be sure to select the thunix.net project in the dropdown and please add a comment to tell us your thoughts! Thank you!_
|
||||||
|
|||||||
@@ -2,38 +2,65 @@
|
|||||||
|
|
||||||
**How do I sign up for an account?**
|
**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?**
|
**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?**
|
**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?**
|
**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!**
|
**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!**
|
**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!**
|
**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?**
|
**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)
|
## 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`
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# Season’s Greetings - December 2025
|
||||||
|
|
||||||
|
As the year winds down, a sincere thank-you to everyone who’s made Thunix feel like a real community and not just “a server with accounts.” Whether you’re here for SSH, web hosting, email, or the other UNIX-y odds and ends, we’re glad you’re part of this little corner of the internet.
|
||||||
|
|
||||||
|
If you’re 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 we’ll keep doing our best to keep Thunix stable, secure, and fun.
|
||||||
|
|
||||||
# Changes to Terms of Service and Service Updates - April 2025
|
# 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.
|
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.
|
for more users.
|
||||||
|
|
||||||
More to come very soon.
|
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 you’re new to Gemini, dive in—it’s 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.
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
$site_name="🌻 thunix 🌻";
|
$site_name="🌻 thunix 🌻";
|
||||||
|
|
||||||
//Root for the site, in a browser
|
//Root for the site, in a browser
|
||||||
$site_root="https://".$_SERVER['HTTP_HOST'];
|
$site_root="//".$_SERVER['HTTP_HOST'];
|
||||||
//Local base root for app files
|
//Local base root for app files
|
||||||
$doc_root="/var/www/thunix.cf";
|
$doc_root="/var/www/thunix.cf";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
include "../config.php";
|
include "../config.php";
|
||||||
// This code is licensed under the AGPL 3 or later by ubergeek (https://tildegit.org/ubergeek)
|
// 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'];
|
$name = $_GET['contact_name'];
|
||||||
$return_addr = $_GET['email_address'];
|
$return_addr = $_GET['email_address'];
|
||||||
$type = $_GET['type'];
|
$type = $_GET['type'];
|
||||||
@@ -19,7 +25,10 @@ Message: $body";
|
|||||||
|
|
||||||
if ( $tv != "tildeverse" ) {
|
if ( $tv != "tildeverse" ) {
|
||||||
print "Spam attempt";
|
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();
|
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
|
// In the future, here, we *should* be able to build a process that
|
||||||
// auto opens an issue in the tildegit project
|
// 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()
|
die()
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -64,3 +64,84 @@ a:visited {
|
|||||||
clear: both;
|
clear: both;
|
||||||
font-size: smaller;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,19 @@
|
|||||||
- [Terms of Service](/tos)
|
- [Terms of Service](/tos)
|
||||||
- [Privacy Policy](/privacy)
|
- [Privacy Policy](/privacy)
|
||||||
- [Contact Us](/contact)
|
- [Contact Us](/contact)
|
||||||
- [Donations](/donate)
|
- [Donations](https://donate.tilde.club)
|
||||||
|
|
||||||
- Resources and User Content
|
- Resources and User Content
|
||||||
---------------------------
|
---------------------------
|
||||||
- [Wiki](https://wiki.thunix.net/)
|
- [Wiki](https://wiki.thunix.net/)
|
||||||
- [User Web Directories](/users)
|
- [User Web Directories](/users)
|
||||||
- [User Gopher Directories](https://gopher.tildeverse.org/thunix.net)
|
- [User Gopher Directories](https://gopher.tildeverse.org/thunix.net)
|
||||||
|
- [User Gemini Directories](https://gemini.tildeverse.org/?gemini://thunix.net/)
|
||||||
|
|
||||||
- Services and Status
|
- Services and Status
|
||||||
--------------------
|
--------------------
|
||||||
- [Status and Information](/server)
|
- [Status and Information](/server)
|
||||||
- [Service News](/news)
|
- [Service News](/news)
|
||||||
- [thunix Mirror Services](https://ftp.thunix.net/)
|
|
||||||
- [Web Server Stats](https://stats.thunix.net/)
|
- [Web Server Stats](https://stats.thunix.net/)
|
||||||
- [Web Mail](/webmail/)
|
- [Web Mail](/webmail/)
|
||||||
- [ZNC Service](https://thunix.net:1356/)
|
- [ZNC Service](https://thunix.net:1356/)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
// This code is licensed under the AGPL 3 or later by ubergeek (https://tildegit.org/ubergeek)
|
|
||||||
include "../config.php";
|
include "../config.php";
|
||||||
|
|
||||||
|
// 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'];
|
$name = $_GET['contact_name'];
|
||||||
$email = $_GET['email_address'];
|
$email = $_GET['email_address'];
|
||||||
$username = $_GET['username'];
|
$username = $_GET['username'];
|
||||||
@@ -9,15 +14,12 @@ $interest = $_GET['interest'];
|
|||||||
$pubkey = $_GET['pubkey'];
|
$pubkey = $_GET['pubkey'];
|
||||||
$tv = $_GET['tv'];
|
$tv = $_GET['tv'];
|
||||||
|
|
||||||
// username passed lowercased
|
|
||||||
$username = strtolower($username);
|
$username = strtolower($username);
|
||||||
|
|
||||||
// strip new line characters from the end
|
|
||||||
$pubkey = trim($pubkey);
|
$pubkey = trim($pubkey);
|
||||||
|
|
||||||
$from = 'From: www-data <www-data@thunix.net>';
|
$from = 'From: www-data <www-data@thunix.net>';
|
||||||
$destination_addr = "newuser@thunix.net";
|
$destination_addr = 'newuser@thunix.net';
|
||||||
$subject = "New User Registration";
|
$subject = 'New User Registration';
|
||||||
$mailbody = "A new user has tried to register.
|
$mailbody = "A new user has tried to register.
|
||||||
Username: $username
|
Username: $username
|
||||||
Real Name: $name
|
Real Name: $name
|
||||||
@@ -25,39 +27,33 @@ Email Address: $email
|
|||||||
Interest: $interest
|
Interest: $interest
|
||||||
Pubkey: $pubkey";
|
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';
|
$success = 'success1';
|
||||||
if ( $tv == "tildeverse" )
|
if ($tv == 'tildeverse') {
|
||||||
{
|
|
||||||
// Success!
|
|
||||||
$success = 'success2';
|
$success = 'success2';
|
||||||
|
if (posix_getpwnam($username)) {
|
||||||
// Check if username already taken
|
|
||||||
if (posix_getpwnam($username)) {
|
|
||||||
$success = 'success3';
|
$success = 'success3';
|
||||||
}
|
}
|
||||||
|
$valid_key_starts = ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2', 'ssh-ed25519'];
|
||||||
// Simple SSH public key format check
|
$key_parts = explode(' ', $pubkey, 3);
|
||||||
$valid_key_starts = ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2', 'ssh-ed25519'];
|
if (!in_array($key_parts[0], $valid_key_starts) || count($key_parts) < 2) {
|
||||||
$key_parts = explode(' ', $pubkey, 3);
|
|
||||||
if (!in_array($key_parts[0], $valid_key_starts) || count($key_parts) < 2) {
|
|
||||||
$success = 'success4';
|
$success = 'success4';
|
||||||
}
|
}
|
||||||
|
if ($success === 'success2') {
|
||||||
if ($success == "success2") {
|
|
||||||
mail($destination_addr, $subject, $mailbody, $from);
|
mail($destination_addr, $subject, $mailbody, $from);
|
||||||
$fp = fopen($user_queue, 'a');
|
$fp = fopen($user_queue, 'a');
|
||||||
fwrite($fp, "'$username','$email','$pubkey'\n");
|
fwrite($fp, "'$username','$email','$pubkey'\n");
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
}
|
$fp2 = fopen('/var/signups', 'a');
|
||||||
|
fwrite($fp2, 'makeuser ' . $username . ' ' . $email . ' "' . addslashes($pubkey) . "\"\n");
|
||||||
|
fclose($fp2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header("Location: $site_root/?page=$success");
|
if ($terminalMode) {
|
||||||
|
header("Location: $site_root/terminal/view.php?page=$success");
|
||||||
|
} else {
|
||||||
|
header("Location: $site_root/?page=$success");
|
||||||
|
}
|
||||||
die();
|
die();
|
||||||
|
|
||||||
?>
|
|
||||||
265
includes/terminal.css
Normal file
265
includes/terminal.css
Normal 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 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);
|
||||||
|
}
|
||||||
27
terminal/api/_bootstrap.php
Normal file
27
terminal/api/_bootstrap.php
Normal 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
58
terminal/api/menu.php
Normal 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
53
terminal/api/page.php
Normal 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
51
terminal/api/pages.php
Normal 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
49
terminal/api/server.php
Normal 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
42
terminal/api/users.php
Normal 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
159
terminal/index.php
Normal 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 can’t run. Redirecting you to the classic site… If you’re 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
545
terminal/terminal.js
Normal 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(/ /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 <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
234
terminal/view.php
Normal 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>
|
||||||
2
wiki.php
2
wiki.php
@@ -82,7 +82,7 @@ print " </div>
|
|||||||
|
|
||||||
echo $Parsedown->text($footer);
|
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>
|
</div>
|
||||||
<!-- End Footer -->
|
<!-- End Footer -->
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user