Browse Source

initial commit

main
xenua 12 months ago
commit
dee6235501
Signed by: xenua
GPG Key ID: 8F93B68BD37255B8
  1. 3
      .gitignore
  2. 3834
      Cargo.lock
  3. 33
      Cargo.toml
  4. 35
      README.md
  5. 1
      entity/.gitignore
  6. 10
      entity/Cargo.toml
  7. 34
      entity/src/click.rs
  8. 7
      entity/src/lib.rs
  9. 43
      entity/src/link.rs
  10. 7
      entity/src/mod.rs
  11. 5
      entity/src/prelude.rs
  12. 35
      entity/src/session.rs
  13. 30
      entity/src/user.rs
  14. 1
      migration/.gitignore
  15. 22
      migration/Cargo.toml
  16. 41
      migration/README.md
  17. 12
      migration/src/lib.rs
  18. 130
      migration/src/m20230515_053227_initial.rs
  19. 6
      migration/src/main.rs
  20. 196
      src/data.rs
  21. 122
      src/main.rs
  22. 133
      src/middleware.rs
  23. 131
      src/utils.rs
  24. 13
      src/views/admin.rs
  25. 65
      src/views/auth.rs
  26. 211
      src/views/link.rs
  27. 4
      src/views/mod.rs
  28. 0
      src/views/other.rs
  29. 28
      src/views/user.rs

3
.gitignore vendored

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
/target
.env

3834
Cargo.lock generated

File diff suppressed because it is too large Load Diff

33
Cargo.toml

@ -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"

35
README.md

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
# ^V
this is unfinished software
a tiny link shortener (& maybe pastebin in the future)
## running it
^V needs a postgresql database. for ssl, run it behind a reverse proxy like nginx.
all config is done through env vars (.env file works too): here's a list of them
(which may be incomplete. the full list can be found at the start of the main() function in src/main.rs)
| var | default | description |
|----------------:|:---------------------------------|:---------------------------------------------------------------------------------------------|
| LISTEN_ADDR | 127.0.0.1:8080 | the listen address with port. |
| DATABASE_URL | | the address to the postgres database. this is passed directly to sea_orm::Database::connect |
| BASE_DOMAIN | | ^V assumes https if this is set, and will use secure cookies for that domain |
| V_ID_CHARSET | abcdefghijkmnpqrstuwxyz123467890 | the charset for generated ids that show up in links (manual overrides don't care about this) |
| V_ID_GEN_LENGTH | 6 | length of the generated ids (again, manually set ids don't care.) |
| V_DEV_TEST_DATA | | whether or not to generate a `dev` user with some test data; probably only useful for devs |
## dev info
if you want to tinker with ^V, here's some pointers
- it uses sea_orm for data storage abstraction.
- defining new things works as follows:
1. the schema is defined through migrations (migration/), then
2. it is applied to an actual database: \
`$ sea migrate fresh`
3. entity mappings are generated from that database: \
`$ sea generate entity --with-serde both --serde-skip-deserializing-primary-key -o entity/src`
- the `sea` command is provided from `$ cargo install sea-orm-cli` and needs to run with `DATABASE_URL` set (or its `-u` option)

1
entity/.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
/target

10
entity/Cargo.toml

@ -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"] }

34
entity/src/click.rs

@ -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 {}

7
entity/src/lib.rs

@ -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;

43
entity/src/link.rs

@ -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 {}

7
entity/src/mod.rs

@ -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;

5
entity/src/prelude.rs

@ -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;

35
entity/src/session.rs

@ -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 {}

30
entity/src/user.rs

@ -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 {}

1
migration/.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
/target

22
migration/Cargo.toml

@ -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
]

41
migration/README.md

@ -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
```

12
migration/src/lib.rs

@ -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)]
}
}

130
migration/src/m20230515_053227_initial.rs

@ -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(())
}
}

6
migration/src/main.rs

@ -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;
}

196
src/data.rs

@ -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,
}

122
src/main.rs

@ -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(())
}

133
src/middleware.rs

@ -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,
}
}

131
src/utils.rs

@ -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)
}

13
src/views/admin.rs

@ -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 { "" }))
}

65
src/views/auth.rs

@ -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("/")
}

211
src/views/link.rs

@ -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"))
}

4
src/views/mod.rs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
pub mod admin;
pub mod auth;
pub mod link;
pub mod user;

0
src/views/other.rs

28
src/views/user.rs

@ -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…
Cancel
Save