Compare commits

...

2 Commits

38 changed files with 734 additions and 73 deletions

View File

@@ -357,7 +357,9 @@ name = "app"
version = "0.1.0"
dependencies = [
"bcrypt",
"chrono",
"models",
"rand 0.8.5",
"sea-orm",
"validator",
]

View File

@@ -8,7 +8,7 @@ use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use app::state::AppState;
use app::{persistence::api_key, state::AppState};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
@@ -28,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// --- Test User ID (Keep for testing) ---
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
let user_id_str = user_id_header.to_str().unwrap_or("0");
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
return Ok(AuthUser { id: user_id });
}
// --- API Key Authentication ---
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
return match api_key::validate_api_key(&state.conn, &api_key).await {
Ok(user) => Ok(AuthUser { id: user.id }),
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
};
}
// --- JWT Authentication (Fallback) ---
let token = get_token_from_header(&parts.headers)
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
@@ -56,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
.and_then(|header| header.strip_prefix("Bearer "))
.map(|token| token.to_owned())
}
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.and_then(|header| header.strip_prefix("ApiKey "))
.map(|key| key.to_owned())
}

View File

@@ -0,0 +1,93 @@
use crate::{
error::ApiError,
extractor::{AuthUser, Json},
models::ApiErrorResponse,
};
use app::{persistence::api_key, state::AppState};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get},
Router,
};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
use sea_orm::prelude::Uuid;
#[utoipa::path(
get,
path = "/me/api-keys",
responses(
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn get_keys(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
Ok(Json(ApiKeyListSchema::from(keys)))
}
#[utoipa::path(
post,
path = "/me/api-keys",
request_body = ApiKeyRequest,
responses(
(status = 201, description = "API key created", body = ApiKeyResponse),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn create_key(
State(state): State<AppState>,
auth_user: AuthUser,
Json(params): Json<ApiKeyRequest>,
) -> Result<impl IntoResponse, ApiError> {
let (key_model, plaintext_key) =
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
delete,
path = "/me/api-keys/{key_id}",
responses(
(status = 204, description = "API key deleted"),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 404, description = "API key not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
params(
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
),
security(
("bearerAuth" = [])
)
)]
async fn delete_key(
State(state): State<AppState>,
auth_user: AuthUser,
Path(key_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn create_api_key_router() -> Router<AppState> {
Router::new()
.route("/", get(get_keys).post(create_key))
.route("/{key_id}", delete(delete_key))
}

View File

@@ -1,7 +1,7 @@
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use app::{
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
persistence::{follow::get_following_ids, thought::get_feed_for_user},
state::AppState,
};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
@@ -23,8 +23,8 @@ async fn feed_get(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
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 own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
thoughts_with_authors.extend(own_thoughts);

View File

@@ -1,5 +1,6 @@
use axum::Router;
pub mod api_key;
pub mod auth;
pub mod feed;
pub mod root;

View File

@@ -1,5 +1,8 @@
use crate::error::ApiError;
use app::{persistence::thought::get_thoughts_by_tag_name, state::AppState};
use app::{
persistence::{tag, thought::get_thoughts_by_tag_name},
state::AppState,
};
use axum::{
extract::{Path, State},
response::IntoResponse,
@@ -19,20 +22,28 @@ async fn get_thoughts_by_tag(
Path(tag_name): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await;
println!(
"Result from get_thoughts_by_tag_name: {:?}",
thoughts_with_authors
);
let thoughts_with_authors = thoughts_with_authors?;
println!("Thoughts with authors: {:?}", thoughts_with_authors);
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
println!("Thoughts schema: {:?}", thoughts_schema);
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
pub fn create_tag_router() -> Router<AppState> {
Router::new().route("/{tag_name}", get(get_thoughts_by_tag))
#[utoipa::path(
get,
path = "/popular",
responses((status = 200, description = "List of popular tags", body = Vec<String>))
)]
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let tags = tag::get_popular_tags(&state.conn).await;
println!("Fetched popular tags: {:?}", tags);
let tags = tags?;
Ok(Json(tags))
}
pub fn create_tag_router() -> Router<AppState> {
Router::new()
.route("/{tag_name}", get(get_thoughts_by_tag))
.route("/popular", get(get_popular_tags))
}

View File

@@ -19,9 +19,12 @@ use models::schemas::user::{UserListSchema, UserSchema};
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::extractor::{Json, Valid};
use crate::models::ApiErrorResponse;
use crate::{error::ApiError, extractor::AuthUser};
use crate::{
extractor::{Json, Valid},
routers::api_key::create_api_key_router,
};
#[utoipa::path(
get,
@@ -245,7 +248,12 @@ async fn get_user_by_param(
}
} else {
match get_user_by_username(&state.conn, &username).await {
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
Ok(Some(user)) => {
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
.await
.unwrap_or_default();
Json(UserSchema::from((user, top_friends))).into_response()
}
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
Err(e) => ApiError::from(e).into_response(),
}
@@ -329,7 +337,9 @@ async fn get_me(
let user = get_user(&state.conn, auth_user.id)
.await?
.ok_or(UserError::NotFound)?;
Ok(axum::Json(UserSchema::from(user)))
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
Ok(axum::Json(UserSchema::from((user, top_friends))))
}
#[utoipa::path(
@@ -358,6 +368,7 @@ pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", get(users_get))
.route("/me", get(get_me).put(update_me))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param))
.route("/{username}/thoughts", get(user_thoughts_get))
.route(

View File

@@ -12,4 +12,6 @@ path = "src/lib.rs"
bcrypt = "0.17.1"
models = { path = "../models" }
validator = "0.20"
rand = "0.8.5"
sea-orm = { version = "1.1.12" }
chrono = { workspace = true }

View File

@@ -0,0 +1,93 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use models::domains::{api_key, user};
use rand::distributions::{Alphanumeric, DistString};
use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
};
use crate::error::UserError;
const KEY_PREFIX: &str = "th_";
const KEY_RANDOM_LENGTH: usize = 32;
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
fn generate_key() -> String {
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
format!("{}{}", KEY_PREFIX, random_part)
}
pub async fn create_api_key(
db: &DbConn,
user_id: Uuid,
name: String,
) -> Result<(api_key::Model, String), UserError> {
let plaintext_key = generate_key();
let key_hash =
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
let new_key = api_key::ActiveModel {
user_id: Set(user_id),
name: Set(name),
key_hash: Set(key_hash),
key_prefix: Set(key_prefix),
..Default::default()
}
.insert(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok((new_key, plaintext_key))
}
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
if !plaintext_key.starts_with(KEY_PREFIX)
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
{
return Err(UserError::Validation("Invalid API key format".to_string()));
}
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
let candidate_keys = api_key::Entity::find()
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
for key in candidate_keys {
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
return super::user::get_user(db, key.user_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound);
}
}
Err(UserError::Validation("Invalid API key".to_string()))
}
pub async fn get_api_keys_for_user(
db: &DbConn,
user_id: Uuid,
) -> Result<Vec<api_key::Model>, DbErr> {
api_key::Entity::find()
.filter(api_key::Column::UserId.eq(user_id))
.all(db)
.await
}
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
let result = api_key::Entity::delete_many()
.filter(api_key::Column::Id.eq(key_id))
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if result.rows_affected == 0 {
Err(UserError::NotFound)
} else {
Ok(())
}
}

View File

@@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result<String, BcryptError> {
}
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
// Validate the parameters
params
.validate()
.map_err(|e| UserError::Validation(e.to_string()))?;
@@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::
hash_password(&params.password).map_err(|e| UserError::Internal(e.to_string()))?;
let new_user = user::ActiveModel {
username: Set(params.username),
username: Set(params.username.clone()),
password_hash: Set(Some(hashed_password)),
email: Set(Some(params.email)),
display_name: Set(Some(params.username)),
..Default::default()
};

View File

@@ -7,7 +7,7 @@ use models::domains::follow;
pub async fn add_follower(
db: &DbConn,
followed_id: Uuid,
following_id: Uuid,
follower_actor_id: &str,
) -> Result<(), UserError> {
let follower_username = follower_actor_id
@@ -20,21 +20,21 @@ pub async fn add_follower(
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?;
follow_user(db, follower.id, followed_id)
follow_user(db, follower.id, following_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok(())
}
pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> {
if follower_id == followed_id {
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
if follower_id == following_id {
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
}
let follow = follow::ActiveModel {
follower_id: Set(follower_id),
followed_id: Set(followed_id),
following_id: Set(following_id),
};
follow.insert(db).await?;
@@ -44,11 +44,11 @@ pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> R
pub async fn unfollow_user(
db: &DbConn,
follower_id: Uuid,
followed_id: Uuid,
following_id: Uuid,
) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id))
.filter(follow::Column::FollowedId.eq(followed_id))
.filter(follow::Column::FollowingId.eq(following_id))
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
@@ -60,18 +60,18 @@ pub async fn unfollow_user(
Ok(())
}
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followed_users = follow::Entity::find()
.filter(follow::Column::FollowerId.eq(user_id))
.all(db)
.await?;
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
Ok(followed_users.into_iter().map(|f| f.following_id).collect())
}
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followers = follow::Entity::find()
.filter(follow::Column::FollowedId.eq(user_id))
.filter(follow::Column::FollowingId.eq(user_id))
.all(db)
.await?;
Ok(followers.into_iter().map(|f| f.follower_id).collect())

View File

@@ -1,3 +1,4 @@
pub mod api_key;
pub mod auth;
pub mod follow;
pub mod tag;

View File

@@ -1,6 +1,8 @@
use models::domains::{tag, thought_tag};
use chrono::{Duration, Utc};
use models::domains::{tag, thought, thought_tag};
use sea_orm::{
sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set,
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
};
use std::collections::HashSet;
@@ -84,3 +86,34 @@ where
thought_tag::Entity::insert_many(links).exec(db).await?;
Ok(())
}
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
where
C: ConnectionTrait,
{
let seven_days_ago = Utc::now() - Duration::days(7);
let popular_tags = tag::Entity::find()
.select_only()
.column(tag::Column::Name)
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
.join(
sea_orm::JoinType::InnerJoin,
tag::Relation::ThoughtTag.def(),
)
.join(
sea_orm::JoinType::InnerJoin,
thought_tag::Relation::Thought.def(),
)
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
.group_by(tag::Column::Name)
.group_by(tag::Column::Id)
.order_by_desc(Expr::col(Alias::new("count")))
.order_by_asc(tag::Column::Name)
.limit(10)
.into_tuple::<(String, i64)>()
.all(db)
.await?;
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
}

View File

@@ -69,9 +69,9 @@ pub async fn get_thoughts_by_user(
pub async fn get_feed_for_user(
db: &DbConn,
followed_ids: Vec<Uuid>,
following_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if followed_ids.is_empty() {
if following_ids.is_empty() {
return Ok(vec![]);
}
@@ -83,7 +83,7 @@ pub async fn get_feed_for_user(
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(followed_ids))
.filter(thought::Column::AuthorId.is_in(following_ids))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)

View File

@@ -1,6 +1,7 @@
use sea_orm::prelude::Uuid;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait,
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::domains::{top_friends, user};
@@ -127,3 +128,12 @@ pub async fn update_user_profile(
.await
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.join(JoinType::InnerJoin, top_friends::Relation::User.def().rev())
.filter(top_friends::Column::UserId.eq(user_id))
.order_by_asc(top_friends::Column::Position)
.all(db)
.await
}

View File

@@ -19,7 +19,7 @@ use models::schemas::{
user_inbox_post,
user_outbox_get,
get_me,
update_me
update_me,
),
components(schemas(
CreateUserParams,

View File

@@ -4,6 +4,7 @@ mod m20240101_000001_init;
mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
pub struct Migrator;
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250905_000001_init::Migration),
Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration),
Box::new(m20250906_134056_add_api_keys::Migration),
]
}
}

