Initial commit
This commit is contained in:
commit
dd596b0f09
22 changed files with 1244 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use nix
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/target
|
||||
/.direnv
|
||||
/tudbut
|
||||
/replies
|
||||
/index
|
||||
/invites
|
||||
/webname.txt
|
||||
/root
|
||||
33
Cargo.lock
generated
Normal file
33
Cargo.lock
generated
Normal 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
9
Cargo.toml
Normal 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
29
README.md
Normal 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
16
format.md
Normal 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
11
shell.nix
Normal 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
181
src/account.rs
Normal 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
18
src/html/editscreen.html
Normal 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
114
src/html/header.html
Normal 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
32
src/html/index.html
Normal 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
74
src/html/login.html
Normal 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
130
src/html/mod.rs
Normal 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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\n", "<br>"),
|
||||
)
|
||||
.replace(
|
||||
"CONTENT",
|
||||
&p.get_content()
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.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
26
src/html/post.html
Normal 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>
|
||||
|
||||
16
src/html/post_preview.html
Normal file
16
src/html/post_preview.html
Normal 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
18
src/html/postview.html
Normal 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
63
src/http/get.rs
Normal 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
61
src/http/mod.rs
Normal 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
121
src/http/post.rs
Normal 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
106
src/main.rs
Normal 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
32
src/path.rs
Normal 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
145
src/post.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue