0fcd617fd1
migrate DB entries to authz engine
and wire authz engine to caldav and carddav
1113 lines
37 KiB
Rust
1113 lines
37 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Path, Query, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use chrono::{DateTime, NaiveDate, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use tracing::error;
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
|
|
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
|
use crate::application::dtos::contact_dto::{
|
|
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
|
|
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto,
|
|
};
|
|
use crate::application::dtos::user_dto::UserDto;
|
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
|
use crate::application::services::contact_service::ContactService;
|
|
use crate::domain::errors::ErrorKind;
|
|
use crate::interfaces::middleware::auth::AuthUser;
|
|
|
|
const SYSTEM_BOOK_ID: &str = "system";
|
|
|
|
/// Combined state for the contacts REST API.
|
|
#[derive(Clone)]
|
|
pub struct ContactsApiState {
|
|
pub contact_service: Arc<ContactService>,
|
|
pub auth_service: Option<Arc<AuthApplicationService>>,
|
|
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
|
pub expose_system_users: bool,
|
|
}
|
|
|
|
/// Address book entry with `is_readonly` and `is_system` flags.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct AddressBookResponse {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub owner_id: String,
|
|
pub description: Option<String>,
|
|
pub color: Option<String>,
|
|
pub is_public: bool,
|
|
pub is_readonly: bool,
|
|
pub is_system: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Request body for creating an address book.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct CreateAddressBookRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub color: Option<String>,
|
|
pub is_public: Option<bool>,
|
|
}
|
|
|
|
/// Request body for updating an address book.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateAddressBookRequest {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub color: Option<String>,
|
|
pub is_public: Option<bool>,
|
|
}
|
|
|
|
/// Request body for creating a contact.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct CreateContactRequest {
|
|
pub full_name: Option<String>,
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub nickname: Option<String>,
|
|
#[serde(default)]
|
|
pub email: Vec<EmailDto>,
|
|
#[serde(default)]
|
|
pub phone: Vec<PhoneDto>,
|
|
#[serde(default)]
|
|
pub address: Vec<AddressDto>,
|
|
pub organization: Option<String>,
|
|
pub title: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub photo_url: Option<String>,
|
|
pub birthday: Option<NaiveDate>,
|
|
pub anniversary: Option<NaiveDate>,
|
|
}
|
|
|
|
/// Request body for updating a contact (all fields optional).
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateContactRequest {
|
|
pub full_name: Option<String>,
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub nickname: Option<String>,
|
|
pub email: Option<Vec<EmailDto>>,
|
|
pub phone: Option<Vec<PhoneDto>>,
|
|
pub address: Option<Vec<AddressDto>>,
|
|
pub organization: Option<String>,
|
|
pub title: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub photo_url: Option<String>,
|
|
pub birthday: Option<NaiveDate>,
|
|
pub anniversary: Option<NaiveDate>,
|
|
}
|
|
|
|
/// Request body for creating or renaming a group.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct GroupNameRequest {
|
|
pub name: String,
|
|
}
|
|
|
|
/// Request body for adding a contact to a group.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct AddMemberRequest {
|
|
pub contact_id: String,
|
|
}
|
|
|
|
/// Query parameters for paginated listing.
|
|
///
|
|
/// Both fields are optional: when omitted, regular address books return
|
|
/// the full contact list (the frontend relies on this), while the system
|
|
/// book falls back to its own defaults (limit 100, offset 0).
|
|
#[derive(Deserialize)]
|
|
pub struct ListQuery {
|
|
limit: Option<i64>,
|
|
offset: Option<i64>,
|
|
/// System address book only — when `true`, includes the calling user
|
|
/// in the returned contacts. Default `false` matches the share-modal
|
|
/// "you can't share with yourself" semantics; the admin drive-owner
|
|
/// surface flips it on so an admin can add themselves as Owner.
|
|
/// Ignored for non-system books.
|
|
#[serde(default)]
|
|
include_self: bool,
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
fn domain_err_to_response(err: crate::domain::errors::DomainError) -> Response {
|
|
let status = match err.kind {
|
|
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
|
ErrorKind::AccessDenied => StatusCode::FORBIDDEN,
|
|
ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
(
|
|
status,
|
|
Json(serde_json::json!({ "error": err.to_string() })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn system_book_unavailable() -> Response {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "System address book not available" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn system_book_readonly() -> Response {
|
|
(
|
|
StatusCode::METHOD_NOT_ALLOWED,
|
|
Json(serde_json::json!({ "error": "System address book is read-only" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Attach a quoted `ETag` header to an existing response.
|
|
fn with_etag(mut response: Response, etag: &str) -> Response {
|
|
if let Ok(val) = HeaderValue::from_str(&format!("\"{}\"", etag)) {
|
|
response.headers_mut().insert(header::ETAG, val);
|
|
}
|
|
response
|
|
}
|
|
|
|
/// Return `false` when the stored etag does not satisfy the `If-Match` value.
|
|
/// Handles the `*` wildcard and quoted strings per RFC 7232.
|
|
fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
|
|
match if_match {
|
|
None | Some("*") => true,
|
|
Some(value) => value.trim_matches('"') == stored_etag,
|
|
}
|
|
}
|
|
|
|
/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts
|
|
/// inside the virtual system address book.
|
|
///
|
|
/// `given_name`/`family_name` come from OIDC standard claims at JIT
|
|
/// provisioning (or NULL for password-only or pre-OIDC users). When
|
|
/// they're present, prefer a "First Last" full name; otherwise fall
|
|
/// back to the username (which is always present).
|
|
fn user_to_contact(user: UserDto) -> ContactDto {
|
|
// Display fallback chain: given+family name → username → email.
|
|
// Username is `Option<String>` post PR 16; externals start with None.
|
|
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
|
(Some(g), Some(f)) => format!("{g} {f}"),
|
|
(Some(g), None) => g.to_string(),
|
|
(None, Some(f)) => f.to_string(),
|
|
(None, None) => user.username.clone().unwrap_or_else(|| user.email.clone()),
|
|
};
|
|
ContactDto {
|
|
id: user.id.clone(),
|
|
address_book_id: SYSTEM_BOOK_ID.to_string(),
|
|
uid: format!("{}@oxicloud", user.id),
|
|
full_name: Some(full_name),
|
|
first_name: user.given_name.clone(),
|
|
last_name: user.family_name.clone(),
|
|
nickname: None,
|
|
email: vec![EmailDto {
|
|
email: user.email,
|
|
r#type: "work".to_string(),
|
|
is_primary: true,
|
|
}],
|
|
phone: vec![],
|
|
address: vec![],
|
|
organization: Some("OxiCloud".to_string()),
|
|
title: None,
|
|
notes: None,
|
|
photo_url: user.image.clone(),
|
|
birthday: None,
|
|
anniversary: None,
|
|
created_at: user.created_at,
|
|
updated_at: user.updated_at,
|
|
etag: user.id,
|
|
}
|
|
}
|
|
|
|
// ── Address books ─────────────────────────────────────────────────────────────
|
|
|
|
/// List all address books accessible to the current user (owned + shared),
|
|
/// plus the virtual read-only system book listing all OxiCloud users.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books",
|
|
responses(
|
|
(status = 200, description = "List of address books"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn list_address_books(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
) -> impl IntoResponse {
|
|
match state
|
|
.contact_service
|
|
.list_user_address_books(auth_user.id)
|
|
.await
|
|
{
|
|
Ok(books) => {
|
|
let user_id_str = auth_user.id.to_string();
|
|
let mut response: Vec<AddressBookResponse> = books
|
|
.into_iter()
|
|
.map(|b| {
|
|
let is_readonly = b.owner_id != user_id_str;
|
|
AddressBookResponse {
|
|
id: b.id,
|
|
name: b.name,
|
|
owner_id: b.owner_id,
|
|
description: b.description,
|
|
color: b.color,
|
|
is_public: b.is_public,
|
|
is_readonly,
|
|
is_system: false,
|
|
created_at: b.created_at,
|
|
updated_at: b.updated_at,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Skip the system address book for external callers so they
|
|
// don't see an internal-user directory entry (let alone its
|
|
// contents). The system book is only useful to internal
|
|
// users picking sharees out of the directory.
|
|
let hide_system_for_external = match state.auth_service.as_ref() {
|
|
Some(svc) => {
|
|
crate::interfaces::middleware::user::require_internal_user(svc, auth_user.id)
|
|
.await
|
|
.is_err()
|
|
}
|
|
None => false,
|
|
};
|
|
if state.expose_system_users
|
|
&& state.auth_service.is_some()
|
|
&& !hide_system_for_external
|
|
{
|
|
let now = Utc::now();
|
|
response.push(AddressBookResponse {
|
|
id: SYSTEM_BOOK_ID.to_string(),
|
|
name: "OxiCloud Users".to_string(),
|
|
owner_id: "system".to_string(),
|
|
description: Some("All users registered on this OxiCloud instance".to_string()),
|
|
color: None,
|
|
is_public: false,
|
|
is_readonly: true,
|
|
is_system: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
}
|
|
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
Err(err) => {
|
|
error!(
|
|
"Error listing address books for user {}: {}",
|
|
auth_user.id, err
|
|
);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new personal address book.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/address-books",
|
|
responses(
|
|
(status = 201, description = "Address book created", body = AddressBookResponse),
|
|
(status = 400, description = "Invalid input"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn create_address_book(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Json(body): Json<CreateAddressBookRequest>,
|
|
) -> impl IntoResponse {
|
|
let dto = CreateAddressBookDto {
|
|
name: body.name,
|
|
owner_id: auth_user.id.to_string(),
|
|
description: body.description,
|
|
color: body.color,
|
|
is_public: body.is_public,
|
|
};
|
|
match state.contact_service.create_address_book(dto).await {
|
|
Ok(book) => {
|
|
let response = AddressBookResponse {
|
|
id: book.id,
|
|
name: book.name,
|
|
owner_id: book.owner_id,
|
|
description: book.description,
|
|
color: book.color,
|
|
is_public: book.is_public,
|
|
is_readonly: false,
|
|
is_system: false,
|
|
created_at: book.created_at,
|
|
updated_at: book.updated_at,
|
|
};
|
|
(StatusCode::CREATED, Json(response)).into_response()
|
|
}
|
|
Err(err) => {
|
|
error!("Error creating address book: {}", err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update an address book's name, description, or color.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/address-books/{book_id}",
|
|
params(("book_id" = String, Path, description = "Address book UUID")),
|
|
responses(
|
|
(status = 200, description = "Address book updated", body = AddressBookResponse),
|
|
(status = 403, description = "Only the owner can update"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn update_address_book(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
Json(body): Json<UpdateAddressBookRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = UpdateAddressBookDto {
|
|
name: body.name,
|
|
description: body.description,
|
|
color: body.color,
|
|
is_public: body.is_public,
|
|
user_id: auth_user.id.to_string(),
|
|
};
|
|
match state
|
|
.contact_service
|
|
.update_address_book(&book_id, dto)
|
|
.await
|
|
{
|
|
Ok(book) => {
|
|
let response = AddressBookResponse {
|
|
id: book.id,
|
|
name: book.name,
|
|
owner_id: book.owner_id.clone(),
|
|
description: book.description,
|
|
color: book.color,
|
|
is_public: book.is_public,
|
|
is_readonly: book.owner_id != auth_user.id.to_string(),
|
|
is_system: false,
|
|
created_at: book.created_at,
|
|
updated_at: book.updated_at,
|
|
};
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
Err(err) => {
|
|
error!("Error updating address book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete an address book and all its contacts. Only the owner can do this.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/address-books/{book_id}",
|
|
params(("book_id" = String, Path, description = "Address book UUID")),
|
|
responses(
|
|
(status = 204, description = "Address book deleted"),
|
|
(status = 403, description = "Only the owner can delete"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn delete_address_book(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
match state
|
|
.contact_service
|
|
.delete_address_book(&book_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
error!("Error deleting address book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Contacts ──────────────────────────────────────────────────────────────────
|
|
|
|
/// List contacts in an address book.
|
|
/// `book_id = "system"` returns all OxiCloud users (excluding the caller).
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books/{book_id}/contacts",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID or \"system\""),
|
|
("limit" = Option<i64>, Query, description = "Max results (omit for the full book; system book defaults to 100)"),
|
|
("offset" = Option<i64>, Query, description = "Pagination offset (omit for none; system book defaults to 0)"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "List of contacts"),
|
|
(status = 403, description = "Access denied"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn list_contacts(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
Query(params): Query<ListQuery>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
if !state.expose_system_users {
|
|
return system_book_unavailable();
|
|
}
|
|
let Some(auth_service) = &state.auth_service else {
|
|
return system_book_unavailable();
|
|
};
|
|
// External callers must not enumerate the internal-user
|
|
// directory through the system address book.
|
|
if let Err(e) =
|
|
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
|
.await
|
|
{
|
|
return e.into_response();
|
|
}
|
|
let caller_id = auth_user.id.to_string();
|
|
match auth_service
|
|
.list_users(params.limit.unwrap_or(100), params.offset.unwrap_or(0))
|
|
.await
|
|
{
|
|
Ok(users) => {
|
|
let include_self = params.include_self;
|
|
let contacts: Vec<ContactDto> = users
|
|
.into_iter()
|
|
.filter(|u| include_self || u.id != caller_id)
|
|
.map(user_to_contact)
|
|
.collect();
|
|
(StatusCode::OK, Json(contacts)).into_response()
|
|
}
|
|
Err(err) => {
|
|
error!("Error listing OxiCloud users: {}", err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
} else {
|
|
match state
|
|
.contact_service
|
|
.list_contacts(&book_id, params.limit, params.offset, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(),
|
|
Err(err) => {
|
|
error!("Error listing contacts in book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new contact in an address book.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/address-books/{book_id}/contacts",
|
|
params(("book_id" = String, Path, description = "Address book UUID")),
|
|
responses(
|
|
(status = 201, description = "Contact created"),
|
|
(status = 400, description = "Invalid input"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn create_contact(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
Json(body): Json<CreateContactRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = CreateContactDto {
|
|
address_book_id: book_id.clone(),
|
|
full_name: body.full_name,
|
|
first_name: body.first_name,
|
|
last_name: body.last_name,
|
|
nickname: body.nickname,
|
|
email: body.email,
|
|
phone: body.phone,
|
|
address: body.address,
|
|
organization: body.organization,
|
|
title: body.title,
|
|
notes: body.notes,
|
|
photo_url: body.photo_url,
|
|
birthday: body.birthday,
|
|
anniversary: body.anniversary,
|
|
user_id: auth_user.id.to_string(),
|
|
};
|
|
match state.contact_service.create_contact(dto).await {
|
|
Ok(contact) => {
|
|
let etag = contact.etag.clone();
|
|
with_etag((StatusCode::CREATED, Json(contact)).into_response(), &etag)
|
|
}
|
|
Err(err) => {
|
|
error!("Error creating contact in book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a single contact. Returns an `ETag` header for optimistic concurrency.
|
|
/// `book_id = "system"` looks up an OxiCloud user by UUID.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books/{book_id}/contacts/{contact_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID or \"system\""),
|
|
("contact_id" = String, Path, description = "Contact UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Contact details"),
|
|
(status = 403, description = "Access denied"),
|
|
(status = 404, description = "Contact not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn get_contact(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, contact_id)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
if !state.expose_system_users {
|
|
return system_book_unavailable();
|
|
}
|
|
let Some(auth_service) = &state.auth_service else {
|
|
return system_book_unavailable();
|
|
};
|
|
if let Err(e) =
|
|
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
|
.await
|
|
{
|
|
return e.into_response();
|
|
}
|
|
let Ok(uuid) = Uuid::parse_str(&contact_id) else {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({ "error": "Invalid user ID format" })),
|
|
)
|
|
.into_response();
|
|
};
|
|
match auth_service.get_user_by_id(uuid).await {
|
|
Ok(user) => {
|
|
let contact = user_to_contact(user);
|
|
let etag = contact.etag.clone();
|
|
with_etag((StatusCode::OK, Json(contact)).into_response(), &etag)
|
|
}
|
|
Err(err) => {
|
|
error!("Error fetching OxiCloud user {}: {}", contact_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
} else {
|
|
match state
|
|
.contact_service
|
|
.get_contact(&contact_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(contact) => {
|
|
let etag = contact.etag.clone();
|
|
with_etag((StatusCode::OK, Json(contact)).into_response(), &etag)
|
|
}
|
|
Err(err) => {
|
|
error!("Error fetching contact {}: {}", contact_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update a contact. Honours `If-Match` for optimistic concurrency — returns
|
|
/// `412 Precondition Failed` when the stored ETag does not match.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/address-books/{book_id}/contacts/{contact_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("contact_id" = String, Path, description = "Contact UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Contact updated"),
|
|
(status = 400, description = "Invalid input"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Contact not found"),
|
|
(status = 412, description = "ETag mismatch — contact was modified"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn update_contact(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
headers: HeaderMap,
|
|
Path((book_id, contact_id)): Path<(String, String)>,
|
|
Json(body): Json<UpdateContactRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
|
|
// ETag check: fetch current contact first, compare against If-Match.
|
|
let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok());
|
|
|
|
if if_match.is_some() {
|
|
match state
|
|
.contact_service
|
|
.get_contact(&contact_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(current) => {
|
|
if !if_match_passes(if_match, ¤t.etag) {
|
|
return (
|
|
StatusCode::PRECONDITION_FAILED,
|
|
Json(serde_json::json!({
|
|
"error": "Contact was modified — fetch the latest version and retry"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
Err(err) => return domain_err_to_response(err),
|
|
}
|
|
}
|
|
|
|
let dto = UpdateContactDto {
|
|
full_name: body.full_name,
|
|
first_name: body.first_name,
|
|
last_name: body.last_name,
|
|
nickname: body.nickname,
|
|
email: body.email,
|
|
phone: body.phone,
|
|
address: body.address,
|
|
organization: body.organization,
|
|
title: body.title,
|
|
notes: body.notes,
|
|
photo_url: body.photo_url,
|
|
birthday: body.birthday,
|
|
anniversary: body.anniversary,
|
|
user_id: auth_user.id.to_string(),
|
|
};
|
|
|
|
match state.contact_service.update_contact(&contact_id, dto).await {
|
|
Ok(contact) => {
|
|
let etag = contact.etag.clone();
|
|
with_etag((StatusCode::OK, Json(contact)).into_response(), &etag)
|
|
}
|
|
Err(err) => {
|
|
error!("Error updating contact {}: {}", contact_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete a contact. Optionally honours `If-Match`.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/address-books/{book_id}/contacts/{contact_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("contact_id" = String, Path, description = "Contact UUID"),
|
|
),
|
|
responses(
|
|
(status = 204, description = "Contact deleted"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Contact not found"),
|
|
(status = 412, description = "ETag mismatch"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn delete_contact(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
headers: HeaderMap,
|
|
Path((book_id, contact_id)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
|
|
let if_match = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok());
|
|
|
|
if if_match.is_some() {
|
|
match state
|
|
.contact_service
|
|
.get_contact(&contact_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(current) => {
|
|
if !if_match_passes(if_match, ¤t.etag) {
|
|
return (
|
|
StatusCode::PRECONDITION_FAILED,
|
|
Json(serde_json::json!({
|
|
"error": "Contact was modified — fetch the latest version and retry"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
Err(err) => return domain_err_to_response(err),
|
|
}
|
|
}
|
|
|
|
match state
|
|
.contact_service
|
|
.delete_contact(&contact_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
error!("Error deleting contact {}: {}", contact_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Groups ────────────────────────────────────────────────────────────────────
|
|
|
|
/// List contact groups in an address book. The system book has no groups.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books/{book_id}/groups",
|
|
params(("book_id" = String, Path, description = "Address book UUID or \"system\"")),
|
|
responses(
|
|
(status = 200, description = "List of contact groups"),
|
|
(status = 403, description = "Access denied"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn list_groups(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return (StatusCode::OK, Json(Vec::<ContactGroupDto>::new())).into_response();
|
|
}
|
|
match state
|
|
.contact_service
|
|
.list_groups(&book_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(groups) => (StatusCode::OK, Json(groups)).into_response(),
|
|
Err(err) => {
|
|
error!("Error listing groups in book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a contact group in an address book.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/address-books/{book_id}/groups",
|
|
params(("book_id" = String, Path, description = "Address book UUID")),
|
|
responses(
|
|
(status = 201, description = "Group created"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Address book not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn create_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path(book_id): Path<String>,
|
|
Json(body): Json<GroupNameRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = CreateContactGroupDto {
|
|
address_book_id: book_id.clone(),
|
|
name: body.name,
|
|
user_id: auth_user.id.to_string(),
|
|
};
|
|
match state.contact_service.create_group(dto).await {
|
|
Ok(group) => (StatusCode::CREATED, Json(group)).into_response(),
|
|
Err(err) => {
|
|
error!("Error creating group in book {}: {}", book_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a single contact group by ID.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Group details"),
|
|
(status = 403, description = "Access denied"),
|
|
(status = 404, description = "Group not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn get_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "System address book has no groups" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
match state
|
|
.contact_service
|
|
.get_group(&group_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(group) => (StatusCode::OK, Json(group)).into_response(),
|
|
Err(err) => {
|
|
error!("Error fetching group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rename a contact group.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Group updated"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Group not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn update_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id)): Path<(String, String)>,
|
|
Json(body): Json<GroupNameRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = UpdateContactGroupDto {
|
|
name: body.name,
|
|
user_id: auth_user.id.to_string(),
|
|
};
|
|
match state.contact_service.update_group(&group_id, dto).await {
|
|
Ok(group) => (StatusCode::OK, Json(group)).into_response(),
|
|
Err(err) => {
|
|
error!("Error updating group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete a contact group.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
),
|
|
responses(
|
|
(status = 204, description = "Group deleted"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Group not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn delete_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
match state
|
|
.contact_service
|
|
.delete_group(&group_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
error!("Error deleting group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Group membership ──────────────────────────────────────────────────────────
|
|
|
|
/// List contacts that belong to a group.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}/contacts",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Contacts in the group"),
|
|
(status = 403, description = "Access denied"),
|
|
(status = 404, description = "Group not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn list_contacts_in_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "System address book has no groups" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
match state
|
|
.contact_service
|
|
.list_contacts_in_group(&group_id, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(),
|
|
Err(err) => {
|
|
error!("Error listing contacts in group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add a contact to a group.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}/contacts",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
),
|
|
responses(
|
|
(status = 204, description = "Contact added to group"),
|
|
(status = 400, description = "Invalid contact ID"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Group or contact not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn add_contact_to_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id)): Path<(String, String)>,
|
|
Json(body): Json<AddMemberRequest>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = GroupMembershipDto {
|
|
group_id: group_id.clone(),
|
|
contact_id: body.contact_id,
|
|
};
|
|
match state
|
|
.contact_service
|
|
.add_contact_to_group(dto, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
error!("Error adding contact to group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove a contact from a group.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/address-books/{book_id}/groups/{group_id}/contacts/{contact_id}",
|
|
params(
|
|
("book_id" = String, Path, description = "Address book UUID"),
|
|
("group_id" = String, Path, description = "Group UUID"),
|
|
("contact_id" = String, Path, description = "Contact UUID"),
|
|
),
|
|
responses(
|
|
(status = 204, description = "Contact removed from group"),
|
|
(status = 403, description = "Access denied or read-only book"),
|
|
(status = 404, description = "Group or contact not found"),
|
|
),
|
|
security(("bearerAuth" = [])),
|
|
tag = "contacts"
|
|
)]
|
|
pub async fn remove_contact_from_group(
|
|
State(state): State<ContactsApiState>,
|
|
auth_user: AuthUser,
|
|
Path((book_id, group_id, contact_id)): Path<(String, String, String)>,
|
|
) -> impl IntoResponse {
|
|
if book_id == SYSTEM_BOOK_ID {
|
|
return system_book_readonly();
|
|
}
|
|
let dto = GroupMembershipDto {
|
|
group_id: group_id.clone(),
|
|
contact_id,
|
|
};
|
|
match state
|
|
.contact_service
|
|
.remove_contact_from_group(dto, auth_user.id)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
error!("Error removing contact from group {}: {}", group_id, err);
|
|
domain_err_to_response(err)
|
|
}
|
|
}
|
|
}
|