diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e400df5..dffbd78 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -44,6 +44,7 @@ base64 = "0.21" quick-xml = "0.32" futures-util = "0.3" url = "2.5" +once_cell = "1.19" # Error handling thiserror = "1.0" diff --git a/backend/migrations/20240902150000_rename_tenants_id/down.sql b/backend/migrations/20240902150000_rename_tenants_id/down.sql new file mode 100644 index 0000000..db3cee5 --- /dev/null +++ b/backend/migrations/20240902150000_rename_tenants_id/down.sql @@ -0,0 +1 @@ +ALTER TABLE public.tenants RENAME COLUMN id TO tenant_id; diff --git a/backend/migrations/20240902150000_rename_tenants_id/up.sql b/backend/migrations/20240902150000_rename_tenants_id/up.sql new file mode 100644 index 0000000..af5daab --- /dev/null +++ b/backend/migrations/20240902150000_rename_tenants_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE public.tenants RENAME COLUMN tenant_id TO id; diff --git a/backend/src/bin/maintenance.rs b/backend/src/bin/maintenance.rs index f6e17f2..1140837 100644 --- a/backend/src/bin/maintenance.rs +++ b/backend/src/bin/maintenance.rs @@ -1,83 +1,314 @@ use std::env; +use std::sync::Arc; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use diesel::prelude::*; +use once_cell::sync::Lazy; +use reqwest::{Client, Method, StatusCode}; +use serde_json::json; use uuid::Uuid; use backend::{ config::AppConfig, - db, - models::{DocumentAsset, DocumentAssetObject}, + db::{self, PgPool}, + models::{DocumentAsset, DocumentAssetObject, Tenant}, s3, - schema::{document_asset_objects, document_assets}, - storage::{ObjectStorage, S3Storage}, + schema::{document_asset_objects, document_assets, tenants}, + storage::{ObjectStorage, S3Storage, TenantStorage}, + utils::tracing::init_tracing, }; +static QUICKWIT_INDEX_TEMPLATE: Lazy = Lazy::new(|| { + json!({ + "version": 0.8, + "index_id": "documents", + "doc_mapping": { + "tokenizers": [ + { + "name": "substring", + "type": "ngram", + "min_gram": 2, + "max_gram": 20, + "prefix_only": false + } + ], + "field_mappings": [ + { "name": "tenant_id", "type": "text", "stored": true }, + { "name": "document_id", "type": "text", "stored": true }, + { "name": "version_id", "type": "text", "stored": true }, + { "name": "title", "type": "text", "tokenizer": "substring", "stored": true }, + { "name": "text", "type": "text", "tokenizer": "substring", "record": "position" } + ] + }, + "search_settings": { + "default_search_fields": ["title", "text"] + } + }) +}); + +#[derive(Debug)] +enum Command { + ListTenants, + DeleteAssets(String), + QuickwitCreate(String), + QuickwitDelete(String), +} + +impl Command { + fn usage() -> &'static str { + "Usage: maintenance |quickwit-create-index |quickwit-delete-index >" + } + + fn parse() -> Result { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("list-tenants") => Ok(Self::ListTenants), + Some("delete-assets") => Ok(Self::DeleteAssets( + args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + )), + Some("quickwit-create-index") => Ok(Self::QuickwitCreate( + args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + )), + Some("quickwit-delete-index") => Ok(Self::QuickwitDelete( + args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + )), + _ => Err(anyhow!(Self::usage())), + } + } +} + #[tokio::main] async fn main() -> Result<()> { - let mut args = env::args().skip(1); - match args.next().as_deref() { - Some("delete-assets") => delete_all_assets().await?, - Some(cmd) => { - eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets"); - std::process::exit(1); + init_tracing("info"); + let command = Command::parse()?; + let config = AppConfig::load_and_log("maintenance")?; + let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; + + match command { + Command::ListTenants => list_tenants(&pool)?, + Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?, + Command::QuickwitCreate(slug) => { + quickwit_index(&config, &pool, &slug, Method::POST).await? } - None => { - eprintln!("Usage: maintenance delete-assets"); - std::process::exit(1); + Command::QuickwitDelete(slug) => { + quickwit_index(&config, &pool, &slug, Method::DELETE).await? } } Ok(()) } -async fn delete_all_assets() -> Result<()> { - let config = AppConfig::from_env()?; - tracing::info!( - component = "maintenance", - database_url = %config.redacted_database_url(), - pool_size = config.database_max_pool_size, - s3_bucket = %config.s3_bucket, - "loaded backend configuration" - ); - let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; - - let s3_client = s3::build_client(&config).await?; - let storage = S3Storage::new(s3_client, config.s3_bucket.clone()); - +fn list_tenants(pool: &PgPool) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; - - let assets: Vec = document_assets::table + let tenants: Vec = tenants::table + .order(tenants::slug.asc()) .load(&mut conn) - .context("failed to load document assets")?; + .context("failed to load tenants")?; - if assets.is_empty() { - println!("No assets found."); + if tenants.is_empty() { + println!("No tenants found."); return Ok(()); } - println!("Deleting {} assets…", assets.len()); + for tenant in tenants { + println!("{} ({})", tenant.slug, tenant.id); + } + + Ok(()) +} + +async fn delete_assets_for_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_slug: &str, +) -> Result<()> { + let s3_client = s3::build_client(config).await?; + let storage: Arc = + Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .filter(tenants::slug.eq(tenant_slug)) + .first(&mut conn) + .optional() + .context("failed to load tenant")? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?; + + let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) + .with_context(|| format!("missing storage root for tenant {}", tenant.slug))?; + + let assets: Vec = document_assets::table + .filter(document_assets::tenant_id.eq(tenant.id)) + .load(&mut conn) + .with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?; + + if assets.is_empty() { + println!("Tenant {}: no assets", tenant.slug); + return Ok(()); + } + + println!( + "Tenant {} ({}): deleting {} assets…", + tenant.slug, + tenant.id, + assets.len() + ); let asset_ids: Vec = assets.iter().map(|asset| asset.id).collect(); let objects: Vec = document_asset_objects::table + .filter(document_asset_objects::tenant_id.eq(tenant.id)) .filter(document_asset_objects::asset_id.eq_any(&asset_ids)) .load(&mut conn) - .context("failed to load document asset objects")?; + .with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?; for object in &objects { - if let Err(err) = storage.delete_object(&object.s3_key).await { + if let Err(err) = tenant_storage.delete_object(&object.s3_key).await { eprintln!( - "Failed to delete object {} from storage: {err}", - object.s3_key + "Failed to delete object {} (tenant {}): {err}", + object.s3_key, tenant.slug ); } } - diesel::delete(document_assets::table) - .execute(&mut conn) - .context("failed to remove asset records")?; + diesel::delete( + document_asset_objects::table + .filter(document_asset_objects::tenant_id.eq(tenant.id)) + .filter(document_asset_objects::asset_id.eq_any(&asset_ids)), + ) + .execute(&mut conn) + .with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?; - println!("Asset records deleted."); + diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id))) + .execute(&mut conn) + .with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?; + + println!("Tenant {}: asset records deleted.", tenant.slug); Ok(()) } + +async fn quickwit_index( + config: &AppConfig, + pool: &PgPool, + slug: &str, + method: Method, +) -> Result<()> { + let endpoint = config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| anyhow!("quickwit endpoint not configured"))?; + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .filter(tenants::slug.eq(slug)) + .first(&mut conn) + .optional() + .context("failed to query tenants")? + .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + + let client = Client::new(); + let index_id = format!("documents-{}", tenant.id); + let base_endpoint = endpoint.trim_end_matches('/'); + + match method { + Method::POST => { + let payload = render_index_template(&index_id); + let response = client + .post(format!("{}/api/v1/indexes", base_endpoint)) + .header("content-type", "application/json") + .body(payload) + .send() + .await + .context("failed to send create index request")?; + + match response.status() { + status if status.is_success() => { + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq(Some(index_id.clone()))) + .execute(&mut conn) + .context("failed to update tenant quickwit_index")?; + + println!( + "Tenant '{}' quickwit index set to '{}'.", + tenant.slug, index_id + ); + } + StatusCode::CONFLICT => { + let lookup = client + .get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id)) + .send() + .await + .context("failed to verify existing quickwit index")?; + + let lookup_status = lookup.status(); + if !lookup_status.is_success() { + let body = lookup.text().await.unwrap_or_default(); + return Err(anyhow!( + "quickwit reported conflict but index lookup failed with status {}: {}", + lookup_status, + body + )); + } + + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq(Some(index_id.clone()))) + .execute(&mut conn) + .context("failed to update tenant quickwit_index")?; + + println!( + "Tenant '{}' quickwit index set to '{}'.", + tenant.slug, index_id + ); + } + status => { + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "quickwit create index failed with status {}: {}", + status, + body + )); + } + } + } + Method::DELETE => { + let response = client + .delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id)) + .send() + .await + .context("failed to send delete index request")?; + + match response.status() { + status if status.is_success() || status == StatusCode::NOT_FOUND => { + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq::>(None)) + .execute(&mut conn) + .context("failed to clear tenant quickwit_index")?; + + println!("Tenant '{}' quickwit index cleared.", tenant.slug); + } + status => { + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "quickwit delete index failed with status {}: {}", + status, + body + )); + } + } + } + _ => unreachable!(), + } + + Ok(()) +} + +fn render_index_template(index_id: &str) -> String { + let mut template = QUICKWIT_INDEX_TEMPLATE.clone(); + if let Some(obj) = template.as_object_mut() { + obj.insert( + "index_id".to_string(), + serde_json::Value::String(index_id.to_string()), + ); + } + template.to_string() +} diff --git a/backend/src/bin/webdav.rs b/backend/src/bin/webdav.rs index 7364592..7ea88e0 100644 --- a/backend/src/bin/webdav.rs +++ b/backend/src/bin/webdav.rs @@ -1,47 +1,24 @@ use std::net::SocketAddr; -use std::sync::Arc; use tokio::net::TcpListener; use tower::make::Shared; -use tracing_subscriber::EnvFilter; -use backend::auth::jwt::JwtService; -use backend::config::AppConfig; -use backend::db; -use backend::routes::webdav; -use backend::s3::build_client; -use backend::state::AppState; -use backend::storage::S3Storage; +use backend::{routes::webdav, utils::bootstrap::init_component}; #[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv::dotenv().ok(); - init_tracing(); - - let config = AppConfig::from_env()?; + let state = init_component("webdav", None).await?; + let webdav_host = state.config.webdav_host.clone(); + let webdav_port = state.config.webdav_port; tracing::info!( component = "webdav", - database_url = %config.redacted_database_url(), - pool_size = config.database_max_pool_size, - server_host = %config.server_host, - server_port = config.server_port, - webdav_host = %config.webdav_host, - webdav_port = config.webdav_port, - quickwit_enabled = config.quickwit_endpoint.is_some(), - s3_bucket = %config.s3_bucket, - "loaded backend configuration" + webdav_host = %webdav_host, + webdav_port, + "starting webdav server" ); - let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; - let s3_client = build_client(&config).await?; - let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); - let jwt = JwtService::from_config(&config)?; - let state = AppState::new(pool, config, storage, jwt); - let listen_addr: SocketAddr = { - let config = state.config.clone(); - format!("{}:{}", config.webdav_host, config.webdav_port).parse()? - }; - let router = webdav::create_router().with_state(state); + let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?; + let router = webdav::create_router().with_state(state.as_ref().clone()); let listener = TcpListener::bind(listen_addr).await?; tracing::info!("listening for WebDAV on {}", listen_addr); @@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, Shared::new(router)).await?; Ok(()) } - -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(false) - .compact() - .init(); -} diff --git a/backend/src/bin/worker.rs b/backend/src/bin/worker.rs index ce75d22..66ab151 100644 --- a/backend/src/bin/worker.rs +++ b/backend/src/bin/worker.rs @@ -1,33 +1,13 @@ -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use tokio::signal; -use tracing_subscriber::EnvFilter; -use backend::{ - auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client, - state::AppState, storage::S3Storage, Worker, -}; +use backend::{default_handlers, utils::bootstrap::init_component, Worker}; #[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv::dotenv().ok(); - init_tracing(); - - let config = AppConfig::from_env()?; - tracing::info!( - component = "worker", - database_url = %config.redacted_database_url(), - pool_size = 1, - quickwit_enabled = config.quickwit_endpoint.is_some(), - s3_bucket = %config.s3_bucket, - "loaded backend configuration" - ); - let pool = db::init_pool_with_size(&config.database_url, 1)?; - let s3_client = build_client(&config).await?; - let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); - let jwt = JwtService::from_config(&config)?; - - let state = Arc::new(AppState::new(pool, config, storage, jwt)); + let state = init_component("worker", Some(1)).await?; + tracing::info!(component = "worker", "starting worker process"); let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); tokio::select! { @@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(false) - .compact() - .init(); -} diff --git a/backend/src/config.rs b/backend/src/config.rs index 2340190..4e63ed9 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -34,6 +34,20 @@ pub struct AppConfig { } impl AppConfig { + pub fn load_and_log(component: &str) -> Result { + dotenv::dotenv().ok(); + let config = Self::from_env()?; + tracing::info!( + component, + database_url = %config.redacted_database_url(), + pool_size = config.database_max_pool_size, + quickwit_enabled = config.quickwit_endpoint.is_some(), + s3_bucket = %config.s3_bucket, + "loaded backend configuration" + ); + Ok(config) + } + pub fn from_env() -> Result { let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") diff --git a/backend/src/main.rs b/backend/src/main.rs index d2c06af..7c52b98 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,57 +1,28 @@ use std::net::SocketAddr; -use std::sync::Arc; use tokio::net::TcpListener; use tower::make::Shared; -use tracing_subscriber::EnvFilter; -use backend::auth::jwt::JwtService; -use backend::config::AppConfig; -use backend::db; -use backend::routes; -use backend::s3::build_client; -use backend::state::AppState; -use backend::storage::S3Storage; +use backend::{routes, utils::bootstrap::init_component}; #[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv::dotenv().ok(); - init_tracing(); - - let config = AppConfig::from_env()?; + let state = init_component("api", None).await?; + let server_host = state.config.server_host.clone(); + let server_port = state.config.server_port; tracing::info!( component = "api", - database_url = %config.redacted_database_url(), - pool_size = config.database_max_pool_size, - server_host = %config.server_host, - server_port = config.server_port, - quickwit_enabled = config.quickwit_endpoint.is_some(), - s3_bucket = %config.s3_bucket, - "loaded backend configuration" + server_host = %server_host, + server_port, + "starting api server" ); - let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; - let s3_client = build_client(&config).await?; - let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); - let jwt = JwtService::from_config(&config)?; - let state = AppState::new(pool, config, storage, jwt); + let router = routes::create_router(state.as_ref().clone()); - let router = routes::create_router(state.clone()); - - let addr: SocketAddr = - format!("{}:{}", state.config.server_host, state.config.server_port).parse()?; + let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?; let listener = TcpListener::bind(addr).await?; tracing::info!("listening on {}", addr); axum::serve(listener, Shared::new(router)).await?; Ok(()) } - -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(false) - .compact() - .init(); -} diff --git a/backend/src/models.rs b/backend/src/models.rs index b05f3e0..0387832 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -28,9 +28,9 @@ pub struct NewUserMembership { #[derive(Debug, Clone, Queryable, Identifiable)] #[diesel(table_name = tenants)] -#[diesel(primary_key(tenant_id))] +#[diesel(primary_key(id))] pub struct Tenant { - pub tenant_id: Uuid, + pub id: Uuid, pub slug: String, pub storage_root: Option, pub quickwit_index: Option, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 83ce3a4..08e1d30 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -99,11 +99,11 @@ pub async fn login( .iter() .find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug)) }) { - return issue_session(&state, &mut conn, &user, tenant.1.tenant_id); + return issue_session(&state, &mut conn, &user, tenant.1.id); } if memberships.len() == 1 { - let tenant_id = memberships[0].1.tenant_id; + let tenant_id = memberships[0].1.id; return issue_session(&state, &mut conn, &user, tenant_id); } @@ -115,7 +115,7 @@ pub async fn login( let tenants = memberships .into_iter() .map(|(_, tenant)| TenantSummary { - tenant_id: tenant.tenant_id, + tenant_id: tenant.id, slug: tenant.slug, }) .collect(); diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs index e0e2357..2e277ba 100644 --- a/backend/src/routes/correspondents.rs +++ b/backend/src/routes/correspondents.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap}; -use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; +use axum::{extract::Path, http::StatusCode, Json}; use chrono::Utc; use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; use serde::{Deserialize, Serialize}; @@ -12,10 +12,12 @@ use crate::{ error::{AppError, AppResult}, models::{Correspondent, NewCorrespondent}, schema::{correspondents, document_correspondents}, + utils::{ + db::{no_content, EnsureEntity, IntoJsonResponse}, + time::to_iso, + }, }; -use super::documents::to_iso; - #[derive(Serialize)] pub struct CorrespondentUsage { pub total: i64, @@ -92,7 +94,7 @@ pub async fn list_correspondents( response.push(build_summary(correspondent, role_counts)); } - Ok(Json(response)) + response.into_json() } pub async fn create_correspondent( @@ -128,8 +130,13 @@ pub async fn create_correspondent( Err(err) => return Err(AppError::from(err)), } - let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?; - Ok(Json(build_summary(correspondent, BTreeMap::new()))) + let correspondent: Correspondent = correspondents::table + .find(new_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .one()?; + + build_summary(correspondent, BTreeMap::new()).into_json() } pub async fn update_correspondent( @@ -144,7 +151,8 @@ pub async fn update_correspondent( let existing: Correspondent = correspondents::table .find(correspondent_id) .filter(correspondents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; + .first(&mut conn) + .one()?; let mut new_name: Option = None; if let Some(ref candidate) = payload.name { @@ -176,7 +184,7 @@ pub async fn update_correspondent( 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))); + return build_summary(existing.clone(), usage).into_json(); } let mut changeset = CorrespondentChangeset::default(); @@ -199,9 +207,10 @@ pub async fn update_correspondent( let updated: Correspondent = correspondents::table .find(correspondent_id) .filter(correspondents::tenant_id.eq(tenant_id)) - .first(&mut conn)?; + .first(&mut conn) + .one()?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; - Ok(Json(build_summary(updated, usage))) + build_summary(updated, usage).into_json() } pub async fn delete_correspondent( @@ -211,7 +220,7 @@ pub async fn delete_correspondent( tenant_id, .. }: TenantScopedConn, -) -> AppResult { +) -> AppResult { let usage: i64 = document_correspondents::table .filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id)) @@ -233,7 +242,7 @@ pub async fn delete_correspondent( if deleted == 0 { return Err(AppError::not_found()); } - Ok(StatusCode::NO_CONTENT) + no_content() } fn build_summary( diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 2607e25..8863f5f 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -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 { - 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, @@ -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, -) -> AppResult { +) -> AppResult { 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, ) -> AppResult<(StatusCode, Json)> { - 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 { +) -> AppResult { 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)> = 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 { +) -> AppResult { 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::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() -} - -async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result> { +async fn quickwit_search( + endpoint: &str, + index: &str, + tenant_id: Uuid, + query: &str, +) -> anyhow::Result> { + 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"); diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index ea05d52..49c5a27 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -16,8 +16,9 @@ use crate::{ use super::documents::{ load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, - to_document_response, to_iso, DocumentResponse, + to_document_response, DocumentResponse, }; +use crate::utils::time::to_iso; #[derive(Deserialize)] pub struct CreateFolderRequest { diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index 1e7ed9c..5df7ef3 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -10,6 +10,7 @@ use crate::auth::TenantScopedConn; use crate::error::{AppError, AppResult}; use crate::models::{NewTag, Tag}; use crate::schema::{document_tags, tags}; +use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse}; #[derive(Deserialize)] pub struct CreateTagRequest { @@ -39,7 +40,10 @@ pub async fn list_tags( .. }: TenantScopedConn, ) -> AppResult>> { - let tag_list: Vec = tags::table.order(tags::label.asc()).load(&mut conn)?; + let tag_list: Vec = 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)) @@ -49,7 +53,7 @@ pub async fn list_tags( let usage_map: HashMap = usage_rows.into_iter().collect(); - let response = tag_list + let response: Vec = tag_list .into_iter() .map(|tag| TagCatalogEntry { id: tag.id, @@ -59,7 +63,7 @@ pub async fn list_tags( }) .collect(); - Ok(Json(response)) + response.into_json() } pub async fn create_tag( @@ -98,13 +102,16 @@ pub async fn create_tag( let tag: Tag = tags::table .find(new_tag.id) .filter(tags::tenant_id.eq(tenant_id)) - .first(&mut conn)?; - Ok(Json(TagCatalogEntry { + .first(&mut conn) + .one()?; + + TagCatalogEntry { id: tag.id, label: tag.label, color: tag.color, usage_count: 0, - })) + } + .into_json() } pub async fn update_tag( @@ -119,7 +126,8 @@ pub async fn update_tag( let existing: Tag = tags::table .find(tag_id) .filter(tags::tenant_id.eq(tenant_id)) - .first(&mut conn)?; + .first(&mut conn) + .one()?; let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?; @@ -130,12 +138,13 @@ pub async fn update_tag( .filter(document_tags::tag_id.eq(tag_id)) .select(count_star()) .first(&mut conn)?; - return Ok(Json(TagCatalogEntry { + return TagCatalogEntry { id: existing.id, label: existing.label.clone(), color: existing.color.clone(), usage_count, - })); + } + .into_json(); } let mut new_label: Option = None; @@ -218,19 +227,21 @@ pub async fn update_tag( let updated: Tag = tags::table .find(tag_id) .filter(tags::tenant_id.eq(tenant_id)) - .first(&mut conn)?; + .first(&mut conn) + .one()?; 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 { + TagCatalogEntry { id: updated.id, label: updated.label, color: updated.color, usage_count, - })) + } + .into_json() } pub async fn delete_tag( @@ -240,7 +251,7 @@ pub async fn delete_tag( tenant_id, .. }: TenantScopedConn, -) -> AppResult { +) -> AppResult { let usage: i64 = document_tags::table .filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tenant_id.eq(tenant_id)) @@ -263,5 +274,5 @@ pub async fn delete_tag( return Err(AppError::not_found()); } - Ok(StatusCode::NO_CONTENT) + no_content() } diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index d521b2e..ad12bab 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -20,17 +20,26 @@ use crate::error::{AppError, AppResult}; use crate::models::{Document, DocumentVersion, Folder, User}; use crate::schema::{ document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, - folders::dsl as folders_dsl, users::dsl as users_dsl, + folders::dsl as folders_dsl, tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, }; use crate::state::AppState; +use crate::utils::{http::inline_content_disposition, time::to_http_date}; const REALM: &str = "Papercrate WebDAV"; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; #[derive(Clone, Debug)] -struct WebDavUser { +struct TenantEntry { + tenant_id: Uuid, + slug: String, +} + +#[derive(Clone, Debug)] +struct WebDavContext { _user_id: Uuid, _username: String, + tenants: Vec, } pub fn create_router() -> Router { @@ -68,7 +77,7 @@ async fn handle_propfind( path: &str, headers: HeaderMap, ) -> Result { - let _user = match authenticate(state, &headers)? { + let context = match authenticate(state, &headers)? { Some(user) => user, None => return Ok(unauthorized_response()), }; @@ -79,25 +88,44 @@ async fn handle_propfind( }; let segments = parse_segments(path)?; - let resolution = match resolve_path(state, &segments)? { - Some(resolved) => resolved, - None => return Ok(not_found_response()), - }; - let resources = match resolution { - ResolvedPath::Root => { - let contents = fetch_folder_contents(state, None)?; - build_resources_for_folder(None, &[], &contents, depth) + let resources = if segments.is_empty() { + build_account_root_resources(&context.tenants, depth) + } else { + let (requested_slug, remainder) = segments.split_first().unwrap(); + let tenant_entry = match context + .tenants + .iter() + .find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug)) + { + Some(entry) => TenantEntry { + tenant_id: entry.tenant_id, + slug: entry.slug.clone(), + }, + None => return Ok(not_found_response()), + }; + + let resolution = match resolve_path(state, &tenant_entry, remainder)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + match resolution { + ResolvedPath::TenantRoot { chain } => { + let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?; + build_resources_for_folder(None, &chain, &contents, depth) + } + ResolvedPath::Folder { folder, chain } => { + let contents = + fetch_folder_contents(state, tenant_entry.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), } - ResolvedPath::Folder { folder, chain } => { - let contents = fetch_folder_contents(state, 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) @@ -118,13 +146,34 @@ async fn handle_get_or_head( headers: HeaderMap, method: Method, ) -> Result { - let _user = match authenticate(state, &headers)? { + let context = match authenticate(state, &headers)? { Some(user) => user, None => return Ok(unauthorized_response()), }; let segments = parse_segments(path)?; - let resolution = match resolve_path(state, &segments)? { + let (requested_slug, remainder) = match segments.split_first() { + Some(values) => values, + None => return Ok(method_not_allowed()), + }; + + let tenant_entry = match context + .tenants + .iter() + .find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug)) + { + Some(entry) => TenantEntry { + tenant_id: entry.tenant_id, + slug: entry.slug.clone(), + }, + None => return Ok(not_found_response()), + }; + + if remainder.is_empty() { + return Ok(method_not_allowed()); + } + + let resolution = match resolve_path(state, &tenant_entry, remainder)? { Some(resolved) => resolved, None => return Ok(not_found_response()), }; @@ -219,21 +268,29 @@ fn parse_segments(path: &str) -> AppResult> { fn fetch_folder_contents( state: &AppState, + tenant_id: Uuid, folder_id: Option, ) -> AppResult { - let mut conn = state.db_unscoped()?; + let mut conn = state.db_for_tenant(tenant_id)?; let folder = match folder_id { - Some(id) => Some(folders_dsl::folders.find(id).first::(&mut conn)?), + Some(id) => Some( + folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(&mut conn)?, + ), None => None, }; let subfolders: Vec = 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(&mut 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(&mut conn)?, @@ -241,6 +298,7 @@ fn fetch_folder_contents( 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 { @@ -290,8 +348,9 @@ async fn stream_document( ) -> Result { let range_header = headers.get(header::RANGE).cloned(); - let url = state - .storage + 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), @@ -338,7 +397,7 @@ async fn stream_document( builder = builder.header("Accept-Ranges", "bytes"); - if let Some(disposition) = content_disposition(&document.filename) { + if let Some(disposition) = inline_content_disposition(&document.filename) { builder = builder.header(header::CONTENT_DISPOSITION, disposition); } @@ -360,7 +419,7 @@ async fn stream_document( .map_err(|err| AppError::internal(format!("failed to build response: {err}"))) } -fn authenticate(state: &AppState, headers: &HeaderMap) -> Result, AppError> { +fn authenticate(state: &AppState, headers: &HeaderMap) -> Result, AppError> { tracing::debug!("webdav authenticate invoked"); let authorization = match headers.get(header::AUTHORIZATION) { Some(value) => match value.to_str() { @@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result = memberships_dsl::user_memberships + .inner_join(tenant_dsl::tenants) + .filter(memberships_dsl::user_id.eq(user.id)) + .select((tenant_dsl::id, tenant_dsl::slug)) + .load(&mut conn)?; + + if tenant_rows.is_empty() { + tracing::warn!(%username, "webdav user has no tenant memberships"); + return Ok(None); + } + + let tenants: Vec = tenant_rows + .into_iter() + .map(|(tenant_id, slug)| TenantEntry { tenant_id, slug }) + .collect(); + + tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success"); + Ok(Some(WebDavContext { _user_id: user.id, _username: user.username, + tenants, })) } @@ -444,10 +520,10 @@ fn build_resources_for_folder( let display_name = folder .map(|folder| folder.name.clone()) - .unwrap_or_else(|| "/".to_string()); + .unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string())); let href = build_href(chain, true); - let last_modified = folder.map(|folder| format_http_date(folder.updated_at)); + let last_modified = folder.map(|folder| to_http_date(folder.updated_at)); resources.push(DavResource { href, @@ -471,7 +547,7 @@ fn build_resources_for_folder( is_collection: true, content_length: None, content_type: None, - last_modified: Some(format_http_date(subfolder.updated_at)), + last_modified: Some(to_http_date(subfolder.updated_at)), }); } @@ -488,6 +564,37 @@ fn build_resources_for_folder( resources } +fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec { + let mut resources = Vec::new(); + + resources.push(DavResource { + href: "/".to_string(), + display_name: "/".to_string(), + is_collection: true, + content_length: None, + content_type: None, + last_modified: None, + }); + + if depth == 0 { + return resources; + } + + for tenant in tenants { + let href = build_href(&[tenant.slug.clone()], true); + resources.push(DavResource { + href, + display_name: tenant.slug.clone(), + is_collection: true, + content_length: None, + content_type: None, + last_modified: None, + }); + } + + resources +} + fn build_resources_for_document( chain: &[String], document: &Document, @@ -509,7 +616,7 @@ fn document_to_resource( is_collection: false, content_length: Some(version.size_bytes), content_type: document.content_type.clone(), - last_modified: Some(format_http_date(document.updated_at)), + last_modified: Some(to_http_date(document.updated_at)), } } @@ -590,32 +697,6 @@ fn render_multistatus(resources: &[DavResource]) -> Result, quick_xml::E Ok(writer.into_inner()) } -fn format_http_date(value: chrono::NaiveDateTime) -> String { - let datetime = chrono::DateTime::::from_naive_utc_and_offset(value, chrono::Utc); - datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string() -} - -fn content_disposition(filename: &str) -> Option { - 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 - )) -} - struct WebDavFolderContents { _folder: Option, subfolders: Vec, @@ -635,9 +716,10 @@ struct DavResource { content_type: Option, last_modified: Option, } - enum ResolvedPath { - Root, + TenantRoot { + chain: Vec, + }, Folder { folder: Folder, chain: Vec, @@ -649,37 +731,37 @@ enum ResolvedPath { }, } -fn resolve_path(state: &AppState, segments: &[String]) -> AppResult> { - if segments.is_empty() { - return Ok(Some(ResolvedPath::Root)); - } - - let mut conn = state.db_unscoped()?; +fn resolve_path( + state: &AppState, + tenant: &TenantEntry, + segments: &[String], +) -> AppResult> { + let mut conn = state.db_for_tenant(tenant.tenant_id)?; let mut parent_id: Option = None; - let mut chain: Vec = Vec::new(); + let mut chain: Vec = vec![tenant.slug.clone()]; let mut current_folder: Option = None; + if segments.is_empty() { + return Ok(Some(ResolvedPath::TenantRoot { chain })); + } + for (index, segment) in segments.iter().enumerate() { let is_last = index == segments.len() - 1; - match find_folder_by_name(&mut conn, parent_id, segment)? { - Some(folder) => { - if is_last { - chain.push(folder.name.clone()); - return Ok(Some(ResolvedPath::Folder { folder, chain })); - } - - parent_id = Some(folder.id); - chain.push(folder.name.clone()); - current_folder = Some(folder); - continue; + if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)? + { + chain.push(folder.name.clone()); + if is_last { + return Ok(Some(ResolvedPath::Folder { folder, chain })); } - None => {} + parent_id = Some(folder.id); + current_folder = Some(folder); + continue; } if is_last { if let Some((document, version)) = - find_document_by_filename(&mut conn, parent_id, segment)? + find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)? { chain.push(document.filename.clone()); return Ok(Some(ResolvedPath::Document { @@ -691,26 +773,22 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult(&mut conn) - .optional()? - { + if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? { if folder.parent_id != parent_id { return Ok(None); } - if !is_last { - parent_id = Some(folder.id); - chain.push(folder.name.clone()); - current_folder = Some(folder); - continue; - } else { - chain.push(folder.name.clone()); + 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(&mut conn, uuid)? { + if let Some((document, version)) = + find_document_by_id(&mut conn, tenant.tenant_id, uuid)? + { if document.folder_id != parent_id { return Ok(None); } @@ -723,19 +801,6 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult AppResult, name: &str, ) -> AppResult> { - let result = match parent_id { - Some(parent) => folders_dsl::folders - .filter(folders_dsl::parent_id.eq(Some(parent))) - .filter(folders_dsl::name.eq(name)) - .first::(conn) - .optional()?, - None => folders_dsl::folders - .filter(folders_dsl::parent_id.is_null()) - .filter(folders_dsl::name.eq(name)) - .first::(conn) - .optional()?, + 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(result) + Ok(query + .filter(folders_dsl::name.eq(name)) + .first::(conn) + .optional()?) +} + +fn find_folder_by_id( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + Ok(folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(folder_id) + .first::(conn) + .optional()?) } fn find_document_by_filename( conn: &mut PgConnection, + tenant_id: Uuid, parent_id: Option, filename: &str, ) -> AppResult> { 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(); @@ -790,10 +869,12 @@ fn find_document_by_filename( fn find_document_by_id( conn: &mut PgConnection, + tenant_id: Uuid, document_id: Uuid, ) -> AppResult> { 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::(conn) .optional()? diff --git a/backend/src/schema.rs b/backend/src/schema.rs index b56d4ea..9a6e54e 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -151,8 +151,8 @@ diesel::table! { } diesel::table! { - tenants (tenant_id) { - tenant_id -> Uuid, + tenants (id) { + id -> Uuid, slug -> Text, storage_root -> Nullable, quickwit_index -> Nullable, diff --git a/backend/src/state.rs b/backend/src/state.rs index 37c6e78..3dff178 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -11,7 +11,7 @@ use crate::{ config::AppConfig, db::PgPool, error::{AppError, AppResult}, - storage::ObjectStorage, + storage::{ObjectStorage, TenantStorage}, tenants::{apply_tenant_guc, TenantService}, }; @@ -21,12 +21,28 @@ pub type PgPooledConnection = PooledConnection>; pub struct AppState { pub pool: PgPool, pub config: Arc, - pub storage: Arc, + storage: Arc, pub jwt: JwtService, pub tenants: TenantService, } impl AppState { + pub async fn initialize( + config: AppConfig, + pool_size_override: Option, + ) -> anyhow::Result { + let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size); + let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?; + let s3_client = crate::s3::build_client(&config).await?; + let storage = Arc::new(crate::storage::S3Storage::new( + s3_client, + config.s3_bucket.clone(), + )); + let jwt = crate::auth::jwt::JwtService::from_config(&config)?; + + Ok(Self::new(pool, config, storage, jwt)) + } + pub fn new( pool: PgPool, config: AppConfig, @@ -56,4 +72,10 @@ impl AppState { .get() .map_err(|err| AppError::internal(format!("database pool error: {err}"))) } + + pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult { + let tenant = self.tenants.get_by_id(tenant_id)?; + TenantStorage::new(self.storage.clone(), &tenant) + .map_err(|err| AppError::internal(format!("tenant storage error: {err}"))) + } } diff --git a/backend/src/storage.rs b/backend/src/storage.rs index df0b84f..bb867fa 100644 --- a/backend/src/storage.rs +++ b/backend/src/storage.rs @@ -1,11 +1,15 @@ use std::time::Duration; -use anyhow::{Context, Result}; +use std::sync::Arc; + +use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::Client as S3Client; +use crate::models::Tenant; + #[async_trait] pub trait ObjectStorage: Send + Sync + 'static { async fn put_object( @@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage { Ok(()) } } +#[derive(Clone)] +pub struct TenantStorage { + inner: Arc, + root: String, +} + +impl TenantStorage { + pub fn new(inner: Arc, tenant: &Tenant) -> Result { + let root = tenant + .storage_root + .as_ref() + .ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))? + .to_owned(); + + Ok(Self { inner, root }) + } + + fn qualify(&self, key: &str) -> String { + format!("{}{}", self.root, key) + } + + pub async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let qualified = self.qualify(key); + self.inner + .put_object(&qualified, bytes, content_type, content_disposition) + .await + } + + pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result { + let qualified = self.qualify(key); + self.inner.presign_get_object(&qualified, expires_in).await + } + + pub async fn get_object(&self, key: &str) -> Result> { + let qualified = self.qualify(key); + self.inner.get_object(&qualified).await + } + + pub async fn delete_object(&self, key: &str) -> Result<()> { + let qualified = self.qualify(key); + self.inner.delete_object(&qualified).await + } +} diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 683ee01..b525a5a 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -1,8 +1,3 @@ -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; - use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use diesel::{pg::PgConnection, prelude::*, sql_types::Text}; use uuid::Uuid; @@ -33,42 +28,26 @@ impl TenantRepository { #[derive(Clone)] pub struct TenantService { pool: PgPool, - cache_by_id: Arc>>, - cache_by_slug: Arc>>, } impl TenantService { pub fn new(pool: PgPool) -> Self { - Self { - pool, - cache_by_id: Arc::new(RwLock::new(HashMap::new())), - cache_by_slug: Arc::new(RwLock::new(HashMap::new())), - } + Self { pool } } pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult { - if let Some(tenant) = self.cache_by_id.read().unwrap().get(&tenant_id) { - return Ok(tenant.clone()); - } - let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?; - self.store(&tenant); Ok(tenant) } pub fn get_by_slug(&self, slug: &str) -> AppResult { - if let Some(tenant) = self.cache_by_slug.read().unwrap().get(slug) { - return Ok(tenant.clone()); - } - let slug_owned = slug.to_owned(); let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?; - self.store(&tenant); Ok(tenant) } pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult { - Ok(self.get_by_slug(slug)?.tenant_id) + Ok(self.get_by_slug(slug)?.id) } fn load(&self, loader: F) -> AppResult @@ -82,17 +61,6 @@ impl TenantService { let tenant = loader(&mut conn)?; Ok(tenant) } - - fn store(&self, tenant: &Tenant) { - { - let mut by_id = self.cache_by_id.write().unwrap(); - by_id.insert(tenant.tenant_id, tenant.clone()); - } - { - let mut by_slug = self.cache_by_slug.write().unwrap(); - by_slug.insert(tenant.slug.clone(), tenant.clone()); - } - } } pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { diff --git a/backend/src/utils/bootstrap.rs b/backend/src/utils/bootstrap.rs new file mode 100644 index 0000000..2549572 --- /dev/null +++ b/backend/src/utils/bootstrap.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use anyhow::Result; + +use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing}; + +/// Initialize tracing, load configuration, and build the shared `AppState`. +/// Optionally override the connection pool size for lightweight components. +pub async fn init_component(name: &str, pool_override: Option) -> Result> { + init_tracing("info"); + let config = AppConfig::load_and_log(name)?; + let state = AppState::initialize(config, pool_override).await?; + Ok(Arc::new(state)) +} diff --git a/backend/src/utils/db.rs b/backend/src/utils/db.rs new file mode 100644 index 0000000..0b0c72f --- /dev/null +++ b/backend/src/utils/db.rs @@ -0,0 +1,59 @@ +use diesel::{pg::PgConnection, result::Error as DieselError}; +use uuid::Uuid; + +use crate::{ + error::{AppError, AppResult}, + state::AppState, +}; + +pub trait EnsureEntity { + fn one(self) -> AppResult; + fn maybe(self) -> AppResult>; +} + +impl EnsureEntity for Result { + fn one(self) -> AppResult { + self.map_err(AppError::from) + } + + fn maybe(self) -> AppResult> { + match self { + Ok(value) => Ok(Some(value)), + Err(DieselError::NotFound) => Ok(None), + Err(err) => Err(AppError::from(err)), + } + } +} + +impl AppState { + pub fn with_tenant_conn(&self, tenant_id: Uuid, f: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.db_for_tenant(tenant_id)?; + f(&mut conn) + } +} + +pub fn validate_bulk_ids(ids: &mut Vec, label: &str) -> AppResult<()> { + if ids.is_empty() { + return Err(AppError::bad_request(format!("{label} must not be empty"))); + } + ids.sort_unstable(); + ids.dedup(); + Ok(()) +} + +pub trait IntoJsonResponse { + fn into_json(self) -> AppResult>; +} + +impl IntoJsonResponse for T { + fn into_json(self) -> AppResult> { + Ok(axum::Json(self)) + } +} + +pub fn no_content() -> AppResult { + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/backend/src/utils/http.rs b/backend/src/utils/http.rs new file mode 100644 index 0000000..8e90153 --- /dev/null +++ b/backend/src/utils/http.rs @@ -0,0 +1,22 @@ +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + +/// Build an inline `Content-Disposition` header value for a given filename. +pub fn inline_content_disposition(filename: &str) -> Option { + if filename.is_empty() { + return None; + } + + let sanitized: String = filename + .chars() + .map(|ch| match ch { + '"' | '\\' => '_', + _ => ch, + }) + .collect(); + let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC); + + Some(format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + sanitized, encoded + )) +} diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 772e15b..5b8738e 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -1,2 +1,8 @@ +pub mod bootstrap; +pub mod db; +pub mod http; pub mod json; pub mod storage_paths; +pub mod time; +pub mod tracing; +pub mod validation; diff --git a/backend/src/utils/time.rs b/backend/src/utils/time.rs new file mode 100644 index 0000000..2489dfa --- /dev/null +++ b/backend/src/utils/time.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; + +/// Format a timestamp as RFC3339 using UTC. +pub fn to_iso(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() +} + +/// Format a timestamp for HTTP headers (RFC 7231 date). +pub fn to_http_date(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc) + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string() +} diff --git a/backend/src/utils/tracing.rs b/backend/src/utils/tracing.rs new file mode 100644 index 0000000..b3d2f30 --- /dev/null +++ b/backend/src/utils/tracing.rs @@ -0,0 +1,14 @@ +use tracing_subscriber::EnvFilter; + +/// Initialize tracing with an optional default level. +/// +/// Falls back to `default_level` when `RUST_LOG` is not provided. +pub fn init_tracing(default_level: &str) { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .compact() + .init(); +} diff --git a/backend/src/utils/validation.rs b/backend/src/utils/validation.rs new file mode 100644 index 0000000..33f6aba --- /dev/null +++ b/backend/src/utils/validation.rs @@ -0,0 +1,10 @@ +use crate::error::{AppError, AppResult}; + +/// Ensure an entity exists, returning a bad request error when it does not. +pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> { + if exists { + Ok(()) + } else { + Err(AppError::bad_request(format!("{entity} does not exist"))) + } +} diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs index df8270c..c4a40be 100644 --- a/backend/src/workers/analyze.rs +++ b/backend/src/workers/analyze.rs @@ -14,6 +14,7 @@ use crate::{ models::{Document, DocumentAsset, DocumentVersion}, schema::{document_assets, document_versions, documents}, state::AppState, + storage::TenantStorage, }; use super::{JobExecution, JobHandler}; @@ -40,7 +41,12 @@ impl JobHandler for AnalyzeDocumentJob { JOB_ANALYZE_DOCUMENT } - async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + _storage: TenantStorage, + ) -> JobExecution { let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { Ok(payload) => payload, Err(err) => { diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs index a5be08f..bc88c17 100644 --- a/backend/src/workers/index.rs +++ b/backend/src/workers/index.rs @@ -15,6 +15,7 @@ use crate::{ models::{Document, DocumentVersion}, schema::{document_asset_objects, document_assets, document_versions, documents}, state::AppState, + storage::TenantStorage, }; use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler}; @@ -39,7 +40,12 @@ impl JobHandler for IndexDocumentTextJob { JOB_INDEX_DOCUMENT_TEXT } - async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) { Ok(payload) => payload, Err(err) => { @@ -52,16 +58,29 @@ impl JobHandler for IndexDocumentTextJob { let quickwit_endpoint = match &state.config.quickwit_endpoint { Some(endpoint) => endpoint.clone(), None => { - warn!("quickwit endpoint missing; skipping indexing"); - return JobExecution::Success; + return JobExecution::Failed { + error: "quickwit endpoint missing".into(), + }; } }; - let quickwit_index = match &state.config.quickwit_index { - Some(index) => index.clone(), + let tenant = match state.tenants.get_by_id(job.tenant_id) { + Ok(tenant) => tenant, + Err(err) => { + warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: format!("failed to load tenant: {err:?}"), + }; + } + }; + + let quickwit_index = match tenant.quickwit_index.clone() { + Some(index) => index, None => { - warn!("quickwit index missing; skipping indexing"); - return JobExecution::Success; + return JobExecution::Failed { + error: "tenant quickwit index not configured".into(), + }; } }; @@ -95,7 +114,7 @@ impl JobHandler for IndexDocumentTextJob { } let s3_key = context.text_s3_key.unwrap(); - let text = match state.storage.get_object(&s3_key).await { + let text = match storage.get_object(&s3_key).await { Ok(bytes) => match String::from_utf8(bytes) { Ok(text) => text, Err(err) => { @@ -129,6 +148,7 @@ impl JobHandler for IndexDocumentTextJob { let payload = json!({ "document_id": context.document.id, "version_id": context.version.id, + "tenant_id": job.tenant_id, "title": context.document.title.to_lowercase(), "text": text.to_lowercase() }); diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs index b6e49d2..6ea7ce5 100644 --- a/backend/src/workers/mod.rs +++ b/backend/src/workers/mod.rs @@ -8,6 +8,7 @@ use crate::{ jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, models::Job, state::AppState, + storage::TenantStorage, }; pub mod analyze; @@ -25,7 +26,7 @@ pub enum JobExecution { #[async_trait] pub trait JobHandler: Send + Sync { fn job_type(&self) -> &'static str; - async fn handle(&self, state: Arc, job: Job) -> JobExecution; + async fn handle(&self, state: Arc, job: Job, storage: TenantStorage) -> JobExecution; } pub struct Worker { @@ -84,8 +85,20 @@ impl Worker { if let Some(job) = job_opt { if let Some(handler) = self.handlers.get(job.job_type.as_str()) { - let result = handler.handle(self.state.clone(), job.clone()).await; - match result { + let execution = match self.state.storage_for_tenant(job.tenant_id) { + Ok(storage) => { + handler + .handle(self.state.clone(), job.clone(), storage) + .await + } + Err(err) => { + error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job"); + JobExecution::Failed { + error: format!("tenant storage unavailable: {err:?}"), + } + } + }; + match execution { JobExecution::Success => { if let Ok(mut conn) = self.state.db_unscoped() { mark_job_succeeded(&mut conn, job.id)?; diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index 8450157..f2971f4 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -25,6 +25,7 @@ use crate::{ }, schema::{document_asset_objects, document_assets, document_versions, documents}, state::AppState, + storage::TenantStorage, utils::storage_paths::document_asset_object_prefix, }; @@ -55,7 +56,12 @@ impl JobHandler for GenerateOcrTextJob { JOB_GENERATE_OCR_TEXT } - async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) { Ok(payload) => payload, Err(err) => { @@ -92,7 +98,7 @@ impl JobHandler for GenerateOcrTextJob { return JobExecution::Success; } - let bytes = match state.storage.get_object(&context.version.s3_key).await { + let bytes = match storage.get_object(&context.version.s3_key).await { Ok(bytes) => bytes, Err(err) => { warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr"); @@ -129,7 +135,7 @@ impl JobHandler for GenerateOcrTextJob { if context.existing_asset.is_some() { for object in &context.existing_objects { - if let Err(err) = state.storage.delete_object(&object.s3_key).await { + if let Err(err) = storage.delete_object(&object.s3_key).await { warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object"); } } @@ -144,8 +150,7 @@ impl JobHandler for GenerateOcrTextJob { asset_id, ); - if let Err(err) = state - .storage + if let Err(err) = storage .put_object( &s3_key, generation.text.into_bytes(), @@ -168,11 +173,8 @@ impl JobHandler for GenerateOcrTextJob { .await { Ok(Ok(())) => { - if state.config.quickwit_endpoint.is_some() && state.config.quickwit_index.is_some() - { - if let Err(err) = enqueue_index_job(&state, &payload) { - warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); - } + if let Err(err) = enqueue_index_job(&state, &payload) { + warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); } JobExecution::Success } diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index cf4b7f8..121814f 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -19,6 +19,7 @@ use crate::{ }, schema::{document_asset_objects, document_assets, document_versions, documents}, state::AppState, + storage::TenantStorage, utils::storage_paths::document_asset_object_key, }; @@ -53,7 +54,12 @@ impl JobHandler for GenerateThumbnailsJob { JOB_GENERATE_THUMBNAILS } - async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) { Ok(p) => p, Err(err) => { @@ -89,7 +95,7 @@ impl JobHandler for GenerateThumbnailsJob { return JobExecution::Success; } - let bytes = match state.storage.get_object(&initial.version.s3_key).await { + let bytes = match storage.get_object(&initial.version.s3_key).await { Ok(bytes) => bytes, Err(err) => { warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry"); @@ -148,7 +154,7 @@ impl JobHandler for GenerateThumbnailsJob { if initial.existing_preview.is_some() { for object in &initial.existing_preview_objects { - if let Err(err) = state.storage.delete_object(&object.s3_key).await { + if let Err(err) = storage.delete_object(&object.s3_key).await { warn!( job_id = %job.id, error = %err, @@ -161,7 +167,7 @@ impl JobHandler for GenerateThumbnailsJob { if initial.existing_thumbnail.is_some() { for object in &initial.existing_thumbnail_objects { - if let Err(err) = state.storage.delete_object(&object.s3_key).await { + if let Err(err) = storage.delete_object(&object.s3_key).await { warn!( job_id = %job.id, error = %err, @@ -193,8 +199,7 @@ impl JobHandler for GenerateThumbnailsJob { ordinal, ); - if let Err(err) = state - .storage + if let Err(err) = storage .put_object( &s3_key, image.image_bytes.clone(), @@ -235,8 +240,7 @@ impl JobHandler for GenerateThumbnailsJob { ordinal, ); - if let Err(err) = state - .storage + if let Err(err) = storage .put_object( &s3_key, image.image_bytes.clone(), diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 0a47dc6..dc1b3fd 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -22,7 +22,8 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use http_body_util::BodyExt; use once_cell::sync::Lazy; use rand::rngs::OsRng; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json; use tokio::sync::Mutex; use tower::util::ServiceExt; use uuid::Uuid; @@ -235,11 +236,57 @@ impl TestApp { ); let body = body_to_vec(response.into_body()).await?; - #[derive(serde::Deserialize)] + #[derive(Deserialize)] struct LoginResponse { access_token: String, } - let parsed: LoginResponse = serde_json::from_slice(&body)?; + + if let Ok(parsed) = serde_json::from_slice::(&body) { + return Ok(parsed.access_token); + } + + #[derive(Deserialize)] + struct TenantSummary { + tenant_id: Uuid, + slug: String, + } + + #[derive(Deserialize)] + struct TenantSelectionResponse { + selection_token: String, + tenants: Vec, + } + + let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; + ensure!( + !selection.tenants.is_empty(), + "login returned no tenant options", + ); + + #[derive(Serialize)] + struct SelectTenantPayload { + tenant_id: Uuid, + } + + let target_tenant = selection.tenants[0].tenant_id; + let select_response = self + .post_json( + "/api/auth/select-tenant", + &SelectTenantPayload { + tenant_id: target_tenant, + }, + Some(&selection.selection_token), + ) + .await?; + + ensure!( + select_response.status() == StatusCode::OK, + "tenant selection failed with status {}", + select_response.status() + ); + + let select_body = body_to_vec(select_response.into_body()).await?; + let parsed: LoginResponse = serde_json::from_slice(&select_body)?; Ok(parsed.access_token) } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 2d3738a..8ae6011 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -60,6 +60,7 @@ const initialAppState = { token: STORED_TOKEN, error: null, isRefreshing: false, + tenantSelection: null, }; const AppStateContext = React.createContext(null); @@ -68,11 +69,42 @@ const AppDispatchContext = React.createContext(null); const appStateReducer = (state, action) => { switch (action.type) { case 'LOGIN_REQUEST': - return { ...state, status: 'authenticating', error: null }; + return { ...state, status: 'authenticating', error: null, tenantSelection: null }; case 'LOGIN_SUCCESS': - return { ...state, status: 'authenticated', token: action.token, error: null }; + return { + ...state, + status: 'authenticated', + token: action.token, + error: null, + tenantSelection: null, + }; case 'LOGIN_FAILURE': - return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; + return { + status: 'logged-out', + token: '', + error: action.error || null, + isRefreshing: false, + tenantSelection: null, + }; + case 'TENANT_SELECTION_REQUIRED': + return { + status: 'selecting-tenant', + token: '', + error: null, + isRefreshing: false, + tenantSelection: { + selectionToken: action.selectionToken, + tenants: action.tenants, + }, + }; + case 'CLEAR_TENANT_SELECTION': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + }; case 'BOOTSTRAP_START': return { ...state, status: 'bootstrapping', error: null }; case 'BOOTSTRAP_SUCCESS': @@ -87,11 +119,18 @@ const appStateReducer = (state, action) => { token: action.token, isRefreshing: false, status: state.status === 'logged-out' ? 'authenticated' : state.status, + tenantSelection: null, }; case 'TOKEN_REFRESH_FAILURE': - return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; + return { + status: 'logged-out', + token: '', + error: action.error || null, + isRefreshing: false, + tenantSelection: null, + }; case 'LOGOUT': - return { status: 'logged-out', token: '', error: null, isRefreshing: false }; + return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null }; case 'RESET_ERROR': return { ...state, error: null }; default: @@ -194,35 +233,79 @@ const DropOverlay = ({ active, folderName }) => ( ); -const LoginView = ({ onSubmit, status }) => ( -
-
-

