Compare commits

..

No commits in common. "main" and "0.0.1" have entirely different histories.

14 changed files with 75 additions and 872 deletions

159
Cargo.lock generated
View file

@ -120,15 +120,6 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@ -438,8 +429,6 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding", "urlencoding",
"utoipa",
"utoipa-swagger-ui",
"uuid", "uuid",
] ]
@ -775,17 +764,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.115",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@ -994,7 +972,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs",
] ]
[[package]] [[package]]
@ -2641,40 +2618,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.115",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]] [[package]]
name = "rust_decimal" name = "rust_decimal"
version = "1.40.0" version = "1.40.0"
@ -2766,15 +2709,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.28" version = "0.1.28"
@ -4011,48 +3945,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.115",
]
[[package]]
name = "utoipa-swagger-ui"
version = "9.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [
"axum",
"base64 0.22.1",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.21.0" version = "1.21.0"
@ -4083,16 +3975,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -4273,15 +4155,6 @@ dependencies = [
"wasite", "wasite",
] ]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -4795,44 +4668,12 @@ dependencies = [
"syn 2.0.115", "syn 2.0.115",
] ]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"

View file

@ -22,5 +22,3 @@ jsonwebtoken = "9.3.1"
anyhow = "1.0.95" anyhow = "1.0.95"
reqwest = { version = "0.12.12", features = ["json", "rustls-tls"] } reqwest = { version = "0.12.12", features = ["json", "rustls-tls"] }
serde_json = "1.0.138" serde_json = "1.0.138"
utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "macros"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }

View file

@ -2,7 +2,6 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_event_table; mod m20220101_000001_create_event_table;
mod m20250215_213300_create_user_table; mod m20250215_213300_create_user_table;
mod m20260216_215000_refactor_user_id_to_sub;
pub struct Migrator; pub struct Migrator;
@ -12,7 +11,6 @@ impl MigratorTrait for Migrator {
vec![ vec![
Box::new(m20220101_000001_create_event_table::Migration), Box::new(m20220101_000001_create_event_table::Migration),
Box::new(m20250215_213300_create_user_table::Migration), Box::new(m20250215_213300_create_user_table::Migration),
Box::new(m20260216_215000_refactor_user_id_to_sub::Migration),
] ]
} }
} }

View file

@ -1,123 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Drop foreign key
manager
.drop_foreign_key(
ForeignKey::drop()
.name("fk-event-user_id")
.table(Event::Table)
.to_owned(),
)
.await?;
// Add user_sub column
manager
.alter_table(
Table::alter()
.table(Event::Table)
.add_column(ColumnDef::new(Event::UserSub).string().null())
.to_owned(),
)
.await?;
// Copy user_sub from user table via user_id
manager
.exec_stmt(
Query::update()
.table(Event::Table)
.values([(
Event::UserSub,
SimpleExpr::SubQuery(
None,
Box::new(
Query::select()
.column(User::Sub)
.from(User::Table)
.and_where(
Expr::col((User::Table, User::Id))
.eq(Expr::col((Event::Table, Event::UserId))),
)
.to_owned()
.into_sub_query_statement(),
),
),
)])
.to_owned(),
)
.await?;
// Drop user_id column
manager
.alter_table(
Table::alter()
.table(Event::Table)
.drop_column(Event::UserId)
.to_owned(),
)
.await?;
// Create new foreign key to user.sub
manager
.create_foreign_key(
ForeignKey::create()
.name("fk-event-user_sub")
.from(Event::Table, Event::UserSub)
.to(User::Table, User::Sub)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_foreign_key(
ForeignKey::drop()
.name("fk-event-user_sub")
.table(Event::Table)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Event::Table)
.drop_column(Event::UserSub)
.add_column(ColumnDef::new(Event::UserId).integer())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk-event-user_id")
.from(Event::Table, Event::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
Sub,
}
#[derive(DeriveIden)]
enum Event {
Table,
UserId,
UserSub,
}

View file

@ -11,7 +11,6 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use utoipa::ToSchema;
#[derive(Clone, FromRef)] #[derive(Clone, FromRef)]
pub struct AppState { pub struct AppState {
@ -217,12 +216,11 @@ pub struct TokenResponse {
pub struct Claims { pub struct Claims {
pub sub: String, pub sub: String,
pub email: Option<String>, pub email: Option<String>,
pub preferred_username: Option<String>, pub name: Option<String>,
pub exp: usize, pub exp: usize,
pub groups: Option<Vec<String>>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CurrentUser { pub struct CurrentUser {
pub id: i32, pub id: i32,
pub sub: String, pub sub: String,
@ -275,48 +273,3 @@ where
Err((axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response()) Err((axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response())
} }
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceAccount {
pub sub: String,
}
impl<S> FromRequestParts<S> for ServiceAccount
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = AppState::from_ref(state);
let oidc = state.oidc;
let auth_header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
if let Some(auth_header) = auth_header {
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
if let Ok(claims) = oidc.validate_token(token).await {
if claims
.groups
.as_ref()
.map(|g| g.contains(&"agent".to_string()))
.unwrap_or(false)
{
return Ok(ServiceAccount { sub: claims.sub });
}
}
}
}
Err((
axum::http::StatusCode::UNAUTHORIZED,
"Unauthorized Service Account",
)
.into_response())
}
}

View file

@ -1,13 +1,12 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "event")] #[sea_orm(table_name = "event")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i64, pub id: i64,
pub user_sub: String, pub user_id: Option<i32>,
pub name: String, pub name: String,
pub from: DateTime, pub from: DateTime,
pub to: DateTime, pub to: DateTime,
@ -17,8 +16,8 @@ pub struct Model {
pub enum Relation { pub enum Relation {
#[sea_orm( #[sea_orm(
belongs_to = "super::user::Entity", belongs_to = "super::user::Entity",
from = "Column::UserSub", from = "Column::UserId",
to = "super::user::Column::Sub" to = "super::user::Column::Id"
)] )]
User, User,
} }

