diff --git a/thoughts-backend/api/src/extractor/mod.rs b/thoughts-backend/api/src/extractor/mod.rs index d8fac17..05b32f7 100644 --- a/thoughts-backend/api/src/extractor/mod.rs +++ b/thoughts-backend/api/src/extractor/mod.rs @@ -1,8 +1,10 @@ mod auth; mod json; +mod optional_auth; mod valid; pub use auth::AuthUser; pub use auth::Claims; pub use json::Json; +pub use optional_auth::OptionalAuthUser; pub use valid::Valid; diff --git a/thoughts-backend/api/src/extractor/optional_auth.rs b/thoughts-backend/api/src/extractor/optional_auth.rs new file mode 100644 index 0000000..faa4989 --- /dev/null +++ b/thoughts-backend/api/src/extractor/optional_auth.rs @@ -0,0 +1,21 @@ +use super::AuthUser; +use crate::error::ApiError; +use app::state::AppState; +use axum::{extract::FromRequestParts, http::request::Parts}; + +pub struct OptionalAuthUser(pub Option); + +impl FromRequestParts for OptionalAuthUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + match AuthUser::from_request_parts(parts, state).await { + Ok(user) => Ok(OptionalAuthUser(Some(user))), + // If the user is not authenticated for any reason, we just treat them as a guest. + Err(_) => Ok(OptionalAuthUser(None)), + } + } +} diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs index a92d03a..b3bcf51 100644 --- a/thoughts-backend/api/src/routers/feed.rs +++ b/thoughts-backend/api/src/routers/feed.rs @@ -24,9 +24,11 @@ async fn feed_get( auth_user: AuthUser, ) -> Result { let following_ids = get_following_ids(&state.conn, auth_user.id).await?; - let mut thoughts_with_authors = get_feed_for_user(&state.conn, following_ids).await?; + let mut thoughts_with_authors = + get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?; - let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?; + let own_thoughts = + get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?; thoughts_with_authors.extend(own_thoughts); let thoughts_schema: Vec = thoughts_with_authors diff --git a/thoughts-backend/api/src/routers/tag.rs b/thoughts-backend/api/src/routers/tag.rs index 2f47f16..65ae3de 100644 --- a/thoughts-backend/api/src/routers/tag.rs +++ b/thoughts-backend/api/src/routers/tag.rs @@ -1,4 +1,4 @@ -use crate::error::ApiError; +use crate::{error::ApiError, extractor::OptionalAuthUser}; use app::{ persistence::{tag, thought::get_thoughts_by_tag_name}, state::AppState, @@ -20,8 +20,10 @@ use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; async fn get_thoughts_by_tag( State(state): State, Path(tag_name): Path, + viewer: OptionalAuthUser, ) -> Result { - let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await; + let thoughts_with_authors = + get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await; let thoughts_with_authors = thoughts_with_authors?; let thoughts_schema: Vec = thoughts_with_authors .into_iter() diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 81b560d..eaded96 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -19,8 +19,8 @@ use models::schemas::user::{UserListSchema, UserSchema}; use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; -use crate::models::ApiErrorResponse; use crate::{error::ApiError, extractor::AuthUser}; +use crate::{extractor::OptionalAuthUser, models::ApiErrorResponse}; use crate::{ extractor::{Json, Valid}, routers::api_key::create_api_key_router, @@ -63,12 +63,14 @@ async fn users_get( async fn user_thoughts_get( State(state): State, Path(username): Path, + viewer: OptionalAuthUser, ) -> Result { let user = get_user_by_username(&state.conn, &username) .await? .ok_or(UserError::NotFound)?; - let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?; + let thoughts_with_authors = + get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?; let thoughts_schema: Vec = thoughts_with_authors .into_iter() @@ -272,12 +274,13 @@ async fn get_user_by_param( async fn user_outbox_get( State(state): State, Path(username): Path, + viewer: OptionalAuthUser, ) -> Result { let user = get_user_by_username(&state.conn, &username) .await? .ok_or(UserError::NotFound)?; - let thoughts = get_thoughts_by_user(&state.conn, user.id).await?; + let thoughts = get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?; // Format the outbox as an ActivityPub OrderedCollection let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username); diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs index fd95ed8..cfca09e 100644 --- a/thoughts-backend/app/src/persistence/follow.rs +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -76,3 +76,16 @@ pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result, D .await?; Ok(followers.into_iter().map(|f| f.follower_id).collect()) } + +pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result, DbErr> { + let following = get_following_ids(db, user_id).await?; + let followers = get_follower_ids(db, user_id).await?; + + let following_set: std::collections::HashSet = following.into_iter().collect(); + let followers_set: std::collections::HashSet = followers.into_iter().collect(); + + Ok(following_set + .intersection(&followers_set) + .cloned() + .collect()) +} diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 84b0e35..7b610e0 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -1,6 +1,7 @@ use sea_orm::{ - prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, - QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait, + prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr, + EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, + TransactionTrait, }; use models::{ @@ -11,7 +12,10 @@ use models::{ use crate::{ error::UserError, - persistence::tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags}, + persistence::{ + follow, + tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags}, + }, }; pub async fn create_thought( @@ -25,6 +29,7 @@ pub async fn create_thought( author_id: Set(author_id), content: Set(params.content.clone()), reply_to_id: Set(params.reply_to_id), + visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)), ..Default::default() } .insert(&txn) @@ -52,7 +57,13 @@ pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> pub async fn get_thoughts_by_user( db: &DbConn, user_id: Uuid, + viewer_id: Option, ) -> Result, DbErr> { + let mut friend_ids = vec![]; + if let Some(viewer) = viewer_id { + friend_ids = follow::get_friend_ids(db, viewer).await?; + } + thought::Entity::find() .select_only() .column(thought::Column::Id) @@ -60,8 +71,10 @@ pub async fn get_thoughts_by_user( .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) .column(thought::Column::AuthorId) + .column(thought::Column::Visibility) .column_as(user::Column::Username, "author_username") .join(JoinType::InnerJoin, thought::Relation::User.def()) + .filter(apply_visibility_filter(user_id, viewer_id, &friend_ids)) .filter(thought::Column::AuthorId.eq(user_id)) .order_by_desc(thought::Column::CreatedAt) .into_model::() @@ -72,20 +85,37 @@ pub async fn get_thoughts_by_user( pub async fn get_feed_for_user( db: &DbConn, following_ids: Vec, + viewer_id: Option, ) -> Result, UserError> { if following_ids.is_empty() { return Ok(vec![]); } + let mut friend_ids = vec![]; + if let Some(viewer) = viewer_id { + friend_ids = follow::get_friend_ids(db, viewer) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + } + thought::Entity::find() .select_only() .column(thought::Column::Id) .column(thought::Column::Content) .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) + .column(thought::Column::Visibility) .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") .join(JoinType::InnerJoin, thought::Relation::User.def()) + .filter( + Condition::any().add(following_ids.iter().fold( + Condition::all(), + |cond, &author_id| { + cond.add(apply_visibility_filter(author_id, viewer_id, &friend_ids)) + }, + )), + ) .filter(thought::Column::AuthorId.is_in(following_ids)) .order_by_desc(thought::Column::CreatedAt) .into_model::() @@ -97,14 +127,21 @@ pub async fn get_feed_for_user( pub async fn get_thoughts_by_tag_name( db: &DbConn, tag_name: &str, + viewer_id: Option, ) -> Result, DbErr> { - thought::Entity::find() + let mut friend_ids = Vec::new(); + if let Some(viewer) = viewer_id { + friend_ids = follow::get_friend_ids(db, viewer).await?; + } + + let thoughts = thought::Entity::find() .select_only() .column(thought::Column::Id) .column(thought::Column::Content) .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) .column(thought::Column::AuthorId) + .column(thought::Column::Visibility) .column_as(user::Column::Username, "author_username") .join(JoinType::InnerJoin, thought::Relation::User.def()) .join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def()) @@ -113,5 +150,49 @@ pub async fn get_thoughts_by_tag_name( .order_by_desc(thought::Column::CreatedAt) .into_model::() .all(db) - .await + .await?; + + let visible_thoughts = thoughts + .into_iter() + .filter(|thought| { + let mut condition = thought.visibility == thought::Visibility::Public; + if let Some(viewer) = viewer_id { + if thought.author_id == viewer { + condition = true; + } + if thought.visibility == thought::Visibility::FriendsOnly + && friend_ids.contains(&thought.author_id) + { + condition = true; + } + } + condition + }) + .collect(); + + Ok(visible_thoughts) +} + +fn apply_visibility_filter( + user_id: Uuid, + viewer_id: Option, + friend_ids: &[Uuid], +) -> SimpleExpr { + let mut condition = + Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public)); + + if let Some(viewer) = viewer_id { + // Viewers can see their own thoughts of any visibility + if user_id == viewer { + condition = condition + .add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)) + .add(thought::Column::Visibility.eq(thought::Visibility::Private)); + } + // If the thought's author is a friend of the viewer, they can see it + else if !friend_ids.is_empty() && friend_ids.contains(&user_id) { + condition = + condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)); + } + } + condition.into() } diff --git a/thoughts-backend/migration/src/m20250906_145755_add_visibility_to_thoughts.rs b/thoughts-backend/migration/src/m20250906_145755_add_visibility_to_thoughts.rs new file mode 100644 index 0000000..edefdff --- /dev/null +++ b/thoughts-backend/migration/src/m20250906_145755_add_visibility_to_thoughts.rs @@ -0,0 +1,59 @@ +use super::m20250905_000001_init::Thought; +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> { + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE thought_visibility AS ENUM ('public', 'friends_only', 'private')", + ) + .await?; + + // 2. Add the new column to the thoughts table + manager + .alter_table( + Table::alter() + .table(Thought::Table) + .add_column( + ColumnDef::new(ThoughtExtension::Visibility) + .enumeration( + "thought_visibility", + ["public", "friends_only", "private"], + ) + .not_null() + .default("public"), // Default new thoughts to public + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Thought::Table) + .drop_column(ThoughtExtension::Visibility) + .to_owned(), + ) + .await?; + + // Drop the ENUM type + manager + .get_connection() + .execute_unprepared("DROP TYPE thought_visibility") + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum ThoughtExtension { + Visibility, +} diff --git a/thoughts-backend/models/src/domains/thought.rs b/thoughts-backend/models/src/domains/thought.rs index f0c9f84..cdae5b9 100644 --- a/thoughts-backend/models/src/domains/thought.rs +++ b/thoughts-backend/models/src/domains/thought.rs @@ -1,4 +1,19 @@ use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "thought_visibility")] +pub enum Visibility { + #[sea_orm(string_value = "public")] + Public, + #[sea_orm(string_value = "friends_only")] + FriendsOnly, + #[sea_orm(string_value = "private")] + Private, +} #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "thought")] @@ -8,6 +23,7 @@ pub struct Model { pub author_id: Uuid, pub content: String, pub reply_to_id: Option, + pub visibility: Visibility, pub created_at: DateTimeWithTimeZone, } diff --git a/thoughts-backend/models/src/params/thought.rs b/thoughts-backend/models/src/params/thought.rs index 495f9e0..6037cb8 100644 --- a/thoughts-backend/models/src/params/thought.rs +++ b/thoughts-backend/models/src/params/thought.rs @@ -3,6 +3,8 @@ use utoipa::ToSchema; use uuid::Uuid; use validator::Validate; +use crate::domains::thought::Visibility; + #[derive(Deserialize, Validate, ToSchema)] pub struct CreateThoughtParams { #[validate(length( @@ -11,6 +13,6 @@ pub struct CreateThoughtParams { message = "Content must be between 1 and 128 characters" ))] pub content: String, - + pub visibility: Option, pub reply_to_id: Option, } diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index e8c74ca..c672996 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -1,4 +1,7 @@ -use crate::domains::{thought, user}; +use crate::domains::{ + thought::{self, Visibility}, + user, +}; use common::DateTimeWithTimeZoneWrapper; use sea_orm::FromQueryResult; use serde::Serialize; @@ -12,6 +15,7 @@ pub struct ThoughtSchema { pub author_username: String, #[schema(example = "This is my first thought! #welcome")] pub content: String, + pub visibility: Visibility, pub reply_to_id: Option, pub created_at: DateTimeWithTimeZoneWrapper, } @@ -22,6 +26,7 @@ impl ThoughtSchema { id: thought.id, author_username: author.username.clone(), content: thought.content.clone(), + visibility: thought.visibility.clone(), reply_to_id: thought.reply_to_id, created_at: thought.created_at.into(), } @@ -44,6 +49,7 @@ pub struct ThoughtWithAuthor { pub id: Uuid, pub content: String, pub created_at: sea_orm::prelude::DateTimeWithTimeZone, + pub visibility: Visibility, pub author_id: Uuid, pub author_username: String, pub reply_to_id: Option, @@ -57,6 +63,7 @@ impl From for ThoughtSchema { content: model.content, created_at: model.created_at.into(), reply_to_id: model.reply_to_id, + visibility: model.visibility, } } } diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index f22fa57..3bb436d 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -1,11 +1,12 @@ -use crate::api::main::create_user_with_password; +use crate::api::main::{create_user_with_password, login_user}; use super::main::setup; -use axum::http::StatusCode; +use app::persistence::follow; +use axum::{http::StatusCode, Router}; use http_body_util::BodyExt; use sea_orm::prelude::Uuid; use serde_json::{json, Value}; -use utils::testing::{make_delete_request, make_post_request}; +use utils::testing::{make_delete_request, make_get_request, make_jwt_request, make_post_request}; #[tokio::test] async fn test_thought_endpoints() { @@ -81,3 +82,84 @@ async fn test_thought_replies() { assert_eq!(reply_thought["reply_to_id"], original_thought_id); assert_eq!(reply_thought["author_username"], "user2"); } + +#[tokio::test] +async fn test_thought_visibility() { + let app = setup().await; + let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await; + let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await; + let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await; + + // Make author and friend follow each other + follow::follow_user(&app.db, author.id, friend.id) + .await + .unwrap(); + follow::follow_user(&app.db, friend.id, author.id) + .await + .unwrap(); + + let author_jwt = login_user(app.router.clone(), "author", "password123").await; + let friend_jwt = login_user(app.router.clone(), "friend", "password123").await; + let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await; + + // Author posts one of each visibility + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(json!({"content": "public", "visibility": "Public"}).to_string()), + &author_jwt, + ) + .await; + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(json!({"content": "friends", "visibility": "FriendsOnly"}).to_string()), + &author_jwt, + ) + .await; + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(json!({"content": "private", "visibility": "Private"}).to_string()), + &author_jwt, + ) + .await; + + // Helper to get thoughts and count them + async fn get_thought_count(router: Router, jwt: Option<&str>) -> usize { + let response = if let Some(token) = jwt { + make_jwt_request(router, "/users/author/thoughts", "GET", None, token).await + } else { + make_get_request(router, "/users/author/thoughts", None).await + }; + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + + v["thoughts"].as_array().unwrap().len() + } + + // Assertions + assert_eq!( + get_thought_count(app.router.clone(), Some(&author_jwt)).await, + 3, + "Author should see all their posts" + ); + assert_eq!( + get_thought_count(app.router.clone(), Some(&friend_jwt)).await, + 2, + "Friend should see public and friends_only posts" + ); + assert_eq!( + get_thought_count(app.router.clone(), Some(&stranger_jwt)).await, + 1, + "Stranger should see only public posts" + ); + assert_eq!( + get_thought_count(app.router.clone(), None).await, + 1, + "Unauthenticated guest should see only public posts" + ); +}