refactor: type safety + dedup cleanup across 13 code smells
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled

- typed PagedResponse/CreatedApiKeyResponse/NotificationSummaryResponse replace json! blocks
- extract TagRow/ApiKeyRow/OutboxRow to module level, top_friend uses sqlx flatten
- add should_broadcast() helper, inline dead let bindings in federation_event
- add UploadContext struct, extract_upload_field, wants_activity_json helpers
- rename PostgresFederationRepository→PgFederationRepository, PostgresApUserRepository→PgApUserRepository
- add IntoAnyhow trait replacing ~30 .map_err(|e| anyhow!(e)) calls
- extract build_ap_service shared between bootstrap and worker factories
- add postgres/constants.rs, PartialEq+Eq on PasswordHash
This commit is contained in:
2026-05-29 12:02:03 +02:00
parent 84edf58de6
commit 9798a1d829
20 changed files with 485 additions and 569 deletions

View File

@@ -37,11 +37,13 @@ pub async fn post_api_key(
Deps(d): Deps<ApiKeysDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<CreateApiKeyRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<CreatedApiKeyResponse>, ApiError> {
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
Ok(Json(
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }),
))
Ok(Json(CreatedApiKeyResponse {
id: key.id.as_uuid(),
name: key.name,
key: raw,
}))
}
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
pub async fn delete_api_key_handler(

View File

@@ -5,7 +5,7 @@ use crate::{
handlers::auth::to_user_response,
};
use api_types::requests::{PaginationQuery, SearchQuery};
use api_types::responses::ThoughtResponse;
use api_types::responses::{PagedResponse, ThoughtResponse};
use application::use_cases::feed::{
get_home_feed, get_popular_tags as uc_get_popular_tags, get_public_feed, get_tag_feed,
get_user_feed,
@@ -75,6 +75,13 @@ impl TryFrom<FeedOptionsQuery> for FeedOptions {
}
}
fn wants_activity_json(headers: &HeaderMap) -> bool {
headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("application/activity+json"))
}
deps_struct!(FeedDeps {
feed: FeedRepository,
follows: FollowRepository,
@@ -116,19 +123,19 @@ pub async fn home_feed(
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let opts = FeedOptions::try_from(opts_q)?;
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page, opts).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
Ok(Json(PagedResponse {
items: result.items.iter().map(to_thought_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[utoipa::path(
@@ -141,19 +148,19 @@ pub async fn public_feed(
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let opts = FeedOptions::try_from(opts_q)?;
let result = get_public_feed(&*d.feed, viewer, page, opts).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
Ok(Json(PagedResponse {
items: result.items.iter().map(to_thought_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[utoipa::path(
@@ -210,12 +217,7 @@ pub async fn get_following_handler(
Query(q): Query<PaginationQuery>,
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") {
if wants_activity_json(&headers) {
let user = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id;
let page = q.page().try_into().ok();
@@ -232,10 +234,12 @@ pub async fn get_following_handler(
per_page: q.per_page(),
};
let result = list_local_following(&*d.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(PagedResponse {
items: result.items.iter().map(to_user_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
})
.into_response())
}
@@ -253,12 +257,7 @@ pub async fn get_followers_handler(
Query(q): Query<PaginationQuery>,
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") {
if wants_activity_json(&headers) {
let user = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id;
let page = q.page().try_into().ok();
@@ -275,10 +274,12 @@ pub async fn get_followers_handler(
per_page: q.per_page(),
};
let result = list_local_followers(&*d.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(PagedResponse {
items: result.items.iter().map(to_user_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
})
.into_response())
}
@@ -297,7 +298,7 @@ pub async fn user_thoughts_handler(
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?;
let page = PageParams {
page: q.page(),
@@ -305,12 +306,12 @@ pub async fn user_thoughts_handler(
};
let opts = FeedOptions::try_from(opts_q)?;
let result = get_user_feed(&*d.feed, user.id.clone(), page, opts, viewer).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>()
})))
Ok(Json(PagedResponse {
items: result.items.iter().map(to_thought_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[utoipa::path(
@@ -353,18 +354,17 @@ pub async fn tag_thoughts_handler(
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let opts = FeedOptions::try_from(opts_q)?;
let result = get_tag_feed(&*d.feed, &tag_name, page, opts, viewer).await?;
Ok(Json(serde_json::json!({
"tag": tag_name,
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
Ok(Json(PagedResponse {
items: result.items.iter().map(to_thought_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}

View File

@@ -3,7 +3,7 @@ use crate::{
errors::ApiError,
extractors::{AuthUser, Deps},
};
use api_types::requests::NotificationUpdateRequest;
use api_types::{requests::NotificationUpdateRequest, responses::NotificationSummaryResponse};
use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
@@ -22,17 +22,17 @@ deps_struct!(NotificationsDeps {
pub async fn list_notifications(
Deps(d): Deps<NotificationsDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<NotificationSummaryResponse>, ApiError> {
let page = PageParams {
page: 1,
per_page: 20,
};
let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
let unread = count_unread_notifications(&*d.notifications, &uid).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"unread": unread
})))
Ok(Json(NotificationSummaryResponse {
total: result.total,
unread,
}))
}
#[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" = [])))]

View File

@@ -6,12 +6,12 @@ use crate::{
};
use api_types::{
requests::{PaginationQuery, UpdateProfileRequest},
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
responses::{ErrorResponse, PagedResponse, ProfileField, RemoteActorResponse, UserResponse},
};
use application::use_cases::profile::{
count_local_users, get_user as fetch_user, get_user_by_id_or_username, get_user_profile,
list_local_following, list_users, update_profile, upload_avatar as upload_avatar_uc,
upload_banner as upload_banner_uc, UploadConfig,
upload_banner as upload_banner_uc, UploadConfig, UploadContext,
};
use axum::{
extract::{Multipart, Path, Query},
@@ -54,6 +54,13 @@ impl FromAppState for UsersDeps {
}
}
fn wants_activity_json(headers: &HeaderMap) -> bool {
headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("application/activity+json"))
}
#[utoipa::path(
get, path = "/users/{username}",
params(("username" = String, Path, description = "Username")),
@@ -68,12 +75,7 @@ pub async fn get_user(
OptionalAuthUser(viewer): OptionalAuthUser,
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") {
if wants_activity_json(&headers) {
let user = get_user_by_id_or_username(&*d.users, &username).await?;
let json = d.federation.actor_json(&user.id).await?;
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
@@ -145,17 +147,19 @@ pub async fn get_me_following(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<UserResponse>>, ApiError> {
use domain::models::feed::PageParams;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = list_local_following(&*d.follows, &uid, page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>(),
})))
Ok(Json(PagedResponse {
items: result.items.iter().map(to_user_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[utoipa::path(
@@ -170,7 +174,7 @@ pub async fn get_me_following(
pub async fn get_users(
Deps(d): Deps<UsersDeps>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<PagedResponse<UserResponse>>, ApiError> {
use domain::models::feed::PageParams;
let page = params
.get("page")
@@ -184,38 +188,37 @@ pub async fn get_users(
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
let result = d.search.search_users(q, &page_params).await?;
let users: Vec<_> = result
.items
.iter()
.map(crate::handlers::auth::to_user_response)
.collect();
return Ok(Json(serde_json::json!({
"items": users, "total": result.total, "page": result.page, "per_page": result.per_page
})));
return Ok(Json(PagedResponse {
items: result.items.iter().map(to_user_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}));
}
let result = list_users(&*d.users, page_params).await?;
let items: Vec<_> = result
let items: Vec<UserResponse> = result
.items
.iter()
.map(|u| {
serde_json::json!({
"id": u.id.as_uuid(),
"username": u.username,
"displayName": u.display_name,
"avatarUrl": u.avatar_url,
"bio": u.bio,
"headerUrl": null,
"customCss": null,
"local": true,
"isFollowedByViewer": false,
"joinedAt": null,
})
.map(|u| UserResponse {
id: u.id.as_uuid(),
username: u.username.clone(),
display_name: u.display_name.clone(),
bio: u.bio.clone(),
avatar_url: u.avatar_url.clone(),
header_url: None,
custom_css: None,
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
})
.collect();
Ok(Json(serde_json::json!({
"items": items, "total": result.total, "page": result.page, "per_page": result.per_page
})))
Ok(Json(PagedResponse {
items,
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[utoipa::path(
@@ -266,6 +269,25 @@ pub async fn lookup_handler(
}))
}
async fn extract_upload_field(
mut multipart: Multipart,
) -> Result<(String, axum::body::Bytes), ApiError> {
let field = multipart
.next_field()
.await
.map_err(|_| ApiError::BadRequest("invalid multipart".into()))?
.ok_or_else(|| ApiError::BadRequest("no file field".into()))?;
let content_type = field
.content_type()
.ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))?
.to_string();
let data = field
.bytes()
.await
.map_err(|_| ApiError::BadRequest("failed to read upload".into()))?;
Ok((content_type, data))
}
#[utoipa::path(
put, path = "/users/me/avatar",
request_body(content = String, content_type = "multipart/form-data", description = "Image file (JPEG, PNG, WebP, AVIF, GIF)"),
@@ -278,35 +300,17 @@ pub async fn lookup_handler(
pub async fn upload_avatar(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
mut multipart: Multipart,
multipart: Multipart,
) -> Result<Json<UserResponse>, ApiError> {
let field = multipart
.next_field()
.await
.map_err(|_| ApiError::BadRequest("invalid multipart".into()))?
.ok_or_else(|| ApiError::BadRequest("no file field".into()))?;
// Content-type is client-supplied; the use-case allowlist prevents obviously
// wrong types, but magic-byte validation is not performed. Serve media files
// from an isolated origin to prevent MIME-based XSS.
let content_type = field
.content_type()
.ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))?
.to_string();
let data = field
.bytes()
.await
.map_err(|_| ApiError::BadRequest("failed to read upload".into()))?;
upload_avatar_uc(
&*d.users,
&*d.media,
&*d.events,
&uid,
&d.base_url,
&d.upload_config,
&content_type,
data,
)
.await?;
let (content_type, data) = extract_upload_field(multipart).await?;
let ctx = UploadContext {
users: &*d.users,
media: &*d.media,
events: &*d.events,
upload_config: &d.upload_config,
base_url: &d.base_url,
};
upload_avatar_uc(&ctx, &uid, &content_type, data).await?;
let user = fetch_user(&*d.users, &uid).await?;
Ok(Json(to_user_response(&user)))
}
@@ -323,35 +327,17 @@ pub async fn upload_avatar(
pub async fn upload_banner(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
mut multipart: Multipart,
multipart: Multipart,
) -> Result<Json<UserResponse>, ApiError> {
let field = multipart
.next_field()
.await
.map_err(|_| ApiError::BadRequest("invalid multipart".into()))?
.ok_or_else(|| ApiError::BadRequest("no file field".into()))?;
// Content-type is client-supplied; the use-case allowlist prevents obviously
// wrong types, but magic-byte validation is not performed. Serve media files
// from an isolated origin to prevent MIME-based XSS.
let content_type = field
.content_type()
.ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))?
.to_string();
let data = field
.bytes()
.await
.map_err(|_| ApiError::BadRequest("failed to read upload".into()))?;
upload_banner_uc(
&*d.users,
&*d.media,
&*d.events,
&uid,
&d.base_url,
&d.upload_config,
&content_type,
data,
)
.await?;
let (content_type, data) = extract_upload_field(multipart).await?;
let ctx = UploadContext {
users: &*d.users,
media: &*d.media,
events: &*d.events,
upload_config: &d.upload_config,
base_url: &d.base_url,
};
upload_banner_uc(&ctx, &uid, &content_type, data).await?;
let user = fetch_user(&*d.users, &uid).await?;
Ok(Json(to_user_response(&user)))
}

View File

@@ -31,73 +31,60 @@ impl PasswordHasher for NoOpHasher {
}
}
/// No-op ActivityPubRepository for presentation layer tests.
pub struct NoOpApRepo;
#[async_trait]
impl ActivityPubRepository for NoOpApRepo {
async fn outbox_entries_for_actor(
&self,
_uid: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
async fn outbox_entries_for_actor(&self, _: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn outbox_page_for_actor(
&self,
_uid: &UserId,
_before: Option<chrono::DateTime<chrono::Utc>>,
_limit: usize,
_: &UserId,
_: Option<chrono::DateTime<chrono::Utc>>,
_: usize,
) -> Result<Vec<OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn find_remote_actor_id(
&self,
_actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
async fn find_remote_actor_id(&self, _: &str) -> Result<Option<UserId>, DomainError> {
Ok(None)
}
async fn intern_remote_actor(&self, _actor_ap_url: &str) -> Result<UserId, DomainError> {
async fn intern_remote_actor(&self, _: &str) -> Result<UserId, DomainError> {
Err(DomainError::NotFound)
}
async fn update_remote_actor_display(
&self,
_user_id: &UserId,
_display_name: Option<&str>,
_avatar_url: Option<&str>,
_: &UserId,
_: Option<&str>,
_: Option<&str>,
) -> Result<(), DomainError> {
Ok(())
}
async fn accept_note(
&self,
_input: activitypub::AcceptNoteInput<'_>,
_: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
async fn apply_note_update(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
async fn retract_note(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
async fn retract_actor_notes(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
Ok(0)
}
async fn get_thought_ap_id(
&self,
_thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
async fn get_thought_ap_id(&self, _: &ThoughtId) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn get_actor_ap_urls(
&self,
_user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
async fn get_actor_ap_urls(&self, _: &UserId) -> Result<Option<ActorApUrls>, DomainError> {
Ok(None)
}
async fn sync_remote_actor_to_user(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
async fn sync_remote_actor_to_user(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
}