Initial commit

This commit is contained in:
Tove 2026-04-05 21:17:37 +02:00
commit dd596b0f09
22 changed files with 1244 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/target
/.direnv
/tudbut
/replies
/index
/invites
/webname.txt
/root

33
Cargo.lock generated Normal file
View file

@ -0,0 +1,33 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horrorhttp"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21ebae148589deab7214d2835d3e5ea9bc5ce6983fa774bd78ee864ed5c6b2a5"
dependencies = [
"readformat",
]
[[package]]
name = "plank"
version = "0.1.0"
dependencies = [
"horrorhttp",
"readformat",
"urlencoding",
]
[[package]]
name = "readformat"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94c3a263091233283319d916f89668dac0ee49ffefa7cb7537c03810c7693674"
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"

9
Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "plank"
version = "0.1.0"
edition = "2024"
[dependencies]
horrorhttp = "0.2.5"
readformat = "1.0.4"
urlencoding = "2.1.3"

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# Plank
A hard, imageless board.
Inspired by the function of bulletin boards and forums, and the
simplicity of imageboards; Hardened with hatred and spite.
Everything is a file. Plank is unix-compliant.
## Setup
1. Run the binary in an empty directory.
2. Log in with `root`:`init`
3. Create an account with your root invite
4. Delete the root account as specified on the login page
5. Log in with the account you created
## Moderation
To delete an account, all of its posts, and every account that was invited by them,
delete the folder of the account (e.g. ./root/tudbut). This essentially undoes
their whole existence.
To disable someone's invite link, you can remove the ./invites file associated with it.
To discover which to remove, check their account.txt
## Format
For file format, see format.md

16
format.md Normal file
View file

@ -0,0 +1,16 @@
post format ({username}/{id}.txt):
```
TIMESTAMP {id}
PARENT {parent | "none"}
TITLE {title}
{content}
```
account format ({username}/account.txt):
```
USERNAME {username}
PASSHASH {password > sha512 > sha512}
TIMESTAMP {creationunixms}
INVITECODE {invitecode}
```

11
shell.nix Normal file
View file

@ -0,0 +1,11 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rustc
cargo
clippy
rustfmt
cargo-watch
rust-analyzer
];
}

181
src/account.rs Normal file
View file

