a lot of stuff
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m29s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m28s

This commit is contained in:
2025-10-16 00:38:03 +02:00
parent 5b420a6b62
commit aef34791f3
21 changed files with 2987 additions and 736 deletions
+53 -19
View File
@@ -605,11 +605,13 @@ dependencies = [
"aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.21.7",
"bytes",
"chrono",
"diesel",
"diesel_migrations",
"dotenv",
"futures-util",
"hex",
"http-body-util",
"hyper 1.7.0",
@@ -619,6 +621,7 @@ dependencies = [
"once_cell",
"pdfium-render",
"percent-encoding",
"quick-xml",
"rand 0.8.5",
"reqwest",
"serde",
@@ -628,7 +631,7 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http 0.5.2",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
@@ -1269,6 +1272,23 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -1288,9 +1308,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@@ -2354,6 +2379,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2547,6 +2581,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
@@ -2566,12 +2601,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.2",
"tower-http 0.6.6",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -3320,23 +3357,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags",
"bytes",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.6"
@@ -3353,6 +3373,7 @@ dependencies = [
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -3642,6 +3663,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.81"
+5 -2
View File
@@ -8,7 +8,7 @@ edition = "2021"
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["make", "util"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
# Database
@@ -38,8 +38,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
pdfium-render = "0.8"
mime_guess = "2.0"
tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
percent-encoding = "2.3"
base64 = "0.21"
quick-xml = "0.32"
futures-util = "0.3"
# Error handling
thiserror = "1.0"
+2 -1
View File
@@ -20,7 +20,7 @@ COPY migrations ./migrations
COPY tests ./tests
COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker
RUN cargo build --release --bin backend --bin worker --bin webdav
RUN cargo install diesel_cli --no-default-features --features postgres
FROM debian:trixie-slim AS runtime
@@ -52,6 +52,7 @@ RUN apt-get update \
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
COPY migrations ./migrations
COPY diesel.toml ./
+48
View File
@@ -0,0 +1,48 @@
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;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
init_tracing();
let config = AppConfig::from_env()?;
let pool = db::init_pool(&config.database_url)?;
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 listener = TcpListener::bind(listen_addr).await?;
tracing::info!("listening for WebDAV on {}", listen_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();
}
+9
View File
@@ -7,6 +7,8 @@ pub struct AppConfig {
pub database_url: String,
pub server_host: String,
pub server_port: u16,
pub webdav_host: String,
pub webdav_port: u16,
pub jwt_secret: String,
pub jwt_issuer: String,
pub jwt_audience: String,
@@ -34,6 +36,11 @@ impl AppConfig {
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
@@ -69,6 +76,8 @@ impl AppConfig {
database_url,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
+202 -34
View File
@@ -205,6 +205,12 @@ pub struct BulkTagResponse {
pub removed: usize,
}
#[derive(Serialize)]
pub struct BulkCorrespondentResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Deserialize)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
@@ -218,6 +224,69 @@ pub struct AssignCorrespondentsRequest {
pub replace: bool,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Deserialize)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
#[derive(Deserialize)]
pub struct CorrespondentRoleQuery {
pub role: String,
@@ -850,40 +919,8 @@ pub async fn assign_correspondents(
return Err(AppError::bad_request("assignments must not be empty"));
}
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in &payload.assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
let roles_vec: Vec<String> = role_set.into_iter().collect();
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let replace = payload.replace;
let user_id = user.user_id;
@@ -950,6 +987,137 @@ pub async fn assign_correspondents(
Ok(StatusCode::NO_CONTENT)
}
pub async fn bulk_assign_correspondents(
State(state): State<AppState>,
user: AuthenticatedUser,
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();
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let action = payload.action;
let user_id = user.user_id;
let mut conn = state.db()?;
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign correspondents to deleted documents",
));
}
if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec))
.load(conn)?;
if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
}
match action {
BulkCorrespondentAction::Add => {
let mut removed = 0;
if !roles_vec.is_empty() {
removed = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
}
let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len());
for doc_id in &document_ids {
for (correspondent_id, role) in &normalized_pairs {
new_rows.push(NewDocumentCorrespondent {
document_id: *doc_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id),
});
}
}
let assigned = if new_rows.is_empty() {
0
} else {
diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?
};
if assigned > 0 || removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((assigned, removed))
}
BulkCorrespondentAction::Remove => {
let mut removed = 0;
if !normalized_pairs.is_empty() {
let mut grouped: HashMap<String, Vec<Uuid>> = HashMap::new();
for (correspondent_id, role) in &normalized_pairs {
grouped
.entry(role.clone())
.or_default()
.push(*correspondent_id);
}
for (role, ids) in grouped {
removed += diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::role.eq(role.as_str()))
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
)
.execute(conn)?;
}
}
if removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((0, removed))
}
}
})?;
Ok((
StatusCode::OK,
Json(BulkCorrespondentResponse { assigned, removed }),
))
}
pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
+51 -1
View File
@@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{document_tags, documents, folders};
use crate::schema::{document_correspondents, document_tags, documents, folders};
use crate::state::AppState;
use crate::{
auth::AuthenticatedUser,
@@ -60,6 +60,7 @@ pub struct FolderContentsResponse {
pub struct DocumentSearchQuery {
pub query: Option<String>,
pub tags: Option<String>,
pub correspondents: Option<String>,
}
#[derive(Serialize)]
@@ -353,6 +354,55 @@ pub async fn search_documents(
}
}
if let Some(correspondents_param) = params
.correspondents
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if let Ok(ids) = correspondent_ids {
if !ids.is_empty() {
let mut doc_id_set: Option<HashSet<Uuid>> = None;
for correspondent_id in &ids {
let docs_for_correspondent: Vec<Uuid> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(*correspondent_id))
.select(document_correspondents::document_id)
.load(&mut conn)?;
let docs_set: HashSet<Uuid> = docs_for_correspondent.into_iter().collect();
doc_id_set = Some(match doc_id_set {
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
None => docs_set,
});
if let Some(ref set) = doc_id_set {
if set.is_empty() {
break;
}
}
}
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
}
let new_filter = match &filter_ids {
Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(),
None => matching_doc_ids.clone(),
};
filter_ids = Some(new_filter);
}
}
}
if let Some(ref set) = filter_ids {
if set.is_empty() {
return Ok(Json(vec![]));
+6 -1
View File
@@ -15,6 +15,7 @@ pub mod documents;
pub mod folders;
pub mod health;
pub mod tags;
pub mod webdav;
pub fn create_router(state: AppState) -> Router<()> {
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
@@ -59,6 +60,10 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route(
"/bulk/correspondents",
post(documents::bulk_assign_correspondents),
)
.route(
"/bulk/reanalyze",
post(documents::reanalyze_selected_documents),
@@ -125,9 +130,9 @@ pub fn create_router(state: AppState) -> Router<()> {
Router::new()
.merge(download_routes)
.merge(protected_routes)
.nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check))
.merge(protected_routes)
.with_state(state)
.layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
+808
View File
@@ -0,0 +1,808 @@
use std::time::Duration;
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderMap, Method, StatusCode};
use axum::response::Response;
use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use diesel::prelude::*;
use diesel::PgConnection;
use futures_util::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;
use uuid::Uuid;
use crate::auth::password;
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,
};
use crate::state::AppState;
const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct WebDavUser {
_user_id: Uuid,
_username: String,
}
pub fn create_router() -> Router<AppState> {
Router::new().fallback(webdav_entrypoint)
}
async fn webdav_entrypoint(
State(state): State<AppState>,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, AppError> {
let method = req.method().clone();
let headers = req.headers().clone();
let path = req.uri().path().trim_start_matches('/').to_string();
tracing::debug!(method = %method, %path, "webdav entrypoint" );
match method {
ref m if m == Method::OPTIONS => Ok(handle_options()),
ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await,
ref m if m == Method::HEAD => {
handle_get_or_head(&state, &path, headers, Method::HEAD).await
}
_ => {
if method.as_str() == "PROPFIND" {
handle_propfind(&state, &path, headers).await
} else {
Ok(method_not_allowed())
}
}
}
}
async fn handle_propfind(
state: &AppState,
path: &str,
headers: HeaderMap,
) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? {
Some(user) => user,
None => return Ok(unauthorized_response()),
};
let depth = match parse_depth(&headers) {
Ok(value) => value,
Err(response) => return Ok(response),
};
let segments = parse_segments(path)?;
let 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)
}
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)
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
let response = Response::builder()
.status(multi_status())
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(body))
.expect("valid response");
Ok(response)
}
async fn handle_get_or_head(
state: &AppState,
path: &str,
headers: HeaderMap,
method: Method,
) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? {
Some(user) => user,
None => return Ok(unauthorized_response()),
};
let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
let (document, version, chain) = match resolution {
ResolvedPath::Document {
document,
version,
chain,
} => (document, version, chain),
_ => return Ok(method_not_allowed()),
};
stream_document(state, &document, &version, &chain, headers, method).await
}
fn handle_options() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("DAV", "1,2")
.header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD")
.header("Accept-Ranges", "bytes")
.body(Body::empty())
.expect("valid OPTIONS response")
}
fn method_not_allowed() -> Response {
Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.expect("valid response")
}
fn not_found_response() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("valid response")
}
fn unauthorized_response() -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
header::WWW_AUTHENTICATE,
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
)
.body(Body::empty())
.expect("valid response")
}
fn multi_status() -> StatusCode {
StatusCode::from_u16(207).expect("valid multi-status")
}
fn parse_depth(headers: &HeaderMap) -> Result<u8, Response> {
match headers.get("Depth") {
None => Ok(1),
Some(value) => match value.to_str() {
Ok("0") => Ok(0),
Ok("1") => Ok(1),
Ok("infinity") => Err(Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Body::empty())
.expect("valid response")),
_ => Err(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::empty())
.expect("valid response")),
},
}
}
fn parse_segments(path: &str) -> AppResult<Vec<String>> {
if path.trim_matches('/').is_empty() {
return Ok(vec![]);
}
let segments = path
.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| {
percent_decode_str(segment)
.decode_utf8()
.map(|cow| cow.into_owned())
.map_err(|_| AppError::bad_request("invalid UTF-8 in path"))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(segments)
}
fn fetch_folder_contents(
state: &AppState,
folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?;
let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
None => None,
};
let subfolders: Vec<Folder> = match folder_id {
Some(id) => folders_dsl::folders
.filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc())
.load(&mut conn)?,
None => folders_dsl::folders
.filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc())
.load(&mut conn)?,
};
let mut docs_query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null())
.into_boxed();
docs_query = match folder_id {
Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))),
None => docs_query.filter(documents_dsl::folder_id.is_null()),
};
let documents: Vec<Document> = docs_query
.order(documents_dsl::uploaded_at.desc())
.load(&mut conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
Vec::new()
} else {
document_versions_dsl::document_versions
.filter(document_versions_dsl::id.eq_any(&version_ids))
.load(&mut conn)?
};
let mut version_map = versions
.into_iter()
.map(|version| (version.id, version))
.collect::<std::collections::HashMap<_, _>>();
let mut entries = Vec::with_capacity(documents.len());
for document in documents {
if let Some(version) = version_map.remove(&document.current_version_id) {
entries.push(DocumentEntry { document, version });
}
}
Ok(WebDavFolderContents {
_folder: folder,
subfolders,
documents: entries,
})
}
async fn stream_document(
state: &AppState,
document: &Document,
version: &DocumentVersion,
_chain: &[String],
headers: HeaderMap,
method: Method,
) -> Result<Response, AppError> {
let range_header = headers.get(header::RANGE).cloned();
let url = state
.storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
let client = reqwest::Client::new();
let mut request = client.request(method.clone(), url.clone());
if let Some(range) = range_header.clone() {
request = request.header(header::RANGE, range.clone());
}
let upstream = request
.send()
.await
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
return Err(AppError::internal(format!(
"upstream download returned status {status}"
)));
}
let mut builder = Response::builder().status(status);
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type);
} else if let Some(ref typ) = document.content_type {
builder = builder.header(header::CONTENT_TYPE, typ);
}
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
builder = builder.header(header::CONTENT_LENGTH, content_length);
}
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
builder = builder.header(header::CONTENT_RANGE, range);
}
builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = content_disposition(&document.filename) {
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
}
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
if method == Method::HEAD {
return builder
.body(Body::empty())
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
}
let stream = upstream
.bytes_stream()
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream);
builder
.body(body)
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
}
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
tracing::debug!("webdav authenticate invoked");
let authorization = match headers.get(header::AUTHORIZATION) {
Some(value) => match value.to_str() {
Ok(header) if header.starts_with("Basic ") => {
tracing::debug!("authorization header present");
&header[6..]
}
Ok(other) => {
tracing::warn!(header = %other, "non-basic authorization header");
return Ok(None);
}
Err(err) => {
tracing::warn!(error = %err, "invalid authorization header");
return Ok(None);
}
},
None => {
tracing::debug!("no authorization header");
return Ok(None);
}
};
let decoded = match BASE64.decode(authorization) {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(error = %err, "failed to decode basic credentials");
return Ok(None);
}
};
let credential_str = match String::from_utf8(decoded) {
Ok(value) => value,
Err(err) => {
tracing::warn!(error = %err, "invalid utf-8 basic credentials");
return Ok(None);
}
};
let (username, password) = match credential_str.split_once(':') {
Some((username, password)) if !username.is_empty() => (username, password),
_ => return Ok(None),
};
tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?;
let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username))
.first(&mut conn)
{
Ok(user) => user,
Err(diesel::result::Error::NotFound) => {
tracing::warn!(%username, "webdav user not found");
return Ok(None);
}
Err(err) => return Err(AppError::from(err)),
};
let valid = password::verify_password(password, &user.password_hash)
.map_err(|_| AppError::internal("failed to verify password"))?;
if !valid {
tracing::warn!(%username, "webdav password invalid");
return Ok(None);
}
tracing::debug!(%username, "webdav login success");
Ok(Some(WebDavUser {
_user_id: user.id,
_username: user.username,
}))
}
fn build_resources_for_folder(
folder: Option<&Folder>,
chain: &[String],
contents: &WebDavFolderContents,
depth: u8,
) -> Vec<DavResource> {
let mut resources = Vec::new();
let display_name = folder
.map(|folder| folder.name.clone())
.unwrap_or_else(|| "/".to_string());
let href = build_href(chain, true);
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
resources.push(DavResource {
href,
display_name,
is_collection: true,
content_length: None,
content_type: None,
last_modified,
});
if depth == 0 {
return resources;
}
for subfolder in &contents.subfolders {
let mut child_chain = chain.to_vec();
child_chain.push(subfolder.name.clone());
resources.push(DavResource {
href: build_href(&child_chain, true),
display_name: subfolder.name.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: Some(format_http_date(subfolder.updated_at)),
});
}
for entry in &contents.documents {
let mut child_chain = chain.to_vec();
child_chain.push(entry.document.filename.clone());
resources.push(document_to_resource(
&child_chain,
&entry.document,
&entry.version,
));
}
resources
}
fn build_resources_for_document(
chain: &[String],
document: &Document,
version: &DocumentVersion,
) -> Vec<DavResource> {
vec![document_to_resource(chain, document, version)]
}
fn document_to_resource(
chain: &[String],
document: &Document,
version: &DocumentVersion,
) -> DavResource {
let href = build_href(chain, false);
DavResource {
href,
display_name: document.title.clone(),
is_collection: false,
content_length: Some(version.size_bytes),
content_type: document.content_type.clone(),
last_modified: Some(format_http_date(document.updated_at)),
}
}
fn build_href(names: &[String], is_collection: bool) -> String {
if names.is_empty() {
return "/".to_string();
}
let encoded = names
.iter()
.map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>();
let mut path = format!("/{}", encoded.join("/"));
if is_collection && !path.ends_with('/') {
path.push('/');
}
path
}
fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::Error> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
let mut multistatus = BytesStart::new("D:multistatus");
multistatus.push_attribute(("xmlns:D", "DAV:"));
writer.write_event(Event::Start(multistatus))?;
for resource in resources {
writer.write_event(Event::Start(BytesStart::new("D:response")))?;
writer.write_event(Event::Start(BytesStart::new("D:href")))?;
writer.write_event(Event::Text(BytesText::new(&resource.href)))?;
writer.write_event(Event::End(BytesEnd::new("D:href")))?;
writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?;
writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
if resource.is_collection {
writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
if let Some(length) = resource.content_length {
writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
writer.write_event(Event::Text(BytesText::new(&length.to_string())))?;
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
}
if let Some(content_type) = &resource.content_type {
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
writer.write_event(Event::Text(BytesText::new(content_type)))?;
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
}
if let Some(last_modified) = &resource.last_modified {
writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
writer.write_event(Event::Text(BytesText::new(last_modified)))?;
writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
writer.write_event(Event::Start(BytesStart::new("D:status")))?;
writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
writer.write_event(Event::End(BytesEnd::new("D:status")))?;
writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
writer.write_event(Event::End(BytesEnd::new("D:response")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(writer.into_inner())
}
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>,
documents: Vec<DocumentEntry>,
}
struct DocumentEntry {
document: Document,
version: DocumentVersion,
}
struct DavResource {
href: String,
display_name: String,
is_collection: bool,
content_length: Option<i64>,
content_type: Option<String>,
last_modified: Option<String>,
}
enum ResolvedPath {
Root,
Folder {
folder: Folder,
chain: Vec<String>,
},
Document {
document: Document,
version: DocumentVersion,
chain: Vec<String>,
},
}
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
if segments.is_empty() {
return Ok(Some(ResolvedPath::Root));
}
let mut conn = state.db()?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None;
for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1;
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;
}
None => {}
}
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,
}));
}
}
if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = folders_dsl::folders
.find(uuid)
.first::<Folder>(&mut conn)
.optional()?
{
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());
return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
}
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
if document.folder_id != parent_id {
return Ok(None);
}
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
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);
}
Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain }))
}
fn find_folder_by_name(
conn: &mut PgConnection,
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()?,
};
Ok(result)
}
fn find_document_by_filename(
conn: &mut PgConnection,
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::filename.eq(filename))
.into_boxed();
query = match parent_id {
Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))),
None => query.filter(documents_dsl::folder_id.is_null()),
};
if let Some(document) = query.first::<Document>(conn).optional()? {
let version = document_versions_dsl::document_versions
.find(document.current_version_id)
.first::<DocumentVersion>(conn)?;
return Ok(Some((document, version)));
}
Ok(None)
}
fn find_document_by_id(
conn: &mut PgConnection,
document_id: Uuid,
) -> AppResult<Option<(Document, DocumentVersion)>> {
if let Some(document) = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null())
.find(document_id)
.first::<Document>(conn)
.optional()?
{
let version = document_versions_dsl::document_versions
.find(document.current_version_id)
.first::<DocumentVersion>(conn)?;
return Ok(Some((document, version)));
}
Ok(None)
}
+248 -1
View File
@@ -20,6 +20,8 @@ struct DocumentInfo {
issued_at: Option<String>,
tags: Vec<TagSummary>,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
@@ -75,6 +77,23 @@ struct TagSummary {
label: String,
}
#[derive(Deserialize)]
struct DocumentCorrespondentInfo {
name: String,
role: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
#[derive(Deserialize)]
struct AnalyzeJobPayload {
document_id: Uuid,
@@ -157,12 +176,12 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.tags.is_empty());
assert!(current_version.download_path.starts_with("/download/"));
let current_version = detail
.document
.current_version
.as_ref()
.expect("current version detail");
assert!(current_version.download_path.starts_with("/download/"));
assert_eq!(current_version.version_number, 1);
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty());
@@ -616,6 +635,234 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_to_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkcorresp";
app.insert_user("corra", password, "admin").await?;
let token = app.login_token("corra", password).await?;
let first = app
.upload_document(
"/api/documents",
"letter-one.txt",
"text/plain",
b"letter one",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"letter-two.txt",
"text/plain",
b"letter two",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let sender = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Acme Corp" }),
Some(&token),
)
.await?;
assert_eq!(sender.status(), StatusCode::OK);
let sender_body = body_to_vec(sender.into_body()).await?;
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
let receiver = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Bank Ltd" }),
Some(&token),
)
.await?;
assert_eq!(receiver.status(), StatusCode::OK);
let receiver_body = body_to_vec(receiver.into_body()).await?;
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
let assign_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": sender_summary.id,
"role": "sender"
},
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
]
});
let assign_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(assign_resp.status(), StatusCode::OK);
let assign_body = body_to_vec(assign_resp.into_body()).await?;
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
assert_eq!(assign_result.assigned, 4);
assert_eq!(assign_result.removed, 0);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Acme Corp"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let duplicate_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(duplicate_resp.status(), StatusCode::OK);
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
assert_eq!(duplicate_result.assigned, 0);
assert_eq!(duplicate_result.removed, 0);
let replacement = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Charlie" }),
Some(&token),
)
.await?;
assert_eq!(replacement.status(), StatusCode::OK);
let replacement_body = body_to_vec(replacement.into_body()).await?;
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
let replace_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": replacement_summary.id,
"role": "sender"
}
]
});
let replace_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&replace_payload,
Some(&token),
)
.await?;
assert_eq!(replace_resp.status(), StatusCode::OK);
let replace_body = body_to_vec(replace_resp.into_body()).await?;
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
assert_eq!(replace_result.assigned, 2);
assert_eq!(replace_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let remove_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
],
"action": "remove"
});
let remove_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&remove_payload,
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.assigned, 0);
assert_eq!(remove_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 1);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(!detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_selected_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
+1
View File
@@ -21,6 +21,7 @@ Documents
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document.
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title).
+329 -200
View File
@@ -7,6 +7,178 @@ import { CORRESPONDENT_ROLES } from '../constants/correspondents';
const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase();
const formatRoleLabel = (role) => {
const normalized = normalizeRole(role);
if (!normalized) return 'Other';
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
};
const compareCorrespondents = (a, b) => {
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
if (rankedA !== rankedB) {
return rankedA - rankedB;
}
return (a.name || '').localeCompare(b.name || '');
};
const sortCorrespondents = (entries = []) =>
entries
.map((entry) => ({
id: entry.id,
name: entry.name || '',
role: normalizeRole(entry.role),
}))
.sort(compareCorrespondents);
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
<div className="correspondent-list">
{entries.length ? (
entries.map((entry) => (
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
<span className="correspondent-pill__label">
<strong>{formatRoleLabel(entry.role)}</strong>
<span>
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
>
×
</button>
) : null}
</span>
))
) : (
<span className="meta">No correspondents yet.</span>
)}
</div>
);
const TagSection = ({
title,
tags = [],
onRemove,
onAdd,
emptyMessage = 'No tags yet.',
addPlaceholder = 'Add or create tag',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<div className="tag-list">
{tags.length ? (
tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
return (
<span key={key} className="tag-pill" style={style || undefined}>
{tag.label}{' '}
{onRemove ? (
<button type="button" onClick={() => onRemove(tag)}>
×
</button>
) : null}
</span>
);
})
) : (
<span className="meta">{emptyMessage}</span>
)}
</div>
{onAdd ? (
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onAdd({ value, input });
}}
>
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((option) => (
<option key={option.id || option.label || option} value={option.label || option} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
const CorrespondentSection = ({
title,
entries = [],
onRemove,
onAdd,
showCount = false,
addPlaceholder = 'Add or create correspondent',
addButtonLabel = 'Add',
datalistId,
datalistOptions = [],
className,
}) => (
<div className={className}>
<dt>{title}</dt>
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
{onAdd ? (
<form
className="correspondent-form"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const nameInput = form.elements.correspondent;
const roleSelect = form.elements.role;
const value = nameInput.value.trim();
const role = roleSelect.value;
if (!value) return;
onAdd({ name: value, role, input: nameInput });
form.reset();
}}
>
<input
name="correspondent"
placeholder={addPlaceholder}
list={datalistId}
/>
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
{CORRESPONDENT_ROLES.map((role) => (
<option key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
<button type="submit">{addButtonLabel}</button>
{datalistId ? (
<datalist id={datalistId}>
{datalistOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
) : null}
</form>
) : null}
</div>
);
const computeStackAngle = (docId, index) => {
if (index === 0) return 0;
let hash = 0;
@@ -95,7 +267,6 @@ const DetailPanel = ({
selectedDocuments = [],
tags = [],
tagLookupById = new Map(),
tagLookupByLabel = new Map(),
onTagAdd,
onTagRemove,
onRegenerateThumbnails,
@@ -103,10 +274,9 @@ const DetailPanel = ({
onOpenPreview,
onBulkTagAdd,
onBulkTagRemove,
onBulkMove,
onBulkReanalyze,
folderOptions = [],
defaultMoveTarget = 'root',
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onPromoteSelection,
activePreviewId = null,
onUpdateTitle = async () => false,
@@ -257,20 +427,25 @@ const DetailPanel = ({
});
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
const commonTags = useMemo(() => {
if (selectedCount < 2) return [];
const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label)));
if (!tagSets.length) return [];
const intersection = new Set(tagSets[0]);
tagSets.slice(1).forEach((set) => {
[...intersection].forEach((label) => {
if (!set.has(label)) {
intersection.delete(label);
const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return [];
const tagMap = new Map();
selectedDocuments.forEach((doc) => {
(doc.tags || []).forEach((tag) => {
const label = (tag?.label || '').trim();
if (!label) return;
if (!tagMap.has(label)) {
const fallback = tagLookupById.get(tag.id) || {};
tagMap.set(label, {
id: tag.id,
label,
color: tag.color || fallback.color,
});
}
});
});
return [...intersection];
}, [selectedDocuments, selectedCount]);
return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label));
}, [selectedDocuments, tagLookupById]);
const stackTotalSizeBytes = useMemo(() => {
if (!stackPreviews.length) return 0;
@@ -302,6 +477,87 @@ const DetailPanel = ({
});
}, [availableCorrespondents]);
const singleCorrespondents = useMemo(() => {
if (!singleDoc) return [];
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0];
return doc ? sortCorrespondents(doc.correspondents || []) : [];
}
const map = new Map();
selectedDocuments.forEach((doc) => {
if (!doc?.id) return;
(doc.correspondents || []).forEach((entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
const key = `${entry.id}:${normalizedRole}`;
if (!map.has(key)) {
map.set(key, {
id: entry.id,
name: entry.name || '',
role: normalizedRole,
documentIds: new Set(),
});
}
map.get(key).documentIds.add(doc.id);
});
});
return [...map.values()]
.map((entry) => ({
id: entry.id,
name: entry.name,
role: entry.role,
documentIds: [...entry.documentIds],
count: entry.documentIds.size,
}))
.sort(compareCorrespondents);
}, [selectedDocuments]);
const handleBulkCorrespondentRemove = useCallback(
(entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
if (onBulkCorrespondentRemove) {
return onBulkCorrespondentRemove({
assignments: [
{
correspondent_id: entry.id,
role: normalizedRole,
},
],
documentIds: entry.documentIds,
});
}
if (!onCorrespondentRemove) return;
const targets = entry.documentIds && entry.documentIds.length
? entry.documentIds
: selectedDocuments
.filter((doc) =>
(doc.correspondents || []).some(
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
),
)
.map((doc) => doc.id);
return Promise.all(
targets.map((documentId) =>
onCorrespondentRemove({
documentId,
correspondentId: entry.id,
role: normalizedRole,
}),
),
).catch(() => {});
},
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
);
const renderSingle = () => {
if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
@@ -320,21 +576,6 @@ const DetailPanel = ({
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const docCorrespondents = Array.isArray(singleDoc.correspondents)
? singleDoc.correspondents
: [];
const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
const roleA = (a.role || '').toLowerCase();
const roleB = (b.role || '').toLowerCase();
const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
if (indexA !== indexB) {
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
(indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
}
return (a.name || '').localeCompare(b.name || '');
});
return (
<>
<div>
@@ -448,119 +689,39 @@ const DetailPanel = ({
Re-run analysis
</button>
</div>
<div>
<dt>Tags</dt>
<div className="tag-list">
{tagsForDoc.length ? (
tagsForDoc.map((tag) => {
const colorSource = tag?.color || tagLookupById.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span key={tag.id} className="tag-pill" style={style || undefined}>
{tag.label}{' '}
<button type="button" onClick={() => onTagRemove(singleDoc.id, tag.id)}>
×
</button>
</span>
);
})
) : (
<span className="meta">No tags yet.</span>
)}
</div>
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onTagAdd(singleDoc, value, input);
}}
>
<input name="tag" placeholder="Add or create tag" list="tag-catalog" />
<button type="submit">Add</button>
<datalist id="tag-catalog">
{tags.map((tag) => (
<option key={tag.id} value={tag.label} />
))}
</datalist>
</form>
</div>
<div>
<dt>Correspondents</dt>
<div className="correspondent-list">
{sortedCorrespondents.length ? (
sortedCorrespondents.map((entry) => {
const roleLabel = entry.role
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
: 'Other';
return (
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
<span className="correspondent-pill__label">
<strong>{roleLabel}</strong>
<span>{entry.name}</span>
</span>
<button
type="button"
className="correspondent-pill__remove"
onClick={() =>
onCorrespondentRemove?.({
documentId: singleDoc.id,
correspondentId: entry.id,
role: entry.role,
})
}
aria-label={`Remove ${entry.name} as ${roleLabel}`}
>
×
</button>
</span>
);
})
) : (
<span className="meta">No correspondents yet.</span>
)}
</div>
<form
className="correspondent-form"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const nameInput = form.elements.correspondent;
const roleSelect = form.elements.role;
const value = nameInput.value.trim();
const role = roleSelect.value;
if (!value) return;
onCorrespondentAdd?.({
document: singleDoc,
name: value,
role,
input: nameInput,
});
form.reset();
}}
>
<input
name="correspondent"
placeholder="Add or create correspondent"
list="correspondent-catalog"
/>
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
{CORRESPONDENT_ROLES.map((role) => (
<option key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
<button type="submit">Add</button>
<datalist id="correspondent-catalog">
{correspondentOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</form>
</div>
<TagSection
title="Tags"
tags={tagsForDoc.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color || tagLookupById.get(tag.id)?.color,
}))}
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
datalistId="tag-catalog-single"
datalistOptions={tags}
/>
<CorrespondentSection
title="Correspondents"
entries={singleCorrespondents}
onRemove={(entry) =>
onCorrespondentRemove?.({
documentId: singleDoc.id,
correspondentId: entry.id,
role: entry.role,
})
}
onAdd={({ name, role, input }) =>
onCorrespondentAdd?.({
document: singleDoc,
name,
role,
input,
})
}
datalistId="correspondent-catalog-single"
datalistOptions={correspondentOptions}
/>
{metadata && (
<div>
<dt>Metadata</dt>
@@ -593,64 +754,32 @@ const DetailPanel = ({
<div>
<strong>Total size (stack):</strong> {sizeLabel}
</div>
<div>
<strong>Common tags:</strong>{' '}
{commonTags.length ? commonTags.join(', ') : 'None'}
</div>
</div>
<div className="bulk-tags">
<strong>Bulk tag operations</strong>
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onBulkTagAdd?.({ label: value, input });
}}
>
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
<button type="submit">Add tag</button>
</form>
<form
className="inline"
onSubmit={(event) => {
event.preventDefault();
const input = event.currentTarget.elements.tag;
const value = input.value.trim();
if (!value) return;
onBulkTagRemove?.({ label: value, input });
}}
>
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
<button type="submit" className="secondary">
Remove tag
</button>
</form>
</div>
<datalist id="tag-catalog">
{tags.map((tag) => (
<option key={tag.id} value={tag.label} />
))}
</datalist>
<div className="bulk-move">
<label htmlFor="detail-bulk-move" className="meta">
Move selection to folder
</label>
<select
id="detail-bulk-move"
name="target"
defaultValue={defaultMoveTarget || 'root'}
onChange={(event) => onBulkMove?.({ target: event.target.value })}
>
{folderOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
<TagSection
title="Tags"
tags={bulkTagUnion}
emptyMessage="No tags assigned."
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })}
onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })}
addPlaceholder="Add tag to selection"
addButtonLabel="Add tag"
datalistId="tag-catalog-bulk"
datalistOptions={tags}
className="bulk-tags"
/>
<CorrespondentSection
title="Correspondents"
entries={bulkCorrespondents}
onRemove={handleBulkCorrespondentRemove}
onAdd={({ name, role, input }) =>
onBulkCorrespondentAdd?.({ name, role, input })
}
addPlaceholder="Add correspondent to selection"
datalistId="correspondent-catalog-bulk"
datalistOptions={correspondentOptions}
showCount
className="bulk-correspondents"
/>
<button
type="button"
className="secondary"
+49 -86
View File
@@ -3,60 +3,6 @@ import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
const FilterBar = ({
query,
onQueryChange,
tags,
activeTagIds,
onToggleTag,
onClear,
hasFilters,
}) => (
<div className="filter-bar">
<input
type="search"
placeholder="Search documents"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
<div className="tag-filters">
{tags.length ? (
tags.map((tag) => {
const isActive = activeTagIds.includes(tag.id);
const style = getTagColorStyle(tag.color);
const buttonStyle = style
? {
...style,
opacity: isActive ? 1 : 0.95,
boxShadow: isActive ? '0 0 0 1px var(--shadow-soft)' : undefined,
}
: undefined;
return (
<button
key={tag.id}
type="button"
className={`tag-filter${isActive ? ' active' : ''}`}
onClick={() => onToggleTag(tag.id)}
style={buttonStyle}
>
{tag.label}
</button>
);
})
) : (
<span className="meta">No tags yet</span>
)}
</div>
<div className="filter-actions">
{hasFilters && (
<button type="button" className="secondary" onClick={onClear}>
Clear
</button>
)}
</div>
</div>
);
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
const url = useMemo(
() =>
@@ -103,6 +49,8 @@ const DocumentsTable = ({
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
selectedFolderIds = [],
onFolderRowClick,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
@@ -114,7 +62,6 @@ const DocumentsTable = ({
onDocumentDelete,
onFolderRename,
onDocumentRename,
filterBar,
tagLookupById,
onDocumentListFocus,
onDocumentListKeyDown,
@@ -131,6 +78,10 @@ const DocumentsTable = ({
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
);
const selectedFolderSet = useMemo(
() => new Set(selectedFolderIds || []),
[selectedFolderIds],
);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
@@ -235,7 +186,6 @@ const DocumentsTable = ({
</div>
</div>
<div className="column-body">
<div className="column-toolbar">{filterBar}</div>
<div
ref={scrollRef}
className="documents-scroll"
@@ -275,20 +225,33 @@ const DocumentsTable = ({
subfolders.map((folder) => {
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
return (
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`}
}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={() => {
onFolderSelect(folder.id);
onClick={(event) => {
onFolderRowClick?.(folder.id, event);
const shouldNavigate =
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey;
if (shouldNavigate) {
onFolderSelect(folder.id);
}
if (scrollRef.current) {
scrollRef.current.focus({ preventScroll: true });
}
onFocusedRowChange?.(`folder:${folder.id}`);
}}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
@@ -413,36 +376,36 @@ const DocumentsTable = ({
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
}}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
@@ -504,4 +467,4 @@ const DocumentsTable = ({
};
export default DocumentsTable;
export { FilterBar, DocumentThumbnailImage };
export { DocumentThumbnailImage };
+784 -361
View File
File diff suppressed because it is too large Load Diff
+81 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useMatch } from 'react-router-dom';
import {
ChevronIcon,
@@ -8,6 +8,7 @@ import {
CorrespondentIcon,
EditIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({
node,
@@ -138,8 +139,12 @@ const Sidebar = ({
draggedFolderId,
onShowTags,
tags = [],
activeTagIds = [],
onToggleTagFilter,
onShowCorrespondents,
correspondents = [],
activeCorrespondentIds = [],
onToggleCorrespondentFilter,
}) => {
const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags');
@@ -147,6 +152,19 @@ const Sidebar = ({
const handleShowCorrespondents = onShowCorrespondents || (() => {});
const correspondentsRouteMatch = useMatch('/correspondents');
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
const sortedCorrespondents = useMemo(
() =>
[...correspondents].sort((a, b) =>
(a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }),
),
[correspondents],
);
const activeCorrespondentSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const handleToggleTag = onToggleTagFilter || (() => {});
const activeTagSet = new Set(activeTagIds);
const renderNodes = useCallback(
(ids, depth) =>
@@ -221,6 +239,30 @@ const Sidebar = ({
<TagIcon className="sidebar-item__icon" />
<span>All tags</span>
</span>
<div className="sidebar-tag-cloud" role="list">
{tags.length ? (
tags.map((tag) => {
const isActive = activeTagSet.has(tag.id);
const style = getTagColorStyle(tag.color);
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
return (
<button
key={tag.id}
type="button"
role="listitem"
className={className}
style={style || undefined}
onClick={() => handleToggleTag(tag.id)}
aria-pressed={isActive}
>
{tag.label}
</button>
);
})
) : (
<span className="meta">No tags yet</span>
)}
</div>
</div>
<div className="sidebar-section">
<div className="sidebar-section__header">
@@ -231,10 +273,14 @@ const Sidebar = ({
role="button"
tabIndex={0}
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
onClick={handleShowCorrespondents}
onClick={() => {
onToggleCorrespondentFilter?.(null);
handleShowCorrespondents();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleCorrespondentFilter?.(null);
handleShowCorrespondents();
}
}}
@@ -242,6 +288,39 @@ const Sidebar = ({
<CorrespondentIcon className="sidebar-item__icon" />
<span>All correspondents</span>
</span>
<ul className="sidebar-correspondent-list">
{sortedCorrespondents.length ? (
sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed';
const handleSelect = () => {
const nextId = isActive ? null : correspondent.id;
onToggleCorrespondentFilter?.(nextId);
};
return (
<li key={correspondent.id}>
<span
className={className}
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelect();
}
}}
>
{label}
</span>
</li>
);
})
) : (
<li className="meta">No correspondents yet</li>
)}
</ul>
</div>
</aside>
);
+151 -28
View File
@@ -221,14 +221,16 @@ button.icon-button.ghost:hover:not([disabled]) {
.app-bar__main {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-bar__meta {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 0 0 auto;
}
.app-bar h1 {
@@ -257,6 +259,52 @@ button.icon-button.ghost:hover:not([disabled]) {
margin: 0;
}
.app-bar__search {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 320px;
max-width: 420px;
min-width: 240px;
margin: 0 auto;
}
.app-bar__search input[type='search'] {
flex: 1;
min-width: 180px;
padding: 0.4rem 0.6rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface-subtle);
color: var(--fg);
}
.app-bar__search input[type='search']:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.app-bar__search-clear {
border: none;
background: transparent;
color: var(--accent);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
}
.app-bar__search-clear:hover {
text-decoration: underline;
}
.app-bar__right {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.app-main {
flex: 1;
display: grid;
@@ -363,6 +411,34 @@ button.icon-button.ghost:hover:not([disabled]) {
pointer-events: none;
}
.document-drag-preview__folder-thumb {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.document-drag-preview__folder-thumb svg {
width: 48px;
height: 48px;
color: var(--accent-strong, var(--accent));
}
.document-drag-preview__folder-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.9);
}
.document-drag-preview__count {
position: absolute;
bottom: 4px;
@@ -852,6 +928,80 @@ button.icon-button.ghost:hover:not([disabled]) {
color: currentColor;
}
.sidebar-tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.25rem 0 0.1rem;
}
.sidebar-tag-pill {
border: 1px solid var(--sidebar-pill-border, rgba(15, 23, 42, 0.08));
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
background: var(--surface-soft);
color: var(--fg);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.sidebar-tag-pill:hover {
transform: none;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
border-color: var(--accent-soft, rgba(59, 130, 246, 0.35));
}
.sidebar-tag-pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sidebar-tag-pill.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.sidebar-correspondent-list {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sidebar-correspondent-item {
display: block;
width: 100%;
background: transparent;
border-radius: 4px;
padding: 0.35rem 0.5rem;
text-align: left;
color: var(--fg);
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.sidebar-correspondent-item:hover {
background: var(--sidebar-hover-bg);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.sidebar-correspondent-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sidebar-correspondent-item.active {
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
}
.documents-panel {
padding: 1.25rem 1.25rem 0 1.25rem;
@@ -893,7 +1043,6 @@ button.icon-button.ghost:hover:not([disabled]) {
}
.documents-panel .documents-scroll {
flex: 1;
overflow-y: auto;
border: none;
background: transparent;
@@ -1064,32 +1213,6 @@ button.icon-button.ghost:hover:not([disabled]) {
min-width: 180px;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.tag-filter {
border: 1px solid var(--border);
background: var(--surface-subtle);
color: var(--fg);
padding: 0.25rem 0.6rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.85rem;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
}
.tag-filter:hover {
filter: brightness(0.97);
}
.tag-filter.active {
box-shadow: 0 0 0 1px currentColor inset;
transform: translateY(-1px);
}
.filter-actions {
display: flex;
gap: 0.32rem;
+27
View File
@@ -0,0 +1,27 @@
import { generateRandomTagColor } from './utils/colors';
class TagManager {
constructor({ colorGenerator = generateRandomTagColor } = {}) {
this.colorGenerator = typeof colorGenerator === 'function' ? colorGenerator : generateRandomTagColor;
}
normalizeLabel(label) {
if (typeof label !== 'string') {
return '';
}
return label.trim();
}
buildPayload({ label, color } = {}) {
const normalizedLabel = this.normalizeLabel(label);
if (!normalizedLabel) {
throw new Error('Tag label is required.');
}
const payload = { label: normalizedLabel };
const trimmedColor = typeof color === 'string' && color.trim().length ? color.trim() : null;
payload.color = trimmedColor || this.colorGenerator();
return payload;
}
}
export default TagManager;
+4
View File
@@ -46,6 +46,10 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.backend.image) -}}
{{- end -}}
{{- define "papercrate.webdav.image" -}}
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.webdav.image) -}}
{{- end -}}
{{- define "papercrate.frontend.image" -}}
{{- include "papercrate.image" (dict "Values" .Values "image" .Values.frontend.image) -}}
{{- end -}}
@@ -0,0 +1,91 @@
{{- if .Values.webdav.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "papercrate.fullname" . }}-webdav
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: webdav
spec:
replicas: {{ .Values.webdav.replicaCount }}
selector:
matchLabels:
{{- include "papercrate.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: webdav
template:
metadata:
labels:
{{- include "papercrate.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: webdav
spec:
{{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.global.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- range .Values.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
containers:
- name: webdav
image: {{ include "papercrate.webdav.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.webdav.image "Chart" .Chart) }}
imagePullPolicy: {{ .Values.webdav.image.pullPolicy }}
command:
- /usr/local/bin/papercrate-webdav
ports:
- name: http
containerPort: {{ .Values.webdav.service.port }}
protocol: TCP
env:
- name: SERVER_HOST
value: 0.0.0.0
- name: SERVER_PORT
value: {{ .Values.webdav.service.port | quote }}
- name: WEBDAV_HOST
value: 0.0.0.0
- name: WEBDAV_PORT
value: {{ .Values.webdav.service.port | quote }}
{{- with .Values.global.s3.endpoint }}
- name: AWS_ENDPOINT_URL
value: {{ . | quote }}
{{- end }}
{{- with .Values.global.s3.region }}
- name: AWS_REGION
value: {{ . | quote }}
{{- end }}
{{- with .Values.global.s3.bucket }}
- name: S3_BUCKET
value: {{ . | quote }}
{{- end }}
{{- range .Values.webdav.env.extra }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
envFrom:
- secretRef:
name: {{ if .Values.webdav.env.existingSecret }}{{ .Values.webdav.env.existingSecret }}{{ else }}{{ include "papercrate.fullname" . }}-backend-env{{ end }}
{{- with .Values.global.s3.credentialsSecret }}
- secretRef:
name: {{ . }}
{{- end }}
{{- range .Values.webdav.envFrom }}
- {{ toYaml . | nindent 12 | trim }}
{{- end }}
{{- with .Values.webdav.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.webdav.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.webdav.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.webdav.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,19 @@
{{- if .Values.webdav.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "papercrate.fullname" . }}-webdav
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: webdav
spec:
type: {{ .Values.webdav.service.type }}
ports:
- name: http
port: {{ .Values.webdav.service.port }}
targetPort: http
protocol: TCP
selector:
{{- include "papercrate.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: webdav
{{- end }}
+19
View File
@@ -28,6 +28,25 @@ backend:
tolerations: []
affinity: {}
webdav:
enabled: true
replicaCount: 1
image:
repository: registry.example.com/papercrate-backend
tag: ""
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 3001
env:
existingSecret: ""
extra: []
envFrom: []
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
worker:
replicaCount: 1
enabled: true