Compare commits

...

3 Commits

18 changed files with 437 additions and 18 deletions

View File

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

View File

@@ -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<AuthUser>);
impl FromRequestParts<AppState> for OptionalAuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
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)),
}
}
}

View File

@@ -24,9 +24,11 @@ async fn feed_get(
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
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<ThoughtSchema> = thoughts_with_authors

View File

@@ -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<AppState>,
Path(tag_name): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
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<ThoughtSchema> = thoughts_with_authors
.into_iter()

View File

@@ -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<AppState>,
Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
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<ThoughtSchema> = thoughts_with_authors
.into_iter()
@@ -272,12 +274,13 @@ async fn get_user_by_param(
async fn user_outbox_get(
State(state): State<AppState>,
Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
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);

View File

@@ -76,3 +76,16 @@ pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, D
.await?;
Ok(followers.into_iter().map(|f| f.follower_id).collect())
}
pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, 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<Uuid> = following.into_iter().collect();
let followers_set: std::collections::HashSet<Uuid> = followers.into_iter().collect();
Ok(following_set
.intersection(&followers_set)
.cloned()
.collect())
}

View File

@@ -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(
@@ -24,6 +28,8 @@ pub async fn create_thought(
let new_thought = thought::ActiveModel {
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)
@@ -51,15 +57,24 @@ 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<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, 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)
.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())
.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::<ThoughtWithAuthor>()
@@ -70,19 +85,37 @@ pub async fn get_thoughts_by_user(
pub async fn get_feed_for_user(
db: &DbConn,
following_ids: Vec<Uuid>,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, 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::<ThoughtWithAuthor>()
@@ -94,13 +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<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, 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())
@@ -109,5 +150,49 @@ pub async fn get_thoughts_by_tag_name(
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.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<Uuid>,
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()
}

View File

@@ -0,0 +1,16 @@
use api::{models::ApiErrorResponse, routers::api_key::*};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse, ApiKeySchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_keys, create_key, delete_key),
components(schemas(
ApiKeySchema,
ApiKeyListSchema,
ApiKeyRequest,
ApiKeyResponse,
ApiErrorResponse,
))
)]
pub(super) struct ApiKeyApi;

View File

@@ -6,9 +6,11 @@ use utoipa::{
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi;
mod api_key;
mod auth;
mod feed;
mod root;
mod tag;
mod thought;
mod user;
@@ -18,8 +20,10 @@ mod user;
(path = "/", api = root::RootApi),
(path = "/auth", api = auth::AuthApi),
(path = "/users", api = user::UserApi),
(path = "/users/me/api-keys", api = api_key::ApiKeyApi),
(path = "/thoughts", api = thought::ThoughtApi),
(path = "/feed", api = feed::FeedApi),
(path = "/tags", api = tag::TagApi),
),
tags(
(name = "root", description = "Root API"),
@@ -27,6 +31,7 @@ mod user;
(name = "user", description = "User & Social API"),
(name = "thought", description = "Thoughts API"),
(name = "feed", description = "Feed API"),
(name = "tag", description = "Tag Discovery API"),
),
modifiers(&SecurityAddon),
)]

View File

@@ -0,0 +1,12 @@
// in thoughts-backend/doc/src/tag.rs
use api::{models::ApiErrorResponse, routers::tag::*};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_thoughts_by_tag, get_popular_tags),
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
)]
pub(super) struct TagApi;

View File

@@ -2,7 +2,7 @@ use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::user::*;
use models::params::user::CreateUserParams;
use models::params::user::{CreateUserParams, UpdateUserParams};
use models::schemas::{
thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema},
@@ -24,6 +24,7 @@ use models::schemas::{
components(schemas(
CreateUserParams,
UserListSchema,
UpdateUserParams,
UserSchema,
ThoughtSchema,
ThoughtListSchema,

View File

@@ -5,6 +5,8 @@ mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
mod m20250906_145148_add_reply_to_thoughts;
mod m20250906_145755_add_visibility_to_thoughts;
pub struct Migrator;
@@ -17,6 +19,8 @@ impl MigratorTrait for Migrator {
Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration),
Box::new(m20250906_134056_add_api_keys::Migration),
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
]
}
}

View File

@@ -0,0 +1,46 @@
use sea_orm_migration::{prelude::*, schema::*};
use crate::m20250905_000001_init::Thought;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.add_column(uuid_null(ThoughtExtension::ReplyToId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_thought_reply_to_id")
.from_tbl(Thought::Table)
.from_col(ThoughtExtension::ReplyToId)
.to_tbl(Thought::Table)
.to_col(Thought::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.drop_foreign_key(Alias::new("fk_thought_reply_to_id"))
.drop_column(ThoughtExtension::ReplyToId)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum ThoughtExtension {
ReplyToId,
}

View File

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

View File

@@ -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")]
@@ -7,6 +22,8 @@ pub struct Model {
pub id: Uuid,
pub author_id: Uuid,
pub content: String,
pub reply_to_id: Option<Uuid>,
pub visibility: Visibility,
pub created_at: DateTimeWithTimeZone,
}

View File

@@ -1,7 +1,10 @@
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
use crate::domains::thought::Visibility;
#[derive(Deserialize, Validate, ToSchema)]
pub struct CreateThoughtParams {
#[validate(length(
@@ -10,4 +13,6 @@ pub struct CreateThoughtParams {
message = "Content must be between 1 and 128 characters"
))]
pub content: String,
pub visibility: Option<Visibility>,
pub reply_to_id: Option<Uuid>,
}

View File

@@ -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,8 @@ 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<Uuid>,
pub created_at: DateTimeWithTimeZoneWrapper,
}
@@ -21,6 +26,8 @@ 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(),
}
}
@@ -42,8 +49,10 @@ 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<Uuid>,
}
impl From<ThoughtWithAuthor> for ThoughtSchema {
@@ -53,6 +62,8 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
author_username: model.author_username,
content: model.content,
created_at: model.created_at.into(),
reply_to_id: model.reply_to_id,
visibility: model.visibility,
}
}
}

View File

@@ -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;
use utils::testing::{make_delete_request, make_post_request};
use serde_json::{json, Value};
use utils::testing::{make_delete_request, make_get_request, make_jwt_request, make_post_request};
#[tokio::test]
async fn test_thought_endpoints() {
@@ -48,3 +49,117 @@ async fn test_thought_endpoints() {
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn test_thought_replies() {
let app = setup().await;
let user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
let user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
// 1. User 1 posts an original thought
let body = json!({ "content": "This is the original post!" }).to_string();
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let original_thought: Value = serde_json::from_slice(&body).unwrap();
let original_thought_id = original_thought["id"].as_str().unwrap();
// 2. User 2 replies to the original thought
let reply_body = json!({
"content": "This is a reply.",
"reply_to_id": original_thought_id
})
.to_string();
let response =
make_post_request(app.router.clone(), "/thoughts", reply_body, Some(user2.id)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let reply_thought: Value = serde_json::from_slice(&body).unwrap();
// 3. Verify the reply is linked correctly
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"
);
}