more multi-tenancy
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.tenants RENAME COLUMN id TO tenant_id;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.tenants RENAME COLUMN tenant_id TO id;
|
||||
+273
-42
@@ -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<serde_json::Value> = 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 <list-tenants|delete-assets <slug>|quickwit-create-index <slug>|quickwit-delete-index <slug>>"
|
||||
}
|
||||
|
||||
fn parse() -> Result<Self> {
|
||||
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<DocumentAsset> = document_assets::table
|
||||
let tenants: Vec<Tenant> = 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<dyn ObjectStorage> =
|
||||
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<DocumentAsset> = 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<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||
|
||||
let objects: Vec<DocumentAssetObject> = 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::<Option<String>>(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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,20 @@ pub struct AppConfig {
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load_and_log(component: &str) -> Result<Self> {
|
||||
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<Self> {
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||
|
||||
+9
-38
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub quickwit_index: Option<String>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> = 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<impl IntoResponse> {
|
||||
) -> AppResult<StatusCode> {
|
||||
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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-14
@@ -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<Json<Vec<TagCatalogEntry>>> {
|
||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
||||
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))
|
||||
@@ -49,7 +53,7 @@ pub async fn list_tags(
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response = tag_list
|
||||
let response: Vec<TagCatalogEntry> = 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<String> = 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<impl axum::response::IntoResponse> {
|
||||
) -> AppResult<StatusCode> {
|
||||
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()
|
||||
}
|
||||
|
||||
+201
-120
@@ -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<TenantEntry>,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
@@ -68,7 +77,7 @@ async fn handle_propfind(
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
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<Response, AppError> {
|
||||
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<Vec<String>> {
|
||||
|
||||
fn fetch_folder_contents(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
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::<Folder>(&mut conn)?),
|
||||
Some(id) => Some(
|
||||
folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<Folder>(&mut 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(&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<Response, AppError> {
|
||||
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<Option<WebDavUser>, AppError> {
|
||||
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() {
|
||||
@@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
tracing::debug!(%username, "webdav login success");
|
||||
Ok(Some(WebDavUser {
|
||||
let tenant_rows: Vec<(Uuid, String)> = 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<TenantEntry> = 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<DavResource> {
|
||||
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<Vec<u8>, quick_xml::E
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
|
||||
fn format_http_date(value: chrono::NaiveDateTime) -> String {
|
||||
let datetime = chrono::DateTime::<chrono::Utc>::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<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
|
||||
))
|
||||
}
|
||||
|
||||
struct WebDavFolderContents {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
@@ -635,9 +716,10 @@ struct DavResource {
|
||||
content_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
|
||||
enum ResolvedPath {
|
||||
Root,
|
||||
TenantRoot {
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -649,37 +731,37 @@ enum ResolvedPath {
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
||||
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<Option<ResolvedPath>> {
|
||||
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
||||
let mut current_folder: Option<Folder> = 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<Option<Resol
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = folders_dsl::folders
|
||||
.find(uuid)
|
||||
.first::<Folder>(&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<Option<Resol
|
||||
}
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -744,32 +809,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
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::<Folder>(conn)
|
||||
.optional()?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(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::<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();
|
||||
|
||||
@@ -790,10 +869,12 @@ fn find_document_by_filename(
|
||||
|
||||
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()?
|
||||
|
||||
@@ -151,8 +151,8 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tenants (tenant_id) {
|
||||
tenant_id -> Uuid,
|
||||
tenants (id) {
|
||||
id -> Uuid,
|
||||
slug -> Text,
|
||||
storage_root -> Nullable<Text>,
|
||||
quickwit_index -> Nullable<Text>,
|
||||
|
||||
+24
-2
@@ -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<ConnectionManager<PgConnection>>;
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub storage: Arc<dyn ObjectStorage>,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn initialize(
|
||||
config: AppConfig,
|
||||
pool_size_override: Option<u32>,
|
||||
) -> anyhow::Result<Self> {
|
||||
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<TenantStorage> {
|
||||
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}")))
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -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<dyn ObjectStorage>,
|
||||
root: String,
|
||||
}
|
||||
|
||||
impl TenantStorage {
|
||||
pub fn new(inner: Arc<dyn ObjectStorage>, tenant: &Tenant) -> Result<Self> {
|
||||
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<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> 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<String> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.presign_get_object(&qualified, expires_in).await
|
||||
}
|
||||
|
||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-34
@@ -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<RwLock<HashMap<Uuid, Tenant>>>,
|
||||
cache_by_slug: Arc<RwLock<HashMap<String, Tenant>>>,
|
||||
}
|
||||
|
||||
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<Tenant> {
|
||||
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<Tenant> {
|
||||
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<Uuid> {
|
||||
Ok(self.get_by_slug(slug)?.tenant_id)
|
||||
Ok(self.get_by_slug(slug)?.id)
|
||||
}
|
||||
|
||||
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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<u32>) -> Result<Arc<AppState>> {
|
||||
init_tracing("info");
|
||||
let config = AppConfig::load_and_log(name)?;
|
||||
let state = AppState::initialize(config, pool_override).await?;
|
||||
Ok(Arc::new(state))
|
||||
}
|
||||
@@ -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<T> {
|
||||
fn one(self) -> AppResult<T>;
|
||||
fn maybe(self) -> AppResult<Option<T>>;
|
||||
}
|
||||
|
||||
impl<T> EnsureEntity<T> for Result<T, DieselError> {
|
||||
fn one(self) -> AppResult<T> {
|
||||
self.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn maybe(self) -> AppResult<Option<T>> {
|
||||
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<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> AppResult<T>,
|
||||
{
|
||||
let mut conn = self.db_for_tenant(tenant_id)?;
|
||||
f(&mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, 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<T> {
|
||||
fn into_json(self) -> AppResult<axum::Json<T>>;
|
||||
}
|
||||
|
||||
impl<T> IntoJsonResponse<T> for T {
|
||||
fn into_json(self) -> AppResult<axum::Json<T>> {
|
||||
Ok(axum::Json(self))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn no_content() -> AppResult<axum::http::StatusCode> {
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -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<String> {
|
||||
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
|
||||
))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
||||
/// Format a timestamp as RFC3339 using UTC.
|
||||
pub fn to_iso(dt: NaiveDateTime) -> String {
|
||||
DateTime::<Utc>::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::<Utc>::from_naive_utc_and_offset(dt, Utc)
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
_storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
|
||||
@@ -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<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
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()
|
||||
});
|
||||
|
||||
@@ -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<AppState>, job: Job) -> JobExecution;
|
||||
async fn handle(&self, state: Arc<AppState>, 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)?;
|
||||
|
||||
+12
-10
@@ -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<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
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(),
|
||||
|
||||
@@ -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::<LoginResponse>(&body) {
|
||||
return Ok(parsed.access_token);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
selection_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+208
-51
@@ -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 }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoginView = ({ onSubmit, status }) => (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Papercrate</h1>
|
||||
<p>Authenticate to manage your documents.</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<StatusBanner status={status} />
|
||||
const LoginView = ({
|
||||
onSubmit,
|
||||
status,
|
||||
tenantSelection,
|
||||
onSelectTenant,
|
||||
onCancelSelection,
|
||||
selectingTenantId,
|
||||
}) => {
|
||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Papercrate</h1>
|
||||
{hasTenantSelection ? (
|
||||
<div className="login-card__selection">
|
||||
<p>Select a tenant to finish signing in.</p>
|
||||
<div className="login-card__tenant-list">
|
||||
{tenantSelection.tenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.tenant_id}
|
||||
type="button"
|
||||
onClick={() => onSelectTenant?.(tenant)}
|
||||
disabled={Boolean(selectingTenantId)}
|
||||
className={
|
||||
selectingTenantId === tenant.tenant_id
|
||||
? 'login-card__tenant-button is-loading'
|
||||
: 'login-card__tenant-button'
|
||||
}
|
||||
>
|
||||
{tenant.slug}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__back-button"
|
||||
onClick={() => onCancelSelection?.()}
|
||||
disabled={Boolean(selectingTenantId)}
|
||||
>
|
||||
Use a different account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>Authenticate to manage your documents.</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<StatusBanner status={status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Navigate
|
||||
to="/account/login"
|
||||
@@ -4755,10 +4851,11 @@ const DocumentsRoute = () => {
|
||||
};
|
||||
|
||||
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 <Navigate to={redirectTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<LoginView onSubmit={handleLogin} status={status} />
|
||||
<LoginView
|
||||
onSubmit={handleLogin}
|
||||
status={status}
|
||||
tenantSelection={tenantSelection}
|
||||
onSelectTenant={handleTenantSelect}
|
||||
onCancelSelection={handleCancelSelection}
|
||||
selectingTenantId={selectingTenantId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user