View File

@@ -46,12 +46,12 @@ impl MigrationTrait for Migration {
.table(Follow::Table)
.if_not_exists()
.col(uuid(Follow::FollowerId).not_null())
.col(uuid(Follow::FollowedId).not_null())
.col(uuid(Follow::FollowingId).not_null())
// Composite Primary Key to ensure a user can only follow another once
.primary_key(
Index::create()
.col(Follow::FollowerId)
.col(Follow::FollowedId),
.col(Follow::FollowingId),
)
.foreign_key(
ForeignKey::create()
@@ -62,8 +62,8 @@ impl MigrationTrait for Migration {
)
.foreign_key(
ForeignKey::create()
.name("fk_follow_followed_id")
.from(Follow::Table, Follow::FollowedId)
.name("fk_follow_following_id")
.from(Follow::Table, Follow::FollowingId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
@@ -97,5 +97,5 @@ pub enum Follow {
// The user who is initiating the follow
FollowerId,
// The user who is being followed
FollowedId,
FollowingId,
}

View File

@@ -0,0 +1,69 @@
use super::m20240101_000001_init::User;
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ApiKey::Table)
.if_not_exists()
.col(
ColumnDef::new(ApiKey::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(ApiKey::UserId).not_null())
.foreign_key(
ForeignKey::create()
.name("fk_api_key_user_id")
.from(ApiKey::Table, ApiKey::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.col(text(ApiKey::KeyHash).not_null().unique_key())
.col(string(ApiKey::Name).not_null())
.col(
timestamp_with_time_zone(ApiKey::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-api_keys-key_prefix")
.table(ApiKey::Table)
.col(ApiKey::KeyPrefix)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ApiKey {
Table,
Id,
UserId,
KeyHash,
Name,
CreatedAt,
KeyPrefix,
}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "api_key")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub key_prefix: String,
#[sea_orm(unique)]
pub key_hash: String,
pub name: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -6,7 +6,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub follower_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub followed_id: Uuid,
pub following_id: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -21,12 +21,12 @@ pub enum Relation {
Follower,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::FollowedId",
from = "Column::FollowingId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Followed,
Following,
}
impl Related<super::user::Entity> for Entity {

View File

@@ -2,6 +2,7 @@
pub mod prelude;
pub mod api_key;
pub mod follow;
pub mod tag;
pub mod thought;

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::api_key::Entity as ApiKey;
pub use super::follow::Entity as Follow;
pub use super::tag::Entity as Tag;
pub use super::thought::Entity as Thought;

View File

@@ -28,6 +28,9 @@ pub enum Relation {
#[sea_orm(has_many = "super::top_friends::Entity")]
TopFriends,
#[sea_orm(has_many = "super::api_key::Entity")]
ApiKey,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -6,6 +6,8 @@ use validator::Validate;
pub struct RegisterParams {
#[validate(length(min = 3))]
pub username: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 6))]
pub password: String,
}

View File

@@ -0,0 +1,62 @@
use crate::domains::api_key;
use common::DateTimeWithTimeZoneWrapper;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema)]
pub struct ApiKeySchema {
pub id: Uuid,
pub name: String,
pub key_prefix: String,
pub created_at: DateTimeWithTimeZoneWrapper,
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeySchema,
/// The full plaintext API key. This is only returned on creation.
#[serde(skip_serializing_if = "Option::is_none")]
pub plaintext_key: Option<String>,
}
impl ApiKeyResponse {
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
Self {
key: ApiKeySchema {
id: model.id,
name: model.name,
key_prefix: model.key_prefix,
created_at: model.created_at.into(),
},
plaintext_key,
}
}
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyListSchema {
pub api_keys: Vec<ApiKeySchema>,
}
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
fn from(keys: Vec<api_key::Model>) -> Self {
Self {
api_keys: keys
.into_iter()
.map(|k| ApiKeySchema {
id: k.id,
name: k.name,
key_prefix: k.key_prefix,
created_at: k.created_at.into(),
})
.collect(),
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyRequest {
pub name: String,
}

View File

@@ -1,2 +1,3 @@
pub mod api_key;
pub mod thought;
pub mod user;

View File

@@ -14,12 +14,26 @@ pub struct UserSchema {
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
// In a real implementation, you'd fetch and return this data.
// For now, we'll omit it from the schema to keep it simple.
// pub top_friends: Vec<String>,
pub top_friends: Vec<String>,
pub joined_at: DateTimeWithTimeZoneWrapper,
}
impl From<(user::Model, Vec<user::Model>)> for UserSchema {
fn from((user, top_friends): (user::Model, Vec<user::Model>)) -> Self {
Self {
id: user.id,
username: user.username,
display_name: user.display_name,
bio: user.bio,
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
joined_at: user.created_at.into(),
}
}
}
impl From<user::Model> for UserSchema {
fn from(user: user::Model) -> Self {
Self {
@@ -30,6 +44,7 @@ impl From<user::Model> for UserSchema {
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: vec![], // Defaults to an empty list
joined_at: user.created_at.into(),
}
}

View File

@@ -9,7 +9,7 @@ use utils::testing::{
#[tokio::test]
async fn test_webfinger_discovery() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
// 1. Valid WebFinger lookup for existing user
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
@@ -36,7 +36,7 @@ async fn test_webfinger_discovery() {
#[tokio::test]
async fn test_user_actor_endpoint() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
let response = make_request_with_headers(
app.router.clone(),
@@ -64,9 +64,11 @@ async fn test_user_actor_endpoint() {
async fn test_user_inbox_follow() {
let app = setup().await;
// user1 will be followed
let user1 = create_user_with_password(&app.db, "user1", "password123").await;
let user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
// user2 will be the follower
let user2 = create_user_with_password(&app.db, "user2", "password123").await;
let user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
// Construct a follow activity from user2, targeting user1
let follow_activity = json!({
@@ -90,7 +92,7 @@ async fn test_user_inbox_follow() {
assert_eq!(response.status(), StatusCode::ACCEPTED);
// Verify that user2 is now following user1 in the database
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
let followers = app::persistence::follow::get_following_ids(&app.db, user2.id)
.await
.unwrap();
assert!(
@@ -98,7 +100,7 @@ async fn test_user_inbox_follow() {
"User2 should be following user1"
);
let following = app::persistence::follow::get_followed_ids(&app.db, user1.id)
let following = app::persistence::follow::get_following_ids(&app.db, user1.id)
.await
.unwrap();
assert!(
@@ -111,7 +113,7 @@ async fn test_user_inbox_follow() {
#[tokio::test]
async fn test_user_outbox_get() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
// Create a thought first

View File

@@ -0,0 +1,85 @@
use crate::api::main::{create_user_with_password, login_user, setup};
use axum::http::{header, HeaderName, StatusCode};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use utils::testing::{make_jwt_request, make_request_with_headers};
#[tokio::test]
async fn test_api_key_flow() {
let app = setup().await;
let _ = create_user_with_password(
&app.db,
"apikey_user",
"password123",
"apikey_user@example.com",
)
.await;
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
// 1. Create a new API key using JWT auth
let create_body = json!({ "name": "My Test Key" }).to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me/api-keys",
"POST",
Some(create_body),
&jwt,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
let plaintext_key = v["plaintext_key"]
.as_str()
.expect("Plaintext key not found")
.to_string();
let key_id = v["id"].as_str().expect("Key ID not found").to_string();
assert!(plaintext_key.starts_with("th_"));
// 2. Use the new API key to post a thought
let thought_body = json!({ "content": "Posting with an API key!" }).to_string();
let key = plaintext_key.clone();
let api_key_header = format!("ApiKey {}", key);
let content_type = "application/json";
let headers: Vec<(HeaderName, &str)> = vec![
(header::AUTHORIZATION, &api_key_header),
(header::CONTENT_TYPE, content_type),
];
let response = make_request_with_headers(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_body),
headers,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
// 3. Delete the API key using JWT auth
let response = make_jwt_request(
app.router.clone(),
&format!("/users/me/api-keys/{}", key_id),
"DELETE",
None,
&jwt,
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// 4. Try to use the deleted key again, expecting failure
let body = json!({ "content": "This should fail" }).to_string();
let headers: Vec<(HeaderName, &str)> = vec![
(header::AUTHORIZATION, &api_key_header),
(header::CONTENT_TYPE, content_type),
];
let response =
make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

View File

@@ -11,6 +11,7 @@ async fn test_auth_flow() {
let register_body = json!({
"username": "testuser",
"email": "testuser@example.com",
"password": "password123"
})
.to_string();
@@ -26,6 +27,7 @@ async fn test_auth_flow() {
"/auth/register",
json!({
"username": "testuser",
"email": "testuser@example.com",
"password": "password456"
})
.to_string(),
@@ -48,6 +50,7 @@ async fn test_auth_flow() {
let bad_login_body = json!({
"username": "testuser",
"email": "testuser@example.com",
"password": "wrongpassword"
})
.to_string();

View File

@@ -7,9 +7,9 @@ use utils::testing::make_jwt_request;
#[tokio::test]
async fn test_feed_and_user_thoughts() {
let app = setup().await;
create_user_with_password(&app.db, "user1", "password1").await;
create_user_with_password(&app.db, "user2", "password2").await;
create_user_with_password(&app.db, "user3", "password3").await;
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await;
// As user1, post a thought
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;

View File

@@ -7,8 +7,8 @@ async fn test_follow_endpoints() {
std::env::set_var("AUTH_SECRET", "test-secret");
let app = setup().await;
create_user_with_password(&app.db, "user1", "password1").await;
create_user_with_password(&app.db, "user2", "password2").await;
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;

View File

@@ -38,10 +38,12 @@ pub async fn create_user_with_password(
db: &DatabaseConnection,
username: &str,
password: &str,
email: &str,
) -> user::Model {
let params = RegisterParams {
username: username.to_string(),
password: password.to_string(),
email: email.to_string(),
};
app::persistence::auth::register_user(db, params)
.await

View File

@@ -1,4 +1,5 @@
mod activitypub;
mod api_key;
mod auth;
mod feed;
mod follow;

View File

@@ -1,4 +1,4 @@
use crate::api::main::{create_user_with_password, login_user, setup};
use crate::api::main::{create_user_with_password, login_user, setup, TestApp};
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::{json, Value};
@@ -7,7 +7,8 @@ use utils::testing::{make_get_request, make_jwt_request};
#[tokio::test]
async fn test_hashtag_flow() {
let app = setup().await;
let user = create_user_with_password(&app.db, "taguser", "password123").await;
let user =
create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await;
let token = login_user(app.router.clone(), "taguser", "password123").await;
// 1. Post a thought with hashtags
@@ -25,7 +26,6 @@ async fn test_hashtag_flow() {
// 3. Fetch thoughts by tag "rustlang"
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
println!("Response: {:?}", response);
assert_eq!(response.status(), StatusCode::OK);
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
@@ -49,3 +49,43 @@ async fn test_hashtag_flow() {
assert_eq!(thoughts.len(), 1);
assert_eq!(thoughts[0]["id"], thought_id);
}
#[tokio::test]
async fn test_popular_tags() {
let app = setup().await;
let _ = create_user_with_password(&app.db, "poptag_user", "password123", "poptag@example.com")
.await;
let token = login_user(app.router.clone(), "poptag_user", "password123").await;
// Helper async function to post a thought
async fn post_thought(app: &TestApp, token: &str, content: &str) {
let body = json!({ "content": content }).to_string();
let response =
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), token).await;
assert_eq!(response.status(), StatusCode::CREATED);
}
// 1. Post thoughts to create tag usage data
// Expected counts: rust (3), web (2), axum (2), testing (1)
post_thought(&app, &token, "My first post about #rust and the #web").await;
post_thought(&app, &token, "Another post about #rust and #axum").await;
post_thought(&app, &token, "I'm really enjoying #rust lately").await;
post_thought(&app, &token, "Let's talk about #axum and the #web").await;
post_thought(&app, &token, "Don't forget about #testing").await;
// 2. Fetch the popular tags
let response = make_get_request(app.router.clone(), "/tags/popular", None).await;
println!("Response: {:?}", response);
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Vec<String> = serde_json::from_slice(&body).unwrap();
// 3. Assert the results
assert_eq!(v.len(), 4, "Should return the 4 unique tags used");
assert_eq!(
v,
vec!["rust", "axum", "web", "testing"],
"Tags should be ordered by popularity, then alphabetically"
);
}

View File

@@ -10,8 +10,10 @@ use utils::testing::{make_delete_request, make_post_request};
#[tokio::test]
async fn test_thought_endpoints() {
let app = setup().await;
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2
let user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; // AuthUser is ID 1
let _user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; // Other user is ID 2
// 1. Post a new thought as user 1
let body = json!({ "content": "My first thought!" }).to_string();

View File

@@ -12,7 +12,8 @@ use crate::api::main::{create_user_with_password, login_user, setup};
async fn test_post_users() {
let app = setup().await;
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
.to_owned();
let response = make_post_request(app.router, "/auth/register", body, None).await;
assert_eq!(response.status(), StatusCode::CREATED);
@@ -21,14 +22,15 @@ async fn test_post_users() {
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["username"], "test");
assert!(v["display_name"].is_null());
assert!(v["display_name"].is_string());
}
#[tokio::test]
pub(super) async fn test_post_users_error() {
let app = setup().await;
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
let body =
r#"{"username": "1", "email": "test@example.com", "password": "password123"}"#.to_owned();
let response = make_post_request(app.router, "/auth/register", body, None).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
@@ -43,7 +45,8 @@ pub(super) async fn test_post_users_error() {
pub async fn test_get_users() {
let app = setup().await;
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
.to_owned();
make_post_request(app.router.clone(), "/auth/register", body, None).await;
let response = make_get_request(app.router, "/users", None).await;
@@ -65,6 +68,7 @@ async fn test_me_endpoints() {
// 1. Register a new user
let register_body = json!({
"username": "me_user",
"email": "me_user@example.com",
"password": "password123"
})
.to_string();
@@ -82,7 +86,7 @@ async fn test_me_endpoints() {
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["username"], "me_user");
assert!(v["bio"].is_null());
assert!(v["display_name"].is_null());
assert!(v["display_name"].is_string());
// 4. PUT /users/me to update the profile
let update_body = json!({
@@ -119,10 +123,14 @@ async fn test_update_me_top_friends() {
let app = setup().await;
// 1. Create users for the test
let user_me = create_user_with_password(&app.db, "me_user", "password123").await;
let friend1 = create_user_with_password(&app.db, "friend1", "password123").await;
let friend2 = create_user_with_password(&app.db, "friend2", "password123").await;
let _friend3 = create_user_with_password(&app.db, "friend3", "password123").await;
let user_me =
create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await;
let friend1 =
create_user_with_password(&app.db, "friend1", "password123", "friend1@example.com").await;
let friend2 =
create_user_with_password(&app.db, "friend2", "password123", "friend2@example.com").await;
let _friend3 =
create_user_with_password(&app.db, "friend3", "password123", "friend3@example.com").await;
// 2. Log in as "me_user"
let token = login_user(app.router.clone(), "me_user", "password123").await;
@@ -183,3 +191,57 @@ async fn test_update_me_top_friends() {
assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
assert_eq!(top_friends_list_2[0].position, 1);
}
#[tokio::test]
async fn test_update_me_css_and_images() {
let app = setup().await;
// 1. Create and log in as a user
let _ =
create_user_with_password(&app.db, "css_user", "password123", "css_user@example.com").await;
let token = login_user(app.router.clone(), "css_user", "password123").await;
// 2. Attempt to update with an invalid avatar URL
let invalid_body = json!({
"avatar_url": "not-a-valid-url"
})
.to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me",
"PUT",
Some(invalid_body),
&token,
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
// 3. Update profile with valid URLs and custom CSS
let valid_body = json!({
"avatar_url": "https://example.com/new-avatar.png",
"header_url": "https://example.com/new-header.jpg",
"custom_css": "body { color: blue; }"
})
.to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me",
"PUT",
Some(valid_body),
&token,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
// 4. Verify the changes were persisted by fetching the profile again
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["avatar_url"], "https://example.com/new-avatar.png");
assert_eq!(v["header_url"], "https://example.com/new-header.jpg");
assert_eq!(v["custom_css"], "body { color: blue; }");
}