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())
|
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 {
|
Ok(domain::models::remote_actor::RemoteActor {
|
||||||
url: actor.ap_id.to_string(),
|
url: actor.ap_id.to_string(),
|
||||||
handle: actor.username.clone(),
|
handle: full_handle,
|
||||||
display_name: Some(actor.username.clone()),
|
display_name: Some(actor.username.clone()),
|
||||||
inbox_url: actor.inbox_url.to_string(),
|
inbox_url: actor.inbox_url.to_string(),
|
||||||
shared_inbox_url: None,
|
shared_inbox_url: None,
|
||||||
@@ -1403,6 +1406,111 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ pub struct SearchQuery {
|
|||||||
|
|
||||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FollowRemoteRequest {
|
pub struct NotificationUpdateRequest {
|
||||||
pub handle: String,
|
pub read: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ mod config;
|
|||||||
mod factory;
|
mod factory;
|
||||||
|
|
||||||
use activitypub_base::{
|
use activitypub_base::{
|
||||||
actor_handler::actor_handler,
|
|
||||||
followers_handler::{followers_handler, following_handler},
|
|
||||||
inbox::inbox_handler,
|
inbox::inbox_handler,
|
||||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||||
outbox::outbox_handler,
|
outbox::outbox_handler,
|
||||||
@@ -50,7 +48,6 @@ async fn main() {
|
|||||||
axum::routing::get(nodeinfo_well_known_handler),
|
axum::routing::get(nodeinfo_well_known_handler),
|
||||||
)
|
)
|
||||||
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
||||||
.route("/users/{username}", axum::routing::get(actor_handler))
|
|
||||||
.route(
|
.route(
|
||||||
"/users/{username}/inbox",
|
"/users/{username}/inbox",
|
||||||
axum::routing::post(inbox_handler),
|
axum::routing::post(inbox_handler),
|
||||||
@@ -59,14 +56,6 @@ async fn main() {
|
|||||||
"/users/{username}/outbox",
|
"/users/{username}/outbox",
|
||||||
axum::routing::get(outbox_handler),
|
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());
|
.layer(infra.ap_service.federation_config().middleware());
|
||||||
|
|
||||||
let base = presentation::routes::router()
|
let base = presentation::routes::router()
|
||||||
|
|||||||
@@ -198,6 +198,17 @@ pub trait RemoteActorRepository: Send + Sync {
|
|||||||
pub trait FederationActionPort: Send + Sync {
|
pub trait FederationActionPort: Send + Sync {
|
||||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
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 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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -547,6 +547,26 @@ impl FederationActionPort for TestStore {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
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]
|
#[async_trait]
|
||||||
@@ -806,6 +826,13 @@ mod federation_port_tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.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)]
|
#[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 application::use_cases::search::{search_thoughts, search_users};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
http::{header, HeaderMap},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||||
ThoughtResponse {
|
ThoughtResponse {
|
||||||
@@ -151,34 +154,82 @@ pub async fn search_handler(
|
|||||||
|
|
||||||
pub async fn get_following_handler(
|
pub async fn get_following_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
headers: HeaderMap,
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
) -> 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 {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_following(&*s.follows, &user.id, page).await?;
|
let result = get_following(&*s.follows, &user.id, page).await?;
|
||||||
Ok(Json(
|
Ok(Json(serde_json::json!({
|
||||||
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
|
"total": result.total,
|
||||||
))
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followers_handler(
|
pub async fn get_followers_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
headers: HeaderMap,
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
) -> 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 {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = get_followers(&*s.follows, &user.id, page).await?;
|
let result = get_followers(&*s.follows, &user.id, page).await?;
|
||||||
Ok(Json(
|
Ok(Json(serde_json::json!({
|
||||||
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
|
"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(
|
#[utoipa::path(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod federation;
|
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
use api_types::requests::NotificationUpdateRequest;
|
||||||
use application::use_cases::notifications::{
|
use application::use_cases::notifications::{
|
||||||
list_notifications as uc_list_notifications, mark_all_notifications_read,
|
list_notifications as uc_list_notifications, mark_all_notifications_read,
|
||||||
mark_notification_read as uc_mark_notification_read,
|
mark_notification_read as uc_mark_notification_read,
|
||||||
@@ -21,26 +22,136 @@ pub async fn list_notifications(
|
|||||||
per_page: 20,
|
per_page: 20,
|
||||||
};
|
};
|
||||||
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
||||||
Ok(Json(
|
Ok(Json(serde_json::json!({
|
||||||
serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }),
|
"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(
|
pub async fn mark_notification_read(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<NotificationUpdateRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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)
|
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(
|
pub async fn mark_all_read(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
|
Json(body): Json<NotificationUpdateRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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)
|
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?;
|
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn post_follow(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let target = get_user_by_username(&*s.users, &username).await?;
|
if username.contains('@') {
|
||||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
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)
|
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(
|
pub async fn delete_follow(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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?;
|
let target = get_user_by_username(&*s.users, &username).await?;
|
||||||
unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn post_block(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -76,7 +95,7 @@ pub async fn post_block(
|
|||||||
block_user(&*s.blocks, &*s.events, &uid, &target.id).await?;
|
block_user(&*s.blocks, &*s.events, &uid, &target.id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn delete_block(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
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();
|
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
|
||||||
Ok(Json(serde_json::json!({ "topFriends": usernames })))
|
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::{
|
use api_types::{
|
||||||
requests::{PaginationQuery, UpdateProfileRequest},
|
requests::{PaginationQuery, UpdateProfileRequest},
|
||||||
responses::{ErrorResponse, UserResponse},
|
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::feed::list_users;
|
use application::use_cases::feed::list_users;
|
||||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||||
use application::use_cases::search::search_users;
|
use application::use_cases::search::search_users;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
http::{header, HeaderMap},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,16 +30,28 @@ pub async fn get_user(
|
|||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
) -> Result<Json<UserResponse>, ApiError> {
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
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 {
|
} else {
|
||||||
false
|
let is_followed = if let Some(viewer_id) = viewer {
|
||||||
};
|
s.follows.find(&viewer_id, &user.id).await?.is_some()
|
||||||
let mut resp = to_user_response(&user);
|
} else {
|
||||||
resp.is_followed_by_viewer = is_followed;
|
false
|
||||||
Ok(Json(resp))
|
};
|
||||||
|
let mut resp = to_user_response(&user);
|
||||||
|
resp.is_followed_by_viewer = is_followed;
|
||||||
|
Ok(Json(resp).into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -92,7 +106,7 @@ pub async fn get_me(
|
|||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_me_following_list(
|
pub async fn get_me_following(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
@@ -170,3 +184,135 @@ pub async fn get_user_count(
|
|||||||
let count = s.users.count().await?;
|
let count = s.users.count().await?;
|
||||||
Ok(Json(serde_json::json!({ "count": count })))
|
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 crate::{handlers::*, openapi, state::AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,36 +11,38 @@ pub fn router() -> Router<AppState> {
|
|||||||
// auth
|
// auth
|
||||||
.route("/auth/register", post(auth::post_register))
|
.route("/auth/register", post(auth::post_register))
|
||||||
.route("/auth/login", post(auth::post_login))
|
.route("/auth/login", post(auth::post_login))
|
||||||
// users — static paths before parameterised
|
// users — static before parameterised
|
||||||
.route("/users", get(users::get_users))
|
.route("/users", get(users::get_users))
|
||||||
.route("/users/count", get(users::get_user_count))
|
.route("/users/count", get(users::get_user_count))
|
||||||
.route(
|
.route("/users/lookup", get(users::lookup_handler))
|
||||||
"/users/me",
|
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
||||||
get(users::get_me)
|
.route("/users/me/following", get(users::get_me_following))
|
||||||
.patch(users::patch_profile)
|
|
||||||
.put(users::patch_profile),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/users/me/following-list",
|
|
||||||
get(users::get_me_following_list),
|
|
||||||
)
|
|
||||||
.route("/users/me/top-friends", put(social::put_top_friends))
|
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||||
// /users/{username} is owned by the AP router (returns AP actor JSON for federation).
|
.route("/users/{username}", get(users::get_user))
|
||||||
// The REST user profile lives at /users/{username}/profile to avoid the conflict.
|
|
||||||
.route("/users/{username}/profile", get(users::get_user))
|
|
||||||
.route(
|
.route(
|
||||||
"/users/{username}/top-friends",
|
"/users/{username}/top-friends",
|
||||||
get(social::get_top_friends_handler),
|
get(social::get_top_friends_handler),
|
||||||
)
|
)
|
||||||
// follows & blocks (use {id} param)
|
|
||||||
.route(
|
.route(
|
||||||
"/users/{id}/follow",
|
"/users/{username}/follow",
|
||||||
post(social::post_follow).delete(social::delete_follow),
|
post(social::post_follow).delete(social::delete_follow),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/users/{id}/block",
|
"/users/{username}/block",
|
||||||
post(social::post_block).delete(social::delete_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
|
// thoughts
|
||||||
.route("/thoughts", post(thoughts::post_thought))
|
.route("/thoughts", post(thoughts::post_thought))
|
||||||
.route(
|
.route(
|
||||||
@@ -63,42 +65,23 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/feed", get(feed::home_feed))
|
.route("/feed", get(feed::home_feed))
|
||||||
.route("/feed/public", get(feed::public_feed))
|
.route("/feed/public", get(feed::public_feed))
|
||||||
.route("/search", get(feed::search_handler))
|
.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/popular", get(feed::get_popular_tags))
|
||||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||||
// notifications
|
// notifications
|
||||||
.route("/notifications", get(notifications::list_notifications))
|
|
||||||
.route(
|
.route(
|
||||||
"/notifications/read-all",
|
"/notifications",
|
||||||
post(notifications::mark_all_read),
|
get(notifications::list_notifications).patch(notifications::mark_all_read),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/notifications/{id}/read",
|
"/notifications/{id}",
|
||||||
post(notifications::mark_notification_read),
|
patch(notifications::mark_notification_read),
|
||||||
)
|
)
|
||||||
// api keys
|
// api keys
|
||||||
.route(
|
.route(
|
||||||
"/api-keys",
|
"/api-keys",
|
||||||
get(api_keys::get_api_keys).post(api_keys::post_api_key),
|
get(api_keys::get_api_keys).post(api_keys::post_api_key),
|
||||||
)
|
)
|
||||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
|
.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),
|
|
||||||
);
|
|
||||||
|
|
||||||
openapi::serve(api_routes)
|
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 { useState } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -24,7 +24,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await followRemoteUser(actor.handle, token);
|
await followUser(actor.handle, token);
|
||||||
setFollowed(true);
|
setFollowed(true);
|
||||||
toast.success(`Follow request sent to ${actor.handle}`);
|
toast.success(`Follow request sent to ${actor.handle}`);
|
||||||
} catch {
|
} 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);
|
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
|
||||||
|
|
||||||
export const getMeFollowingList = (token: string) =>
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const getUserProfile = (username: string, token: string | null) =>
|
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) =>
|
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) =>
|
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) =>
|
export const getTopFriends = (username: string, token: string | null) =>
|
||||||
apiFetch(`/users/${username}/top-friends`, {}, z.object({ topFriends: z.array(z.string()) }), token);
|
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) =>
|
export const unfollowUser = (username: string, token: string) =>
|
||||||
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
|
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(
|
apiFetch(
|
||||||
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
|
`/notifications/${id}`,
|
||||||
{},
|
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||||
RemoteActorSchema,
|
z.null(),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
export const followRemoteUser = (handle: string, token: string) =>
|
export const markAllNotificationsRead = (token: string) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
`/federation/follow`,
|
"/notifications",
|
||||||
{ method: "POST", body: JSON.stringify({ handle }) },
|
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||||
z.null(),
|
z.null(),
|
||||||
token
|
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) =>
|
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
`/users?page=${page}&per_page=${pageSize}`,
|
`/users?page=${page}&per_page=${pageSize}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user