View file

@ -1,8 +1,7 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user")] #[sea_orm(table_name = "user")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]

View file

@ -1,7 +1,7 @@
use crate::auth::AppState; use crate::auth::AppState;
use crate::entities::user; use crate::entities::user;
use crate::handlers::Json;
use axum::{ use axum::{
Json,
extract::{Query, State}, extract::{Query, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@ -15,15 +15,12 @@ pub struct AuthCallback {
pub state: String, pub state: String,
} }
#[tracing::instrument(skip(state))]
pub async fn login(State(state): State<AppState>) -> Response { pub async fn login(State(state): State<AppState>) -> Response {
tracing::info!("Initiating login redirect");
let oidc = state.oidc; let oidc = state.oidc;
let (auth_url, _csrf_token, _nonce) = oidc.auth_url(); let (auth_url, _csrf_token, _nonce) = oidc.auth_url();
Redirect::to(&auth_url).into_response() Redirect::to(&auth_url).into_response()
} }
#[tracing::instrument(skip(state, params), fields(code = %params.code))]
pub async fn callback( pub async fn callback(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<AuthCallback>, Query(params): Query<AuthCallback>,
@ -36,7 +33,7 @@ pub async fn callback(
Ok(claims) => { Ok(claims) => {
let sub = claims.sub; let sub = claims.sub;
let email = claims.email.unwrap_or_default(); let email = claims.email.unwrap_or_default();
let name = claims.preferred_username.unwrap_or_default(); let name = claims.name.unwrap_or_default();
let existing_user = user::Entity::find() let existing_user = user::Entity::find()
.filter(user::Column::Sub.eq(&sub)) .filter(user::Column::Sub.eq(&sub))
@ -45,22 +42,14 @@ pub async fn callback(
.unwrap_or(None); .unwrap_or(None);
if existing_user.is_none() { if existing_user.is_none() {
tracing::info!("Creating new user for sub {}", sub);
let new_user = user::ActiveModel { let new_user = user::ActiveModel {
sub: Set(sub.clone()), sub: Set(sub),
email: Set(email), email: Set(email),
name: Set(name), name: Set(name),
..Default::default() ..Default::default()
}; };
new_user.insert(&db).await.unwrap(); new_user.insert(&db).await.unwrap();
} else { };
tracing::debug!("Updating existing user for sub {}", sub);
let mut user: user::ActiveModel = existing_user.unwrap().into();
user.name = Set(name);
user.update(&db).await.unwrap();
}
tracing::info!("User {} logged in successfully", sub);
let mut redirect_url = format!("/#access_token={}", tokens.id_token); let mut redirect_url = format!("/#access_token={}", tokens.id_token);
if let Some(refresh_token) = tokens.refresh_token { if let Some(refresh_token) = tokens.refresh_token {
@ -89,7 +78,6 @@ pub async fn callback(
} }
} }
#[tracing::instrument(skip(state, payload))]
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<RefreshRequest>, Json(payload): Json<RefreshRequest>,
@ -97,10 +85,7 @@ pub async fn refresh(
let oidc = state.oidc; let oidc = state.oidc;
match oidc.refresh_token(payload.refresh_token).await { match oidc.refresh_token(payload.refresh_token).await {
Ok(tokens) => { Ok(tokens) => Json(tokens).into_response(),
tracing::info!("Token refreshed successfully");
Json(tokens).into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Token refresh error: {:?}", e); tracing::error!("Token refresh error: {:?}", e);
(axum::http::StatusCode::UNAUTHORIZED, "Refresh failed").into_response() (axum::http::StatusCode::UNAUTHORIZED, "Refresh failed").into_response()
@ -108,7 +93,7 @@ pub async fn refresh(
} }
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
pub struct RefreshRequest { pub struct RefreshRequest {
pub refresh_token: String, pub refresh_token: String,
} }
@ -117,19 +102,18 @@ pub async fn logout() -> Response {
Redirect::to("/").into_response() Redirect::to("/").into_response()
} }
#[utoipa::path( pub async fn me(
get, crate::auth::CurrentUser {
path = "/auth/me", id,
responses( sub,
(status = 200, description = "Current user profile", body = crate::auth::CurrentUser), email,
(status = 401, description = "Unauthorized") name,
), }: crate::auth::CurrentUser,
security( ) -> Json<crate::auth::CurrentUser> {
("oidc" = []) Json(crate::auth::CurrentUser {
) id,
)] sub,
#[tracing::instrument(skip(user))] email,
pub async fn me(user: crate::auth::CurrentUser) -> Json<crate::auth::CurrentUser> { name,
tracing::debug!("User profile requested for {}", user.sub); })
Json(user)
} }

View file

@ -1,38 +1,25 @@
use crate::auth::{AppState, CurrentUser}; use crate::auth::{AppState, CurrentUser};
use crate::entities::{event, event::Entity as Event}; use crate::entities::{event, event::Entity as Event};
use crate::handlers::Json;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use chrono::{DateTime, TimeZone, Utc};
use sea_orm::*; use sea_orm::*;
use sea_orm::{ActiveValue::Set, sea_query::Expr};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; #[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateEventRequest { pub struct CreateEventRequest {
pub name: String, pub name: String,
pub from: String, pub from: String,
pub to: String, pub to: String,
} }
#[derive(Debug, Deserialize, IntoParams)] #[derive(Deserialize)]
pub struct ListEventsQuery { pub struct ListEventsQuery {
pub upcoming: Option<bool>, pub upcoming: Option<bool>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EventResponse {
pub id: i64,
pub user_sub: String,
pub name: String,
pub from: DateTime<Utc>,
pub to: DateTime<Utc>,
}
fn parse_datetime(dt_str: &str) -> Result<chrono::NaiveDateTime, StatusCode> { fn parse_datetime(dt_str: &str) -> Result<chrono::NaiveDateTime, StatusCode> {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") {
return Ok(dt); return Ok(dt);
@ -43,23 +30,9 @@ fn parse_datetime(dt_str: &str) -> Result<chrono::NaiveDateTime, StatusCode> {
dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST) dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST)
} }
#[utoipa::path(
post,
path = "/events",
request_body = CreateEventRequest,
responses(
(status = 200, description = "Event created successfully", body = event::Model),
(status = 400, description = "Invalid request payload"),
(status = 401, description = "Unauthorized")
),
security(
("oidc" = [])
)
)]
#[tracing::instrument(skip(state, payload), fields(user_sub = %sub, event_name = %payload.name))]
pub async fn create_event( pub async fn create_event(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser { sub, .. }: CurrentUser, CurrentUser { id, .. }: CurrentUser,
Json(payload): Json<CreateEventRequest>, Json(payload): Json<CreateEventRequest>,
) -> Result<Json<event::Model>, StatusCode> { ) -> Result<Json<event::Model>, StatusCode> {
let db = state.db; let db = state.db;
@ -70,7 +43,7 @@ pub async fn create_event(
name: Set(payload.name), name: Set(payload.name),
from: Set(from), from: Set(from),
to: Set(to), to: Set(to),
user_sub: Set(sub.clone()), user_id: Set(Some(id)),
..Default::default() ..Default::default()
}; };
@ -79,174 +52,97 @@ pub async fn create_event(
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
tracing::info!("Created event {} for user {}", result.id, sub); tracing::info!("Created event {} for user {}", result.id, id);
Ok(Json(result)) Ok(Json(result))
} }
#[utoipa::path(
get,
path = "/events",
params(ListEventsQuery),
responses(
(status = 200, description = "List of events", body = [event::Model]),
(status = 401, description = "Unauthorized")
),
security(
("oidc" = [])
)
)]
#[tracing::instrument(skip(state), fields(user_sub = %sub))]
pub async fn list_events( pub async fn list_events(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser { sub, .. }: CurrentUser, CurrentUser { id, .. }: CurrentUser,
Query(query): Query<ListEventsQuery>, Query(query): Query<ListEventsQuery>,
) -> Result<Json<Vec<event::Model>>, StatusCode> { ) -> Result<Json<Vec<event::Model>>, StatusCode> {
let db = state.db; let db = state.db;
tracing::debug!("Listing events for user"); let mut find = Event::find().filter(event::Column::UserId.eq(id));
let mut find = Event::find().filter(event::Column::UserSub.eq(sub.clone()));
if let Some(true) = query.upcoming { if let Some(true) = query.upcoming {
let now = chrono::Utc::now().naive_utc(); let now = chrono::Utc::now().naive_utc();
find = find.filter(event::Column::From.gte(now)); find = find.filter(event::Column::From.gte(now));
} }
let events = find.all(&db).await.map_err(|e| { let events = find
tracing::error!("Failed to list events: {:?}", e); .all(&db)
StatusCode::INTERNAL_SERVER_ERROR .await
})?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tracing::info!("Listed {} events for user {}", events.len(), sub);
Ok(Json(events)) Ok(Json(events))
} }
#[utoipa::path(
get,
path = "/events/{id}",
params(
("id" = i32, Path, description = "Event database id")
),
responses(
(status = 200, description = "Event details", body = event::Model),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Event not found")
),
security(
("oidc" = [])
)
)]
#[tracing::instrument(skip(state), fields(user_sub = %sub, event_id = %event_id))]
pub async fn get_event( pub async fn get_event(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser { sub, .. }: CurrentUser, CurrentUser { id, .. }: CurrentUser,
Path(event_id): Path<i32>, Path(event_id): Path<i32>,
) -> Result<Json<event::Model>, StatusCode> { ) -> Result<Json<event::Model>, StatusCode> {
let db = state.db; let db = state.db;
let event = Event::find_by_id(event_id) let event = Event::find_by_id(event_id)
.filter(event::Column::UserSub.eq(sub.clone())) .filter(event::Column::UserId.eq(id))
.one(&db) .one(&db)
.await .await
.map_err(|e| { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
tracing::error!("Failed to fetch event {}: {:?}", event_id, e); .ok_or(StatusCode::NOT_FOUND)?;
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or_else(|| {
tracing::warn!("Event {} not found for user {}", event_id, sub);
StatusCode::NOT_FOUND
})?;
tracing::info!("Fetched event {} for user {}", event_id, sub);
Ok(Json(event)) Ok(Json(event))
} }
#[utoipa::path(
put,
path = "/events/{id}",
params(
("id" = i32, Path, description = "Event database id")
),
request_body = CreateEventRequest,
responses(
(status = 200, description = "Event updated successfully", body = event::Model),
(status = 400, description = "Invalid request payload"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Event not found")
),
security(
("oidc" = [])
)
)]
#[tracing::instrument(skip(state, payload), fields(user_sub = %sub, event_id = %event_id, event_name = %payload.name))]
pub async fn update_event( pub async fn update_event(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser { sub, .. }: CurrentUser, CurrentUser { id, .. }: CurrentUser,
Path(event_id): Path<i32>, Path(event_id): Path<i32>,
Json(payload): Json<CreateEventRequest>, Json(payload): Json<CreateEventRequest>,
) -> Result<Json<EventResponse>, StatusCode> { ) -> Result<Json<event::Model>, StatusCode> {
let db = state.db; let db = state.db;
let from = parse_datetime(&payload.from)?; let from = parse_datetime(&payload.from)?;
let to = parse_datetime(&payload.to)?; let to = parse_datetime(&payload.to)?;
let result = Event::update_many() let event = Event::find_by_id(event_id)
.col_expr(event::Column::Name, Expr::value(payload.name.clone())) .filter(event::Column::UserId.eq(id))
.col_expr(event::Column::From, Expr::value(from)) .one(&db)
.col_expr(event::Column::To, Expr::value(to))
.filter(event::Column::Id.eq(event_id as i64))
.filter(event::Column::UserSub.eq(sub.clone()))
.exec(&db)
.await .await
.map_err(|e| { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
tracing::error!("Service failed to update event {}: {:?}", event_id, e); .ok_or(StatusCode::NOT_FOUND)?;
let mut event: event::ActiveModel = event.into();
event.name = Set(payload.name);
event.from = Set(from);
event.to = Set(to);
let result = event.update(&db).await.map_err(|e| {
tracing::error!("Failed to update event {}: {:?}", event_id, e);
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
if result.rows_affected == 0 { tracing::info!("Updated event {} for user {}", event_id, id);
return Err(StatusCode::NOT_FOUND);
Ok(Json(result))
} }
Ok(Json(EventResponse {
id: event_id as i64,
user_sub: sub,
name: payload.name,
from: Utc.from_utc_datetime(&from),
to: Utc.from_utc_datetime(&to),
}))
}
#[utoipa::path(
delete,
path = "/events/{id}",
params(
("id" = i32, Path, description = "Event database id")
),
responses(
(status = 204, description = "Event deleted successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Event not found")
),
security(
("oidc" = [])
)
)]
#[tracing::instrument(skip(state), fields(user_sub = %sub, event_id = %event_id))]
pub async fn delete_event( pub async fn delete_event(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser { sub, .. }: CurrentUser, CurrentUser { id, .. }: CurrentUser,
Path(event_id): Path<i32>, Path(event_id): Path<i32>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
let db = state.db; let db = state.db;
Event::delete_by_id(event_id as i64) let event = Event::find_by_id(event_id)
.filter(event::Column::UserSub.eq(sub.clone())) .filter(event::Column::UserId.eq(id))
.exec(&db) .one(&db)
.await .await
.map_err(|e| { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
tracing::error!("Failed to delete event {}: {:?}", event_id, e); .ok_or(StatusCode::NOT_FOUND)?;
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("Deleted event {} for user {}", event_id, sub); event
.delete(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View file

@ -1,49 +1,2 @@
pub mod auth; pub mod auth;
pub mod event; pub mod event;
pub mod service;
use axum::{
extract::Request,
extract::{FromRequest, rejection::JsonRejection},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use std::future::Future;
use std::pin::Pin;
pub struct Json<T>(pub T);
impl<S, T> FromRequest<S> for Json<T>
where
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
S: Send + Sync,
T: 'static,
{
type Rejection = Response;
fn from_request(
req: Request,
state: &S,
) -> Pin<Box<dyn Future<Output = Result<Self, Self::Rejection>> + Send + '_>> {
let fut = axum::Json::<T>::from_request(req, state);
Box::pin(async move {
match fut.await {
Ok(value) => Ok(Self(value.0)),
Err(rejection) => {
tracing::error!(%rejection, "Json extraction failed");
Err((StatusCode::BAD_REQUEST, rejection.body_text()).into_response())
}
}
})
}
}
impl<T> IntoResponse for Json<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
axum::Json(self.0).into_response()
}
}

View file

@ -1,228 +0,0 @@
use crate::auth::{AppState, ServiceAccount};
use crate::entities::{event, event::Entity as Event};
use crate::handlers::Json;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
};
use chrono::{DateTime, TimeZone, Utc};
use sea_orm::{ActiveValue::Set, sea_query::Expr, *};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Serialize, ToSchema)]
pub struct ServiceEventResponse {
pub id: i64,
pub user_sub: String,
pub name: String,
pub from: DateTime<Utc>,
pub to: DateTime<Utc>,
}
impl From<event::Model> for ServiceEventResponse {
fn from(m: event::Model) -> Self {
Self {
id: m.id,
user_sub: m.user_sub,
name: m.name,
from: Utc.from_utc_datetime(&m.from),
to: Utc.from_utc_datetime(&m.to),
}
}
}
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct ServiceCreateEventRequest {
pub name: String,
pub from: String,
pub to: String,
pub user_sub: String,
}
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct ServiceUpdateEventRequest {
pub name: String,
pub from: String,
pub to: String,
pub user_sub: String,
}
#[derive(Deserialize, IntoParams)]
pub struct ServiceListEventsQuery {
pub user_sub: String,
pub upcoming: Option<bool>,
}
fn parse_datetime(dt_str: &str) -> Result<chrono::NaiveDateTime, StatusCode> {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") {
return Ok(dt);
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S") {
return Ok(dt);
}
dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST)
}
#[utoipa::path(
post,
path = "/service/v1/events",
request_body = ServiceCreateEventRequest,
responses(
(status = 200, description = "Event created successfully", body = ServiceEventResponse),
(status = 400, description = "Invalid request payload"),
(status = 401, description = "Unauthorized")
),
security(
("oidc" = [])
)
)]
pub async fn service_create_event(
State(state): State<AppState>,
_service_account: ServiceAccount,
Json(payload): Json<ServiceCreateEventRequest>,
) -> Result<Json<ServiceEventResponse>, StatusCode> {
let db = state.db;
let from = parse_datetime(&payload.from)?;
let to = parse_datetime(&payload.to)?;
let new_event = event::ActiveModel {
name: Set(payload.name),
from: Set(from),
to: Set(to),
user_sub: Set(payload.user_sub),
..Default::default()
};
let result = new_event.insert(&db).await.map_err(|e| {
tracing::error!("Service failed to create event: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("Service created event {}", result.id);
Ok(Json(result.into()))
}
#[utoipa::path(
get,
path = "/service/v1/events",
params(ServiceListEventsQuery),
responses(
(status = 200, description = "List of events", body = [ServiceEventResponse]),
(status = 401, description = "Unauthorized")
),
security(
("oidc" = [])
)
)]
pub async fn service_list_events(
State(state): State<AppState>,
_service_account: ServiceAccount,
Query(query): Query<ServiceListEventsQuery>,
) -> Result<Json<Vec<ServiceEventResponse>>, StatusCode> {
let db = state.db;
let mut find = Event::find();
find = find.filter(event::Column::UserSub.eq(query.user_sub));
if let Some(true) = query.upcoming {
let now = chrono::Utc::now().naive_utc();
find = find.filter(event::Column::From.gte(now));
}
let events = find
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let response = events.into_iter().map(ServiceEventResponse::from).collect();
Ok(Json(response))
}
#[utoipa::path(
put,
path = "/service/v1/events/{id}",
params(
("id" = i32, Path, description = "Event database id"),
ServiceListEventsQuery
),
request_body = ServiceUpdateEventRequest,
responses(
(status = 200, description = "Event updated successfully", body = ServiceEventResponse),
(status = 400, description = "Invalid request payload"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Event not found")
),
security(
("oidc" = [])
)
)]
pub async fn service_update_event(
State(state): State<AppState>,
_service_account: ServiceAccount,
Path(event_id): Path<i32>,
Json(payload): Json<ServiceUpdateEventRequest>,
) -> Result<Json<ServiceEventResponse>, StatusCode> {
let db = state.db;
let from = parse_datetime(&payload.from)?;
let to = parse_datetime(&payload.to)?;
let result = Event::update_many()
.col_expr(event::Column::Name, Expr::value(payload.name.clone()))
.col_expr(event::Column::From, Expr::value(from))
.col_expr(event::Column::To, Expr::value(to))
.filter(event::Column::Id.eq(event_id as i64))
.filter(event::Column::UserSub.eq(payload.user_sub.clone()))
.exec(&db)
.await
.map_err(|e| {
tracing::error!("Service failed to update event {}: {:?}", event_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if result.rows_affected == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(ServiceEventResponse {
id: event_id as i64,
user_sub: payload.user_sub,
name: payload.name,
from: Utc.from_utc_datetime(&from),
to: Utc.from_utc_datetime(&to),
}))
}
#[utoipa::path(
delete,
path = "/service/v1/events/{id}",
params(
("id" = i32, Path, description = "Event database id")
),
responses(
(status = 204, description = "Event deleted successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Event not found")
),
security(
("oidc" = [])
)
)]
pub async fn service_delete_event(
State(state): State<AppState>,
_service_account: ServiceAccount,
Path(event_id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
let db = state.db;
Event::delete_by_id(event_id as i64)
.exec(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tracing::info!("Service deleted event {}", event_id);
Ok(StatusCode::NO_CONTENT)
}

View file

@ -3,7 +3,6 @@ mod config;
mod db; mod db;
mod entities; mod entities;
mod handlers; mod handlers;
mod middleware;
mod routes; mod routes;
use crate::auth::{AppState, OidcClient}; use crate::auth::{AppState, OidcClient};
@ -48,9 +47,8 @@ async fn main() {
// Create router // Create router
let app = create_router(state) let app = create_router(state)
.fallback_service(tower_http::services::ServeDir::new("static")) .layer(tower_http::cors::CorsLayer::permissive())
.layer(axum::middleware::from_fn(middleware::log_errors)) .fallback_service(tower_http::services::ServeDir::new("static"));
.layer(tower_http::cors::CorsLayer::permissive());
// Start server // Start server
let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); let addr = SocketAddr::from(([127, 0, 0, 1], config.port));

View file

@ -1,20 +0,0 @@
use axum::{body::Body, extract::Request, middleware::Next, response::Response};
use tracing::error;
pub async fn log_errors(req: Request<Body>, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
if response.status().is_client_error() || response.status().is_server_error() {
error!(
method = ?method,
uri = ?uri,
status = ?response.status(),
"Error response returned"
);
}
response
}

View file

@ -2,46 +2,12 @@ use crate::auth::AppState;
use crate::handlers::{auth, event}; use crate::handlers::{auth, event};
use axum::{ use axum::{
Router, Router,
routing::{get, post, put}, routing::{get, post},
}; };
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::auth::me,
crate::handlers::event::create_event,
crate::handlers::event::list_events,
crate::handlers::event::get_event,
crate::handlers::event::update_event,
crate::handlers::event::delete_event,
crate::handlers::service::service_create_event,
crate::handlers::service::service_list_events,
crate::handlers::service::service_update_event,
crate::handlers::service::service_delete_event,
),
components(
schemas(
crate::auth::CurrentUser,
crate::entities::user::Model,
crate::entities::event::Model,
crate::handlers::event::CreateEventRequest,
crate::handlers::service::ServiceCreateEventRequest,
crate::handlers::service::ServiceEventResponse,
)
),
tags(
(name = "calendar", description = "Calendar Management API")
)
)]
struct ApiDoc;
pub fn create_router(state: AppState) -> Router { pub fn create_router(state: AppState) -> Router {
// Explicitly set the router state type to AppState // Explicitly set the router state type to AppState
Router::<AppState>::new() Router::<AppState>::new()
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
.route("/health", get(health_check)) .route("/health", get(health_check))
.route("/auth/login", get(auth::login)) .route("/auth/login", get(auth::login))
.route("/auth/callback", get(auth::callback)) .route("/auth/callback", get(auth::callback))
@ -55,17 +21,6 @@ pub fn create_router(state: AppState) -> Router {
.put(event::update_event) .put(event::update_event)
.delete(event::delete_event), .delete(event::delete_event),
) )
// Service endpoints
.route(
"/service/v1/events",
post(crate::handlers::service::service_create_event)
.get(crate::handlers::service::service_list_events),
)
.route(
"/service/v1/events/{id}",
put(crate::handlers::service::service_update_event)
.delete(crate::handlers::service::service_delete_event),
)
.with_state(state) .with_state(state)
} }