Compare commits
10 Commits
dbd891d60d
...
908789e639
| Author | SHA1 | Date | |
|---|---|---|---|
| 908789e639 | |||
| fc3b4146a5 | |||
| e64404cf40 | |||
| fbc02bc2f8 | |||
| d1f72c8308 | |||
| abc5f2b936 | |||
| d5a116e483 | |||
| 57110f3b75 | |||
| 812cf7b140 | |||
| 93967e53a2 |
@@ -1382,9 +1382,12 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
||||
domain::errors::DomainError::ExternalService(e.to_string())
|
||||
})?;
|
||||
|
||||
let domain_str = actor.ap_id.host_str().unwrap_or("");
|
||||
let full_handle = format!("{}@{}", actor.username, domain_str);
|
||||
|
||||
Ok(domain::models::remote_actor::RemoteActor {
|
||||
url: actor.ap_id.to_string(),
|
||||
handle: actor.username.clone(),
|
||||
handle: full_handle,
|
||||
display_name: Some(actor.username.clone()),
|
||||
inbox_url: actor.inbox_url.to_string(),
|
||||
shared_inbox_url: None,
|
||||
@@ -1403,6 +1406,111 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn actor_json(
|
||||
&self,
|
||||
user_id: &domain::value_objects::UserId,
|
||||
) -> Result<String, domain::errors::DomainError> {
|
||||
ActivityPubService::actor_json(self, &user_id.as_uuid().to_string())
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: &domain::value_objects::UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, domain::errors::DomainError> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let uuid = user_id.as_uuid();
|
||||
let collection_id = format!("{}/users/{}/followers", self.base_url, uuid);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.count_followers(uuid)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
let obj = if let Some(p) = page {
|
||||
let p = p.max(1);
|
||||
let offset = (p.saturating_sub(1) as usize) * 20;
|
||||
let followers = data
|
||||
.federation_repo
|
||||
.get_followers_page(uuid, offset as u32, 20)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
let has_next = offset + followers.len() < total;
|
||||
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
|
||||
let mut obj = serde_json::json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, p),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
if has_next {
|
||||
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
|
||||
}
|
||||
obj
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})
|
||||
};
|
||||
serde_json::to_string(&obj)
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: &domain::value_objects::UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, domain::errors::DomainError> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let uuid = user_id.as_uuid();
|
||||
let collection_id = format!("{}/users/{}/following", self.base_url, uuid);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.count_following(uuid)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
let obj = if let Some(p) = page {
|
||||
let p = p.max(1);
|
||||
let offset = (p.saturating_sub(1) as usize) * 20;
|
||||
let following = data
|
||||
.federation_repo
|
||||
.get_following_page(uuid, offset as u32, 20)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
let has_next = offset + following.len() < total;
|
||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||
let mut obj = serde_json::json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, p),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
if has_next {
|
||||
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
|
||||
}
|
||||
obj
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})
|
||||
};
|
||||
serde_json::to_string(&obj)
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -83,6 +83,6 @@ pub struct SearchQuery {
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FollowRemoteRequest {
|
||||
pub handle: String,
|
||||
pub struct NotificationUpdateRequest {
|
||||
pub read: bool,
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ mod config;
|
||||
mod factory;
|
||||
|
||||
use activitypub_base::{
|
||||
actor_handler::actor_handler,
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
outbox::outbox_handler,
|
||||
@@ -50,7 +48,6 @@ async fn main() {
|
||||
axum::routing::get(nodeinfo_well_known_handler),
|
||||
)
|
||||
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
||||
.route("/users/{username}", axum::routing::get(actor_handler))
|
||||
.route(
|
||||
"/users/{username}/inbox",
|
||||
axum::routing::post(inbox_handler),
|
||||
@@ -59,14 +56,6 @@ async fn main() {
|
||||
"/users/{username}/outbox",
|
||||
axum::routing::get(outbox_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/followers",
|
||||
axum::routing::get(followers_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/following",
|
||||
axum::routing::get(following_handler),
|
||||
)
|
||||
.layer(infra.ap_service.federation_config().middleware());
|
||||
|
||||
let base = presentation::routes::router()
|
||||
|
||||
@@ -198,6 +198,17 @@ pub trait RemoteActorRepository: Send + Sync {
|
||||
pub trait FederationActionPort: Send + Sync {
|
||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError>;
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -547,6 +547,26 @@ impl FederationActionPort for TestStore {
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -806,6 +826,13 @@ mod federation_port_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_actor_json_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse};
|
||||
|
||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LookupQuery {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn lookup_handler(
|
||||
State(s): State<AppState>,
|
||||
Query(q): Query<LookupQuery>,
|
||||
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&q.handle).await?;
|
||||
Ok(Json(RemoteActorResponse {
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
url: actor.url,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn follow_remote_handler(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Json(body): Json<FollowRemoteRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
s.federation.follow_remote(&uid, &body.handle).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::TestStore,
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoOpAuth;
|
||||
impl AuthService for NoOpAuth {
|
||||
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoOpHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for NoOpHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state() -> crate::state::AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
crate::state::AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: Arc::new(NoOpAuth),
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/federation/lookup", get(lookup_handler))
|
||||
.route("/federation/follow", post(follow_remote_handler))
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_unknown_handle_returns_404() {
|
||||
let req = Request::builder()
|
||||
.uri("/federation/lookup?handle=%40alice%40example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_remote_without_auth_returns_401() {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/federation/follow")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"handle":"@alice@example.com"}"#))
|
||||
.unwrap();
|
||||
let resp = app().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@ use application::use_cases::profile::get_user_by_username;
|
||||
use application::use_cases::search::{search_thoughts, search_users};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use domain::models::feed::PageParams;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||
ThoughtResponse {
|
||||
@@ -151,34 +154,82 @@ pub async fn search_handler(
|
||||
|
||||
pub async fn get_following_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
Path(param): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user = get_user_by_username(&*s.users, &username).await?;
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let accept = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if accept.contains("application/activity+json") {
|
||||
let user_id = resolve_user_id(&s, ¶m).await?;
|
||||
let page = q.page().try_into().ok();
|
||||
let json = s
|
||||
.federation
|
||||
.following_collection_json(&user_id, page)
|
||||
.await?;
|
||||
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
||||
}
|
||||
|
||||
let user = get_user_by_username(&*s.users, ¶m).await?;
|
||||
let page = PageParams {
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_following(&*s.follows, &user.id, page).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
|
||||
))
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn get_followers_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
Path(param): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user = get_user_by_username(&*s.users, &username).await?;
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let accept = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if accept.contains("application/activity+json") {
|
||||
let user_id = resolve_user_id(&s, ¶m).await?;
|
||||
let page = q.page().try_into().ok();
|
||||
let json = s
|
||||
.federation
|
||||
.followers_collection_json(&user_id, page)
|
||||
.await?;
|
||||
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
|
||||
}
|
||||
|
||||
let user = get_user_by_username(&*s.users, ¶m).await?;
|
||||
let page = PageParams {
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_followers(&*s.follows, &user.id, page).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
|
||||
))
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn resolve_user_id(s: &AppState, param: &str) -> Result<UserId, ApiError> {
|
||||
if let Ok(uuid) = uuid::Uuid::parse_str(param) {
|
||||
s.users
|
||||
.find_by_id(&UserId::from_uuid(uuid))
|
||||
.await?
|
||||
.map(|u| u.id)
|
||||
.ok_or_else(|| ApiError::from(domain::errors::DomainError::NotFound))
|
||||
} else {
|
||||
Ok(get_user_by_username(&*s.users, param).await?.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod federation;
|
||||
pub mod feed;
|
||||
pub mod health;
|
||||
pub mod notifications;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||
use api_types::requests::NotificationUpdateRequest;
|
||||
use application::use_cases::notifications::{
|
||||
list_notifications as uc_list_notifications, mark_all_notifications_read,
|
||||
mark_notification_read as uc_mark_notification_read,
|
||||
@@ -21,26 +22,136 @@ pub async fn list_notifications(
|
||||
per_page: 20,
|
||||
};
|
||||
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }),
|
||||
))
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"unread": result.items.iter().filter(|n| !n.read).count()
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||
pub async fn mark_notification_read(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<NotificationUpdateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
|
||||
if body.read {
|
||||
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||
pub async fn mark_all_read(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Json(body): Json<NotificationUpdateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
mark_all_notifications_read(&*s.notifications, &uid).await?;
|
||||
if body.read {
|
||||
mark_all_notifications_read(&*s.notifications, &uid).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Request},
|
||||
routing::{get, patch},
|
||||
Router,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::TestStore,
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoOpAuth;
|
||||
impl AuthService for NoOpAuth {
|
||||
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoOpHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for NoOpHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state() -> crate::state::AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
crate::state::AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: Arc::new(NoOpAuth),
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/notifications", patch(mark_all_read))
|
||||
.route("/notifications/:id", patch(mark_notification_read))
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_notification_without_auth_returns_401() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri("/notifications/00000000-0000-0000-0000-000000000001")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"read":true}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_all_without_auth_returns_401() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri("/notifications")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"read":true}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,27 +46,46 @@ pub async fn delete_boost(
|
||||
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(
|
||||
post, path = "/users/{username}/follow",
|
||||
params(("username" = String, Path, description = "Username or user@domain handle")),
|
||||
responses((status = 204, description = "Following")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_follow(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
if username.contains('@') {
|
||||
s.federation.follow_remote(&uid, &username).await?;
|
||||
} else {
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(
|
||||
delete, path = "/users/{username}/follow",
|
||||
params(("username" = String, Path, description = "Username")),
|
||||
responses((status = 204, description = "Unfollowed")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_follow(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if username.contains('@') {
|
||||
return Err(ApiError::BadRequest(
|
||||
"remote unfollow not yet supported".into(),
|
||||
));
|
||||
}
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
#[utoipa::path(post, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_block(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
@@ -76,7 +95,7 @@ pub async fn post_block(
|
||||
block_user(&*s.blocks, &*s.events, &uid, &target.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
||||
#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
||||
pub async fn delete_block(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
@@ -106,3 +125,106 @@ pub async fn get_top_friends_handler(
|
||||
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
|
||||
Ok(Json(serde_json::json!({ "topFriends": usernames })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::Request,
|
||||
routing::{delete, post},
|
||||
Router,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::TestStore,
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoOpAuth;
|
||||
impl AuthService for NoOpAuth {
|
||||
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoOpHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for NoOpHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state() -> crate::state::AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
crate::state::AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: Arc::new(NoOpAuth),
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/users/{username}/follow",
|
||||
post(post_follow).delete(delete_follow),
|
||||
)
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_without_auth_returns_401() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/users/alice/follow")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_remote_without_auth_returns_401() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/users/alice@example.com/follow")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ use crate::{
|
||||
};
|
||||
use api_types::{
|
||||
requests::{PaginationQuery, UpdateProfileRequest},
|
||||
responses::{ErrorResponse, UserResponse},
|
||||
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
|
||||
};
|
||||
use application::use_cases::feed::list_users;
|
||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||
use application::use_cases::search::search_users;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
|
||||
@@ -28,16 +30,28 @@ pub async fn get_user(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let user = get_user_by_username(&*s.users, &username).await?;
|
||||
let is_followed = if let Some(viewer_id) = viewer {
|
||||
s.follows.find(&viewer_id, &user.id).await?.is_some()
|
||||
|
||||
let accept = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if accept.contains("application/activity+json") {
|
||||
let json = s.federation.actor_json(&user.id).await?;
|
||||
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut resp = to_user_response(&user);
|
||||
resp.is_followed_by_viewer = is_followed;
|
||||
Ok(Json(resp))
|
||||
let is_followed = if let Some(viewer_id) = viewer {
|
||||
s.follows.find(&viewer_id, &user.id).await?.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut resp = to_user_response(&user);
|
||||
resp.is_followed_by_viewer = is_followed;
|
||||
Ok(Json(resp).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -92,7 +106,7 @@ pub async fn get_me(
|
||||
Ok(Json(to_user_response(&user)))
|
||||
}
|
||||
|
||||
pub async fn get_me_following_list(
|
||||
pub async fn get_me_following(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
@@ -170,3 +184,135 @@ pub async fn get_user_count(
|
||||
let count = s.users.count().await?;
|
||||
Ok(Json(serde_json::json!({ "count": count })))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LookupQuery {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn lookup_handler(
|
||||
State(s): State<AppState>,
|
||||
Query(q): Query<LookupQuery>,
|
||||
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&q.handle).await?;
|
||||
Ok(Json(RemoteActorResponse {
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
url: actor.url,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Request},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::TestStore,
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoOpAuth;
|
||||
impl AuthService for NoOpAuth {
|
||||
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoOpHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for NoOpHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state() -> crate::state::AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
crate::state::AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: Arc::new(NoOpAuth),
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/users/{username}", get(get_user))
|
||||
.route("/users/lookup", get(lookup_handler))
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_user_returns_404() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/users/nobody")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_user_with_ap_accept_returns_404_when_actor_not_found() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/users/nobody")
|
||||
.header(header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_unknown_handle_returns_404() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/users/lookup?handle=%40alice%40example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{handlers::*, openapi, state::AppState};
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
routing::{delete, get, patch, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -11,36 +11,38 @@ pub fn router() -> Router<AppState> {
|
||||
// auth
|
||||
.route("/auth/register", post(auth::post_register))
|
||||
.route("/auth/login", post(auth::post_login))
|
||||
// users — static paths before parameterised
|
||||
// users — static before parameterised
|
||||
.route("/users", get(users::get_users))
|
||||
.route("/users/count", get(users::get_user_count))
|
||||
.route(
|
||||
"/users/me",
|
||||
get(users::get_me)
|
||||
.patch(users::patch_profile)
|
||||
.put(users::patch_profile),
|
||||
)
|
||||
.route(
|
||||
"/users/me/following-list",
|
||||
get(users::get_me_following_list),
|
||||
)
|
||||
.route("/users/lookup", get(users::lookup_handler))
|
||||
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
||||
.route("/users/me/following", get(users::get_me_following))
|
||||
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||
// /users/{username} is owned by the AP router (returns AP actor JSON for federation).
|
||||
// The REST user profile lives at /users/{username}/profile to avoid the conflict.
|
||||
.route("/users/{username}/profile", get(users::get_user))
|
||||
.route("/users/{username}", get(users::get_user))
|
||||
.route(
|
||||
"/users/{username}/top-friends",
|
||||
get(social::get_top_friends_handler),
|
||||
)
|
||||
// follows & blocks (use {id} param)
|
||||
.route(
|
||||
"/users/{id}/follow",
|
||||
"/users/{username}/follow",
|
||||
post(social::post_follow).delete(social::delete_follow),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/block",
|
||||
"/users/{username}/block",
|
||||
post(social::post_block).delete(social::delete_block),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/followers",
|
||||
get(feed::get_followers_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/following",
|
||||
get(feed::get_following_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/thoughts",
|
||||
get(feed::user_thoughts_handler),
|
||||
)
|
||||
// thoughts
|
||||
.route("/thoughts", post(thoughts::post_thought))
|
||||
.route(
|
||||
@@ -63,42 +65,23 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/feed", get(feed::home_feed))
|
||||
.route("/feed/public", get(feed::public_feed))
|
||||
.route("/search", get(feed::search_handler))
|
||||
.route(
|
||||
"/users/{username}/follower-list",
|
||||
get(feed::get_followers_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/following-list",
|
||||
get(feed::get_following_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/thoughts",
|
||||
get(feed::user_thoughts_handler),
|
||||
)
|
||||
.route("/tags/popular", get(feed::get_popular_tags))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
// notifications
|
||||
.route("/notifications", get(notifications::list_notifications))
|
||||
.route(
|
||||
"/notifications/read-all",
|
||||
post(notifications::mark_all_read),
|
||||
"/notifications",
|
||||
get(notifications::list_notifications).patch(notifications::mark_all_read),
|
||||
)
|
||||
.route(
|
||||
"/notifications/{id}/read",
|
||||
post(notifications::mark_notification_read),
|
||||
"/notifications/{id}",
|
||||
patch(notifications::mark_notification_read),
|
||||
)
|
||||
// api keys
|
||||
.route(
|
||||
"/api-keys",
|
||||
get(api_keys::get_api_keys).post(api_keys::post_api_key),
|
||||
)
|
||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
|
||||
// federation
|
||||
.route("/federation/lookup", get(federation::lookup_handler))
|
||||
.route(
|
||||
"/federation/follow",
|
||||
post(federation::follow_remote_handler),
|
||||
);
|
||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
||||
|
||||
openapi::serve(api_routes)
|
||||
}
|
||||
|
||||
1054
docs/superpowers/plans/2026-05-14-api-cleanup.md
Normal file
1054
docs/superpowers/plans/2026-05-14-api-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
118
docs/superpowers/specs/2026-05-14-api-cleanup-design.md
Normal file
118
docs/superpowers/specs/2026-05-14-api-cleanup-design.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# REST API Cleanup Design
|
||||
|
||||
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
|
||||
|
||||
## Route Changes
|
||||
|
||||
| Before | After | Reason |
|
||||
|--------|-------|--------|
|
||||
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
|
||||
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
|
||||
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
|
||||
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
|
||||
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
|
||||
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
|
||||
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
|
||||
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
|
||||
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
|
||||
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
|
||||
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
|
||||
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
|
||||
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
|
||||
|
||||
## Content Negotiation at `GET /users/{username}`
|
||||
|
||||
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
|
||||
|
||||
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
|
||||
|
||||
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
|
||||
- Anything else → return `UserResponse` with `Content-Type: application/json`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
|
||||
|
||||
The unified handler in `presentation/src/handlers/users.rs`:
|
||||
1. Looks up user by username via `UserRepository` → 404 if not found
|
||||
2. Checks `Accept` header
|
||||
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
|
||||
4. REST path: returns `UserResponse` as before
|
||||
|
||||
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
|
||||
|
||||
## Unified Follow at `POST /users/{username}/follow`
|
||||
|
||||
The handler detects whether `{username}` is a local user or a remote actor:
|
||||
|
||||
```rust
|
||||
if username.contains('@') {
|
||||
// Remote: e.g. "gabrielkaszewski@mastodon.social"
|
||||
s.federation.follow_remote(&uid, &username).await?;
|
||||
} else {
|
||||
// Local: look up by username, call follow_user use case
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
}
|
||||
```
|
||||
|
||||
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
|
||||
|
||||
## Remote Actor Handle Format Fix
|
||||
|
||||
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
|
||||
|
||||
```rust
|
||||
let domain = actor.ap_id.host_str().unwrap_or("");
|
||||
let full_handle = format!("{}@{}", actor.username, domain);
|
||||
// RemoteActor { handle: full_handle, ... }
|
||||
```
|
||||
|
||||
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
|
||||
|
||||
## Remote Unfollow Scope
|
||||
|
||||
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
|
||||
|
||||
## Notification Endpoints
|
||||
|
||||
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
|
||||
|
||||
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
|
||||
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
|
||||
|
||||
Both replace their existing `POST` counterparts.
|
||||
|
||||
## Frontend (`thoughts-frontend/lib/api.ts`)
|
||||
|
||||
| Function | Change |
|
||||
|----------|--------|
|
||||
| `getUserProfile(username)` | URL: `/users/${username}/profile` → `/users/${username}` |
|
||||
| `getFollowersList(username)` | URL: `/follower-list` → `/followers` |
|
||||
| `getFollowingList(username)` | URL: `/following-list` → `/following` |
|
||||
| `getMeFollowingList()` | URL: `/me/following-list` → `/me/following` |
|
||||
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=` → `/users/lookup?handle=` |
|
||||
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
|
||||
| `markNotificationRead(id)` | **New** — `PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
|
||||
| `markAllNotificationsRead()` | **New** — `PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
|
||||
|
||||
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
|
||||
|
||||
## Files Touched
|
||||
|
||||
**Backend:**
|
||||
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
|
||||
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
|
||||
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
|
||||
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
|
||||
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}` → `{username}` in follow/block; rename follower/following list handlers
|
||||
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
|
||||
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
|
||||
- `crates/presentation/src/routes.rs` — all route changes
|
||||
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
|
||||
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
|
||||
|
||||
**Frontend:**
|
||||
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
|
||||
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
|
||||
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { followRemoteUser, RemoteActor } from "@/lib/api";
|
||||
import { followUser, RemoteActor } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,7 +24,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await followRemoteUser(actor.handle, token);
|
||||
await followUser(actor.handle, token);
|
||||
setFollowed(true);
|
||||
toast.success(`Follow request sent to ${actor.handle}`);
|
||||
} catch {
|
||||
|
||||
@@ -194,18 +194,18 @@ export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token:
|
||||
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
|
||||
|
||||
export const getMeFollowingList = (token: string) =>
|
||||
apiFetch("/users/me/following-list", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getUserProfile = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/profile`, {}, UserSchema, token);
|
||||
apiFetch(`/users/${username}`, {}, UserSchema, token);
|
||||
|
||||
export const getFollowersList = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/follower-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
export const getFollowingList = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/following-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
export const getTopFriends = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/top-friends`, {}, z.object({ topFriends: z.array(z.string()) }), token);
|
||||
@@ -216,22 +216,30 @@ export const followUser = (username: string, token: string) =>
|
||||
export const unfollowUser = (username: string, token: string) =>
|
||||
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
|
||||
|
||||
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||
export const markNotificationRead = (id: string, token: string) =>
|
||||
apiFetch(
|
||||
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
|
||||
{},
|
||||
RemoteActorSchema,
|
||||
`/notifications/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const followRemoteUser = (handle: string, token: string) =>
|
||||
export const markAllNotificationsRead = (token: string) =>
|
||||
apiFetch(
|
||||
`/federation/follow`,
|
||||
{ method: "POST", body: JSON.stringify({ handle }) },
|
||||
"/notifications",
|
||||
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/lookup?handle=${encodeURIComponent(handle)}`,
|
||||
{},
|
||||
RemoteActorSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||
apiFetch(
|
||||
`/users?page=${page}&per_page=${pageSize}`,
|
||||
|
||||
Reference in New Issue
Block a user