@ -0,0 +1,181 @@
#![allow(
clippy::declare_interior_mutable_const,
clippy::borrow_interior_mutable_const
)]
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
sync::{LazyLock, OnceLock},
};
use crate::{PlankData, PlankResult, command_piped, getrandomhex, path::Normalize, timestamp};
const INDEX: LazyLock<&Path> = LazyLock::new(|| Path::new("index"));
const INVITES: LazyLock<&Path> = LazyLock::new(|| Path::new("invites"));
#[derive(Clone)]
pub struct Account {
pub path: PathBuf,
pub name: String,
pub data: OnceLock<String>,
}
pub enum Invite {
ByUser(Account),
Root,
}
impl PlankData for Account {
fn get_self(&self) -> &str {
self.data
.get_or_init(|| fs::read_to_string(self.path.join("account.txt")).unwrap())
}
}
impl Account {
pub fn get_logged_in(username: &str, token: &str) -> Option<Account> {
let account = Self::get_by_username(username)?;
if token == account.get_passwordhash() && token != "/" {
Some(account)
} else {
None
}
}
pub fn get_by_username(username: &str) -> Option<Account> {
let path = INDEX
.join(INDEX.join(username).read_link().ok()?)
.normalize();
let name = username.to_owned();
Some(Account {
path,
name,
data: OnceLock::new(),
})
}
pub fn get_by_invite(invite: &str) -> Option<Account> {
let path = INVITES
.join(INVITES.join(invite).read_link().ok()?)
.normalize();
let name = path.file_name().unwrap().to_string_lossy().to_string();
Some(Account {
path,
name,
data: OnceLock::new(),
})
}
pub fn from_path(path: PathBuf) -> Account {
let name = path.file_name().unwrap().to_string_lossy().to_string();
Account {
path: path.normalize(),
name,
data: OnceLock::new(),
}
}
pub fn get_passwordhash(&self) -> String {
self.get_property("PASSHASH")
}
pub fn get_invite(&self) -> String {
self.get_property("INVITECODE")
}
pub fn delete(&self) {
for file in self.path.read_dir().unwrap() {
let filename = file.unwrap().file_name();
let filename = filename.to_string_lossy();
if filename.ends_with(".txt") {
let _ = fs::remove_file(self.path.join(&*filename));
}
}
let _ = fs::remove_file(INVITES.join(self.get_invite()));
fs::write(
self.path.join("account.txt"),
"\
USERNAME deleted\n\
PASSHASH /\n\
TIMESTAMP 0\n\
INVITECODE /\
",
)
.unwrap();
}
}
impl Invite {
pub fn get(invite: &str) -> PlankResult<Invite> {
if let Some(account) = Account::get_by_invite(invite) {
return PlankResult::Done(Invite::ByUser(account));
}
PlankResult::Denied
}
pub fn get_path_prefix(&self) -> &Path {
match self {
Invite::ByUser(account) => account.path.as_path(),
Invite::Root => Path::new("."),
}
}
}
pub fn create_account(username: &str, password: &str, invite: Invite) -> PlankResult<Account> {
if !username
.chars()
.all(|c: char| (c.is_ascii_alphanumeric() && c.is_lowercase()) || c == '-' || c != '_')
{
return PlankResult::Denied;
}
let timestamp = timestamp();
let dir = invite.get_path_prefix().join(username);
if let Err(x) = fs::create_dir(&dir) {
return PlankResult::Failure(x.to_string());
}
let invitecode = getrandomhex(10);
// create index symlink
if let Err(x) = symlink(Path::new("..").join(&dir).normalize(), INDEX.join(username)) {
return PlankResult::Failure(x.to_string());
}
// create invite symlink
if let Err(x) = symlink(
Path::new("..").join(&dir).normalize(),
INVITES.join(&invitecode),
) {
return PlankResult::Failure(x.to_string());
}
let passhash = hash_password(password);
if let Err(x) = fs::write(
Path::new(&dir).join("account.txt"),
format!(
"\
USERNAME {username}\n\
PASSHASH {passhash}\n\
TIMESTAMP {timestamp}\n\
INVITECODE {invitecode}",
),
) {
return PlankResult::Failure(x.to_string());
}
PlankResult::Done(Account {
path: dir,
name: username.to_owned(),
data: OnceLock::new(),
})
}
pub fn create_root_account(username: &str, password: &str) -> PlankResult<Account> {
create_account(username, password, Invite::Root)
}
pub fn hash_password(password: &str) -> String {
command_piped("sha512sum", &command_piped("sha512sum", password))
.split_once(" ")
.unwrap()
.0
.to_owned()
}

18
src/html/editscreen.html Normal file
View file

@ -0,0 +1,18 @@
<!--
placeholders:
username: current logged in username
postid: holds the post's id
title: holds the post title
-->
<post>
<div class="content">
<form method="post">
<h3> USERNAME: <input type="text" autocomplete="off" name="title" value="TITLE" required> </h3>
<textarea id="edit" name="content" placeholder="So basically, ..." required></textarea>
<br>
<br>
<button type="submit" name="fake" value="no">Submit</button>
</form>
</div>
</post>

114
src/html/header.html Normal file
View file

@ -0,0 +1,114 @@
<!--
placeholders:
webname: holds the site name
title: holds the post title
-->
<head>
<meta name="viewport" content="width=device-width height=device-height initial-scale=1">
<meta charset="UTF-8">
<meta property="og:site_name" content="WEBNAME" />
<meta property="og:title" content="TITLE" />
<title>TITLE - WEBNAME</title>
<style>
html {
background-color: #202020;
color: #cccccc;
font-family: sans-serif;
word-wrap: break-word;
}
a {
color: #ffffff;
text-decoration: none;
}
div.posts {
margin: auto;
display: block;
width: fit-content;
}
post .content {
max-width: min(70vw, max(50vw, 100em));
background-color: #202030;
display: block;
padding: 0 5px 5px 5px;
border: 1px solid #808080;
}
post .content .controls {
display: block;
text-align: right;
}
post {
display: block;
margin-left: 5px;
padding-left: 5px;
border-left: 2px solid #808080;
padding-bottom: 5px;
padding-top: 5px;
}
h3 {
display: block;
margin-top: 5px;
}
h6 {
margin: 0;
padding-top: 5px;
display: block;
}
pre {
white-space: pre-wrap;
display: block;
}
pre.inline {
display: inline;
}
button {
padding: 10px;
border-radius: 3px;
border: 1px solid #000000;
background-color: #000000;
color: #ffffff;
}
textarea {
min-height: 25vh;
width: 100%;
color: #ffffff;
background-color: #303040;
border: 3px solid #000000;
}
input {
color: #ffffff;
background-color: #303040;
border: 3px solid #000000;
}
block {
display: block;
}
img {
display: block;
margin: auto;
margin-top: 10px;
margin-bottom: 10px;
max-width: 90%;
}
b.title {
font-size: 1.3em;
margin: 5px;
}
</style>
</head>

