diff --git a/migrations/2025-03-16-185127_create_posts/up.sql b/migrations/2025-03-16-185127_create_posts/up.sql index 96f371d..3c67070 100644 --- a/migrations/2025-03-16-185127_create_posts/up.sql +++ b/migrations/2025-03-16-185127_create_posts/up.sql @@ -2,7 +2,11 @@ CREATE TABLE posts ( id SERIAL PRIMARY KEY, title VARCHAR NOT NULL, content TEXT NOT NULL, - author_id INTEGER NOT NULL + author_id INTEGER NOT NULL, + tags TEXT[] NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + last_updated_at INTEGER ); CREATE INDEX idx_author_id ON posts(author_id); diff --git a/src/database/models/post.rs b/src/database/models/post.rs index ba80839..7f763a1 100644 --- a/src/database/models/post.rs +++ b/src/database/models/post.rs @@ -1,12 +1,22 @@ -use serde::{Serialize, Deserialize}; +use actix_web::{web::Data, FromRequest}; +use serde::{Deserialize, Serialize}; + +use chrono::NaiveDateTime; use diesel::prelude::*; +use std::pin::Pin; + +use crate::{schema::posts::dsl::{id as schema_post_id, posts}, State}; #[derive(Queryable, Serialize, Deserialize)] pub struct Post { pub id: i32, pub title: String, pub content: String, - pub author_id: i32 + pub author_id: i32, + pub tags: Vec>, + pub created_at: NaiveDateTime, + pub updated_at: Option, + pub last_updated_by: Option } #[derive(Insertable, Serialize, Deserialize)] @@ -14,5 +24,62 @@ pub struct Post { pub struct NewPost { pub title: String, pub content: String, - pub author_id: i32 -} \ No newline at end of file + pub author_id: i32, + pub tags: Option>, +} + +#[derive(Serialize, Deserialize, AsChangeset)] +#[diesel(table_name = crate::schema::posts)] +pub struct EditPost { + pub title: String, + pub content: String, + pub tags: Option>, +} + +impl FromRequest for Post { + type Error = actix_web::error::Error; + type Future = Pin>>>; + + fn from_request( + req: &actix_web::HttpRequest, + _: &mut actix_web::dev::Payload, + ) -> Self::Future { + let req = req.clone(); + + Box::pin(async move { + let state = req.app_data::>(); + if state.is_none() { + return Err(actix_web::error::ErrorInternalServerError( + "cannot retrieve app state, therefore can not retrieve post data", + )); + } + + let state = state.unwrap(); + let mut conn = match state.db.get() { + Ok(conn) => conn, + Err(..) => Err(actix_web::error::ErrorInternalServerError( + "cannot retrieve database connection from the pool", + ))?, + }; + + let post_id = actix_web::web::Path::::extract(&req).await; + match post_id { + Ok(id) => { + let post = posts + .filter(schema_post_id.eq(id.into_inner())) + .first::(&mut conn) + .optional(); + + match post { + Ok(Some(post)) => Ok(post), + Ok(None) => Err(actix_web::error::ErrorBadRequest( + "post with this ID is not found", + )), + Err(why) => Err(actix_web::error::ErrorInternalServerError(why)), + } + } + Err(why) => Err(actix_web::error::ErrorInternalServerError(why)), + } + }) + } +} diff --git a/src/database/models/user.rs b/src/database/models/user.rs index 7b60a19..7978ce6 100644 --- a/src/database/models/user.rs +++ b/src/database/models/user.rs @@ -3,7 +3,7 @@ use serde::{Serialize, Deserialize}; use chrono::NaiveDateTime; use diesel::prelude::*; -#[derive(Queryable, Serialize, Deserialize)] +#[derive(Queryable, Serialize, Deserialize, Debug)] pub struct User { pub id: i32, pub username: String, diff --git a/src/schema.rs b/src/schema.rs index 78e1bd1..f3515ad 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -6,6 +6,10 @@ diesel::table! { title -> Varchar, content -> Text, author_id -> Int4, + tags -> Array>, + created_at -> Timestamp, + updated_at -> Nullable, + last_updated_by -> Nullable, } } diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index cb9ac5f..6acbbdc 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -1,15 +1,18 @@ use actix_web::{ - error::{ErrorBadRequest, ErrorInternalServerError}, - web::Data, HttpRequest, dev::Payload, - FromRequest, Error + dev::Payload, + error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound}, + web::Data, + Error, FromRequest, HttpRequest, }; -use jsonwebtoken::{Header, EncodingKey, DecodingKey, Validation}; -use serde::{Serialize, Deserialize}; -use std::future::{self, ready}; use chrono::Utc; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::future::{self, ready}; -use crate::State; +use crate::schema::users::dsl::{id as schema_user_id, users}; +use crate::{database::models::user::User, State}; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; static WEEK: i64 = 60 * 60 * 24 * 7; @@ -27,12 +30,13 @@ impl Claims { exp: (now + WEEK) as usize, user_id, }; - + jsonwebtoken::encode( - &Header::default(), - &payload, - &EncodingKey::from_secret(secret.as_ref()) - ).unwrap() + &Header::default(), + &payload, + &EncodingKey::from_secret(secret.as_ref()), + ) + .unwrap() } } @@ -40,6 +44,23 @@ impl Claims { pub struct AuthorizedUser { pub token: String, pub user_id: i32, + pub user: User, +} + +fn get_user(state: &Data, user_id: i32) -> Option { + let mut conn = match state.db.get() { + Ok(conn) => conn, + Err(..) => None?, + }; + + let user = users + .filter(schema_user_id.eq(user_id)) + .first::(&mut conn); + + match user { + Ok(user) => Some(user), + Err(..) => None, + } } impl FromRequest for AuthorizedUser { @@ -50,11 +71,11 @@ impl FromRequest for AuthorizedUser { let state = req.app_data::>(); let header = req.headers().get("Authorization"); if header.is_none() { - return future::ready(Err(ErrorBadRequest("no Authorization header specified"))) + return future::ready(Err(ErrorBadRequest("no Authorization header specified"))); } if state.is_none() { - return future::ready(Err(ErrorInternalServerError("cannot retrieve JWT token"))) + return future::ready(Err(ErrorInternalServerError("cannot retrieve JWT token"))); } let state = state.unwrap(); @@ -67,9 +88,9 @@ impl FromRequest for AuthorizedUser { } let claims = jsonwebtoken::decode::( - parts[1], - &DecodingKey::from_secret(state.config.jwt_secret.as_ref()), - &Validation::default() + parts[1], + &DecodingKey::from_secret(state.config.jwt_secret.as_ref()), + &Validation::default(), ); if claims.is_err() { @@ -77,14 +98,24 @@ impl FromRequest for AuthorizedUser { } let claims = claims.unwrap().claims; - let authorized_user = AuthorizedUser { - user_id: claims.user_id, - token: v.to_string() - }; + let user = get_user(state, claims.user_id); - ready(Ok(authorized_user)) + match user { + Some(user) => { + let authorized_user = AuthorizedUser { + user_id: claims.user_id, + token: v.to_string(), + user, + }; + + ready(Ok(authorized_user)) + } + None => ready(Err(ErrorNotFound( + "invalid login credentials, user not found", + ))), + } } Err(e) => ready(Err(ErrorBadRequest(e))), } } -} \ No newline at end of file +} diff --git a/src/v1/posts.rs b/src/v1/posts.rs index 50609ad..097e439 100644 --- a/src/v1/posts.rs +++ b/src/v1/posts.rs @@ -1,19 +1,17 @@ use actix_web::{ get, http::StatusCode, - put, + patch, put, web::{scope, Data, Json, ServiceConfig}, HttpResponse, Responder, }; use diesel::{insert_into, ExpressionMethods, QueryDsl, RunQueryDsl}; -use crate::schema::posts::dsl::*; -use crate::schema::users::dsl::{id as user_id, users}; use crate::utils::response::failure; use crate::State; +use crate::{database::models::post::EditPost, schema::posts::dsl as posts_dsl}; use crate::{ database::models::post::{NewPost, Post}, - database::models::user::User, utils::jwt::AuthorizedUser, }; @@ -27,7 +25,7 @@ async fn get(state: Data) -> impl Responder { } }; - match posts.load::(&mut conn) { + match posts_dsl::posts.load::(&mut conn) { Ok(data) => HttpResponse::Ok().json(data), Err(why) => HttpResponse::InternalServerError().body(why.to_string()), } @@ -37,7 +35,7 @@ async fn get(state: Data) -> impl Responder { async fn create_post( state: Data, auth: AuthorizedUser, - mut post: Json, + post: Json, ) -> impl Responder { let mut conn = match state.db.get() { Ok(conn) => conn, @@ -47,18 +45,39 @@ async fn create_post( } }; - let user = users - .filter(user_id.eq(auth.user_id)) - .first::(&mut conn); - - if user.is_err() { - return HttpResponse::InternalServerError() - .json(failure("cannot retrieve user from JWT token. sorry.")); - } else { - post.author_id = user.unwrap().id; + match insert_into(posts_dsl::posts) + .values((post.0, posts_dsl::author_id.eq(auth.user_id))) + .execute(&mut conn) + { + Ok(_) => HttpResponse::new(StatusCode::NO_CONTENT), + Err(why) => HttpResponse::InternalServerError().json(failure(why.to_string())), } +} - match insert_into(posts).values(post.0).execute(&mut conn) { +#[patch("/{id}")] +async fn update_post( + state: Data, + auth: AuthorizedUser, + post: Post, + new_post: Json, +) -> impl Responder { + let mut conn = match state.db.get() { + Ok(conn) => conn, + Err(why) => { + return HttpResponse::InternalServerError() + .body(format!("database connection error: {}", why)) + } + }; + + // TODO: Security, anti-abuse checks + match diesel::update(posts_dsl::posts.filter(posts_dsl::id.eq(post.id))) + .set(( + new_post.0, + posts_dsl::updated_at.eq(diesel::dsl::now), + posts_dsl::last_updated_by.eq(auth.user_id), + )) + .execute(&mut conn) + { Ok(_) => HttpResponse::new(StatusCode::NO_CONTENT), Err(why) => HttpResponse::InternalServerError().json(failure(why.to_string())), }