♻️ refactor: switch to actix web away from tide (#5)

tide has a nice pretty api (the singular reason why i choose it) but it is a [seriously](https://web-frameworks-benchmark.netlify.app/result?l=rust) [**slow**](https://github.com/programatik29/rust-web-benchmarks/blob/master/result/hello-world.md) web framework. the "prettiness" of the code is irrelevant especially when it seriously hinders performance. there's a noticeable and significant change after the rewrite.

obv gonna choose the fastest framework if i'm gonna rewrite anyway duhhhhhhh

Reviewed-on: #5
This commit is contained in:
fawn 2023-08-17 03:26:31 +00:00
parent 1e0f3369db
commit 9d39f8151f
12 changed files with 703 additions and 1309 deletions

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
# rust
/target
.cargo
# production
*.env

1754
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,31 +8,28 @@ license = "OSL-3.0"
edition = "2021"
[dependencies]
async-std = { version = "1.12.0", features = ["attributes", "tokio1"] }
tokio = { version = "1.32.0", features = ["full"] }
tide = "0.16.0"
tide-governor = "1.0.3"
tide-compress = "0.11.0"
actix-web = "4.3.1"
actix-files = "0.6.2"
actix-governor = "0.4.1"
askama = { version = "0.12.0", features = ["with-tide"] }
askama_tide = "0.15.0"
askama = { version = "0.12.0", features = ["with-actix-web"] }
askama_actix = "0.14.0"
sqlx = { version = "0.7.1", features = [
"postgres",
"runtime-async-std-rustls",
] }
sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio-rustls"] }
serde = { version = "1.0.183", features = ["derive"] }
femme = "2.2.1"
serde = { version = "1.0.176", features = ["derive"] }
log = "0.4.20"
dotenvy = "0.15.7"
webhook = "2.1.2"
chrono = "0.4.26"
chrono-tz = "0.8.3"
webhook = "2.1.2"
rs-snowflake = "0.6.0"
once_cell = "1.18.0"
serde_json = "1.0.104"
[profile.release]
strip = true

View file

@ -5,7 +5,7 @@
tamako is a cozy, minimalistic, single-user, _anonymous_ whispers service
![scrot](.github/assets/scrot.png)
![scrot](meta/scrot.png)
## Prerequisites
@ -35,7 +35,7 @@ tamako is a cozy, minimalistic, single-user, _anonymous_ whispers service
tamako comes with a pretty little tui frontend for it called mochi
![mochi](.github/assets/mochi.png)
![mochi](meta/mochi.png)
### Installation

View file

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View file

@ -1,10 +1,14 @@
pub use async_std::main;
pub use actix_web::main;
use actix_web::{
delete,
error::{ErrorBadRequest, ErrorForbidden, ErrorNotFound},
get, post, web, HttpRequest, HttpResponse,
};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tide::{prelude::json, Body, Request, Response, StatusCode};
use crate::{auth, db::Database};
@ -49,24 +53,20 @@ pub struct Whisper {
impl Whisper {
/// Preforms basic validation checks for the whisper
fn validate(&mut self) -> Result<(), Response> {
self.name = self.name.take().filter(|name| !name.is_empty());
fn validate(&mut self) -> actix_web::Result<()> {
self.name.take().filter(|name| !name.is_empty());
if self.message.is_empty() {
return Err(Response::builder(StatusCode::BadRequest)
.body("whispers cannot be empty")
.build());
return Err(ErrorBadRequest("whispers cannot be empty"));
}
if let Some(name) = &self.name {
if name.len() > 32 {
return Err(Response::builder(StatusCode::BadRequest)
.body("name cannot be longer than 32 characters")
.build());
return Err(ErrorBadRequest("name cannot be longer than 32 characters"));
}
}
if self.message.len() > 1024 {
return Err(Response::builder(StatusCode::BadRequest)
.body("whispers cannot be longer than 1024 characters")
.build());
return Err(ErrorBadRequest(
"whispers cannot be longer than 1024 characters",
));
}
Ok(())
@ -125,48 +125,37 @@ impl Private for Vec<Whisper> {
}
/// Authenticates the secret key
#[allow(clippy::unused_async, clippy::similar_names)]
pub async fn auth<T>(req: Request<T>) -> tide::Result<Response>
where
T: Send,
{
#[allow(clippy::unused_async)]
#[post("/api/auth")]
pub async fn authentication(req: HttpRequest) -> actix_web::Result<HttpResponse> {
if !auth::validate_header(&req) {
return Ok(Response::builder(StatusCode::Forbidden)
.body("Invalid token")
.build());
return Err(ErrorForbidden("Invalid token"));
}
let mut res = Response::new(StatusCode::Ok);
res.set_body("Authenticated");
Ok(res)
Ok(HttpResponse::Ok().body("Authenticated"))
}
/// Adds a new whisper
#[allow(clippy::similar_names)]
pub async fn add(mut req: Request<Database>) -> tide::Result<Response> {
let mut whisper = req.body_json::<Whisper>().await?;
if let Err(res) = whisper.validate() {
return Ok(res);
}
// #[post("/")]
pub async fn add(
database: web::Data<Database>,
mut whisper: web::Json<Whisper>,
) -> Result<HttpResponse, Box<dyn std::error::Error>> {
whisper.validate()?;
let database = req.state();
database.add(&whisper).await?;
match webhook::send(&whisper).await {
Ok(_) => tide::log::info!("--> Webhook sent"),
Err(e) => tide::log::error!("Webhook error --> {e}"),
Ok(_) => log::info!("Webhook sent successfully"),
Err(e) => log::error!("Webhook error: {e}"),
};
let mut res = Response::new(StatusCode::Created);
res.set_body(json!(&whisper));
Ok(res)
Ok(HttpResponse::Created().json(whisper))
}
#[derive(Deserialize, Default)]
#[serde(default)]
/// Query params for the list endpoint
struct ListParams {
pub struct ListParams {
/// The number of whispers to return
limit: Option<usize>,
@ -178,8 +167,12 @@ struct ListParams {
}
/// Lists all whispers
pub async fn list(req: Request<Database>) -> tide::Result<Body> {
let database = req.state();
#[get("/api/whisper")]
pub async fn list(
req: HttpRequest,
params: web::Query<ListParams>,
database: web::Data<Database>,
) -> Result<HttpResponse, Box<dyn std::error::Error>> {
let mut whispers = database.list().await?;
// Filter out private whispers if the token is invalid or not provided
@ -190,8 +183,6 @@ pub async fn list(req: Request<Database>) -> tide::Result<Body> {
// Reverse the order of the whispers so that the latest whispers are at the top
whispers.reverse();
let params = req.query::<ListParams>()?;
// Skip `n` whispers if the `offset` param is provided
if let Some(n) = params.offset {
whispers = whispers.into_iter().skip(n).collect();
@ -209,37 +200,38 @@ pub async fn list(req: Request<Database>) -> tide::Result<Body> {
.for_each(|w| w.timestamp = w.pretty_timestamp());
}
Body::from_json(&whispers)
Ok(HttpResponse::Ok().json(whispers))
}
/// Gets a whisper by its snowflake
pub async fn get(req: Request<Database>) -> tide::Result<Body> {
let snowflake = req.param("snowflake")?.parse::<i64>()?;
let database = req.state();
#[get("/api/whisper/{snowflake}")]
pub async fn get(
path: web::Path<i64>,
database: web::Data<Database>,
) -> Result<HttpResponse, Box<dyn std::error::Error>> {
let snowflake = path.into_inner();
let whisper = database.get(snowflake).await?;
Body::from_json(&whisper)
Ok(HttpResponse::Ok().json(whisper))
}
/// Deletes a whisper
#[allow(clippy::similar_names)]
pub async fn delete(req: Request<Database>) -> tide::Result<Response> {
#[delete("/api/whisper/{snowflake}")]
pub async fn delete(
req: HttpRequest,
path: web::Path<i64>,
database: web::Data<Database>,
) -> Result<HttpResponse, Box<dyn std::error::Error>> {
if !auth::validate_header(&req) {
return Err(tide::Error::from_str(
StatusCode::Forbidden,
"Invalid token",
));
return Err(actix_web::error::ErrorForbidden("Invalid token").into());
}
let snowflake = req.param("snowflake")?.parse::<i64>()?;
let database = req.state();
let snowflake = path.into_inner();
database
.delete(snowflake)
.await
.map_err(|_| tide::Error::from_str(tide::StatusCode::NotFound, "Whisper not found"))?;
.map_err(|_| ErrorNotFound(format!("Whisper {snowflake} not found")))?;
let mut res = Response::new(StatusCode::Ok);
res.set_body(format!("Deleted {snowflake}"));
Ok(res)
Ok(HttpResponse::Ok().body(format!("Deleted {snowflake}")))
}

View file

@ -9,19 +9,14 @@ pub fn validate(secret: &str) -> bool {
}
/// Validates the cookie of the given request
pub fn validate_cookie<T>(req: &tide::Request<T>) -> bool {
let Some(cookie) = req.cookie("token") else {
return false;
};
validate(cookie.value())
pub fn validate_cookie(req: &actix_web::HttpRequest) -> bool {
req.cookie("token")
.map_or(false, |cookie| validate(cookie.value()))
}
/// Validates the header of the given request
pub fn validate_header<T>(req: &tide::Request<T>) -> bool {
let Some(header) = req.header("token") else {
return false;
};
validate(&header[0].to_string())
pub fn validate_header(req: &actix_web::HttpRequest) -> bool {
req.headers()
.get("token")
.map_or(false, |header| validate(header.to_str().unwrap()))
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use async_std::process::Command;
use sqlx::postgres::PgPool;
use tokio::process::Command;
use crate::api::Whisper;
@ -11,12 +11,12 @@ pub type Database = Arc<DatabaseState>;
/// The database state that holds the connection pool
pub struct DatabaseState {
/// The connection pool
pool: PgPool
pool: PgPool,
}
impl DatabaseState {
/// Creates a new database state
pub async fn new() -> tide::Result<Self> {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
pool: PgPool::connect(&std::env::var("DATABASE_URL")?).await?,
})
@ -69,7 +69,7 @@ impl DatabaseState {
}
/// Opens a connection to the database
pub async fn open() -> tide::Result<Database> {
pub async fn open() -> Result<Database, Box<dyn std::error::Error>> {
Command::new("sqlx").args(["db", "create"]).output().await?;
let database = Arc::new(DatabaseState::new().await?);
sqlx::migrate!().run(&database.pool).await?;

View file

@ -1,36 +1,45 @@
use actix_web::{middleware, web, App, HttpServer};
mod api;
mod auth;
mod db;
mod templates;
#[api::main]
async fn main() -> tide::Result<()> {
#[tokio::main]
async fn main() -> actix_web::Result<()> {
femme::start();
dotenvy::dotenv().ok();
let database = db::open().await?;
let mut tamako = tide::with_state(database);
tamako.with(tide_compress::CompressMiddleware::new());
tamako.at("/").get(templates::home);
tamako.at("/auth").get(templates::auth);
tamako.at("/assets").serve_dir("assets")?;
tamako.at("/api/health").get(|_| async move { Ok("💚") });
tamako.at("/api/whisper").get(api::list);
tamako.at("/api/whisper/:snowflake").get(api::get);
tamako
.at("/api/whisper")
.with(tide_governor::GovernorMiddleware::per_minute(2)?)
.post(api::add);
tamako.at("/api/whisper/:snowflake").delete(api::delete);
tamako.at("/api/auth").post(api::auth);
tamako.at("*").get(templates::not_found);
let addr = (api::HOST.as_str(), *api::PORT);
tamako.listen(addr).await?;
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(database.clone()))
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.service(templates::home)
.service(templates::auth)
.service(actix_files::Files::new("/assets", "assets"))
.service(web::resource("/api/health").route(web::get().to(|| async { "💚" })))
.service(api::list)
.service(api::get)
.service(
web::resource("/api/whisper")
.route(web::post().to(api::add))
.wrap(actix_governor::Governor::new(
&actix_governor::GovernorConfigBuilder::default()
.per_second(360)
.burst_size(1)
.finish()
.unwrap_or_default(),
)),
)
.service(api::delete)
.service(api::authentication)
.default_service(web::to(templates::not_found))
})
.bind((api::HOST.as_str(), *api::PORT))?
.run()
.await?;
Ok(())
}

View file

@ -1,5 +1,5 @@
use actix_web::{get, web, HttpRequest, Responder};
use askama::Template;
use tide::{Request, Response};
use crate::{
api::{Private, Whisper},
@ -51,8 +51,11 @@ impl WhispersTemplate {
}
/// Renders the whispers page
pub async fn home(req: Request<Database>) -> tide::Result<Response> {
let database = req.state();
#[get("/")]
pub async fn home(
req: HttpRequest,
database: web::Data<Database>,
) -> Result<impl Responder, Box<dyn std::error::Error>> {
let authenticated = crate::auth::validate_cookie(&req);
// If the user is authenticated, show all whispers, otherwise only show public whispers.
let whispers = if authenticated {
@ -64,7 +67,7 @@ pub async fn home(req: Request<Database>) -> tide::Result<Response> {
.rev()
.collect::<Vec<Whisper>>();
Ok(WhispersTemplate::new(whispers, authenticated).into())
Ok(WhispersTemplate::new(whispers, authenticated))
}
/// The template that renders the auth page
@ -85,12 +88,10 @@ impl AuthTemplate {
}
/// Renders the auth page
#[get("/auth")]
#[allow(clippy::unused_async)]
pub async fn auth<T>(_: Request<T>) -> tide::Result<Response>
where
T: Send,
{
Ok(AuthTemplate::new().into())
pub async fn auth() -> impl Responder {
AuthTemplate::new()
}
/// The template that renders the not found page
@ -112,9 +113,6 @@ impl NotFoundTemplate {
/// Renders the not found page
#[allow(clippy::unused_async)]
pub async fn not_found<T>(_: Request<T>) -> tide::Result<Response>
where
T: Send,
{
Ok(NotFoundTemplate::new().into())
pub async fn not_found() -> impl Responder {
NotFoundTemplate::new()
}

View file

@ -19,11 +19,9 @@
}),
});
if (res.ok) {
if (res.status == 201) {
notyf.success("whisper sent");
setInterval(() => {
window.location.reload();
}, 2600);
window.location.reload();
}
else if (res.status == 429) {
notyf.error("too many whispers, slow down");