32
src/html/index.html Normal file
View file

@ -0,0 +1,32 @@
<!--
placeholders:
webname: holds the site name
title: holds the post title
editscreen: holds the edit screen html
posts: holds the post list html
-->
<html>
HEADER
<body>
<a style="position: fixed; top: 10px; left: 10px;" href="/"><button type="button">Home</button></a>
<div class="posts">
<post>
<div class=content>
<h3> WEBNAME </h3>
Below is a list of posts. Click on one to open it.
You cannot reply without an account, and to create
an account you must have an invite from someone
else.
<p>
Log in or see your invite code <a href="/login">here</a>.
</div>
</post>
EDITSCREEN
POSTS
</div>
<body>
<html>

74
src/html/login.html Normal file
View file

@ -0,0 +1,74 @@
<!--
placeholders:
webname: holds the site name
title: holds the post title
editscreen: holds the edit screen html
posts: holds the post list html
-->
<html>
HEADER
<body>
<a style="position: fixed; top: 10px; left: 10px;" href="/"><button type="button">Home</button></a>
<div class="posts">
<post>
<div class=content>
<h3> You </h3>
Username: USERNAME<br>
Invite code: INVITECODE
<p>
<b>Warning: </b> Accounts created with your invite code can be
traced back to you!
<p>
You may delete your account by changing the "/login" in your
browser's URL bar to say "/delete" and hitting enter.
</div>
</post>
<post>
<div class=content>
<form method=POST>
<label for="username">Username: </label><br>
<input type=username name=username required>
<p>
<label for="password">Password: </label><br>
<input type=password name=password required>
<p>
<button type=submit> Log in </button>
</form>
</div>
<post style="display: LOGINFAIL">
<div class=content>
<h3> Login failed </h3>
Either your username is not valid or your password is wrong.
</div>
</post>
</post>
<post>
<div class=content>
<form method=POST>
<label for="username">Username (alphanumeric, lowercase): </label><br>
<input type=username name=username required>
<p>
<label for="password">Password: </label><br>
<input type=password name=password required>
<p>
<label for="invite">Invite code: </label><br>
<input type=text name=invite required>
<p>
<button type=submit> Register </button>
</form>
</div>
<post style="display: REGISTERFAIL">
<div class=content>
<h3> Registration failed </h3>
Either someone with your username already exists or your invite is
invalid.
</div>
</post>
</post>
</div>
<body>
<html>

130
src/html/mod.rs Normal file
View file

