more multi-tenancy

This commit is contained in:
2025-10-23 16:06:56 +02:00
parent e175d28c2c
commit 0ad79c9bc1
35 changed files with 1229 additions and 534 deletions
+48 -79
View File
@@ -7,7 +7,7 @@ use std::{
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc};
use chrono::{NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
use reqwest::Client;
@@ -31,7 +31,13 @@ use crate::schema::{
document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
};
use crate::state::AppState;
use crate::utils::storage_paths::document_version_object_key;
use crate::utils::{
db::{no_content, validate_bulk_ids, IntoJsonResponse},
http::inline_content_disposition,
storage_paths::document_version_object_key,
time::to_iso,
validation::ensure_exists,
};
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
const QUICKWIT_MAX_HITS: usize = 200;
@@ -45,27 +51,6 @@ fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
fn inline_content_disposition(filename: &str) -> Option<String> {
if filename.is_empty() {
return None;
}
let sanitized: String = filename
.chars()
.map(|ch| match ch {
'"' | '\\' => '_',
_ => ch,
})
.collect();
let encoded =
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded
))
}
#[derive(Deserialize)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
@@ -441,13 +426,13 @@ pub async fn list_documents(
.quickwit_endpoint
.as_ref()
.ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?;
let index = state
.config
let tenant = state.tenants.get_by_id(tenant_id)?;
let index = tenant
.quickwit_index
.as_ref()
.ok_or_else(|| AppError::internal("quickwit index not configured"))?;
.ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?;
let ids = quickwit_search(endpoint, index, query_str)
let ids = quickwit_search(endpoint, index, tenant_id, query_str)
.await
.map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?;
@@ -850,12 +835,7 @@ pub async fn reanalyze_selected_documents(
force,
} = payload;
if document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
document_ids.sort();
document_ids.dedup();
validate_bulk_ids(&mut document_ids, "document_ids")?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::id.eq_any(&document_ids))
@@ -962,10 +942,11 @@ pub async fn get_document_asset(
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
let storage = state.storage_for_tenant(tenant_id)?;
let mut object_responses = Vec::with_capacity(objects.len());
for object in objects {
let url = state
.storage
let url = storage
.presign_get_object(
&object.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
@@ -1008,8 +989,9 @@ pub async fn download_document(
.find(doc.current_version_id)
.first(&mut conn)?;
let presigned_url = state
.storage
let storage = state.storage_for_tenant(tenant_id)?;
let presigned_url = storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
@@ -1065,8 +1047,9 @@ pub async fn download_with_token(
drop(conn);
let presigned_url = state
.storage
let storage = state.storage_for_tenant(claims.tenant_id)?;
let presigned_url = storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
@@ -1269,7 +1252,8 @@ pub async fn bulk_move_documents(
))
.execute(&mut conn)?;
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
let body = BulkMoveResponse { updated };
Ok((StatusCode::OK, body.into_json()?))
}
pub async fn assign_correspondents(
@@ -1281,7 +1265,7 @@ pub async fn assign_correspondents(
..
}: TenantScopedConn,
Json(payload): Json<AssignCorrespondentsRequest>,
) -> AppResult<impl IntoResponse> {
) -> AppResult<StatusCode> {
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
@@ -1360,7 +1344,7 @@ pub async fn assign_correspondents(
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
pub async fn bulk_assign_correspondents(
@@ -1372,16 +1356,12 @@ pub async fn bulk_assign_correspondents(
}: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
if payload.document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
let mut document_ids = payload.document_ids;
document_ids.sort();
document_ids.dedup();
validate_bulk_ids(&mut document_ids, "document_ids")?;
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
@@ -1503,10 +1483,8 @@ pub async fn bulk_assign_correspondents(
}
})?;
Ok((
StatusCode::OK,
Json(BulkCorrespondentResponse { assigned, removed }),
))
let body = BulkCorrespondentResponse { assigned, removed };
Ok((StatusCode::OK, body.into_json()?))
}
pub async fn remove_correspondent(
@@ -1517,7 +1495,7 @@ pub async fn remove_correspondent(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
) -> AppResult<StatusCode> {
let role = normalize_role(&query.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
@@ -1558,7 +1536,7 @@ pub async fn remove_correspondent(
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
pub async fn assign_tags(
@@ -1624,17 +1602,8 @@ pub async fn bulk_update_tags(
action,
} = payload;
if document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
if tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
document_ids.sort();
document_ids.dedup();
tag_ids.sort();
tag_ids.dedup();
validate_bulk_ids(&mut document_ids, "document_ids")?;
validate_bulk_ids(&mut tag_ids, "tag_ids")?;
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
@@ -1700,7 +1669,7 @@ pub async fn bulk_update_tags(
}
};
Ok((StatusCode::OK, Json(response)))
Ok((StatusCode::OK, response.into_json()?))
}
pub async fn remove_tag(
@@ -1710,7 +1679,7 @@ pub async fn remove_tag(
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
) -> AppResult<StatusCode> {
diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
@@ -1719,7 +1688,7 @@ pub async fn remove_tag(
)
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
no_content()
}
async fn process_upload(
@@ -1810,8 +1779,9 @@ async fn process_upload(
let content_disposition = inline_content_disposition(&original_name);
state
.storage
let storage = state.storage_for_tenant(tenant_id)?;
storage
.put_object(
&s3_key,
bytes.clone(),
@@ -1915,10 +1885,7 @@ fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> A
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(&mut conn)?;
if !exists {
return Err(AppError::bad_request("folder does not exist"));
}
Ok(())
ensure_exists(exists, "folder")
}
pub(crate) fn load_tags_for_documents(
@@ -2218,15 +2185,17 @@ async fn load_asset_responses(
.collect())
}
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}
async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result<Vec<Uuid>> {
async fn quickwit_search(
endpoint: &str,
index: &str,
tenant_id: Uuid,
query: &str,
) -> anyhow::Result<Vec<Uuid>> {
let tenant_clause = format!("tenant_id:{}", tenant_id);
let quickwit_query = match build_quickwit_query(query) {
Some(q) => {
debug!(%query, quickwit_query = %q, "built quickwit search query");
q
format!("{} AND ({})", tenant_clause, q)
}
None => {
debug!(%query, "quickwit search skipped because query produced no tokens");