more multi-tenancy

This commit is contained in:
2025-10-23 16:06:56 +02:00
parent e175d28c2c
commit 0ad79c9bc1
35 changed files with 1229 additions and 534 deletions
+201 -120
View File
@@ -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()?