@ -0,0 +1,130 @@
use std::{fs, sync::LazyLock};
use crate::{
account::Account,
post::{Post, REPLIES, read_replies},
};
const HEADER: &str = include_str!("header.html");
const EDITSCREEN: &str = include_str!("editscreen.html");
const POST: &str = include_str!("post.html");
const POST_PREVIEW: &str = include_str!("post_preview.html");
const POSTVIEW: &str = include_str!("postview.html");
const INDEX: &str = include_str!("index.html");
const LOGIN: &str = include_str!("login.html");
static WEBNAME: LazyLock<String> =
LazyLock::new(|| fs::read_to_string("webname.txt").unwrap().trim().to_owned());
fn prep(html: &str) -> String {
html.replace("HEADER", HEADER).replace("WEBNAME", &WEBNAME)
}
pub fn postview(login: Option<Account>, p: Post, reply_on_gid: &str) -> String {
prep(POSTVIEW)
.replace("TITLE", &p.get_title())
.replace("POSTID", &p.get_gid())
.replace("POST", &post(login, POST, p, reply_on_gid))
}
pub fn post(login: Option<Account>, template: &str, p: Post, reply_on_gid: &str) -> String {
let replymode = reply_on_gid == p.get_gid() && login.is_some();
prep(template)
.replace("POSTID", &p.get_gid())
.replace("AUTHOR", p.get_author())
.replace(
"TITLE",
&p.get_title()
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>"),
)
.replace(
"CONTENT",
&p.get_content()
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>"),
)
.replace(
"REPLYBTN",
&if replymode {
"<a href=?>[Nevermind]</a> ".to_owned()
} else {
format!("<a href=\"?{}\">[Reply]</a> ", p.get_gid())
},
)
.replace(
"EDITSCREEN",
&if replymode {
let login = login.as_ref().unwrap();
prep(EDITSCREEN)
.replace("USERNAME", &login.name)
.replace("POSTID", reply_on_gid)
.replace(
"TITLE",
&format!("re/ {}", p.get_title().trim_start_matches("re/ ")),
)
} else {
String::new()
},
)
.replace(
"REPLIES",
&if template.contains("REPLIES") {
p.get_replies()
.into_iter()
.map(|x| post(login.clone(), POST, x, reply_on_gid))
.reduce(|a, b| a + &b)
.unwrap_or_default()
} else {
String::new()
},
)
}
pub fn login(acct: Option<Account>, loginfail: bool, regfail: bool) -> String {
prep(LOGIN)
.replace("LOGINFAIL", if loginfail { "block" } else { "none" })
.replace("REGISTERFAIL", if regfail { "block" } else { "none" })
.replace(
"USERNAME",
&if let Some(acct) = acct.as_ref() {
acct.name.to_owned()
} else {
"- not logged in -".to_owned()
},
)
.replace(
"INVITECODE",
&if let Some(acct) = acct.as_ref() {
acct.get_invite()
} else {
"- not logged in -".to_owned()
},
)
}
pub fn index(acct: Option<Account>) -> String {
prep(INDEX)
.replace("TITLE", "Index")
.replace(
"EDITSCREEN",
&if let Some(acct) = acct {
prep(EDITSCREEN)
.replace("USERNAME", &acct.name)
.replace("TITLE", "")
} else {
String::new()
},
)
.replace(
"POSTS",
&read_replies(REPLIES.join("root"))
.into_iter()
.rev()
.map(|x| post(None, POST_PREVIEW, x, ""))
.reduce(|a, b| a + &b)
.unwrap_or_default(),
)
}

26
src/html/post.html Normal file
View file

@ -0,0 +1,26 @@
<!--
placeholders:
author: holds the post's author username
postid: holds the post's id
title: holds the post title
content: holds the post's (escaped) content
replybtn: holds the html of the reply/nevermind button
replies: contains the html of the replies
editscreen: contains the html of the post creation form, if requested
-->
<post id="POSTID">
<div class=content>
<h3> AUTHOR: TITLE </h3>
CONTENT
<div class=controls>
REPLYBTN
</div>
</div>
EDITSCREEN
REPLIES
</post>

View file

@ -0,0 +1,16 @@
<!--
placeholders:
author: holds the post's author username
postid: holds the post's id
title: holds the post title
content: holds the post's (escaped) content
replybtn: holds the html of the reply/nevermind button
replies: contains the html of the replies
editscreen: contains the html of the post creation form, if requested
-->
<post id="POSTID">
<a href="/post/POSTID"><div class=content>
<b class=title> AUTHOR: TITLE </b>
</div></a>
</post>

18
src/html/postview.html Normal file
View file

@ -0,0 +1,18 @@
<!--
placeholders:
webname: holds the site name
title: holds the post title
postid: holds the post's GID
post: holds the post's html
-->
<html>
HEADER
<body>
<a style="position: fixed; top: 10px; left: 10px;" href="/#POSTID"><button type="button">Home</button></a>
<div class="posts">
POST
</div>
<body>
<html>

