xenua
2 years ago
commit
dee6235501
29 changed files with 5192 additions and 0 deletions
@ -0,0 +1,33 @@ |
|||||||
|
[workspace] |
||||||
|
members = [".", "entity", "migration"] |
||||||
|
|
||||||
|
[package] |
||||||
|
name = "v" |
||||||
|
version = "0.1.0" |
||||||
|
edition = "2021" |
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html |
||||||
|
|
||||||
|
[dependencies] |
||||||
|
entity = { path = "entity" } |
||||||
|
migration = { path = "migration" } |
||||||
|
|
||||||
|
dotenv = "0.15.0" |
||||||
|
pretty_env_logger = "0.4.0" |
||||||
|
sea-orm = { version = "0.11", features = ["runtime-tokio-rustls", "sqlx-postgres"] } |
||||||
|
serde = { version = "1.0.163", features = ["derive"] } |
||||||
|
tera = "1.18.1" |
||||||
|
time = { version = "0.3.21", features = ["formatting", "macros"] } |
||||||
|
tokio = { version = "1.28.1", features = ["full"] } |
||||||
|
tracing-subscriber = { version = "0.3.17", features = ["time", "json"] } |
||||||
|
tracing = "0.1.37" |
||||||
|
serde_json = "1.0.96" |
||||||
|
miette = { version = "5.8.0", features = ["fancy"] } |
||||||
|
axum = { version = "0.6.18", features = ["tracing", "multipart", "headers", "macros"] } |
||||||
|
tower-http = { version = "0.4.0", features = ["full"] } |
||||||
|
dashmap = "5.4.0" |
||||||
|
uuid = { version = "1.3.3", features = ["v7", "serde", "v4"] } |
||||||
|
argon2 = "0.5.0" |
||||||
|
cookie = { version = "0.17.0", features = ["secure", "percent-encode"] } |
||||||
|
tower = { version = "0.4.13", features = ["tokio", "tracing"] } |
||||||
|
nanoid = "0.4.0" |
@ -0,0 +1,10 @@ |
|||||||
|
[package] |
||||||
|
name = "entity" |
||||||
|
version = "0.1.0" |
||||||
|
edition = "2021" |
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html |
||||||
|
|
||||||
|
[dependencies] |
||||||
|
sea-orm = { version = "0.11", features = ["runtime-tokio-rustls", "sqlx-postgres"] } |
||||||
|
serde = { version = "1.0.163", features = ["derive"] } |
@ -0,0 +1,34 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] |
||||||
|
#[sea_orm(table_name = "click")] |
||||||
|
pub struct Model { |
||||||
|
#[sea_orm(primary_key)] |
||||||
|
#[serde(skip_deserializing)] |
||||||
|
pub id: i32, |
||||||
|
pub time: Option<DateTime>, |
||||||
|
pub link_id: Option<i32>, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
||||||
|
pub enum Relation { |
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::link::Entity", |
||||||
|
from = "Column::LinkId", |
||||||
|
to = "super::link::Column::Id", |
||||||
|
on_update = "Cascade", |
||||||
|
on_delete = "Cascade" |
||||||
|
)] |
||||||
|
Link, |
||||||
|
} |
||||||
|
|
||||||
|
impl Related<super::link::Entity> for Entity { |
||||||
|
fn to() -> RelationDef { |
||||||
|
Relation::Link.def() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {} |
@ -0,0 +1,7 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
pub mod prelude; |
||||||
|
|
||||||
|
pub mod click; |
||||||
|
pub mod link; |
||||||
|
pub mod user; |
@ -0,0 +1,43 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] |
||||||
|
#[sea_orm(table_name = "link")] |
||||||
|
pub struct Model { |
||||||
|
#[sea_orm(primary_key)] |
||||||
|
#[serde(skip_deserializing)] |
||||||
|
pub id: i32, |
||||||
|
pub target: String, |
||||||
|
pub source: String, |
||||||
|
pub user_id: i32, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
||||||
|
pub enum Relation { |
||||||
|
#[sea_orm(has_many = "super::click::Entity")] |
||||||
|
Click, |
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity", |
||||||
|
from = "Column::UserId", |
||||||
|
to = "super::user::Column::Id", |
||||||
|
on_update = "Cascade", |
||||||
|
on_delete = "Cascade" |
||||||
|
)] |
||||||
|
User, |
||||||
|
} |
||||||
|
|
||||||
|
impl Related<super::click::Entity> for Entity { |
||||||
|
fn to() -> RelationDef { |
||||||
|
Relation::Click.def() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity { |
||||||
|
fn to() -> RelationDef { |
||||||
|
Relation::User.def() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {} |
@ -0,0 +1,7 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
pub mod prelude; |
||||||
|
|
||||||
|
pub mod click; |
||||||
|
pub mod link; |
||||||
|
pub mod user; |
@ -0,0 +1,5 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
pub use super::click::Entity as Click; |
||||||
|
pub use super::link::Entity as Link; |
||||||
|
pub use super::user::Entity as User; |
@ -0,0 +1,35 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] |
||||||
|
#[sea_orm(table_name = "session")] |
||||||
|
pub struct Model { |
||||||
|
#[sea_orm(primary_key)] |
||||||
|
#[serde(skip_deserializing)] |
||||||
|
pub id: i32, |
||||||
|
#[sea_orm(unique)] |
||||||
|
pub token: Uuid, |
||||||
|
pub user_id: i32, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
||||||
|
pub enum Relation { |
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity", |
||||||
|
from = "Column::UserId", |
||||||
|
to = "super::user::Column::Id", |
||||||
|
on_update = "Cascade", |
||||||
|
on_delete = "Cascade" |
||||||
|
)] |
||||||
|
User, |
||||||
|
} |
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity { |
||||||
|
fn to() -> RelationDef { |
||||||
|
Relation::User.def() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {} |
@ -0,0 +1,30 @@ |
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] |
||||||
|
#[sea_orm(table_name = "user")] |
||||||
|
pub struct Model { |
||||||
|
#[sea_orm(primary_key)] |
||||||
|
#[serde(skip_deserializing)] |
||||||
|
pub id: i32, |
||||||
|
#[sea_orm(unique)] |
||||||
|
pub name: String, |
||||||
|
pub password: Option<String>, |
||||||
|
pub is_admin: bool, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
||||||
|
pub enum Relation { |
||||||
|
#[sea_orm(has_many = "super::link::Entity")] |
||||||
|
Link, |
||||||
|
} |
||||||
|
|
||||||
|
impl Related<super::link::Entity> for Entity { |
||||||
|
fn to() -> RelationDef { |
||||||
|
Relation::Link.def() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {} |
@ -0,0 +1,22 @@ |
|||||||
|
[package] |
||||||
|
name = "migration" |
||||||
|
version = "0.1.0" |
||||||
|
edition = "2021" |
||||||
|
publish = false |
||||||
|
|
||||||
|
[lib] |
||||||
|
name = "migration" |
||||||
|
path = "src/lib.rs" |
||||||
|
|
||||||
|
[dependencies] |
||||||
|
async-std = { version = "1", features = ["attributes", "tokio1"] } |
||||||
|
|
||||||
|
[dependencies.sea-orm-migration] |
||||||
|
version = "0.11" |
||||||
|
features = [ |
||||||
|
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. |
||||||
|
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. |
||||||
|
# e.g. |
||||||
|
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature |
||||||
|
"sqlx-postgres", # `DATABASE_DRIVER` feature |
||||||
|
] |
@ -0,0 +1,41 @@ |
|||||||
|
# Running Migrator CLI |
||||||
|
|
||||||
|
- Generate a new migration file |
||||||
|
```sh |
||||||
|
cargo run -- migrate generate MIGRATION_NAME |
||||||
|
``` |
||||||
|
- Apply all pending migrations |
||||||
|
```sh |
||||||
|
cargo run |
||||||
|
``` |
||||||
|
```sh |
||||||
|
cargo run -- up |
||||||
|
``` |
||||||
|
- Apply first 10 pending migrations |
||||||
|
```sh |
||||||
|
cargo run -- up -n 10 |
||||||
|
``` |
||||||
|
- Rollback last applied migrations |
||||||
|
```sh |
||||||
|
cargo run -- down |
||||||
|
``` |
||||||
|
- Rollback last 10 applied migrations |
||||||
|
```sh |
||||||
|
cargo run -- down -n 10 |
||||||
|
``` |
||||||
|
- Drop all tables from the database, then reapply all migrations |
||||||
|
```sh |
||||||
|
cargo run -- fresh |
||||||
|
``` |
||||||
|
- Rollback all applied migrations, then reapply all migrations |
||||||
|
```sh |
||||||
|
cargo run -- refresh |
||||||
|
``` |
||||||
|
- Rollback all applied migrations |
||||||
|
```sh |
||||||
|
cargo run -- reset |
||||||
|
``` |
||||||
|
- Check the status of all migrations |
||||||
|
```sh |
||||||
|
cargo run -- status |
||||||
|
``` |
@ -0,0 +1,12 @@ |
|||||||
|
pub use sea_orm_migration::prelude::*; |
||||||
|
|
||||||
|
mod m20230515_053227_initial; |
||||||
|
|
||||||
|
pub struct Migrator; |
||||||
|
|
||||||
|
#[async_trait::async_trait] |
||||||
|
impl MigratorTrait for Migrator { |
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> { |
||||||
|
vec![Box::new(m20230515_053227_initial::Migration)] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
use sea_orm_migration::prelude::*; |
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)] |
||||||
|
pub struct Migration; |
||||||
|
|
||||||
|
#[derive(Iden)] |
||||||
|
enum Link { |
||||||
|
Table, |
||||||
|
Id, |
||||||
|
Target, |
||||||
|
Source, |
||||||
|
UserId, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Iden)] |
||||||
|
enum Click { |
||||||
|
Table, |
||||||
|
Id, |
||||||
|
LinkId, |
||||||
|
Time, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Iden)] |
||||||
|
enum User { |
||||||
|
Table, |
||||||
|
Id, |
||||||
|
Name, |
||||||
|
Password, |
||||||
|
IsAdmin, |
||||||
|
} |
||||||
|
|
||||||
|
#[async_trait::async_trait] |
||||||
|
impl MigrationTrait for Migration { |
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { |
||||||
|
// Replace the sample below with your own migration scripts
|
||||||
|
manager |
||||||
|
.create_table( |
||||||
|
Table::create() |
||||||
|
.table(User::Table) |
||||||
|
.if_not_exists() |
||||||
|
.col( |
||||||
|
ColumnDef::new(User::Id) |
||||||
|
.integer() |
||||||
|
.not_null() |
||||||
|
.auto_increment() |
||||||
|
.primary_key(), |
||||||
|
) |
||||||
|
.col(ColumnDef::new(User::Name).string().unique_key().not_null()) |
||||||
|
.col(ColumnDef::new(User::Password).string()) |
||||||
|
.col( |
||||||
|
ColumnDef::new(User::IsAdmin) |
||||||
|
.boolean() |
||||||
|
.default(false) |
||||||
|
.not_null(), |
||||||
|
) |
||||||
|
.to_owned(), |
||||||
|
) |
||||||
|
.await?; |
||||||
|
manager |
||||||
|
.create_table( |
||||||
|
Table::create() |
||||||
|
.table(Link::Table) |
||||||
|
.if_not_exists() |
||||||
|
.col( |
||||||
|
ColumnDef::new(Link::Id) |
||||||
|
.integer() |
||||||
|
.not_null() |
||||||
|
.auto_increment() |
||||||
|
.primary_key(), |
||||||
|
) |
||||||
|
.col(ColumnDef::new(Link::Target).string().not_null()) |
||||||
|
.col(ColumnDef::new(Link::Source).string().not_null()) |
||||||
|
.col(ColumnDef::new(Link::UserId).integer().not_null()) |
||||||
|
.foreign_key( |
||||||
|
ForeignKey::create() |
||||||
|
.name("FK_user_link") |
||||||
|
.from(Link::Table, Link::UserId) |
||||||
|
.to(User::Table, User::Id) |
||||||
|
.on_delete(ForeignKeyAction::Cascade) |
||||||
|
.on_update(ForeignKeyAction::Cascade), |
||||||
|
) |
||||||
|
.to_owned(), |
||||||
|
) |
||||||
|
.await?; |
||||||
|
|
||||||
|
manager |
||||||
|
.create_table( |
||||||
|
Table::create() |
||||||
|
.table(Click::Table) |
||||||
|
.if_not_exists() |
||||||
|
.col( |
||||||
|
ColumnDef::new(Click::Id) |
||||||
|
.integer() |
||||||
|
.not_null() |
||||||
|
.auto_increment() |
||||||
|
.primary_key(), |
||||||
|
) |
||||||
|
.col(ColumnDef::new(Click::Time).timestamp()) |
||||||
|
.col(ColumnDef::new(Click::LinkId).integer()) |
||||||
|
.foreign_key( |
||||||
|
ForeignKey::create() |
||||||
|
.name("FK_link_click") |
||||||
|
.from(Click::Table, Click::LinkId) |
||||||
|
.to(Link::Table, Link::Id) |
||||||
|
.on_delete(ForeignKeyAction::Cascade) |
||||||
|
.on_update(ForeignKeyAction::Cascade), |
||||||
|
) |
||||||
|
.to_owned(), |
||||||
|
) |
||||||
|
.await?; |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { |
||||||
|
manager |
||||||
|
.drop_table(Table::drop().table(Click::Table).to_owned()) |
||||||
|
.await?; |
||||||
|
|
||||||
|
manager |
||||||
|
.drop_table(Table::drop().table(Link::Table).to_owned()) |
||||||
|
.await?; |
||||||
|
|
||||||
|
manager |
||||||
|
.drop_table(Table::drop().table(User::Table).to_owned()) |
||||||
|
.await?; |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
use sea_orm_migration::prelude::*; |
||||||
|
|
||||||
|
#[async_std::main] |
||||||
|
async fn main() { |
||||||
|
cli::run_cli(migration::Migrator).await; |
||||||
|
} |
@ -0,0 +1,196 @@ |
|||||||
|
use std::{fmt::Display, sync::Arc, time::Duration}; |
||||||
|
|
||||||
|
use cookie::{Cookie, Key}; |
||||||
|
use dashmap::DashMap; |
||||||
|
use sea_orm::{error::DbErr, DatabaseConnection, EntityTrait, QuerySelect}; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
use tokio::time::Instant; |
||||||
|
|
||||||
|
use entity::{link, user}; |
||||||
|
use uuid::Uuid; |
||||||
|
|
||||||
|
use crate::utils; |
||||||
|
|
||||||
|
#[derive(Clone)] |
||||||
|
pub struct AppState { |
||||||
|
pub sessions: Arc<DashMap<String, UserSession>>, |
||||||
|
pub opts: AppOptions, |
||||||
|
pub cache: Arc<DashMap<String, String>>, |
||||||
|
pub db: DatabaseConnection, |
||||||
|
pub cookie_key: Key, |
||||||
|
} |
||||||
|
|
||||||
|
impl AppState { |
||||||
|
pub fn new(opts: AppOptions, db: DatabaseConnection, cookie_key: Option<Key>) -> Self { |
||||||
|
AppState { |
||||||
|
sessions: Arc::new(DashMap::new()), |
||||||
|
opts, |
||||||
|
cache: Arc::new(DashMap::new()), |
||||||
|
db, |
||||||
|
cookie_key: cookie_key.unwrap_or(Key::generate()), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub fn get_jar(&self) -> cookie::CookieJar { |
||||||
|
cookie::CookieJar::new() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn encrypt_cookie(&self, cookie: Cookie<'static>) -> Cookie { |
||||||
|
let c_name = cookie.name().to_string(); |
||||||
|
let mut jar = self.get_jar(); |
||||||
|
jar.private_mut(&self.cookie_key).add(cookie); |
||||||
|
jar.get(&c_name) |
||||||
|
.expect("we have a thief among us! cookie we just added should be in the jar!") |
||||||
|
.to_owned() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn decrypt_cookie(&self, cookie: Cookie<'static>) -> Option<Cookie> { |
||||||
|
let c_name = cookie.name().to_string(); |
||||||
|
let mut jar = self.get_jar(); |
||||||
|
jar.add(cookie); |
||||||
|
jar.private(&self.cookie_key).get(&c_name).to_owned() |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn seed_cache(&self) -> Result<(), DbErr> { |
||||||
|
link::Entity::find() |
||||||
|
.all(&self.db) |
||||||
|
.await? |
||||||
|
.into_iter() |
||||||
|
.for_each(|link| { |
||||||
|
self.cache.insert(link.source, link.target); |
||||||
|
}); |
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn is_user_admin(&self, id: i32) -> Result<bool, DbErr> { |
||||||
|
Ok(user::Entity::find_by_id(id) |
||||||
|
.select_only() |
||||||
|
.column(user::Column::IsAdmin) |
||||||
|
.one(&self.db) |
||||||
|
.await? |
||||||
|
.map(|u| u.is_admin) |
||||||
|
.unwrap_or(false)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone)] |
||||||
|
pub struct AppOptions { |
||||||
|
pub domain: Option<String>, |
||||||
|
pub charset: Vec<char>, |
||||||
|
pub id_len: usize, |
||||||
|
} |
||||||
|
|
||||||
|
impl AppOptions { |
||||||
|
pub fn use_secure_cookie(&self) -> bool { |
||||||
|
self.domain |
||||||
|
.as_ref() |
||||||
|
.is_some_and(|d| !d.starts_with("http:")) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)] |
||||||
|
pub struct UserSessionCookie { |
||||||
|
pub data: Uuid, |
||||||
|
} |
||||||
|
|
||||||
|
impl UserSessionCookie { |
||||||
|
pub fn new() -> Self { |
||||||
|
UserSessionCookie { |
||||||
|
data: Uuid::new_v4(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl From<UserSession> for UserSessionCookie { |
||||||
|
fn from(value: UserSession) -> Self { |
||||||
|
UserSessionCookie { |
||||||
|
data: value.decrypted_session_cookie, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)] |
||||||
|
pub struct UserSession { |
||||||
|
pub user_id: i32, |
||||||
|
created: Instant, |
||||||
|
expiry: Duration, |
||||||
|
pub decrypted_session_cookie: Uuid, |
||||||
|
pub admin: bool, |
||||||
|
} |
||||||
|
|
||||||
|
impl UserSession { |
||||||
|
pub fn new(user_id: i32, admin: bool) -> Self { |
||||||
|
Self::new_with_expiry(user_id, admin, Duration::from_secs(60 * 60 * 24 * 7)) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn new_with_expiry(user_id: i32, admin: bool, expiry: Duration) -> Self { |
||||||
|
UserSession { |
||||||
|
user_id, |
||||||
|
created: Instant::now(), |
||||||
|
expiry, |
||||||
|
decrypted_session_cookie: Uuid::new_v4(), |
||||||
|
admin, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool { |
||||||
|
self.created.elapsed() > self.expiry |
||||||
|
} |
||||||
|
|
||||||
|
pub fn get_decrypted_cookie(&self) -> String { |
||||||
|
self.decrypted_session_cookie.to_string() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl Display for UserSession { |
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||||
|
write!( |
||||||
|
f, |
||||||
|
"UserSession for id={} ({}) authenticated using cookie={}", |
||||||
|
self.user_id, |
||||||
|
if self.admin { "admin" } else { "normal user" }, |
||||||
|
self.decrypted_session_cookie |
||||||
|
)?; |
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub enum UserSessionError { |
||||||
|
MissingPassword, |
||||||
|
Invalid, |
||||||
|
} |
||||||
|
impl TryFrom<(user::Model, String)> for UserSession { |
||||||
|
type Error = UserSessionError; |
||||||
|
|
||||||
|
fn try_from(value: (user::Model, String)) -> Result<Self, Self::Error> { |
||||||
|
let (user, pw) = value; |
||||||
|
match utils::verify_password( |
||||||
|
pw.as_bytes(), |
||||||
|
user.password.ok_or(Self::Error::MissingPassword)?.as_str(), |
||||||
|
) { |
||||||
|
true => Ok(UserSession::new(user.id, user.is_admin)), |
||||||
|
false => Err(Self::Error::Invalid), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub type CurrentUser = user::Model; |
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Debug)] |
||||||
|
pub struct LoginForm { |
||||||
|
pub username: String, |
||||||
|
pub password: String, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Debug)] |
||||||
|
pub struct UserForm { |
||||||
|
pub username: String, |
||||||
|
pub password: String, |
||||||
|
pub admin: bool, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Debug)] |
||||||
|
pub struct LinkForm { |
||||||
|
pub source: Option<String>, |
||||||
|
pub target: String, |
||||||
|
} |
@ -0,0 +1,122 @@ |
|||||||
|
use std::{env, sync::Arc}; |
||||||
|
|
||||||
|
use axum::{ |
||||||
|
extract::Path, |
||||||
|
http::StatusCode, |
||||||
|
middleware::{from_fn, from_fn_with_state}, |
||||||
|
response::{IntoResponse, Redirect}, |
||||||
|
routing::{get, post}, |
||||||
|
Router, |
||||||
|
}; |
||||||
|
use dotenv::dotenv; |
||||||
|
use miette::IntoDiagnostic; |
||||||
|
use sea_orm::Database; |
||||||
|
use time::macros::format_description; |
||||||
|
use tower::ServiceBuilder; |
||||||
|
use tower_http::trace::TraceLayer; |
||||||
|
use tracing::info; |
||||||
|
use tracing_subscriber::{fmt::time::UtcTime, EnvFilter, FmtSubscriber}; |
||||||
|
|
||||||
|
mod data; |
||||||
|
mod middleware; |
||||||
|
mod utils; |
||||||
|
mod views; |
||||||
|
|
||||||
|
use data::{AppOptions, AppState}; |
||||||
|
|
||||||
|
#[tokio::main] |
||||||
|
async fn main() -> miette::Result<()> { |
||||||
|
dotenv().ok(); |
||||||
|
|
||||||
|
let listen_addr = env::var("LISTEN_ADDR") |
||||||
|
.unwrap_or("127.0.0.1:8080".to_string()) |
||||||
|
.parse() |
||||||
|
.expect("LISTEN_ADDR should be valid"); |
||||||
|
let db_conn = env::var("DATABASE_URL").expect("DATABASE_URL should be set"); |
||||||
|
let sqlx_filter = match env::var("V_LOG_SQL") { |
||||||
|
Ok(_) => "sqlx=info", |
||||||
|
Err(_) => "sqlx=warn", |
||||||
|
}; |
||||||
|
let domain = env::var("BASE_DOMAIN").ok(); |
||||||
|
let charset: Vec<char> = env::var("V_ID_CHARSET") |
||||||
|
.unwrap_or("abcdefghijkmnpqrstuwxyz123467890".to_string()) |
||||||
|
.chars() |
||||||
|
.collect(); |
||||||
|
let id_len = env::var("V_ID_GEN_LENGTH") |
||||||
|
.map(|len| { |
||||||
|
len.parse() |
||||||
|
.expect("V_ID_GEN_LENGTH should parse into an integer") |
||||||
|
}) |
||||||
|
.unwrap_or(6 as usize); |
||||||
|
let test_data = env::var("V_DEV_TEST_DATA").is_ok(); |
||||||
|
|
||||||
|
FmtSubscriber::builder() |
||||||
|
.with_env_filter( |
||||||
|
EnvFilter::from_default_env().add_directive(sqlx_filter.parse().into_diagnostic()?), |
||||||
|
) |
||||||
|
.with_timer(UtcTime::new(format_description!( |
||||||
|
"[year]:[month]:[day]T[hour]:[minute]:[second]" |
||||||
|
))) |
||||||
|
.init(); |
||||||
|
|
||||||
|
let db = Database::connect(db_conn.clone()).await.into_diagnostic()?; |
||||||
|
utils::migrate_and_seed_db(&db).await.into_diagnostic()?; |
||||||
|
if test_data { |
||||||
|
utils::insert_dev_test_data(&db, charset.clone(), id_len).await.into_diagnostic()?; |
||||||
|
info!("V_DEV_TEST_DATA is enabled. credentials: dev:a-password"); |
||||||
|
} |
||||||
|
|
||||||
|
let state = Arc::new(AppState::new( |
||||||
|
AppOptions { |
||||||
|
domain, |
||||||
|
charset, |
||||||
|
id_len, |
||||||
|
}, |
||||||
|
db, |
||||||
|
None, |
||||||
|
)); |
||||||
|
state.seed_cache().await.into_diagnostic()?; |
||||||
|
|
||||||
|
let app = Router::new() |
||||||
|
.route("/", get(|| async { "landing page with login form" })) |
||||||
|
.route("/gay/login", post(views::auth::login)) // this one's excempt from the login requirement
|
||||||
|
.nest( |
||||||
|
"/gay", // everything is under this namespace to avoid blocking possible link uris as much as possible
|
||||||
|
Router::new() |
||||||
|
.layer( |
||||||
|
ServiceBuilder::new() |
||||||
|
.layer(from_fn_with_state(state.clone(), middleware::fetch_user)) |
||||||
|
.layer(from_fn(middleware::reject_unauthenticated)), |
||||||
|
) |
||||||
|
.route("/", get(|| async { "logged in dashboard" })) |
||||||
|
.route("/logout", get(views::auth::logout)) |
||||||
|
.nest("/account", views::user::get_selfservice_routes()) |
||||||
|
.nest("/link", views::link::get_routes()) |
||||||
|
.nest( |
||||||
|
"/admin", |
||||||
|
views::admin::get_routes() |
||||||
|
.layer(from_fn(middleware::admin_only)) |
||||||
|
.nest("/user", views::user::get_admin_routes()), |
||||||
|
), |
||||||
|
) |
||||||
|
.route("/:source", get(views::link::access)) |
||||||
|
.layer( |
||||||
|
ServiceBuilder::new() // all the middleware here is very cheap.
|
||||||
|
.layer(TraceLayer::new_for_http()) |
||||||
|
.layer(from_fn(middleware::errors_for_humans)) |
||||||
|
.layer(from_fn_with_state( |
||||||
|
state.clone(), |
||||||
|
middleware::try_get_user_session, |
||||||
|
)), |
||||||
|
) |
||||||
|
.with_state(state.clone()); |
||||||
|
|
||||||
|
info!("listening on {}", &listen_addr); |
||||||
|
|
||||||
|
axum::Server::bind(&listen_addr) |
||||||
|
.serve(app.into_make_service()) |
||||||
|
.await |
||||||
|
.into_diagnostic()?; |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
@ -0,0 +1,133 @@ |
|||||||
|
use std::sync::Arc; |
||||||
|
|
||||||
|
use axum::{ |
||||||
|
extract::State, |
||||||
|
headers::{ContentLength, Cookie as CookieHeader, Header}, |
||||||
|
http::{HeaderValue, Request, StatusCode}, |
||||||
|
middleware::Next, |
||||||
|
response::{IntoResponse, Redirect, Response}, |
||||||
|
Extension, |
||||||
|
}; |
||||||
|
use cookie::{Cookie, CookieJar}; |
||||||
|
use entity::user; |
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
use tracing::{debug, error, info, warn}; |
||||||
|
|
||||||
|
use crate::{ |
||||||
|
data::{AppState, CurrentUser, UserSession}, |
||||||
|
utils, |
||||||
|
}; |
||||||
|
|
||||||
|
pub async fn try_get_user_session<B>( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
mut req: Request<B>, |
||||||
|
next: Next<B>, |
||||||
|
) -> Response { |
||||||
|
let cookies = req.headers().get_all(CookieHeader::name()); |
||||||
|
let mut jar = CookieJar::new(); |
||||||
|
|
||||||
|
for cookie in cookies.iter() { |
||||||
|
if let Some(cook) = cookie.to_str().map(|s| s.to_string()).ok() { |
||||||
|
if let Some(c) = Cookie::parse_encoded(cook).ok() { |
||||||
|
jar.add(c); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// invalid session cookie would fail to decrypt and become None
|
||||||
|
let dec_cookie = jar |
||||||
|
.get("_session") |
||||||
|
.and_then(|cookie| state.decrypt_cookie(cookie.to_owned())); |
||||||
|
let user = dec_cookie |
||||||
|
.and_then(|session_cookie| state.sessions.get_mut(session_cookie.value())) |
||||||
|
.map(|kv| *kv.value()); |
||||||
|
|
||||||
|
debug!( |
||||||
|
"user is {}", |
||||||
|
match user { |
||||||
|
Some(u) => format!("{u}"), |
||||||
|
None => "none".to_string(), |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
req.extensions_mut().insert(user); |
||||||
|
|
||||||
|
next.run(req).await |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn reject_unauthenticated<B>( |
||||||
|
Extension(user_session): Extension<Option<UserSession>>, |
||||||
|
mut req: Request<B>, |
||||||
|
next: Next<B>, |
||||||
|
) -> Result<Response, Redirect> { |
||||||
|
match user_session.is_some_and(|session| !session.is_expired()) { |
||||||
|
true => { |
||||||
|
req.extensions_mut() |
||||||
|
.insert(user_session.expect("fucking cosmic rays")); |
||||||
|
Ok(next.run(req).await) |
||||||
|
} |
||||||
|
false => { |
||||||
|
debug!("unauthenticated request rejected"); |
||||||
|
Err(Redirect::to("/?redirect_reason=not_logged_in")) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// get the actual user model. will Probably be fine for performance given that it can only run for logged in requests (thanks, type system <3)
|
||||||
|
pub async fn fetch_user<B>( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user_session): Extension<UserSession>, |
||||||
|
mut req: Request<B>, |
||||||
|
next: Next<B>, |
||||||
|
) -> Result<impl IntoResponse, StatusCode> { |
||||||
|
let u = user::Entity::find() |
||||||
|
.filter(user::Column::Id.eq(user_session.user_id)) |
||||||
|
.one(&state.db) |
||||||
|
.await |
||||||
|
.map_err(utils::log_into_status_code)? |
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?; |
||||||
|
debug!("fetched user '{}'", u.name); |
||||||
|
req.extensions_mut().insert(u as CurrentUser); |
||||||
|
Ok(next.run(req).await) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn admin_only<B>( |
||||||
|
Extension(user): Extension<CurrentUser>, |
||||||
|
req: Request<B>, |
||||||
|
next: Next<B>, |
||||||
|
) -> Result<Response, Redirect> { |
||||||
|
match user.is_admin { |
||||||
|
true => Ok(next.run(req).await), |
||||||
|
false => Err(Redirect::to("/?redirect_reason=admin_only")), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn errors_for_humans<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse { |
||||||
|
let resp = next.run(req).await; |
||||||
|
match resp |
||||||
|
.headers() |
||||||
|
.get(ContentLength::name()) |
||||||
|
.is_some_and(|len| len == "0") |
||||||
|
{ |
||||||
|
true => { |
||||||
|
debug!("filling response body with rendered status code"); |
||||||
|
// it's empty, so fill it with rendered status code
|
||||||
|
let status = resp.status(); |
||||||
|
let body = format!("{}", status); |
||||||
|
let content_len = match HeaderValue::try_from(body.len().to_string()) { |
||||||
|
Ok(hv) => hv, |
||||||
|
Err(e) => { |
||||||
|
error!("hrrrng captain, i'm trying to header this value, but the length of my content keeps alerting the guards: {e}"); |
||||||
|
|
||||||
|
return resp; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
let (mut parts, _) = resp.into_parts(); |
||||||
|
parts.headers.remove(ContentLength::name()); |
||||||
|
parts.headers.insert(ContentLength::name(), content_len); |
||||||
|
Response::from_parts(parts, body).into_response() |
||||||
|
} |
||||||
|
false => resp, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,131 @@ |
|||||||
|
use std::fmt::Display; |
||||||
|
|
||||||
|
use argon2::password_hash::rand_core::OsRng; |
||||||
|
use argon2::password_hash::SaltString; |
||||||
|
use argon2::Argon2; |
||||||
|
use argon2::PasswordHash; |
||||||
|
use argon2::PasswordHasher; |
||||||
|
use argon2::PasswordVerifier; |
||||||
|
use axum::http::StatusCode; |
||||||
|
use entity::link; |
||||||
|
use entity::prelude::*; |
||||||
|
use entity::user; |
||||||
|
use migration::Migrator; |
||||||
|
use migration::MigratorTrait; |
||||||
|
use sea_orm::{DatabaseConnection, DbErr}; |
||||||
|
use sea_orm::entity::prelude::*; |
||||||
|
|
||||||
|
use serde_json::json; |
||||||
|
use tera::{Context, Tera}; |
||||||
|
use tokio::sync::OnceCell; |
||||||
|
use tracing::error; |
||||||
|
use tracing::info; |
||||||
|
|
||||||
|
pub async fn migrate_and_seed_db(db: &DatabaseConnection) -> Result<(), DbErr> { |
||||||
|
Migrator::up(db, None).await?; |
||||||
|
match User::find().one(db).await? { |
||||||
|
Some(_) => Ok(()), |
||||||
|
None => { |
||||||
|
User::insert(user::ActiveModel::from_json(json!({ |
||||||
|
"name": "admin", |
||||||
|
"is_admin": true, |
||||||
|
"password": hash_password("adminadmin".as_bytes()), |
||||||
|
}))?) |
||||||
|
.exec(db) |
||||||
|
.await?; |
||||||
|
info!("created default admin user. log in as admin / no password on the web interface"); |
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn insert_dev_test_data(db: &DatabaseConnection, charset: Vec<char>, len: usize) -> Result<(), DbErr> { |
||||||
|
if User::find().filter(user::Column::Name.eq("dev")).one(db).await?.is_none() { |
||||||
|
|
||||||
|
let dev_password = hash_password("a-password".as_bytes()); |
||||||
|
let dev_uid = User::insert(user::ActiveModel::from_json(json!({ |
||||||
|
"name": "dev", |
||||||
|
"is_admin": false, |
||||||
|
"password": dev_password, |
||||||
|
}))?).exec(db).await?.last_insert_id; |
||||||
|
info!("created dev user"); |
||||||
|
|
||||||
|
let dev_link1 = Link::insert(link::ActiveModel::from_json(json!({ |
||||||
|
"source": "example", |
||||||
|
"target": "http://example.com", |
||||||
|
"user_id": dev_uid, |
||||||
|
}))?).exec(db).await?.last_insert_id; |
||||||
|
|
||||||
|
let dev_link2 = Link::insert(link::ActiveModel::from_json(json!({ |
||||||
|
"source": gen_id(len, charset), |
||||||
|
"target": "https://random.org", |
||||||
|
"user_id": dev_uid, |
||||||
|
}))?).exec(db).await?.last_insert_id; |
||||||
|
|
||||||
|
info!("created two links for dev user"); |
||||||
|
} |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
static TERA: OnceCell<Tera> = OnceCell::const_new(); |
||||||
|
|
||||||
|
pub async fn get_tera() -> &'static Tera { |
||||||
|
TERA.get_or_init(|| async { |
||||||
|
Tera::new("templates/**/*") |
||||||
|
.map_err(|e| format!("Error parsing tera template: {}", e)) |
||||||
|
.unwrap() |
||||||
|
}) |
||||||
|
.await |
||||||
|
} |
||||||
|
|
||||||
|
fn get_default_context() -> Context { |
||||||
|
todo!() |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn render_template( |
||||||
|
template_name: &str, |
||||||
|
context: Option<Context>, |
||||||
|
) -> Result<String, tera::Error> { |
||||||
|
let tera = get_tera().await; |
||||||
|
let rendered = tera.render(template_name, &context.unwrap_or_else(get_default_context))?; |
||||||
|
Ok(rendered) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn render_status_code(sc: StatusCode) -> String { |
||||||
|
let tera = get_tera().await; |
||||||
|
let mut context = Context::new(); |
||||||
|
context.insert("status_int", &sc.as_u16()); |
||||||
|
context.insert( |
||||||
|
"status_text", |
||||||
|
&sc.canonical_reason() |
||||||
|
.unwrap_or("<unknown canonical reason>"), |
||||||
|
); |
||||||
|
context.insert("status_rendered", &format!("{}", sc)); |
||||||
|
|
||||||
|
tera.render("error.html", &context) |
||||||
|
.expect("status code template should be infaliible") |
||||||
|
} |
||||||
|
|
||||||
|
pub fn hash_password(pw: &[u8]) -> String { |
||||||
|
let argon2 = Argon2::default(); |
||||||
|
let salt = SaltString::generate(&mut OsRng); |
||||||
|
argon2 |
||||||
|
.hash_password(pw, &salt) |
||||||
|
.expect("hell is empty") |
||||||
|
.to_string() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn verify_password(pw: &[u8], hash: &str) -> bool { |
||||||
|
let parsed_hash = PasswordHash::new(hash).expect("hash in the database ought to be valid"); |
||||||
|
Argon2::default().verify_password(pw, &parsed_hash).is_ok() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn log_into_status_code(e: impl Display) -> StatusCode { |
||||||
|
error!("{}", e); |
||||||
|
StatusCode::INTERNAL_SERVER_ERROR |
||||||
|
} |
||||||
|
|
||||||
|
pub fn gen_id(len: usize, charset: Vec<char>) -> String { |
||||||
|
nanoid::nanoid!(len, &charset) |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
use axum::{ |
||||||
|
routing::{get, patch}, |
||||||
|
Router, |
||||||
|
}; |
||||||
|
use std::sync::Arc; |
||||||
|
|
||||||
|
use crate::data::AppState; |
||||||
|
|
||||||
|
pub fn get_routes() -> Router<Arc<AppState>> { |
||||||
|
Router::new() |
||||||
|
.route("/", get(|| async { "admin dashboard" })) |
||||||
|
.route("/:name", patch(|| async { "" }).delete(|| async { "" })) |
||||||
|
} |
@ -0,0 +1,65 @@ |
|||||||
|
use axum::{ |
||||||
|
extract::State, |
||||||
|
http::HeaderValue, |
||||||
|
response::{IntoResponse, Redirect, Response}, |
||||||
|
Extension, Form, |
||||||
|
}; |
||||||
|
use cookie::Cookie; |
||||||
|
use entity::user; |
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; |
||||||
|
use std::sync::Arc; |
||||||
|
use tracing::warn; |
||||||
|
|
||||||
|
use crate::data::{AppState, LoginForm, UserSession}; |
||||||
|
|
||||||
|
pub async fn login( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Form(form): Form<LoginForm>, |
||||||
|
) -> Result<Response, Redirect> { |
||||||
|
if let Some(u) = user::Entity::find() |
||||||
|
.filter(user::Column::Name.eq(form.username)) |
||||||
|
.one(&state.db) |
||||||
|
.await |
||||||
|
.map_err(|_| Redirect::to("/?redirect_reason=login_failed"))? |
||||||
|
{ |
||||||
|
let session = UserSession::new(u.id, u.is_admin); |
||||||
|
let cookie_dec = Cookie::new("_session", session.get_decrypted_cookie()); |
||||||
|
let cookie_enc = state.encrypt_cookie(cookie_dec); |
||||||
|
|
||||||
|
state |
||||||
|
.sessions |
||||||
|
.insert(session.get_decrypted_cookie(), session) |
||||||
|
.map(|old_session| { |
||||||
|
warn!( |
||||||
|
"sound the alarms! same session cookie for user id={} generated twice: '{}'", |
||||||
|
old_session.get_decrypted_cookie(), |
||||||
|
session.get_decrypted_cookie() |
||||||
|
) |
||||||
|
}); |
||||||
|
|
||||||
|
let mut resp = Redirect::to("/gay").into_response(); |
||||||
|
resp.headers_mut().insert( |
||||||
|
"SetCookie", |
||||||
|
HeaderValue::from_str(&cookie_enc.to_string()) |
||||||
|
.expect("there is tomfoolery afoot! this operation should never fail! alas..."), |
||||||
|
); |
||||||
|
Ok(resp) |
||||||
|
} else { |
||||||
|
Err(Redirect::to("/?redirect_reason=login_failed")) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn logout( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user_session): Extension<Option<UserSession>>, |
||||||
|
) -> Redirect { |
||||||
|
if user_session.is_some() { |
||||||
|
// if let Some(key) = session_cookie {
|
||||||
|
// let removed = state.sessions.remove(&key);
|
||||||
|
// if &removed.as_ref().map(|kv| kv.1) != &user_session {
|
||||||
|
// warn!("mismatched user sessions!\ncookie {:?}\nuser_session {:?}\nremoved user session {:?}", &key, user_session, removed.map(|kv| kv.1));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
} |
||||||
|
Redirect::to("/") |
||||||
|
} |
@ -0,0 +1,211 @@ |
|||||||
|
use axum::{ |
||||||
|
extract::{Path, Query, State}, |
||||||
|
http::StatusCode, |
||||||
|
response::Redirect, |
||||||
|
routing::{get, patch}, |
||||||
|
Extension, Form, Router, |
||||||
|
}; |
||||||
|
use entity::link; |
||||||
|
use sea_orm::{entity::prelude::*, ActiveValue, Set}; |
||||||
|
use tera::Context; |
||||||
|
use tracing::error; // use sea_orm::FuckYouForNotKnowingAboutThisTrait;
|
||||||
|
|
||||||
|
use crate::{ |
||||||
|
data::{AppState, CurrentUser, LinkForm}, |
||||||
|
utils, |
||||||
|
}; |
||||||
|
use std::{collections::HashMap, sync::Arc}; |
||||||
|
|
||||||
|
/// crud routes for
|
||||||
|
pub fn get_routes() -> Router<Arc<AppState>> { |
||||||
|
Router::new() |
||||||
|
.route("/", get(list).post(|| async { "make a new link" })) |
||||||
|
.route( |
||||||
|
"/:name", |
||||||
|
patch(|| async { "change a link" }).delete(|| async { "yeet a link" }), |
||||||
|
) |
||||||
|
// admins can change/delete all, users their own
|
||||||
|
} |
||||||
|
|
||||||
|
pub async fn create( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user): Extension<CurrentUser>, |
||||||
|
Form(form): Form<LinkForm>, |
||||||
|
) -> Result<Redirect, String> { |
||||||
|
// what an utter vomit coffin of a function, wow
|
||||||
|
// todo: clean this up
|
||||||
|
let mut tries = 0; |
||||||
|
let target = form.target; |
||||||
|
let mut source = form.source.unwrap_or(String::new()); |
||||||
|
match source.as_str() { |
||||||
|
"" => loop { |
||||||
|
source = utils::gen_id(state.opts.id_len, state.opts.charset.clone()); |
||||||
|
match try_insert_link(&state.db, source.clone(), target.clone(), user.id).await { |
||||||
|
Ok(_) => break, |
||||||
|
Err(e) => match e { |
||||||
|
DbErr::RecordNotInserted => { |
||||||
|
tries += 1; |
||||||
|
if tries > 5 { |
||||||
|
return Err(utils::render_status_code( |
||||||
|
StatusCode::INTERNAL_SERVER_ERROR, |
||||||
|
) |
||||||
|
.await); |
||||||
|
} |
||||||
|
continue; |
||||||
|
} |
||||||
|
_ => { |
||||||
|
return Err( |
||||||
|
utils::render_status_code(StatusCode::INTERNAL_SERVER_ERROR).await |
||||||
|
) |
||||||
|
} |
||||||
|
}, |
||||||
|
} |
||||||
|
}, |
||||||
|
_ => match try_insert_link(&state.db, source.clone(), target.clone(), user.id).await { |
||||||
|
Ok(_) => (), |
||||||
|
Err(e) => { |
||||||
|
return Err(utils::render_status_code(match e { |
||||||
|
DbErr::RecordNotInserted => StatusCode::BAD_REQUEST, |
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR, |
||||||
|
}) |
||||||
|
.await) |
||||||
|
} |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/gay/link/{}", source))) |
||||||
|
} |
||||||
|
|
||||||
|
async fn try_insert_link( |
||||||
|
db: &DatabaseConnection, |
||||||
|
source: String, |
||||||
|
target: String, |
||||||
|
userid: i32, |
||||||
|
) -> Result<(), DbErr> { |
||||||
|
let link = link::ActiveModel { |
||||||
|
user_id: ActiveValue::Set(userid), |
||||||
|
source: ActiveValue::Set(source), |
||||||
|
target: ActiveValue::Set(target), |
||||||
|
id: ActiveValue::NotSet, |
||||||
|
}; |
||||||
|
|
||||||
|
let _res = link::Entity::insert(link).exec(db).await?; |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn update( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user): Extension<CurrentUser>, |
||||||
|
Path(source): Path<String>, |
||||||
|
Form(form): Form<LinkForm>, |
||||||
|
) -> Result<Redirect, String> { |
||||||
|
let mut link: link::ActiveModel = match link::Entity::find() |
||||||
|
.filter(link::Column::Source.eq(source)) |
||||||
|
.one(&state.db) |
||||||
|
.await |
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) |
||||||
|
.and_then(|l| l.ok_or(StatusCode::NOT_FOUND)) |
||||||
|
.map_err(|e| utils::render_status_code(e)) |
||||||
|
{ |
||||||
|
Ok(l) => l.into(), |
||||||
|
Err(f) => Err(f.await)?, // let me do async closures you cowards
|
||||||
|
}; |
||||||
|
|
||||||
|
if (!user.is_admin) |
||||||
|
&& link |
||||||
|
.user_id |
||||||
|
.clone() |
||||||
|
.into_value() |
||||||
|
.expect("the not null field shouldn't be null probably lmao") |
||||||
|
!= Value::from(user.id) |
||||||
|
{ |
||||||
|
return Err(utils::render_status_code(StatusCode::UNAUTHORIZED).await); |
||||||
|
} |
||||||
|
|
||||||
|
link.target = Set(form.target); |
||||||
|
if let Some(source) = form.source { |
||||||
|
link.source = Set(source); |
||||||
|
} |
||||||
|
|
||||||
|
let source = link |
||||||
|
.source |
||||||
|
.clone() |
||||||
|
.into_value() |
||||||
|
.expect("the field is literally not nullable what the fuck"); |
||||||
|
match link |
||||||
|
.update(&state.db) |
||||||
|
.await |
||||||
|
.map_err(|_| utils::render_status_code(StatusCode::INTERNAL_SERVER_ERROR)) |
||||||
|
{ |
||||||
|
Ok(_) => (), |
||||||
|
Err(f) => Err(f.await)?, |
||||||
|
}; |
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/gay/link/{}", source))) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn delete( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user): Extension<CurrentUser>, |
||||||
|
Path(source): Path<String>, |
||||||
|
) -> Result<Redirect, String> { |
||||||
|
let link = match link::Entity::find() |
||||||
|
.filter(link::Column::Source.eq(source)) |
||||||
|
.one(&state.db) |
||||||
|
.await |
||||||
|
{ |
||||||
|
Err(_) => Err(utils::render_status_code(StatusCode::INTERNAL_SERVER_ERROR).await)?, |
||||||
|
Ok(None) => Err(utils::render_status_code(StatusCode::NOT_FOUND).await)?, |
||||||
|
Ok(Some(l)) => { |
||||||
|
if (!user.is_admin) && l.user_id != user.id { |
||||||
|
Err(utils::render_status_code(StatusCode::UNAUTHORIZED).await)? |
||||||
|
} |
||||||
|
l |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
match link.delete(&state.db).await { |
||||||
|
Err(_) => Err(utils::render_status_code(StatusCode::INTERNAL_SERVER_ERROR).await)?, |
||||||
|
Ok(_) => () |
||||||
|
}; |
||||||
|
|
||||||
|
Ok(Redirect::to("/gay/link")) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn list( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Query(params): Query<HashMap<String, String>>, |
||||||
|
Extension(user): Extension<CurrentUser>, |
||||||
|
) -> Result<String, StatusCode> { |
||||||
|
let mut query = link::Entity::find(); |
||||||
|
|
||||||
|
if !(user.is_admin && params.contains_key("all")) { |
||||||
|
query = query.filter(link::Column::UserId.eq(user.id)); |
||||||
|
} |
||||||
|
|
||||||
|
let links = query |
||||||
|
.all(&state.db) |
||||||
|
.await |
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
||||||
|
|
||||||
|
let mut context = Context::new(); |
||||||
|
|
||||||
|
context.insert("user", &user); |
||||||
|
context.insert("links", &links); |
||||||
|
|
||||||
|
Ok(utils::render_template("", Some(context)) |
||||||
|
.await |
||||||
|
.map_err(|e| { |
||||||
|
error!("oopsie woopsie, we had a little rendering fucky wucky! {e}"); |
||||||
|
StatusCode::INTERNAL_SERVER_ERROR |
||||||
|
})?) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn access(State(state): State<Arc<AppState>>, Path(source): Path<String>) -> Redirect { |
||||||
|
state |
||||||
|
.cache |
||||||
|
.get(&source) |
||||||
|
.map(|target| Redirect::to(&target)) |
||||||
|
.unwrap_or(Redirect::to("/404")) |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
pub mod admin; |
||||||
|
pub mod auth; |
||||||
|
pub mod link; |
||||||
|
pub mod user; |
@ -0,0 +1,28 @@ |
|||||||
|
use axum::{ |
||||||
|
extract::State, |
||||||
|
response::Response, |
||||||
|
routing::{get, patch}, |
||||||
|
Extension, Form, Router, |
||||||
|
}; |
||||||
|
|
||||||
|
use std::sync::Arc; |
||||||
|
|
||||||
|
use crate::data::{AppState, UserForm, UserSession}; |
||||||
|
|
||||||
|
pub fn get_selfservice_routes() -> Router<Arc<AppState>> { |
||||||
|
Router::new() |
||||||
|
.route("/", get(|| async { "" }).post(|| async { "" })) |
||||||
|
.route("/:name", patch(|| async { "" }).delete(|| async { "" })) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn get_admin_routes() -> Router<Arc<AppState>> { |
||||||
|
Router::new() |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn create( |
||||||
|
State(state): State<Arc<AppState>>, |
||||||
|
Extension(user_session): Extension<Option<UserSession>>, |
||||||
|
Form(form): Form<UserForm>, |
||||||
|
) -> Response { |
||||||
|
todo!() |
||||||
|
} |
Loading…
Reference in new issue