Initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization, Cookie},
|
||||
typed_header::TypedHeader,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
},
|
||||
AuthenticatedUser, TenantScopedConn,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
http::responders::JsonResponse,
|
||||
services::auth::{
|
||||
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
login,
|
||||
api_token_exchange,
|
||||
signup_start,
|
||||
signup_finish,
|
||||
refresh,
|
||||
logout,
|
||||
me,
|
||||
select_tenant,
|
||||
passkey_register_start,
|
||||
passkey_register_finish,
|
||||
passkey_login_start,
|
||||
passkey_login_finish,
|
||||
),
|
||||
components(schemas(
|
||||
LoginRequest,
|
||||
ApiTokenExchangeRequest,
|
||||
SignupStartRequest,
|
||||
SignupStartResponse,
|
||||
SignupFinishRequest,
|
||||
LoginResponse,
|
||||
LoginResponseVariants,
|
||||
TenantSnippet,
|
||||
TenantSelectionResponse,
|
||||
TenantSelectionRequest,
|
||||
TenantListResponse,
|
||||
crate::auth::AuthenticatedUser,
|
||||
crate::auth::passkeys::RegistrationChallengeResponse,
|
||||
crate::auth::passkeys::AuthenticationChallengeResponse,
|
||||
crate::auth::passkeys::PasskeySummary,
|
||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||
crate::models::ApiCapability,
|
||||
))
|
||||
)]
|
||||
pub struct AuthApiDoc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).login(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/exchange-api-token",
|
||||
request_body = ApiTokenExchangeRequest,
|
||||
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_token_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ApiTokenExchangeRequest>,
|
||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
||||
AuthService::new(&state).exchange_api_token(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/start",
|
||||
request_body = SignupStartRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
||||
(status = 400, description = "Invalid signup request"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn signup_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupStartRequest>,
|
||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
||||
AuthService::new(&state).signup_start(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/finish",
|
||||
request_body = SignupFinishRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
||||
(status = 400, description = "Invalid signup completion"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn signup_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupFinishRequest>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).signup_finish(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/refresh",
|
||||
responses(
|
||||
(status = 200, description = "Refreshed access token", body = LoginResponse),
|
||||
(status = 401, description = "Missing or invalid refresh token")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<Response> {
|
||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||
let refresh_value = cookies
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
AuthService::new(&state).refresh(refresh_value)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/select-tenant",
|
||||
request_body = TenantSelectionRequest,
|
||||
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn select_tenant(
|
||||
State(state): State<AppState>,
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/logout",
|
||||
responses((status = 204, description = "Session revoked")),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let refresh_cookie = jar.as_ref().and_then(|cookies| {
|
||||
cookies
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.map(|value| value.to_owned())
|
||||
});
|
||||
AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/me",
|
||||
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
Json(user)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/start",
|
||||
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_register_start(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
||||
AuthService::new(&state).passkey_register_start(user)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/finish",
|
||||
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_register_finish(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
||||
AuthService::new(&state).passkey_register_finish(user, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/start",
|
||||
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_login_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
||||
AuthService::new(&state).passkey_login_start(&payload.username)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/finish",
|
||||
request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||
responses(
|
||||
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
||||
(status = 401, description = "Authentication failed")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_login_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).passkey_login_finish(payload)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::capability_sets::{
|
||||
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
|
||||
UpdateCapabilitySetRequest,
|
||||
},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capability-sets",
|
||||
responses((status = 200, body = [CapabilitySetResponse])),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn list_capability_sets(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
||||
CapabilitySetService::new().list(&mut conn, tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capabilities",
|
||||
responses((status = 200, body = [crate::models::ApiCapability])),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn list_capabilities(
|
||||
TenantScopedConn { .. }: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
|
||||
CapabilitySetService::new().list_capabilities()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn get_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().get(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/capability-sets",
|
||||
request_body = CreateCapabilitySetRequest,
|
||||
responses((status = 201, body = CapabilitySetResponse)),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn create_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
request_body = UpdateCapabilitySetRequest,
|
||||
responses((status = 200, body = CapabilitySetResponse)),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn update_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
responses((status = 204), (status = 409, description = "Set in use")),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn delete_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::capability_sets::list_capability_sets,
|
||||
crate::routes::capability_sets::list_capabilities,
|
||||
crate::routes::capability_sets::get_capability_set,
|
||||
crate::routes::capability_sets::create_capability_set,
|
||||
crate::routes::capability_sets::update_capability_set,
|
||||
crate::routes::capability_sets::delete_capability_set,
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::services::capability_sets::CapabilitySetResponse,
|
||||
crate::services::capability_sets::CreateCapabilitySetRequest,
|
||||
crate::services::capability_sets::UpdateCapabilitySetRequest,
|
||||
))
|
||||
)]
|
||||
pub struct CapabilitySetsApiDoc;
|
||||
@@ -0,0 +1,312 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
||||
models::{Correspondent, NewCorrespondent},
|
||||
schema::{correspondents, document_correspondents},
|
||||
utils::{
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
time::to_iso,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CorrespondentSummary {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateCorrespondentRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateCorrespondentRequest {
|
||||
#[schema(nullable)]
|
||||
pub name: Option<String>,
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(AsChangeset, Default)]
|
||||
#[diesel(table_name = correspondents)]
|
||||
struct CorrespondentChangeset<'a> {
|
||||
name: Option<&'a str>,
|
||||
metadata: Option<&'a Value>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/correspondents",
|
||||
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub async fn list_correspondents(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
|
||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::correspondent_id)
|
||||
.select((document_correspondents::correspondent_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||
for (correspondent_id, count) in usage_rows {
|
||||
usage_map.insert(correspondent_id, count);
|
||||
}
|
||||
|
||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||
for correspondent in correspondents_list {
|
||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||
response.push(build_summary(correspondent, total));
|
||||
}
|
||||
|
||||
ok_json(response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/correspondents",
|
||||
request_body = CreateCorrespondentRequest,
|
||||
responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub async fn create_correspondent(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
||||
let name = normalize_name(&payload.name, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
|
||||
let metadata_value = normalize_metadata(payload.metadata);
|
||||
let new_id = Uuid::new_v4();
|
||||
let new_correspondent = NewCorrespondent {
|
||||
id: new_id,
|
||||
name: name.clone(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(correspondents::table)
|
||||
.values(&new_correspondent)
|
||||
.execute(&mut conn)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
||||
return Err(AppError::bad_request("correspondent name already exists"));
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let correspondent: Correspondent = correspondents::table
|
||||
.find(new_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
ok_json(build_summary(correspondent, 0))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/correspondents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
||||
request_body = UpdateCorrespondentRequest,
|
||||
responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub async fn update_correspondent(
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
||||
let existing: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
let normalized = normalize_name(candidate, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
if normalized != existing.name {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
correspondents::table
|
||||
.filter(correspondents::name.eq(&normalized))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("correspondent name already exists"),
|
||||
)?;
|
||||
new_name = Some(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_metadata: Option<Value> = None;
|
||||
if let Some(metadata) = payload.metadata.clone() {
|
||||
let candidate = normalize_metadata(Some(metadata));
|
||||
if candidate != existing.metadata {
|
||||
new_metadata = Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if new_name.is_none() && new_metadata.is_none() {
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
return ok_json(build_summary(existing.clone(), usage));
|
||||
}
|
||||
|
||||
let mut changeset = CorrespondentChangeset::default();
|
||||
if let Some(ref name) = new_name {
|
||||
changeset.name = Some(name.as_str());
|
||||
}
|
||||
if let Some(ref metadata) = new_metadata {
|
||||
changeset.metadata = Some(metadata);
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(
|
||||
correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
let updated: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
ok_json(build_summary(updated, usage))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/correspondents/{id}",
|
||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
||||
responses((status = 204, description = "Correspondent deleted")),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub async fn delete_correspondent(
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
if usage > 0 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot delete correspondent that is still assigned to documents",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
correspondents::table
|
||||
.filter(correspondents::id.eq(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary {
|
||||
CorrespondentSummary {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
created_at: to_iso(correspondent.created_at),
|
||||
updated_at: to_iso(correspondent.updated_at),
|
||||
usage_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_metadata(input: Option<Value>) -> Value {
|
||||
match input {
|
||||
None | Some(Value::Null) => Value::Object(Default::default()),
|
||||
Some(value) => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let total: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::correspondents::list_correspondents,
|
||||
crate::routes::correspondents::create_correspondent,
|
||||
crate::routes::correspondents::update_correspondent,
|
||||
crate::routes::correspondents::delete_correspondent
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::correspondents::CorrespondentSummary,
|
||||
crate::routes::correspondents::CreateCorrespondentRequest,
|
||||
crate::routes::correspondents::UpdateCorrespondentRequest
|
||||
))
|
||||
)]
|
||||
pub struct CorrespondentsApiDoc;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{created_json, no_content, ok_json, JsonResponse},
|
||||
services::folders::{
|
||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
|
||||
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use crate::services::documents::DocumentResponse;
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderContentsResponse {
|
||||
#[schema(nullable)]
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 200, description = "Folder detail", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn get_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders/path",
|
||||
request_body = EnsureFolderPathRequest,
|
||||
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn ensure_folder_path(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders",
|
||||
request_body = CreateFolderRequest,
|
||||
responses(
|
||||
(status = 201, description = "Folder created", body = FolderResponse),
|
||||
(status = 200, description = "Folder already existed", body = FolderResponse)
|
||||
),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
|
||||
let response = FolderResponse { folder };
|
||||
if created {
|
||||
created_json(response)
|
||||
} else {
|
||||
ok_json(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents",
|
||||
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
|
||||
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(query): Query<FolderContentsQuery>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<FolderContentsResponse>> {
|
||||
let FolderContentsQuery {
|
||||
include_documents,
|
||||
sort,
|
||||
dir,
|
||||
} = query;
|
||||
|
||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Uuid::parse_str(&folder_identifier)
|
||||
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
|
||||
)
|
||||
};
|
||||
|
||||
let service = FolderService::new(&state);
|
||||
let FolderContentsData {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
} = service.list_folder_contents(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
folder_id,
|
||||
sort,
|
||||
dir,
|
||||
include_documents,
|
||||
)?;
|
||||
|
||||
let documents = if include_documents {
|
||||
service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
ok_json(FolderContentsResponse {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/tree",
|
||||
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn list_folder_tree(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
|
||||
let service = FolderService::new(&state);
|
||||
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
|
||||
ok_json(tree)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 204, description = "Folder deleted")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
request_body = UpdateFolderRequest,
|
||||
responses((status = 204, description = "Folder updated")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::folders::create_folder,
|
||||
crate::routes::folders::ensure_folder_path,
|
||||
crate::routes::folders::get_folder,
|
||||
crate::routes::folders::list_folder_contents,
|
||||
crate::routes::folders::list_folder_tree,
|
||||
crate::routes::folders::delete_folder,
|
||||
crate::routes::folders::update_folder
|
||||
),
|
||||
components(schemas(
|
||||
crate::services::folders::CreateFolderRequest,
|
||||
crate::services::folders::EnsureFolderPathRequest,
|
||||
crate::routes::folders::FolderResponse,
|
||||
crate::services::folders::FolderInfo,
|
||||
crate::services::folders::FolderContentsQuery,
|
||||
crate::routes::folders::FolderContentsResponse,
|
||||
crate::services::folders::FolderTreeNode,
|
||||
crate::services::folders::UpdateFolderRequest
|
||||
))
|
||||
)]
|
||||
pub struct FoldersApiDoc;
|
||||
@@ -0,0 +1,46 @@
|
||||
use axum::{extract::State, http::StatusCode, response::Json};
|
||||
use diesel::RunQueryDsl;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(paths(crate::routes::health::health_check))]
|
||||
pub struct HealthApiDoc;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/health",
|
||||
responses((status = 200, description = "Service is healthy")),
|
||||
tag = "Health"
|
||||
)]
|
||||
pub async fn health_check(State(state): State<AppState>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let database_ok = match state.db_unscoped() {
|
||||
Ok(mut conn) => diesel::sql_query("SELECT 1")
|
||||
.execute(&mut conn)
|
||||
.map(|_| true)
|
||||
.unwrap_or_else(|err| {
|
||||
tracing::error!(error = ?err, "health check database ping failed");
|
||||
false
|
||||
}),
|
||||
Err(err) => {
|
||||
tracing::error!(error = ?err, "health check database connection failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let status = if database_ok {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"status": if database_ok { "ok" } else { "error" },
|
||||
"checks": {
|
||||
"database": if database_ok { "ok" } else { "unavailable" }
|
||||
}
|
||||
});
|
||||
|
||||
(status, Json(payload))
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::{Html, Json},
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
|
||||
models::ApiCapability,
|
||||
openapi::ApiDoc,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability_sets;
|
||||
pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod tenants;
|
||||
pub mod webdav;
|
||||
|
||||
pub fn create_router(state: AppState) -> Router<()> {
|
||||
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
|
||||
let headers: Vec<HeaderValue> = origins
|
||||
.split(',')
|
||||
.filter_map(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| {
|
||||
trimmed
|
||||
.parse::<HeaderValue>()
|
||||
.expect("invalid CORS allowed origin")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let allow_origin = AllowOrigin::list(headers);
|
||||
|
||||
CorsLayer::new()
|
||||
.allow_origin(allow_origin)
|
||||
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true)
|
||||
} else {
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::mirror_request())
|
||||
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true)
|
||||
};
|
||||
|
||||
let auth_routes = Router::new()
|
||||
.route("/signup/start", post(auth::signup_start))
|
||||
.route("/signup/finish", post(auth::signup_finish))
|
||||
.route("/login", post(auth::login))
|
||||
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route(
|
||||
"/passkeys/register/start",
|
||||
post(auth::passkey_register_start),
|
||||
)
|
||||
.route(
|
||||
"/passkeys/register/finish",
|
||||
post(auth::passkey_register_finish),
|
||||
)
|
||||
.route("/passkeys/login/start", post(auth::passkey_login_start))
|
||||
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
.route(
|
||||
"/check",
|
||||
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
get(documents::list_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(documents::upload_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
ApiCapability::DocumentsUpload,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/move",
|
||||
post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/tags",
|
||||
post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/correspondents",
|
||||
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/reanalyze",
|
||||
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(documents::get_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/download",
|
||||
post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/trash",
|
||||
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/assets",
|
||||
get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/assets",
|
||||
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/folder",
|
||||
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions",
|
||||
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions/{version_id}",
|
||||
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions/{version_id}/download",
|
||||
post(documents::refresh_document_version_download).layer(
|
||||
RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/{id}/restore",
|
||||
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/tags",
|
||||
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/tags/{tag_id}",
|
||||
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/correspondents",
|
||||
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/correspondents/{correspondent_id}",
|
||||
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
);
|
||||
|
||||
let download_routes =
|
||||
Router::new().route("/api/download/{token}", get(documents::download_with_token));
|
||||
|
||||
let folders_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
post(folders::create_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/path",
|
||||
post(folders::ensure_folder_path)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/tree",
|
||||
get(folders::list_folder_tree)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(folders::get_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(folders::delete_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(folders::update_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/contents",
|
||||
get(folders::list_folder_contents)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
);
|
||||
|
||||
let tags_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(tags::delete_tag)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
||||
);
|
||||
|
||||
let correspondents_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
])),
|
||||
);
|
||||
|
||||
let profile_routes = Router::new()
|
||||
.route(
|
||||
"/api-tokens",
|
||||
get(profile::list_api_tokens)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens",
|
||||
post(profile::create_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens/{id}/regenerate",
|
||||
post(profile::regenerate_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens/{id}",
|
||||
delete(profile::delete_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/passkeys",
|
||||
get(profile::list_passkeys)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
||||
)
|
||||
.route(
|
||||
"/passkeys/{id}",
|
||||
delete(profile::delete_passkey)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
);
|
||||
|
||||
let capability_sets_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
);
|
||||
|
||||
let capabilities_routes = Router::new().route(
|
||||
"/",
|
||||
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
);
|
||||
|
||||
let protected_state = state.clone();
|
||||
let assets_routes = Router::new()
|
||||
.route(
|
||||
"/{asset_id}",
|
||||
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{asset_id}/download",
|
||||
post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
);
|
||||
|
||||
let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]);
|
||||
let tenants_routes = Router::new()
|
||||
.route("/", get(tenants::list_tenants))
|
||||
.route("/{tenant_id}", get(tenants::get_tenant))
|
||||
.route(
|
||||
"/{tenant_id}",
|
||||
patch(tenants::update_tenant).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users",
|
||||
get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
);
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/capability-sets", capability_sets_routes)
|
||||
.nest("/api/capabilities", capabilities_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.nest("/api/tenants", tenants_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let upload_limit = state.config.upload_body_limit_bytes;
|
||||
|
||||
let openapi_spec = Arc::new(ApiDoc::openapi());
|
||||
let docs_router = Router::new()
|
||||
.route(
|
||||
"/api/docs",
|
||||
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
|
||||
)
|
||||
.route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_spec.clone();
|
||||
move || async move { Json((*spec).clone()) }
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_router)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
usize::try_from(upload_limit).unwrap_or(usize::MAX),
|
||||
))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_swagger_ui(spec_url: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Papercrate API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
html {{ box-sizing: border-box; font-family: sans-serif; }}
|
||||
*, *:before, *:after {{ box-sizing: inherit; }}
|
||||
body {{ margin: 0; background: #fafafa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener('load', () => {{
|
||||
window.ui = SwaggerUIBundle({{
|
||||
url: '{spec_url}',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{passkeys::PasskeySummary, TenantScopedConn},
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::profile::{
|
||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
|
||||
RevokePasskeyQuery,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/passkeys",
|
||||
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_passkeys(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
|
||||
ProfileService::new(&state).list_passkeys(&mut conn, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/api-tokens",
|
||||
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_api_tokens(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
|
||||
ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/api-tokens",
|
||||
request_body = CreateApiTokenRequest,
|
||||
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn create_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateApiTokenRequest>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/api-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 204, description = "API token revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn delete_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/passkeys/{id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Passkey ID"),
|
||||
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
|
||||
),
|
||||
responses((status = 204, description = "Passkey revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn delete_passkey(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(passkey_id): Path<Uuid>,
|
||||
Query(query): Query<RevokePasskeyQuery>,
|
||||
) -> AppResult<StatusCode> {
|
||||
ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::profile::list_api_tokens,
|
||||
crate::routes::profile::create_api_token,
|
||||
crate::routes::profile::regenerate_api_token,
|
||||
crate::routes::profile::delete_api_token,
|
||||
crate::routes::profile::list_passkeys,
|
||||
crate::routes::profile::delete_passkey
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::services::profile::ApiTokenResponse,
|
||||
crate::services::profile::ApiTokenCreatedResponse,
|
||||
crate::services::profile::CreateApiTokenRequest,
|
||||
crate::services::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
)]
|
||||
pub struct ProfileApiDoc;
|
||||
@@ -0,0 +1,358 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
||||
models::{NewTag, Tag},
|
||||
schema::{document_tags, tags},
|
||||
utils::{
|
||||
json::deserialize_patch_field,
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateTagRequest {
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(AsChangeset, Default)]
|
||||
#[diesel(table_name = tags)]
|
||||
struct UpdateTagChangeset<'a> {
|
||||
label: Option<&'a str>,
|
||||
color: Option<Option<&'a str>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn update_tag_request_deserializes_null_fields() {
|
||||
let request: UpdateTagRequest = serde_json::from_value(json!({
|
||||
"label": null,
|
||||
"color": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(request.label, Some(None)));
|
||||
assert!(matches!(request.color, Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tag_request_omitted_fields_are_none() {
|
||||
let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(request.label.is_none());
|
||||
assert!(request.color.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TagCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, ToSchema)]
|
||||
pub struct UpdateTagRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub label: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tags",
|
||||
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn list_tags(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
|
||||
let tag_list: Vec<Tag> = tags::table
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.order(tags::label.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response: Vec<TagCatalogEntry> = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: *usage_map.get(&tag.id).unwrap_or(&0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ok_json(response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/tags",
|
||||
request_body = CreateTagRequest,
|
||||
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn create_tag(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
||||
let label = normalize_name(&payload.label, || {
|
||||
AppError::bad_request("label must not be empty")
|
||||
})?;
|
||||
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: label.clone(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(tags::table)
|
||||
.values(&new_tag)
|
||||
.execute(&mut conn)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(diesel::result::Error::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
_,
|
||||
)) => {
|
||||
return Err(AppError::bad_request("tag label already exists"));
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table
|
||||
.find(new_tag.id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
ok_json(TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
request_body = UpdateTagRequest,
|
||||
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn update_tag(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateTagRequest>,
|
||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
||||
let existing: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
let UpdateTagRequest { label, color } = payload;
|
||||
|
||||
if label.is_none() && color.is_none() {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return ok_json(TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
});
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
let mut label_changed = false;
|
||||
match label {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("label cannot be null"));
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let normalized =
|
||||
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
||||
if normalized != existing.label {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
tags::table
|
||||
.filter(tags::label.eq(&normalized))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("tag label already exists"),
|
||||
)?;
|
||||
new_label = Some(normalized);
|
||||
label_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut color_change: Option<Option<String>> = None;
|
||||
let mut color_changed = false;
|
||||
match color {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
color_change = Some(None);
|
||||
color_changed = true;
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("color must not be empty"));
|
||||
}
|
||||
if existing.color.as_deref() != Some(trimmed) {
|
||||
color_change = Some(Some(trimmed.to_string()));
|
||||
color_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !label_changed && !color_changed {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return ok_json(TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
});
|
||||
}
|
||||
|
||||
let changeset = UpdateTagChangeset {
|
||||
label: new_label.as_deref(),
|
||||
color: color_change
|
||||
.as_ref()
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
let updated: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
ok_json(TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
responses((status = 204, description = "Tag deleted")),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn delete_tag(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
if usage > 0 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot delete tag that is still assigned to documents",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::tags::list_tags,
|
||||
crate::routes::tags::create_tag,
|
||||
crate::routes::tags::update_tag,
|
||||
crate::routes::tags::delete_tag
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::tags::CreateTagRequest,
|
||||
crate::routes::tags::TagCatalogEntry,
|
||||
crate::routes::tags::UpdateTagRequest
|
||||
))
|
||||
)]
|
||||
pub struct TagsApiDoc;
|
||||
@@ -0,0 +1,154 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{http::StatusCode, Json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{AuthenticatedUser, TenantMembershipUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::http::responders::JsonResponse;
|
||||
use crate::services::auth::{AuthService, TenantSnippet};
|
||||
use crate::services::tenants::{
|
||||
TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest,
|
||||
UpdateTenantUserRequest,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants",
|
||||
responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
user: TenantMembershipUser,
|
||||
) -> AppResult<Json<Vec<TenantSnippet>>> {
|
||||
let response = AuthService::new(&state).list_tenants(user.user_id)?;
|
||||
Ok(Json(response.into_inner().tenants))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
||||
responses((status = 200, body = TenantSnippet, description = "Tenant details")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn get_tenant(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: TenantMembershipUser,
|
||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
||||
AuthService::new(&state).get_tenant(user.user_id, tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tenants/{tenant_id}",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
||||
request_body = UpdateTenantRequest,
|
||||
responses((status = 200, body = TenantSnippet, description = "Updated tenant")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn update_tenant(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
||||
TenantApiService::new(&state).update_name(user, tenant_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}/users",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant ID")),
|
||||
responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn list_tenant_users(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<Vec<TenantUserSummary>>> {
|
||||
let response = TenantApiService::new(&state).list_users(&user, tenant_id)?;
|
||||
Ok(Json(response.into_inner().users))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
responses((status = 200, body = TenantUserSummary, description = "Tenant user details")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn get_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
||||
TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
request_body = UpdateTenantUserRequest,
|
||||
responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn update_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<UpdateTenantUserRequest>,
|
||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
||||
TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
responses((status = 204, description = "Membership removed")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn delete_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<StatusCode> {
|
||||
TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
list_tenants,
|
||||
get_tenant,
|
||||
update_tenant,
|
||||
list_tenant_users,
|
||||
get_tenant_user,
|
||||
update_tenant_user,
|
||||
delete_tenant_user,
|
||||
),
|
||||
components(schemas(
|
||||
crate::services::auth::TenantListResponse,
|
||||
crate::services::auth::TenantSnippet,
|
||||
UpdateTenantRequest,
|
||||
UpdateTenantUserRequest,
|
||||
TenantUserListResponse,
|
||||
TenantUserSummary,
|
||||
))
|
||||
)]
|
||||
pub struct TenantsApiDoc;
|
||||
@@ -0,0 +1,846 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap, Method, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use futures_util::StreamExt;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
ensure_active_tenant_with_conn,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
|
||||
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
conn: PgPooledConnection,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
Router::new().fallback(webdav_entrypoint)
|
||||
}
|
||||
|
||||
async fn webdav_entrypoint(
|
||||
State(state): State<AppState>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
let path = req.uri().path().trim_start_matches('/').to_string();
|
||||
|
||||
tracing::debug!(method = %method, %path, "webdav entrypoint" );
|
||||
|
||||
match method {
|
||||
ref m if m == Method::OPTIONS => Ok(handle_options()),
|
||||
ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await,
|
||||
ref m if m == Method::HEAD => {
|
||||
handle_get_or_head(&state, &path, headers, Method::HEAD).await
|
||||
}
|
||||
_ => {
|
||||
if method.as_str() == "PROPFIND" {
|
||||
handle_propfind(&state, &path, headers).await
|
||||
} else {
|
||||
Ok(method_not_allowed())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_propfind(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let depth = match parse_depth(&headers) {
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
} else {
|
||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
match resolution {
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents =
|
||||
fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => build_resources_for_document(&chain, &document, &version),
|
||||
}
|
||||
};
|
||||
|
||||
let body = render_multistatus(&resources).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to render WebDAV response");
|
||||
AppError::internal("failed to render WebDAV response")
|
||||
})?;
|
||||
|
||||
let response = Response::builder()
|
||||
.status(multi_status())
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(body))
|
||||
.expect("valid response");
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_get_or_head(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
if segments.is_empty() {
|
||||
return Ok(method_not_allowed());
|
||||
}
|
||||
|
||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
let (document, version, chain) = match resolution {
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => (document, version, chain),
|
||||
_ => return Ok(method_not_allowed()),
|
||||
};
|
||||
|
||||
stream_document(state, &document, &version, &chain, headers, method).await
|
||||
}
|
||||
|
||||
fn handle_options() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1,2")
|
||||
.header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD")
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(Body::empty())
|
||||
.expect("valid OPTIONS response")
|
||||
}
|
||||
|
||||
fn method_not_allowed() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn not_found_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn unauthorized_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(
|
||||
header::WWW_AUTHENTICATE,
|
||||
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn multi_status() -> StatusCode {
|
||||
StatusCode::from_u16(207).expect("valid multi-status")
|
||||
}
|
||||
|
||||
fn parse_depth(headers: &HeaderMap) -> Result<u8, Response> {
|
||||
match headers.get("Depth") {
|
||||
None => Ok(1),
|
||||
Some(value) => match value.to_str() {
|
||||
Ok("0") => Ok(0),
|
||||
Ok("1") => Ok(1),
|
||||
Ok("infinity") => Err(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")),
|
||||
_ => Err(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||
if path.trim_matches('/').is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.map(|segment| {
|
||||
percent_decode_str(segment)
|
||||
.decode_utf8()
|
||||
.map(|cow| cow.into_owned())
|
||||
.map_err(|_| AppError::bad_request("invalid UTF-8 in path"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn fetch_folder_contents(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(
|
||||
folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<Folder>(conn)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let subfolders: Vec<Folder> = match folder_id {
|
||||
Some(id) => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(conn)?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(conn)?,
|
||||
};
|
||||
|
||||
let mut docs_query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
docs_query = match folder_id {
|
||||
Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))),
|
||||
None => docs_query.filter(documents_dsl::folder_id.is_null()),
|
||||
};
|
||||
|
||||
let documents: Vec<Document> = docs_query
|
||||
.order(documents_dsl::created_at.desc())
|
||||
.load(conn)?;
|
||||
|
||||
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
|
||||
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
document_versions_dsl::document_versions
|
||||
.filter(document_versions_dsl::id.eq_any(&version_ids))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
let mut version_map = versions
|
||||
.into_iter()
|
||||
.map(|version| (version.id, version))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let mut entries = Vec::with_capacity(documents.len());
|
||||
for document in documents {
|
||||
if let Some(version) = version_map.remove(&document.current_version_id) {
|
||||
entries.push(DocumentEntry { document, version });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WebDavFolderContents {
|
||||
_folder: folder,
|
||||
subfolders,
|
||||
documents: entries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_document(
|
||||
state: &AppState,
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
_chain: &[String],
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let range_header = headers.get(header::RANGE).cloned();
|
||||
|
||||
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.storage_context("failed to presign document download")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.request(method.clone(), url.clone());
|
||||
|
||||
if let Some(range) = range_header.clone() {
|
||||
request = request.header(header::RANGE, range.clone());
|
||||
}
|
||||
|
||||
let upstream = request.send().await.map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to fetch document stream");
|
||||
AppError::internal("failed to fetch document stream")
|
||||
})?;
|
||||
|
||||
let status =
|
||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
|
||||
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||
tracing::error!(status = %status, "upstream download returned error status");
|
||||
return Err(AppError::internal("failed to fetch document stream"));
|
||||
}
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
|
||||
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
|
||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||
} else if let Some(ref typ) = document.mime_type {
|
||||
builder = builder.header(header::CONTENT_TYPE, typ);
|
||||
}
|
||||
|
||||
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
|
||||
builder = builder.header(header::CONTENT_LENGTH, content_length);
|
||||
}
|
||||
|
||||
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
|
||||
builder = builder.header(header::CONTENT_RANGE, range);
|
||||
}
|
||||
|
||||
builder = builder.header("Accept-Ranges", "bytes");
|
||||
|
||||
if let Some(disposition) = inline_content_disposition(&document.filename) {
|
||||
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||
}
|
||||
|
||||
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||
|
||||
if method == Method::HEAD {
|
||||
return builder.body(Body::empty()).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
});
|
||||
}
|
||||
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
builder.body(body).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||
tracing::debug!("webdav authenticate invoked");
|
||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||
Some(value) => match value.to_str() {
|
||||
Ok(header) if header.starts_with("Basic ") => {
|
||||
tracing::debug!("authorization header present");
|
||||
&header[6..]
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(header = %other, "non-basic authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "invalid authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::debug!("no authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let decoded = match BASE64.decode(authorization) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "failed to decode basic credentials");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let credential_str = match String::from_utf8(decoded) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "invalid utf-8 basic credentials");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let (presented_username, secret) = match credential_str.split_once(':') {
|
||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
tracing::debug!(presented_username = %presented_username, "attempting webdav login");
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let token = match find_active_token_by_secret(
|
||||
&mut conn,
|
||||
None,
|
||||
secret,
|
||||
Some(ApiCapability::WebdavRead),
|
||||
)? {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) {
|
||||
Ok(user) => user,
|
||||
Err(diesel::result::Error::NotFound) => {
|
||||
tracing::warn!(
|
||||
presented_username = %presented_username,
|
||||
user_id = %token.user_id,
|
||||
"webdav token user missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let tenant_id = match membership_exists {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"webdav token tenant membership missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ensure_active_tenant_with_conn(&mut conn, tenant_id) {
|
||||
tracing::warn!(tenant_id = %tenant_id, error = ?err, "webdav tenant not active");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||
touch_api_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
);
|
||||
Ok(Some(WebDavContext {
|
||||
tenant_id,
|
||||
_user_id: user.id,
|
||||
_username: user.username,
|
||||
conn,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_resources_for_folder(
|
||||
folder: Option<&Folder>,
|
||||
chain: &[String],
|
||||
contents: &WebDavFolderContents,
|
||||
depth: u8,
|
||||
) -> Vec<DavResource> {
|
||||
let mut resources = Vec::new();
|
||||
|
||||
let display_name = folder
|
||||
.map(|folder| folder.name.clone())
|
||||
.unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
|
||||
|
||||
let href = build_href(chain, true);
|
||||
let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
|
||||
|
||||
resources.push(DavResource {
|
||||
href,
|
||||
display_name,
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
mime_type: None,
|
||||
last_modified,
|
||||
});
|
||||
|
||||
if depth == 0 {
|
||||
return resources;
|
||||
}
|
||||
|
||||
for subfolder in &contents.subfolders {
|
||||
let mut child_chain = chain.to_vec();
|
||||
child_chain.push(subfolder.name.clone());
|
||||
resources.push(DavResource {
|
||||
href: build_href(&child_chain, true),
|
||||
display_name: subfolder.name.clone(),
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
mime_type: None,
|
||||
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||
});
|
||||
}
|
||||
|
||||
for entry in &contents.documents {
|
||||
let mut child_chain = chain.to_vec();
|
||||
child_chain.push(entry.document.filename.clone());
|
||||
resources.push(document_to_resource(
|
||||
&child_chain,
|
||||
&entry.document,
|
||||
&entry.version,
|
||||
));
|
||||
}
|
||||
|
||||
resources
|
||||
}
|
||||
|
||||
fn build_resources_for_document(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
) -> Vec<DavResource> {
|
||||
vec![document_to_resource(chain, document, version)]
|
||||
}
|
||||
|
||||
fn document_to_resource(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
) -> DavResource {
|
||||
let href = build_href(chain, false);
|
||||
|
||||
DavResource {
|
||||
href,
|
||||
display_name: document.title.clone(),
|
||||
is_collection: false,
|
||||
content_length: Some(version.size_bytes),
|
||||
mime_type: document.mime_type.clone(),
|
||||
last_modified: Some(to_http_date(document.updated_at)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_href(names: &[String], is_collection: bool) -> String {
|
||||
if names.is_empty() {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
let encoded = names
|
||||
.iter()
|
||||
.map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut path = format!("/{}", encoded.join("/"));
|
||||
if is_collection && !path.ends_with('/') {
|
||||
path.push('/');
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::Error> {
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||
|
||||
let mut multistatus = BytesStart::new("D:multistatus");
|
||||
multistatus.push_attribute(("xmlns:D", "DAV:"));
|
||||
writer.write_event(Event::Start(multistatus))?;
|
||||
|
||||
for resource in resources {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&resource.href)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||
if resource.is_collection {
|
||||
writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||
}
|
||||
writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||
|
||||
if let Some(length) = resource.content_length {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&length.to_string())))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
}
|
||||
|
||||
if let Some(content_type) = &resource.mime_type {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(content_type)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
|
||||
if let Some(last_modified) = &resource.last_modified {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(last_modified)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
}
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
|
||||
struct WebDavFolderContents {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
documents: Vec<DocumentEntry>,
|
||||
}
|
||||
|
||||
struct DocumentEntry {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
}
|
||||
|
||||
struct DavResource {
|
||||
href: String,
|
||||
display_name: String,
|
||||
is_collection: bool,
|
||||
content_length: Option<i64>,
|
||||
mime_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
enum ResolvedPath {
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Document {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
chain: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let is_last = index == segments.len() - 1;
|
||||
|
||||
if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? {
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(conn, tenant_id, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain }))
|
||||
}
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
let mut query = folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||
};
|
||||
|
||||
Ok(query
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_folder_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
Ok(folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(folder_id)
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_document_by_filename(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
filename: &str,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
let mut query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(documents_dsl::filename.eq(filename))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))),
|
||||
None => query.filter(documents_dsl::folder_id.is_null()),
|
||||
};
|
||||
|
||||
if let Some(document) = query.first::<Document>(conn).optional()? {
|
||||
let version = document_versions_dsl::document_versions
|
||||
.find(document.current_version_id)
|
||||
.first::<DocumentVersion>(conn)?;
|
||||
return Ok(Some((document, version)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_document_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
if let Some(document) = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.find(document_id)
|
||||
.first::<Document>(conn)
|
||||
.optional()?
|
||||
{
|
||||
let version = document_versions_dsl::document_versions
|
||||
.find(document.current_version_id)
|
||||
.first::<DocumentVersion>(conn)?;
|
||||
return Ok(Some((document, version)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
Reference in New Issue
Block a user