63
src/http/get.rs Normal file
View file

@ -0,0 +1,63 @@
use horrorhttp::{ConnectionState, ResponseWriter};
use readformat::readf1;
use crate::{
html,
http::{get_login, parse_cookies},
post::Post,
};
pub struct GetHandler;
impl ConnectionState for GetHandler {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
let (path, query) = connection
.path
.split_once("?")
.unwrap_or_else(|| (&connection.path, ""));
let cookies = parse_cookies(
connection
.headers
.get("Cookie")
.map(|x| x.as_str())
.unwrap_or(""),
);
if let Some(postgid) = readf1("/post/{}", path)
&& let Some(p) = Post::get_by_gid(&postgid.replace("/", ":"))
{
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::postview(get_login(&cookies), p, query).into_bytes()),
));
}
if path == "/login" {
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::login(get_login(&cookies), false, false).into_bytes()),
));
}
if path == "/delete" {
if let Some(acct) = get_login(&cookies) {
acct.delete();
}
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::index(get_login(&cookies)).into_bytes()),
));
}
if path == "/" {
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::index(get_login(&cookies)).into_bytes()),
));
}
connection.next()
}
}

61
src/http/mod.rs Normal file
View file

@ -0,0 +1,61 @@
mod get;
mod post;
use std::collections::HashMap;
use horrorhttp::ConnectionState;
use crate::{
account::Account,
http::{get::GetHandler, post::PostHandler},
};
pub struct BaseHandler;
impl ConnectionState for BaseHandler {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
if connection.method == "GET" {
return Some(Box::new(GetHandler));
}
if connection.method == "POST" {
return Some(Box::new(PostHandler));
}
connection.next()
}
}
pub fn get_login(cookies: &HashMap<String, String>) -> Option<Account> {
if let Some(login) = cookies.get("login").and_then(|x| x.split_once(":")) {
Account::get_logged_in(login.0, login.1)
} else {
None
}
}
pub fn parse_cookies(cookie: &str) -> HashMap<String, String> {
cookie
.split("; ")
.filter_map(|x| x.split_once("="))
.map(|(k, v)| {
(
urlencoding::decode(k).unwrap_or_default().replace("+", " "),
urlencoding::decode(v).unwrap_or_default().replace("+", " "),
)
})
.collect()
}
pub fn parse_body(body: &str) -> HashMap<String, String> {
body.split("&")
.filter_map(|x| x.split_once("="))
.map(|(k, v)| {
(
urlencoding::decode(k).unwrap_or_default().replace("+", " "),
urlencoding::decode(v).unwrap_or_default().replace("+", " "),
)
})
.collect()
}

121
src/http/post.rs Normal file
View file

@ -0,0 +1,121 @@
#![allow(clippy::collapsible_if)]
use horrorhttp::{ConnectionState, ResponseWriter};
use readformat::readf1;
use crate::{
PlankResult,
account::{Account, Invite, create_account, hash_password},
html::{self},
http::{get_login, parse_body, parse_cookies},
post::{Post, create_post},
};
pub struct PostHandler;
impl ConnectionState for PostHandler {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
let (path, query) = connection
.path
.split_once("?")
.unwrap_or_else(|| (&connection.path, ""));
let body = parse_body(&String::from_utf8_lossy(&connection.body));
let cookies = parse_cookies(
connection
.headers
.get("Cookie")
.map(|x| x.as_str())
.unwrap_or(""),
);
if let Some(account) = get_login(&cookies) {
if let Some(postgid) = readf1("/post/{}", path)
&& let Some(p) = Post::get_by_gid(&postgid.replace("/", ":"))
&& let Some(parent) = Post::get_by_gid(query)
&& let Some(title) = body.get("title")
&& let Some(content) = body.get("content")
{
create_post(account.clone(), Some(parent), title, content);
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::postview(Some(account), p, "").into_bytes()),
));
}
if path == "/"
&& let Some(title) = body.get("title")
&& let Some(content) = body.get("content")
{
create_post(account.clone(), None, title, content);
return Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::index(Some(account)).into_bytes()),
));
}
}
if path == "/login" {
if let Some(invite) = body.get("invite") {
if let Some(username) = body.get("username")
&& let Some(password) = body.get("password")
{
// it is a registration.
return match Invite::get(invite)
.and_then(|invite| create_account(username, password, invite))
{
PlankResult::Done(acct) => Some(Box::new(
ResponseWriter::new()
.with_status(302, "Found")
.with_header(
"Set-Cookie",
format!(
"login={}:{}; Max-Age=12096000",
acct.name,
acct.get_passwordhash()
),
)
.with_header("Location", "/"),
)),
PlankResult::Denied => Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::login(None, false, true).into_bytes()),
)),
PlankResult::Failure(x) => panic!("{x}"),
};
}
}
if let Some(username) = body.get("username")
&& let Some(password) = body.get("password")
{
// it is a registration.
return if let Some(acct) =
Account::get_logged_in(username, &hash_password(password))
{
Some(Box::new(
ResponseWriter::new()
.with_status(302, "Found")
.with_header(
"Set-Cookie",
format!(
"login={}:{}; Max-Age=12096000",
acct.name,
acct.get_passwordhash()
),
)
.with_header("Location", "/"),
))
} else {
Some(Box::new(
ResponseWriter::new()
.with_header("Content-Type", "text/html")
.with_body(html::login(None, true, false).into_bytes()),
))
};
}
}
connection.next()
}
}

