â»ï¸ 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:
parent
1e0f3369db
commit
9d39f8151f
12 changed files with 703 additions and 1309 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,5 @@
|
|||
# rust
|
||||
/target
|
||||
.cargo
|
||||
|
||||
# production
|
||||
*.env
|
||||
|
|
1754
Cargo.lock
generated
1754
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
23
Cargo.toml
23
Cargo.toml
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
114
src/api/mod.rs
114
src/api/mod.rs
|
@ -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}")))
|
||||
}
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
@ -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?;
|
||||
|
|
57
src/main.rs
57
src/main.rs
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
Loadingâ¦
Reference in a new issue