Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 13s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 12s

This commit is contained in:
2025-12-17 13:37:31 +01:00
commit 3c9a9fc060
399 changed files with 80777 additions and 0 deletions
+260
View File
@@ -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)
}
+134
View File
@@ -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;
+312
View File
@@ -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
+248
View File
@@ -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;
+46
View File
@@ -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))
}
+496
View File
@@ -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>"#
)
}
+150
View File
@@ -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;
+358
View File
@@ -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;
+154
View File
@@ -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;
+846
View File
@@ -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)
}