xenua
2 years ago
commit
dee6235501
29 changed files with 5192 additions and 0 deletions
@ -0,0 +1,33 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,4 @@
|
||||
pub mod admin; |
||||
pub mod auth; |
||||
pub mod link; |
||||
pub mod user; |
@ -0,0 +1,28 @@
@@ -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