Papercrate

-

Authenticate to manage your documents.

-
- - - - - -
- +const LoginView = ({ + onSubmit, + status, + tenantSelection, + onSelectTenant, + onCancelSelection, + selectingTenantId, +}) => { + const hasTenantSelection = Boolean(tenantSelection?.tenants?.length); + + return ( +
+
+

Papercrate

+ {hasTenantSelection ? ( +
+

Select a tenant to finish signing in.

+
+ {tenantSelection.tenants.map((tenant) => ( + + ))} +
+ +
+ ) : ( + <> +

Authenticate to manage your documents.

+
+ + + + + +
+ + )} + +
-
-); + ); +}; @@ -603,7 +686,7 @@ const AppLayout = () => { }, []); useEffect(() => { - if (appStatus === 'logged-out') { + if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { resetWorkspaceState(); } }, [appStatus, resetWorkspaceState]); @@ -3946,19 +4029,32 @@ const AppLayout = () => { setLoading(true); appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); - appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); - setStatusMessage('Login successful.', 'success'); - } catch (error) { - appDispatch({ - type: 'LOGIN_FAILURE', - error: error?.response?.data?.error || 'Login failed. Check credentials.', - }); - notifyApiError(error, 'Login failed. Check credentials.'); - } finally { - setLoading(false); - } - }, - [appDispatch, notifyApiError, setStatusMessage], + + if (data?.selection_token && Array.isArray(data?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: data.selection_token, + tenants: data.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!data?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + const message = error?.response?.data?.error || 'Login failed. Check credentials.'; + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [appDispatch, notifyApiError, setStatusMessage], ); const handleLogout = useCallback(async () => { @@ -4576,7 +4672,7 @@ const AppLayout = () => { ], ); - if (appStatus === 'logged-out' || appStatus === 'authenticating') { + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { return ( { }; const LoginRoute = () => { - const { status: appStatus } = useAppState(); + const { status: appStatus, tenantSelection } = useAppState(); const appDispatch = useAppDispatch(); const location = useLocation(); const [status, setStatus] = useState(null); + const [selectingTenantId, setSelectingTenantId] = useState(null); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -4793,6 +4890,20 @@ const LoginRoute = () => { try { appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); + if (data?.selection_token && Array.isArray(data?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: data.selection_token, + tenants: data.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!data?.access_token) { + throw new Error('Invalid login response.'); + } + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); setStatusMessage('Login successful.', 'success'); } catch (error) { @@ -4804,6 +4915,45 @@ const LoginRoute = () => { [appDispatch, notifyLoginError, setStatusMessage], ); + const handleTenantSelect = useCallback( + async (tenant) => { + if (!tenantSelection?.selectionToken || !tenant?.tenant_id) { + return; + } + + try { + setSelectingTenantId(tenant.tenant_id); + const { data } = await api.post( + '/auth/select-tenant', + { tenant_id: tenant.tenant_id }, + { + headers: { + Authorization: `Bearer ${tenantSelection.selectionToken}`, + }, + }, + ); + + if (!data?.access_token) { + throw new Error('Invalid tenant selection response.'); + } + + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + const message = error?.response?.data?.error || 'Failed to finalize login.'; + notifyLoginError(error, message, 'error'); + } finally { + setSelectingTenantId(null); + } + }, + [appDispatch, notifyLoginError, setStatusMessage, tenantSelection], + ); + + const handleCancelSelection = useCallback(() => { + appDispatch({ type: 'CLEAR_TENANT_SELECTION' }); + setStatusMessage(null); + }, [appDispatch, setStatusMessage]); + const redirectTarget = useMemo(() => { const target = location.state?.from; if (typeof target === 'string' && target.startsWith('/')) { @@ -4812,13 +4962,20 @@ const LoginRoute = () => { return '/documents'; }, [location.state]); - if (appStatus !== 'logged-out' && appStatus !== 'authenticating') { + if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { return ; } return (
- +
); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9bce971..9492ae5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2050,6 +2050,68 @@ form.inline { .login-card .status-banner { margin-bottom: 0; } + +.login-card__selection { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.login-card__tenant-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.login-card__tenant-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 0.6rem; + border: 1px solid var(--border); + background: var(--surface); + color: inherit; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.login-card__tenant-button:hover:not([disabled]) { + background: var(--surface-hover, rgba(255, 255, 255, 0.1)); + border-color: var(--border-strong, var(--border)); +} + +.login-card__tenant-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.login-card__tenant-button.is-loading { + opacity: 0.6; +} + +.login-card__back-button { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; + text-decoration: underline; +} + +.login-card__back-button:hover { + text-decoration: none; +} + +.login-card__back-button:disabled { + opacity: 0.6; + cursor: default; +} .column-toolbar .filter-bar { margin-bottom: 0; } diff --git a/quickwit/documents-index.yaml b/quickwit/documents-index.yaml deleted file mode 100644 index b8d2f90..0000000 --- a/quickwit/documents-index.yaml +++ /dev/null @@ -1,26 +0,0 @@ -version: 0.8 -index_id: documents -doc_mapping: - tokenizers: - - name: substring - type: ngram - min_gram: 2 - max_gram: 20 - prefix_only: false - field_mappings: - - name: document_id - type: text - stored: true - - name: version_id - type: text - stored: true - - name: title - type: text - tokenizer: substring - stored: true - - name: text - type: text - tokenizer: substring - record: position -search_settings: - default_search_fields: [title, text]