106
src/main.rs Normal file
View file

@ -0,0 +1,106 @@
#![allow(dead_code)]
use std::{
fs,
io::Write,
net::TcpListener,
process::{Command, Stdio},
time::SystemTime,
};
use horrorhttp::BodyReaderHeader;
use readformat::readf1;
use crate::account::create_root_account;
pub mod account;
pub mod html;
pub mod http;
pub mod path;
pub mod post;
#[derive(Clone)]
pub enum PlankResult<T> {
Done(T),
Denied,
Failure(String),
}
impl<T> PlankResult<T> {
pub fn unwrap(self) -> T {
match self {
Self::Done(x) => x,
Self::Denied => panic!("Denied"),
Self::Failure(x) => panic!("{x}"),
}
}
pub fn and_then<O>(self, f: impl FnOnce(T) -> PlankResult<O>) -> PlankResult<O> {
match self {
PlankResult::Done(x) => f(x),
PlankResult::Denied => PlankResult::Denied,
PlankResult::Failure(x) => PlankResult::Failure(x),
}
}
}
fn main() {
init();
let _ = create_root_account("root", "init");
println!("http://localhost:7241");
let listener = TcpListener::bind(("::0", 7241)).unwrap();
while let Ok(stream) = listener.accept() {
horrorhttp::handle(stream.0, BodyReaderHeader, http::BaseHandler);
}
}
pub fn init() {
let _ = fs::create_dir("index");
let _ = fs::create_dir("invites");
let _ = fs::create_dir("replies");
let _ = fs::create_dir("replies/root");
if !fs::exists("webname.txt").unwrap() {
fs::write("webname.txt", "Plank").unwrap();
}
}
pub fn command_piped(command: &str, input: &str) -> String {
let mut process = Command::new(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let pipe = process.stdin.as_mut().unwrap();
pipe.write_all(input.as_bytes()).unwrap();
pipe.flush().unwrap();
String::from_utf8_lossy(&process.wait_with_output().unwrap().stdout).to_string()
}
pub fn getrandomhex(bytes: u32) -> String {
command_piped("sh", &format!("head -c{bytes} /dev/urandom | xxd -ps"))
.trim()
.to_string()
}
pub fn timestamp() -> String {
SystemTime::UNIX_EPOCH
.elapsed()
.unwrap()
.as_millis()
.to_string()
}
pub trait PlankData {
fn get_self(&self) -> &str;
fn get_property(&self, property: &str) -> String {
self.get_self()
.lines()
.filter_map(|x| readf1(&(property.to_owned() + " {}"), x))
.next()
.unwrap()
}
}

32
src/path.rs Normal file
View file

@ -0,0 +1,32 @@
use std::path::{Path, PathBuf};
pub trait Normalize {
fn normalize(&self) -> PathBuf;
}
impl Normalize for PathBuf {
fn normalize(&self) -> PathBuf {
self.as_path().normalize()
}
}
// same as normal normalization, except it also removes ../
impl Normalize for Path {
fn normalize(&self) -> PathBuf {
self.components().fold(PathBuf::new(), |a, b| match b {
std::path::Component::Prefix(_) => a.join(b),
std::path::Component::RootDir => a.join(b.as_os_str()),
std::path::Component::CurDir => a,
std::path::Component::ParentDir => {
if a.ends_with("..") {
a.join("..")
} else {
a.parent()
.map(|x| x.to_path_buf())
.unwrap_or(PathBuf::from(".."))
}
}
std::path::Component::Normal(os_str) => a.join(os_str),
})
}
}

145
src/post.rs Normal file
View file

@ -0,0 +1,145 @@
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
sync::{LazyLock, OnceLock},
};
use crate::{PlankData, PlankResult, account::Account, path::Normalize, timestamp};
pub static REPLIES: LazyLock<&Path> = LazyLock::new(|| Path::new("replies"));
#[derive(Clone)]
pub struct Post {
pub path: PathBuf,
pub owner: Account,
pub id: String,
pub data: OnceLock<String>,
}
impl PlankData for Post {
fn get_self(&self) -> &str {
self.data
.get_or_init(|| fs::read_to_string(&self.path).unwrap())
}
}
impl Post {
pub fn get_by_gid(gid: &str) -> Option<Post> {
let (owner, id) = gid.split_once(':')?;
let owner = Account::get_by_username(owner)?;
let path = owner.path.join(id).with_extension("txt").normalize();
if !path.exists() {
return None;
}
Some(Post {
path,
owner,
id: id.to_owned(),
data: OnceLock::new(),
})
}
pub fn get_parent(&self) -> Option<Post> {
Post::get_by_gid(&self.get_property("PARENT"))
}
pub fn get_title(&self) -> String {
self.get_property("TITLE")
}
pub fn get_content(&self) -> String {
self.get_self().split_once("\n\n").unwrap().1.to_owned()
}
pub fn get_replypath(&self) -> PathBuf {
REPLIES.join(self.get_gid())
}
pub fn get_replies(&self) -> Vec<Post> {
let replypath = self.get_replypath();
read_replies(replypath)
}
pub fn get_author(&self) -> &str {
&self.owner.name
}
pub fn get_gid(&self) -> String {
format!("{}:{}", self.owner.name, self.id)
}
}
pub fn read_replies(replypath: PathBuf) -> Vec<Post> {
let mut posts: Vec<_> = replypath
.read_dir()
.unwrap()
.filter_map(|x| {
// get the post being referenced
let direntry = x.unwrap();
let path = replypath.join(direntry.path().read_link().unwrap());
if !path.exists() {
return None;
}
// return the post (costless wrap)
Some(Post {
owner: Account::from_path(path.parent().unwrap().to_path_buf()),
id: path.file_prefix().unwrap().to_string_lossy().to_string(),
path,
data: OnceLock::new(),
})
})
.collect();
posts.sort_unstable_by(|a, b| {
a.id.parse()
.unwrap_or(0u128)
.cmp(&b.id.parse().unwrap_or(0u128))
});
posts
}
pub fn create_post(
poster: Account,
parent: Option<Post>,
title: &str,
content: &str,
) -> PlankResult<Post> {
let id = timestamp();
let post = Post {
path: poster.path.join(&id).with_extension("txt"),
owner: poster,
id: id.clone(),
data: OnceLock::new(),
};
// normalize parent
let parent = parent.map_or_else(|| "root".to_owned(), |x| x.get_gid());
// create REPLIES entry
if let Err(x) = symlink(
Path::new("../..").join(&post.path).normalize(),
REPLIES.join(&parent).join(&id),
) {
return PlankResult::Failure(x.to_string());
}
// create the actual post
if let Err(x) = fs::create_dir(REPLIES.join(post.get_gid())).and_then(|_| {
fs::write(
&post.path,
format!(
"\
TIMESTAMP {id}\n\
PARENT {parent}\n\
TITLE {title}\n\
\n\
{content}"
),
)
}) {
PlankResult::Failure(x.to_string())
} else {
PlankResult::Done(post)
}
}