diff --git a/depell/Cargo.toml b/depell/Cargo.toml index b88fab7..dd6d6f8 100644 --- a/depell/Cargo.toml +++ b/depell/Cargo.toml @@ -4,8 +4,12 @@ version = "0.1.0" edition = "2021" [dependencies] -aes-gcm = { version = "0.10.3", default-features = false, features = ["aes", "rand_core"] } -ed25519-dalek = { version = "2.1.1", default-features = false, features = ["rand_core"] } +anyhow = "1.0.89" +axum = "0.7.7" getrandom = "0.2.15" -rand_core = { version = "0.6.4", features = ["getrandom"] } -x25519-dalek = { version = "2.0.1", default-features = false } +htmlm = "0.3.0" +log = "0.4.22" +rusqlite = "0.32.1" +serde = { version = "1.0.210", features = ["derive"] } +time = "0.3.36" +tokio = { version = "1.40.0", features = ["rt"] } diff --git a/depell/src/index.css b/depell/src/index.css new file mode 100644 index 0000000..685023f --- /dev/null +++ b/depell/src/index.css @@ -0,0 +1,88 @@ +* { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +body { + --primary: white; + --secondary: #EFEFEF; + --error: #ff3333; + --placeholder: #333333; +} + +body { + --small-gap: 5px; + --monospace: 'Courier New', Courier, monospace; + + nav { + display: flex; + justify-content: space-between; + align-items: center; + + section:last-child { + display: flex; + gap: var(--small-gap); + } + } + + main { + margin-top: var(--small-gap); + } +} + +div.preview { + div.stats { + display: flex; + gap: var(--small-gap); + } +} + +form { + display: flex; + flex-direction: column; + gap: var(--small-gap); + + ::placeholder { + color: var(--placeholder); + } + + .error { + color: var(--error); + text-align: center; + } + +} + +pre, +textarea { + outline: none; + border: none; + background: var(--secondary); + padding: var(--small-gap); + padding-top: calc(var(--small-gap) * 1.5); + margin: var(--small-gap) 0px; + font-family: var(--monospace); + resize: none; +} + +input { + font-size: inherit; + outline: none; + border: none; + background: var(--secondary); + padding: var(--small-gap); +} + +input:is(:hover, :focus) { + background: white; +} + +button { + border: none; + outline: none; + font-size: inherit; + background: var(--secondary); +} + +button:hover:not(:active) { + background: white; +} diff --git a/depell/src/index.js b/depell/src/index.js new file mode 100644 index 0000000..81d380d --- /dev/null +++ b/depell/src/index.js @@ -0,0 +1,95 @@ +//// @ts-check + +if (window.location.hostname === 'localhost') { + let id; setInterval(async () => { + let new_id = await fetch('/hot-reload').then(reps => reps.text()); + id ??= new_id; + if (id !== new_id) window.location.reload(); + }, 300); +} + +document.body.addEventListener('htmx:afterSwap', (ev) => { + wireUp(ev.target); +}); + +wireUp(document.body); + +/** @param {HTMLElement} target */ +function wireUp(target) { + execApply(target); + cacheInputs(target); + bindTextareaAutoResize(target); +} + +/** @param {string} content @return {string} */ +function fmtTimestamp(content) { + new Date(parseInt(content) * 1000).toLocaleString() +} + +/** @param {HTMLElement} target */ +function execApply(target) { + /**@type {HTMLElement}*/ let elem; + for (elem of target.querySelectorAll('[apply]')) { + const funcname = elem.getAttribute('apply'); + elem.textContent = window[funcname](elem.textContent); + } +} + +/** @param {HTMLElement} target */ +function bindTextareaAutoResize(target) { + /**@type {HTMLTextAreaElement}*/ let textarea; + for (textarea of target.querySelectorAll("textarea")) { + textarea.style.height = textarea.scrollHeight + "px"; + textarea.style.overflowY = "hidden"; + textarea.addEventListener("input", function() { + textarea.style.height = "auto"; + textarea.style.height = textarea.scrollHeight + "px"; + }); + + textarea.onkeydown = (ev) => { + const selecting = textarea.selectionStart !== textarea.selectionEnd; + + if (ev.key === "Tab") { + ev.preventDefault(); + const prevPos = textarea.selectionStart; + textarea.value = textarea.value.slice(0, textarea.selectionStart) + + ' ' + textarea.value.slice(textarea.selectionEnd); + textarea.selectionStart = textarea.selectionEnd = prevPos + 4; + } + + if (ev.key === "Backspace" && textarea.selectionStart != 0 && !selecting) { + let i = textarea.selectionStart, looped = false; + while (textarea.value.charCodeAt(--i) === ' '.charCodeAt(0)) looped = true; + if (textarea.value.charCodeAt(i) === '\n'.charCodeAt(0) && looped) { + ev.preventDefault(); + let toDelete = (textarea.selectionStart - (i + 1)) % 4; + if (toDelete === 0) toDelete = 4; + const prevPos = textarea.selectionStart; + textarea.value = textarea.value.slice(0, textarea.selectionStart - toDelete) + + textarea.value.slice(textarea.selectionEnd); + textarea.selectionStart = textarea.selectionEnd = prevPos - toDelete; + } + } + } + } +} + +/** @param {HTMLElement} target */ +function cacheInputs(target) { + /**@type {HTMLFormElement}*/ let form; + for (form of target.querySelectorAll('form')) { + const path = form.getAttribute('hx-post') || form.getAttribute('hx-delete'); + if (!path) { + console.warn('form does not have a hx-post or hx-delete attribute', form); + continue; + } + + /**@type {HTMLInputElement}*/ let input; + for (input of form.elements) { + if ('password submit button'.includes(input.type)) continue; + const key = path + input.name; + input.value = localStorage.getItem(key) ?? ''; + input.addEventListener("input", (ev) => localStorage.setItem(key, ev.target.value)); + } + } +} diff --git a/depell/src/main.rs b/depell/src/main.rs index ef0c554..748e4e7 100644 --- a/depell/src/main.rs +++ b/depell/src/main.rs @@ -1,641 +1,537 @@ -#![feature(array_chunks)] -#![feature(write_all_vectored)] use { - aes_gcm::{ - aead::{self, AeadMutInPlace}, - AeadCore, Aes256Gcm, KeyInit, + axum::{ + http::{header::COOKIE, request::Parts}, + response::{AppendHeaders, Html}, }, - ed25519_dalek::{self as ed, ed25519::signature::Signer}, - rand_core::OsRng, - std::{ - collections::{HashMap, HashSet}, - fmt, fs, - io::{self, IoSlice, IoSliceMut, Read, Write}, - mem::{self, MaybeUninit}, - net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream}, - path::PathBuf, - slice, - str::FromStr, - sync::{ - atomic::{self, AtomicUsize}, - Arc, Mutex, - }, - time, - }, - x25519_dalek::{self as x, EphemeralSecret, SharedSecret}, + core::fmt, + htmlm::{html, write_html}, + serde::{Deserialize, Serialize}, + std::net::Ipv4Addr, }; -type Subcommand<'a, T> = (&'a str, &'a str, T); -type BaseSubcommand<'a> = Subcommand<'a, fn(&Cli) -> io::Result<()>>; -type ConsumeSubcommand<'a> = Subcommand<'a, fn(&Cli, EncriptedStream) -> io::Result<()>>; +const MAX_NAME_LENGTH: usize = 32; +const MAX_POSTNAME_LENGTH: usize = 64; +//const MAX_CODE_LENGTH: usize = 1024 * 4; +const SESSION_DURATION_SECS: u64 = 60 * 60; -type Username = [u8; 32]; -type Postname = [u8; 64]; -type Pk = [u8; 32]; -type Nonce = u64; +type Redirect = AppendHeaders<[(&'static str, &'static str); COUNT]>; -const SUBCOMMANDS: &[BaseSubcommand] = &[ - ("help", "print command descriptions", |_| help(SUBCOMMANDS)), - ("serve", "run the server", |cli| { - let port = cli.expect_poption::("port"); +async fn amain() { + use axum::routing::{delete, get, post}; - let config = Arc::new(ServerState { - user_data_dir: cli.expect_poption("user-data-path"), - secret: cli.expect_poption::("secret").0, - active_ips: Default::default(), - max_conns: cli.expect_poption::("max-conns"), - conn_count: Default::default(), - }); - let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).unwrap(); - for incoming in listener.incoming() { - match incoming { - Ok(c) => { - let Ok(std::net::SocketAddr::V4(addr)) = - c.peer_addr().ctx("obtaining socket addr") - else { - continue; - }; + let debug = cfg!(debug_assertions); - let Some(guard) = ConnectionGuard::new(config.clone(), *addr.ip()) else { - continue; - }; + log::set_logger(&Logger).unwrap(); + log::set_max_level(if debug { log::LevelFilter::Warn } else { log::LevelFilter::Error }); - std::thread::spawn(move || _ = guard.config.handle_client(c)); - } - Err(e) => { - eprintln!("accepting conn conn: {e}") - } - } - } - Ok(()) - }), - ("make-secret", "creates secret usable by the server", |_| { - println!("{}", DisplayHex(ed::SigningKey::generate(&mut OsRng).to_bytes())); - Ok(()) - }), - ("make-ping-command", "create a ping command to handshake with the server", |cli| { - let HexSk(secret) = cli.expect_poption::("secret"); - let addr = cli.expect_poption::("addr"); - let id = DisplayHex(ed::VerifyingKey::from(&secret).to_bytes()); - println!("depell consume --server-identity={id} --addr={addr} ping"); - Ok(()) - }), - ("make-profile", "create profile file (private key + name)", |cli| { - let name = cli.expect_option("name"); - let name = str_as_username(name) - .ok_or(io::ErrorKind::InvalidData) - .ctx("name is limmited to 32 characters")?; + db::init(); - let &key = ed::SigningKey::generate(&mut rand_core::OsRng).as_bytes(); - let profile = UserProfile { name, key }; + let router = axum::Router::new() + .route("/", get(Index::page)) + .route("/index-view", get(Index::get)) + .route("/feed", get(Index::page)) + .route("/profile", get(Profile::page)) + .route("/profile-view", get(Profile::get)) + .route("/post", get(Post::page)) + .route("/post-view", get(Post::get)) + .route("/post", post(Post::post)) + .route("/login", get(Login::page)) + .route("/login-view", get(Login::get)) + .route("/login", post(Login::post)) + .route("/login", delete(Login::delete)) + .route("/signup", get(Signup::page)) + .route("/signup-view", get(Signup::get)) + .route("/signup", post(Signup::post)) + .route( + "/hot-reload", + get({ + let id = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + move || async move { id.to_string() } + }), + ); - let out_file = cli.expect_option("out-file"); - _ = fs::write(out_file, as_bytes(&profile)).ctx("while saving profile file"); - Ok(()) - }), - ("consume", "connect to server and do an action", |cli| { - let profile_path = cli.expect_option("profile"); - let mut profile_file = fs::File::open(profile_path).ctx("opening profile file")?; - let profile: UserProfile = read_struct(&mut profile_file).ctx("reading the profile")?; - let sx = x::EphemeralSecret::random_from_rng(OsRng); - let auth = UserAuth::sign(profile, &sx); + let socket = tokio::net::TcpListener::bind((Ipv4Addr::UNSPECIFIED, 8080)).await.unwrap(); - let addr = cli.expect_poption::("addr"); - let mut stream = TcpStream::connect(addr).ctx("creating connection to the server")?; - write_struct(&mut stream, &auth).ctx("sending initial handshake packet")?; - - let HexPk(server_identity) = cli.expect_poption("server-identity"); - let sauth: ServerAuth = read_struct(&mut stream).ctx("reading server auth")?; - let secret = sauth - .verify(auth.x, server_identity, sx) - .map_err(|_| io::ErrorKind::PermissionDenied) - .ctx("authenticating server")?; - let stream = EncriptedStream::new(stream, secret); - - select_subcommand(1, CONSUME_SUBCOMMAND, cli)(cli, stream) - }), -]; - -const CONSUME_SUBCOMMAND: &[ConsumeSubcommand] = &[ - ("help", "this help message", |_, _| help(CONSUME_SUBCOMMAND)), - ("ping", "ping the server to check the connection", |_, mut stream| { - let now = time::Instant::now(); - write_struct(&mut stream, &Qid::Ping)?; - if !matches!(Aid::try_from(read_struct::(&mut stream)?)?, Aid::Pong) { - eprintln!("server did not respond with ping"); - } - println!("{:?}", now.elapsed()); - Ok(()) - }), -]; - -fn main() -> io::Result<()> { - let cli = Cli::parse(); - select_subcommand(0, SUBCOMMANDS, &cli)(&cli) + axum::serve(socket, router).await.unwrap(); } -fn help(subs: &[Subcommand]) -> io::Result<()> { - for (name, desc, _) in subs { - eprintln!("{name} - {desc}"); +trait PublicPage: Default { + fn render(self) -> String; + + async fn get() -> Html { + Html(Self::default().render()) } - Err(io::ErrorKind::NotFound.into()) -} -fn select_subcommand<'a, T>(depth: usize, list: &'a [Subcommand], cli: &Cli) -> &'a T { - &list.iter().find(|&&(name, ..)| name == cli.arg(depth)).unwrap_or(&list[0]).2 -} - -struct ServerState { - user_data_dir: PathBuf, - secret: ed::SigningKey, - max_conns: usize, - active_ips: Mutex>, - conn_count: AtomicUsize, -} - -impl ServerState { - fn handle_client(&self, mut stream: TcpStream) -> io::Result<()> { - let (user, sec) = { - let user_auth: UserAuth = read_struct(&mut stream).ctx("reading auth packet")?; - let sx = x::EphemeralSecret::random_from_rng(OsRng); - let pk = x::PublicKey::from(&sx); - let user = UserData::load(&user_auth, sx, self).ctx("loading user data")?; - let sauth = ServerAuth::sign(&user_auth, &self.secret, pk); - write_struct(&mut stream, &sauth).ctx("sending handshare response")?; - user - }; - - let mut stream = EncriptedStream::new(stream, sec); - - loop { - match Qid::try_from(read_struct::(&mut stream)?)? { - Qid::Ping => write_struct(&mut stream, &Aid::Pong)?, - } - } + async fn page(session: Option) -> Html { + base(Self::default().render(), session).await } } -struct ConnectionGuard { - ip: Ipv4Addr, - config: Arc, -} +trait Page: Default { + fn render(self, session: &Session) -> String; -impl ConnectionGuard { - fn new(config: Arc, ip: Ipv4Addr) -> Option { - if config.conn_count.fetch_add(1, atomic::Ordering::Relaxed) >= config.max_conns { - eprintln!("max connection cap reached"); - config.conn_count.fetch_sub(1, atomic::Ordering::Relaxed); - return None; - } - - if !config.active_ips.lock().unwrap().insert(ip) { - eprintln!("ip already connected, dropping connection"); - config.conn_count.fetch_sub(1, atomic::Ordering::Relaxed); - return None; - } - - Some(Self { ip, config }) - } -} - -impl Drop for ConnectionGuard { - fn drop(&mut self) { - self.config.active_ips.lock().unwrap().remove(&self.ip); - self.config.conn_count.fetch_sub(1, atomic::Ordering::Relaxed); - } -} - -#[repr(u16)] -enum Aid { - Pong, -} - -impl TryFrom for Aid { - type Error = io::ErrorKind; - - fn try_from(value: u16) -> Result { - if value <= Self::Pong as u16 { - Ok(unsafe { mem::transmute::(value) }) - } else { - Err(io::ErrorKind::NotFound) - } - } -} - -#[repr(u16)] -enum Qid { - Ping, -} - -impl TryFrom for Qid { - type Error = io::ErrorKind; - - fn try_from(value: u16) -> Result { - if value <= Self::Ping as u16 { - Ok(unsafe { mem::transmute::(value) }) - } else { - Err(io::ErrorKind::NotFound) - } - } -} - -trait Ctx { - fn ctx(self, label: &str) -> Self; -} - -impl Ctx for Result { - fn ctx(self, label: &str) -> Self { - if let Err(e) = &self { - eprintln!("{label}: {e}") - } - self - } -} - -fn username_as_str(name: &Username) -> Option<&str> { - let len = name.iter().rposition(|&b| b != 0xff)? + 1; - std::str::from_utf8(&name[..len]).ok() -} - -fn str_as_username(name: &str) -> Option { - if name.len() > mem::size_of::() { - return None; - } - let mut buff = [0xffu8; mem::size_of::()]; - buff[..name.len()].copy_from_slice(name.as_bytes()); - Some(buff) -} - -#[repr(packed)] -struct UserProfile { - name: Username, - key: ed::SecretKey, -} - -#[repr(packed)] -struct UserAuth { - signature: ed::Signature, - pk: Pk, - x: x::PublicKey, - name: Username, - nonce: Nonce, -} - -impl UserAuth { - fn sign(UserProfile { name, key }: UserProfile, sx: &x::EphemeralSecret) -> Self { - let nonce = - time::SystemTime::now().duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_secs(); - let mut message = [0; mem::size_of::() + mem::size_of::()]; - message[..mem::size_of::()].copy_from_slice(&name); - message[mem::size_of::()..].copy_from_slice(&nonce.to_le_bytes()); - - let signing_key = ed::SigningKey::from_bytes(&key); - let signature = signing_key.sign(&message); - let pk = ed::VerifyingKey::from(&signing_key).to_bytes(); - let x = x::PublicKey::from(sx); - - Self { signature, pk, x, name, nonce } + async fn get(session: Session) -> Html { + Html(Self::default().render(&session)) } - fn verify( - &self, - pk: Pk, - nonce: Nonce, - sx: x::EphemeralSecret, - ) -> Result { - if nonce >= self.nonce { - eprintln!("invalid auth nonce"); - return Err(ed::SignatureError::default()); - } - - let pk = ed::VerifyingKey::from_bytes(&pk)?; - - let mut message = [0; mem::size_of::() + mem::size_of::()]; - message[..mem::size_of::()].copy_from_slice(&self.name); - message[mem::size_of::()..].copy_from_slice(&self.nonce.to_le_bytes()); - - pk.verify_strict(&message, &self.signature)?; - - Ok(sx.diffie_hellman(&self.x)) + async fn page(session: Session) -> Html { + base(Self::default().render(&session), Some(session)).await } } -#[repr(packed)] -struct ServerAuth { - signature: ed::Signature, - x: x::PublicKey, -} - -impl ServerAuth { - fn sign(user_auth: &UserAuth, sk: &ed::SigningKey, x: x::PublicKey) -> Self { - let signature = sk.sign(user_auth.x.as_bytes()); - Self { signature, x } - } - - fn verify( - &self, - x: x::PublicKey, - pk: ed::VerifyingKey, - sx: EphemeralSecret, - ) -> Result { - pk.verify_strict(x.as_bytes(), &self.signature)?; - Ok(sx.diffie_hellman(&self.x)) - } -} - -struct UserData { - header: UserHeader, - post_headers: fs::File, - posts: fs::File, -} - -impl UserData { - fn load( - auth: &UserAuth, - sx: x::EphemeralSecret, - config: &ServerState, - ) -> io::Result<(Self, SharedSecret)> { - const HEADER_PATH: &str = "header.bin"; - const POST_HEADERS_PATH: &str = "post-headers.bin"; - const POST_PATH: &str = "posts.bin"; - - let mut path = PathBuf::from_iter([ - config.user_data_dir.as_path(), - username_as_str(&auth.name).ok_or(io::ErrorKind::InvalidData)?.as_ref(), - ]); - - if path.exists() { - let mut opts = fs::OpenOptions::new(); - opts.write(true).read(true); - - path.push(HEADER_PATH); - let mut header_file = opts.open(&path).ctx("opening user header file")?; - let mut header: UserHeader = - read_struct(&mut header_file).ctx("reading the user header")?; - path.pop(); - - let secret = auth - .verify(header.pk, header.nonce, sx) - .map_err(|_| io::ErrorKind::PermissionDenied) - .ctx("authenticating user")?; - - header.nonce = auth.nonce; - write_struct(&mut header_file, &header).ctx("saving user nonce")?; - - path.push(POST_HEADERS_PATH); - let post_headers = opts.open(&path).ctx("opening user post header file")?; - path.pop(); - - path.push(POST_PATH); - let posts = opts.open(&path).ctx("opening user post file")?; - path.pop(); - - Ok((Self { header, post_headers, posts }, secret)) - } else { - let secret = auth - .verify(auth.pk, 0, sx) - .map_err(|_| io::ErrorKind::PermissionDenied) - .ctx("verifiing registratio signature")?; - - fs::create_dir_all(&path).ctx("creating new user directory")?; - path.push(HEADER_PATH); - let header = - UserHeader { pk: auth.pk, nonce: auth.nonce, post_count: 0, runs: 0, imports: 0 }; - fs::write(&path, as_bytes(&header)).ctx("writing new user header")?; - path.pop(); - - path.push(POST_HEADERS_PATH); - let post_headers = fs::File::create_new(&path).ctx("creating new user post headers")?; - path.pop(); - - path.push(POST_PATH); - let posts = fs::File::create_new(&path).ctx("creating new user posts")?; - path.pop(); - - Ok((Self { header, post_headers, posts }, secret)) - } - } -} - -#[repr(packed)] -struct UserHeader { - pk: Pk, - nonce: Nonce, - post_count: u32, - imports: u32, - runs: u32, -} - -#[repr(packed)] -struct PostHeader { - name: Postname, - timestamp: u64, - size: u32, - offset: u32, - imports: u32, - runs: u32, -} - -const ASOC_DATA: &[u8] = b"testicle torsion wizard"; - -struct EncriptedStream { - inner: TcpStream, - key: SharedSecret, - buf: Vec, -} - -impl EncriptedStream { - fn new(inner: TcpStream, key: SharedSecret) -> Self { - Self { inner, key, buf: Default::default() } - } -} - -impl Read for EncriptedStream { - fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> { - let mut tag = MaybeUninit::>::uninit(); - let mut nonce = MaybeUninit::>::uninit(); - - let mut bufs = &mut [ - IoSliceMut::new(as_mut_bytes(&mut tag)), - IoSliceMut::new(as_mut_bytes(&mut nonce)), - IoSliceMut::new(buf), - ][..]; - - loop { - let red = self.inner.read_vectored(bufs)?; - if red == 0 { - return Err(io::ErrorKind::UnexpectedEof.into()); - } - IoSliceMut::advance_slices(&mut bufs, red); - if bufs.is_empty() { - break; - } - } - - unsafe { - Aes256Gcm::new(self.key.as_bytes().into()) - .decrypt_in_place_detached(&nonce.assume_init(), ASOC_DATA, buf, &tag.assume_init()) - .map_err(|_| io::ErrorKind::PermissionDenied)?; - } - - Ok(()) - } - - fn read(&mut self, _: &mut [u8]) -> io::Result { - unimplemented!() - } -} - -impl Write for EncriptedStream { - fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { - self.buf.clear(); - self.buf.extend(buf); - - let nonce = Aes256Gcm::generate_nonce(OsRng); - let tag = Aes256Gcm::new(self.key.as_bytes().into()) - .encrypt_in_place_detached(&nonce, ASOC_DATA, &mut self.buf) - .unwrap(); - - self.inner.write_all_vectored(&mut [ - IoSlice::new(as_bytes(&tag)), - IoSlice::new(as_bytes(&nonce)), - IoSlice::new(&self.buf), - ]) - } - - fn flush(&mut self) -> io::Result<()> { - self.inner.flush() - } - - fn write(&mut self, _: &[u8]) -> io::Result { - unimplemented!() - } -} - -fn read_struct(stream: &mut impl Read) -> io::Result { - let mut res = mem::MaybeUninit::uninit(); - stream.read_exact(as_mut_bytes(&mut res))?; - Ok(unsafe { res.assume_init() }) -} - -fn write_struct(stream: &mut impl Write, value: &T) -> io::Result<()> { - stream.write_all(as_bytes(value)) -} - -fn as_mut_bytes(value: &mut T) -> &mut [u8] { - unsafe { slice::from_raw_parts_mut(value as *mut _ as *mut u8, mem::size_of::()) } -} - -fn as_bytes(value: &T) -> &[u8] { - unsafe { slice::from_raw_parts(value as *const _ as *const u8, mem::size_of::()) } -} - #[derive(Default)] -struct Cli { - program: String, - args: Vec, - flags: HashSet, - options: HashMap, +struct Index; + +impl PublicPage for Index { + fn render(self) -> String { + include_str!("welcome-page.html").to_string() + } } -impl Cli { - pub fn parse() -> Self { - let mut s = Self::default(); - let mut args = std::env::args(); - s.program = args.next().unwrap(); +#[derive(Deserialize, Default)] +struct Post { + author: String, + name: String, + #[serde(skip)] + timestamp: u64, + #[serde(skip)] + imports: usize, + #[serde(skip)] + runs: usize, + #[serde(skip)] + dependencies: usize, + code: String, + #[serde(skip)] + error: Option<&'static str>, +} - for arg in args { - if let Some(arg) = arg.strip_prefix("--") { - match arg.split_once('=') { - Some((name, value)) => _ = s.options.insert(name.to_owned(), value.to_owned()), - None => _ = s.flags.insert(arg.to_string()), +impl Page for Post { + fn render(self, session: &Session) -> String { + let Self { name, code, error, .. } = self; + html! { +
+ if let Some(e) = error {
e
} + + + + + +
+ !{include_str!("post-page.html")} + } + } +} + +impl Post { + async fn post( + session: Session, + axum::Form(mut data): axum::Form, + ) -> Result> { + db::with(|db| { + if let Err(e) = db.create_post.insert((&data.name, &session.name, now(), &data.code)) { + if let rusqlite::Error::SqliteFailure(e, _) = e { + if e.code == rusqlite::ErrorCode::ConstraintViolation { + data.error = Some("this name is already used"); + } } - } else { - s.args.push(arg); + data.error = data.error.or_else(|| { + log::error!("{e}"); + Some("internal server error") + }); } + }); + + if data.error.is_some() { + Err(Html(data.render(&session))) + } else { + Ok(redirect("/profile")) } - - s - } - - pub fn arg(&self, index: usize) -> &str { - self.args.get(index).map_or("", String::as_str) - } - - pub fn expect_option(&self, name: &str) -> &str { - self.options.get(name).unwrap_or_else(|| panic!("--{name}= is mandatory")) - } - - pub fn expect_poption>(&self, name: &str) -> T { - self.expect_option(name).parse::().unwrap_or_else(|e| { - panic!("failed to parse --{name}= as {}: {e}", std::any::type_name::()) - }) } } -fn hex_to_array(s: &str) -> Result<[u8; SIZE], &'static str> { - let mut buf = [0u8; SIZE]; - - if s.len() != SIZE * 2 { - return Err("expected 64 character hex string"); - } - - fn byte_to_hex(val: u8) -> Result { - Ok(match val { - b'0'..=b'9' => val - b'0', - b'a'..=b'f' => val - b'a' + 10, - b'A'..=b'F' => val - b'A' + 10, - _ => return Err("invalid hex char"), - }) - } - - for (dst, &[a, b]) in buf.iter_mut().zip(s.as_bytes().array_chunks()) { - *dst = byte_to_hex(b)? | (byte_to_hex(a)? << 4); - } - - Ok(buf) -} - -struct HexPk(ed::VerifyingKey); - -impl std::str::FromStr for HexPk { - type Err = &'static str; - - fn from_str(s: &str) -> Result { - ed::VerifyingKey::from_bytes(&hex_to_array(s)?) - .map_err(|_| "hex code does not represent the valid key") - .map(Self) - } -} - -struct HexSk(ed::SigningKey); - -impl std::str::FromStr for HexSk { - type Err = &'static str; - - fn from_str(s: &str) -> Result { - Ok(Self(ed::SigningKey::from_bytes(&hex_to_array(s)?))) - } -} - -struct DisplayHex([u8; 32]); - -impl fmt::Display for DisplayHex { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for b in self.0 { - write!(f, "{b:02x}")?; - } +impl fmt::Display for Post { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { author, name, timestamp, imports, runs, dependencies, code, .. } = self; + write_html! { f
+
+ author + "/" + name + timestamp +
+
+ "imps: "imports + "runs: "runs + "deps: "dependencies +
+
code
+ if *timestamp == 0 { + + } +
} Ok(()) } } -#[cfg(test)] -#[test] -fn test_hex() { - let expected = [1u8; 32]; +#[derive(Default)] +struct Profile; - let hex = dbg!(DisplayHex(expected).to_string()); - let got: [u8; 32] = hex_to_array(&hex).unwrap(); - - assert_eq!(got, expected); +impl Page for Profile { + fn render(self, session: &Session) -> String { + db::with(|db| { + let iter = db + .get_user_posts + .query_map((&session.name,), |r| { + Ok(Post { + author: r.get(0)?, + name: r.get(1)?, + timestamp: r.get(2)?, + code: r.get(3)?, + ..Default::default() + }) + }) + .inspect_err(|e| log::error!("{e}")) + .into_iter() + .flatten() + .filter_map(|p| p.inspect_err(|e| log::error!("{e}")).ok()); + html! { + for post in iter { + !{post} + } else { + "no posts" + } + !{include_str!("profile-page.html")} + } + }) + } +} + +#[derive(Serialize, Deserialize, Default)] +struct Login { + name: String, + password: String, + #[serde(skip)] + error: Option<&'static str>, +} + +impl PublicPage for Login { + fn render(self) -> String { + let Login { name, password, error } = self; + html! { +
+ if let Some(e) = error {
e
} + + + +
+ } + } +} + +impl Login { + async fn post( + axum::Form(mut data): axum::Form, + ) -> Result, Html> { + // TODO: hash password + let mut id = [0u8; 32]; + db::with(|db| match db.authenticate.query((&data.name, &data.password)) { + Ok(mut r) => { + if r.next().map_or(true, |v| v.is_none()) { + data.error = Some("invalid credentials"); + } else { + getrandom::getrandom(&mut id).unwrap(); + if let Err(e) = db.login.insert((id, &data.name, now() + SESSION_DURATION_SECS)) + { + log::error!("{e}"); + } + } + } + Err(e) => { + log::error!("{e}"); + data.error = Some("internal server error"); + } + }); + + if data.error.is_some() { + Err(Html(data.render())) + } else { + Ok(AppendHeaders([ + ("hx-location", "/feed".into()), + ( + "set-cookie", + format!( + "id={}; SameSite=Strict; Secure; Max-Age={SESSION_DURATION_SECS}", + to_hex(&id) + ), + ), + ])) + } + } + + async fn delete(session: Session) -> Redirect { + _ = db::with(|q| q.logout.execute((session.id,)).inspect_err(|e| log::error!("{e}"))); + redirect("/login") + } +} + +#[derive(Serialize, Deserialize, Default)] +struct Signup { + name: String, + new_password: String, + confirm_password: String, + #[serde(skip)] + error: Option<&'static str>, +} + +impl PublicPage for Signup { + fn render(self) -> String { + let Signup { name, new_password, confirm_password, error } = self; + html! { +
+ if let Some(e) = error {
e
} + + + + +
+ } + } +} + +impl Signup { + async fn post(axum::Form(mut data): axum::Form) -> Result> { + db::with(|db| { + // TODO: hash passwords + match db.register.insert((&data.name, &data.new_password)) { + Ok(_) => {} + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + data.error = Some("username already taken"); + } + Err(e) => { + log::error!("{e}"); + data.error = Some("internal server error"); + } + }; + }); + + if data.error.is_some() { + Err(Html(data.render())) + } else { + Ok(redirect("/login")) + } + } +} + +async fn base(body: String, session: Option) -> Html { + let username = session.map(|s| s.name); + + Html(htmlm::html! { + "" + + + + + + +
+
!{body}
+ + + + + }) +} + +struct Session { + name: String, + id: [u8; 32], +} + +#[axum::async_trait] +impl axum::extract::FromRequestParts for Session { + /// If the extractor fails it'll use this "rejection" type. A rejection is + /// a kind of error that can be converted into a response. + type Rejection = axum::response::Redirect; + + /// Perform the extraction. + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let err = || axum::response::Redirect::permanent("/login"); + + let value = parts + .headers + .get_all(COOKIE) + .into_iter() + .find_map(|c| c.to_str().ok()?.trim().strip_prefix("id=")) + .map(|c| c.split_once(';').unwrap_or((c, "")).0) + .ok_or(err())?; + let mut id = [0u8; 32]; + parse_hex(value, &mut id).ok_or(err())?; + + let (name, expiration) = db::with(|db| { + db.get_session + .query_row((dbg!(id),), |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))) + .inspect_err(|e| log::error!("{e}")) + .map_err(|_| err()) + })?; + + if expiration < now() { + log::error!("expired"); + return Err(err()); + } + + Ok(Self { name, id }) + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn parse_hex(hex: &str, dst: &mut [u8]) -> Option<()> { + fn hex_to_nibble(b: u8) -> Option { + Some(match b { + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + b'0'..=b'9' => b - b'0', + _ => return None, + }) + } + + if hex.len() != dst.len() * 2 { + return None; + } + + for (d, p) in dst.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + *d = (hex_to_nibble(p[0])? << 4) | hex_to_nibble(p[1])?; + } + + Some(()) +} + +fn to_hex(src: &[u8]) -> String { + use std::fmt::Write; + let mut buf = String::new(); + for &b in src { + write!(buf, "{b:02x}").unwrap() + } + buf +} + +fn main() { + tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(amain()); +} + +mod db { + use std::cell::RefCell; + + macro_rules! gen_queries { + ($vis:vis struct $name:ident { + $($qname:ident: $code:literal,)* + }) => { + #[allow(dead_code)] + $vis struct $name<'a> { + $($vis $qname: rusqlite::Statement<'a>,)* + } + + impl<'a> $name<'a> { + fn new(db: &'a rusqlite::Connection) -> Self { + Self { + $($qname: db.prepare($code).unwrap(),)* + } + } + } + }; + } + + gen_queries! { + pub struct Queries { + register: "INSERT INTO user (name, password_hash) VALUES(?, ?)", + authenticate: "SELECT name, password_hash FROM user WHERE name = ? AND password_hash = ?", + login: "INSERT OR REPLACE INTO session (id, username, expiration) VALUES(?, ?, ?)", + logout: "DELETE FROM session WHERE id = ?", + get_session: "SELECT username, expiration FROM session WHERE id = ?", + get_user_posts: "SELECT author, name, timestamp, code FROM post WHERE author = ?", + create_post: "INSERT INTO post (name, author, timestamp, code) VALUES(?, ?, ?, ?)", + } + } + + struct Db { + queries: Queries<'static>, + _db: Box, + } + + impl Db { + fn new() -> Self { + let db = Box::new(rusqlite::Connection::open("db.sqlite").unwrap()); + Self { + queries: Queries::new(unsafe { + std::mem::transmute::<&rusqlite::Connection, &rusqlite::Connection>(&db) + }), + _db: db, + } + } + } + + pub fn with(with: impl FnOnce(&mut Queries) -> T) -> T { + thread_local! { static DB_CONN: RefCell = RefCell::new(Db::new()); } + DB_CONN.with_borrow_mut(|q| with(&mut q.queries)) + } + + pub fn init() { + let db = rusqlite::Connection::open("db.sqlite").unwrap(); + db.execute_batch(include_str!("schema.sql")).unwrap(); + Queries::new(&db); + } +} + +fn redirect(to: &'static str) -> Redirect { + AppendHeaders([("hx-location", to)]) +} + +struct Logger; + +impl log::Log for Logger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + eprintln!("{} - {}", record.module_path().unwrap_or("=="), record.args()); + } + } + + fn flush(&self) {} } diff --git a/depell/src/post-page.html b/depell/src/post-page.html new file mode 100644 index 0000000..dcce9c1 --- /dev/null +++ b/depell/src/post-page.html @@ -0,0 +1,12 @@ +

About posting code

+

+ If you are unfammiliar with hblang, refer to the + hblang/README.md or + vizit mlokis'es posts. Preferably don't edit the code here. +

+ +

Extra textarea features

+
    +
  • proper tab behaviour
  • +
  • snap to previous tab boundary on "empty" lines
  • +
diff --git a/depell/src/profile-page.html b/depell/src/profile-page.html new file mode 100644 index 0000000..e69de29 diff --git a/depell/src/schema.sql b/depell/src/schema.sql new file mode 100644 index 0000000..9529715 --- /dev/null +++ b/depell/src/schema.sql @@ -0,0 +1,51 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS user( + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + PRIMARY KEY (name) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS session( + id BLOB NOT NULL, + username TEXT NOT NULL, + expiration INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES user (name) + PRIMARY KEY (username) +) WITHOUT ROWID; + +CREATE UNIQUE INDEX IF NOT EXISTS + session_id ON session (id); + +CREATE TABLE IF NOT EXISTS post( + name TEXT NOT NULL, + author TEXT, + timestamp INTEGER, + code TEXT NOT NULL, + FOREIGN KEY (author) REFERENCES user (name) ON DELETE SET NULL, + PRIMARY KEY (author, name) +); + +CREATE TABLE IF NOT EXISTS import( + from_name TEXT NOT NULL, + from_author TEXT, + to_name TEXT NOT NULL, + to_author TEXT, + FOREIGN KEY (from_name, from_author) REFERENCES post (name, author), + FOREIGN KEY (to_name, to_author) REFERENCES post (name, author) +); + +CREATE INDEX IF NOT EXISTS + dependencies ON import(from_name, from_author); + +CREATE INDEX IF NOT EXISTS + dependants ON import(to_name, to_author); + +CREATE TABLE IF NOT EXISTS run( + code_name TEXT NOT NULL, + code_author TEXT NOT NULL, + runner TEXT NOT NULL, + FOREIGN KEY (code_name, code_author) REFERENCES post (name, author), + FOREIGN KEY (runner) REFERENCES user(name), + PRIMARY KEY (code_name, code_author, runner) +); diff --git a/depell/src/welcome-page.html b/depell/src/welcome-page.html new file mode 100644 index 0000000..2f3908f --- /dev/null +++ b/depell/src/welcome-page.html @@ -0,0 +1,17 @@ +

Welcome to depell

+

+ Depell (dependency hell) is a simple "social" media site best compared to twitter, except that all you can post is + hblang code with no comments allowed. Instead of likes you + run the program, and instead of retweets you import the program as dependency. Run counts even when ran indirectly. +

+ +

+ The backend only serves the code and frontend compiles and runs it locally. All posts are immutable. +

+ +

Security?

+

+ All code runs in WASM (inside a holey-bytes VM until hblang compiles to wasm) and is controlled by JavaScript. WASM + cant do any form of IO without going trough JavaScript so as long as JS import does not allow wasm to execute + arbitrary JS code, WASM can act as a container inside the JS. +

diff --git a/depell/wasm-hbfmt/Cargo.toml b/depell/wasm-hbfmt/Cargo.toml new file mode 100644 index 0000000..475cc3f --- /dev/null +++ b/depell/wasm-hbfmt/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wasm-hbfmt" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hblang = { version = "0.1.0", path = "../../hblang", default-features = false } +log = { version = "0.4.22", features = ["max_level_off"] } diff --git a/depell/wasm-hbfmt/src/lib.rs b/depell/wasm-hbfmt/src/lib.rs new file mode 100644 index 0000000..2a283c7 --- /dev/null +++ b/depell/wasm-hbfmt/src/lib.rs @@ -0,0 +1,101 @@ +#![no_std] +#![feature(slice_take)] +#![feature(str_from_raw_parts)] + +use hblang::parser::ParserCtx; + +#[cfg(target_arch = "wasm32")] +#[panic_handler] +fn handle_panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +use core::{ + alloc::{GlobalAlloc, Layout}, + cell::UnsafeCell, +}; + +const ARENA_SIZE: usize = 128 * 1024; + +#[repr(C, align(32))] +struct SimpleAllocator { + arena: UnsafeCell<[u8; ARENA_SIZE]>, + head: UnsafeCell<*mut u8>, +} + +impl SimpleAllocator { + const fn new() -> Self { + SimpleAllocator { + arena: UnsafeCell::new([0; ARENA_SIZE]), + head: UnsafeCell::new(core::ptr::null_mut()), + } + } + + unsafe fn reset(&self) { + (*self.head.get()) = self.arena.get().cast::().add(ARENA_SIZE); + } +} + +unsafe impl Sync for SimpleAllocator {} + +unsafe impl GlobalAlloc for SimpleAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let size = layout.size(); + let align = layout.align(); + + let until = self.arena.get() as *mut u8; + + let new_head = (*self.head.get()).sub(size); + let aligned_head = (new_head as usize & (1 << (align - 1))) as *mut u8; + + if until > aligned_head { + return core::ptr::null_mut(); + } + + *self.head.get() = aligned_head; + aligned_head + } + + unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) { + /* lol */ + } +} + +#[cfg_attr(target_arch = "wasm32", global_allocator)] +static ALLOCATOR: SimpleAllocator = SimpleAllocator::new(); + +const MAX_OUTPUT_SIZE: usize = 1024 * 10; + +#[no_mangle] +static mut OUTPUT: [u8; MAX_OUTPUT_SIZE] = unsafe { core::mem::zeroed() }; + +#[no_mangle] +static mut OUTPUT_LEN: usize = 0; + +#[no_mangle] +unsafe extern "C" fn fmt(code: *const u8, len: usize) { + ALLOCATOR.reset(); + + let code = core::str::from_raw_parts(code, len); + + let arena = hblang::parser::Arena::default(); + let mut ctx = ParserCtx::default(); + let exprs = hblang::parser::Parser::parse(&mut ctx, code, "source.hb", &|_, _| Ok(0), &arena); + + struct Write<'a>(&'a mut [u8]); + + impl core::fmt::Write for Write<'_> { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + if let Some(m) = self.0.take_mut(..s.len()) { + m.copy_from_slice(s.as_bytes()); + Ok(()) + } else { + Err(core::fmt::Error) + } + } + } + + let mut f = Write(unsafe { &mut OUTPUT[..] }); + hblang::fmt::fmt_file(exprs, code, &mut f).unwrap(); + unsafe { OUTPUT_LEN = MAX_OUTPUT_SIZE - f.0.len() }; +}