Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/nvisy-postgres/src/query/workspace_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,11 @@ impl WorkspaceFileRepository for PgConnection {
query = query.filter(dsl::file_extension.eq_any(extensions.to_vec()));
}

// Apply the exact content-hash constraint (dedup lookup).
if let Some(hash) = filter.hash() {
query = query.filter(dsl::file_hash_sha256.eq(hash.to_vec()));
}

// Apply sorting
let query = match (sort_by.field, sort_by.order) {
(FileSortField::Name, SortOrder::Asc) => query.order(dsl::display_name.asc()),
Expand Down Expand Up @@ -687,6 +692,7 @@ impl WorkspaceFileRepository for PgConnection {
// Precompute filter values
let search_term = filter.search_term().map(|s| s.to_string());
let extensions: Option<Vec<String>> = filter.extensions().map(|e| e.to_vec());
let hash: Option<Vec<u8>> = filter.hash().map(|h| h.to_vec());

// Build base query with filters
let mut base_query = workspace_files::table
Expand All @@ -711,6 +717,11 @@ impl WorkspaceFileRepository for PgConnection {
base_query = base_query.filter(dsl::file_extension.eq_any(extensions));
}

// Apply the exact content-hash constraint (dedup lookup).
if let Some(ref hash) = hash {
base_query = base_query.filter(dsl::file_hash_sha256.eq(hash));
}

let total = if pagination.include_count {
Some(
base_query
Expand Down Expand Up @@ -749,6 +760,11 @@ impl WorkspaceFileRepository for PgConnection {
query = query.filter(dsl::file_extension.eq_any(extensions));
}

// Apply the exact content-hash constraint (dedup lookup).
if let Some(ref hash) = hash {
query = query.filter(dsl::file_hash_sha256.eq(hash));
}

let limit = pagination.fetch_limit();

// Apply cursor filter if present
Expand Down
17 changes: 17 additions & 0 deletions crates/nvisy-postgres/src/types/filtering/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ pub struct FileFilter {
/// only these extensions — including `Some(empty)`, which matches nothing
/// (an active facet resolved to an empty set).
extensions: Option<Vec<String>>,
/// Exact SHA-256 content hash (32 raw bytes). `None` imposes no constraint;
/// `Some(hash)` matches only files with this exact content. Lets a client
/// check whether identical content already exists before uploading it.
hash: Option<Vec<u8>>,
}

impl FileFilter {
Expand Down Expand Up @@ -63,4 +67,17 @@ impl FileFilter {
pub fn extensions(&self) -> Option<&[String]> {
self.extensions.as_deref()
}

/// Constrains to files whose content hash is exactly `hash` (32 raw bytes).
#[inline]
pub fn with_hash(mut self, hash: Vec<u8>) -> Self {
self.hash = Some(hash);
self
}

/// Returns the content-hash constraint, if one is set.
#[inline]
pub fn hash(&self) -> Option<&[u8]> {
self.hash.as_deref()
}
}
52 changes: 31 additions & 21 deletions crates/nvisy-server/src/handler/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ async fn list_files(
fn list_files_docs(op: TransformOperation) -> TransformOperation {
op.summary("List files")
.description(
"Lists files in a workspace with cursor-based pagination. Use the `after` parameter with the `nextCursor` value from the response to fetch subsequent pages.",
"Lists files in a workspace with cursor-based pagination. Use the `after` parameter with the `nextCursor` value from the response to fetch subsequent pages. Pass `hash` (a hex SHA-256) to find files with identical content — a non-empty result means the file already exists, so an upload can be skipped.",
)
.response::<200, Json<FilesPage>>()
.response::<400, Json<ErrorResponse>>()
.response::<401, Json<ErrorResponse>>()
.response::<403, Json<ErrorResponse>>()
}
Expand Down Expand Up @@ -312,26 +313,31 @@ async fn upload_file(
) -> Result<(StatusCode, Json<Files>)> {
tracing::info!(target: TRACING_TARGET, "Uploading files");

let mut conn = pg_client.get_connection().await?;
let file_store = nats_client.object_store::<FilesBucket>().await?;

auth_claims
.authorize_workspace(&mut conn, workspace.id, Permission::UploadFiles)
.await?;
// Do the quick pre-flight DB work under a connection, then release it: auth,
// resolve the uploader's identity, and read the workspace's upload settings.
// Holding a pooled connection across the streaming below would pin it for the
// whole upload and starve the pool under load, so this scope drops it before
// streaming begins.
let (uploaded_by, expires_at, max_upload_bytes) = {
let mut conn = pg_client.get_connection().await?;

let file_store = nats_client.object_store::<FilesBucket>().await?;
auth_claims
.authorize_workspace(&mut conn, workspace.id, Permission::UploadFiles)
.await?;

let uploaded_by = resolve_account_ref(&mut conn, auth_claims.account_id).await?;

// The uploader is the caller; resolve their identity once for every file below.
let uploaded_by = resolve_account_ref(&mut conn, auth_claims.account_id).await?;
let settings = workspace.settings.or_default();
let expires_at = settings
.retention
.original_documents
.expires_at(jiff::Timestamp::now());
let max_upload_bytes = settings.effective_max_upload_bytes(upload.max_file_bytes());

// Read workspace settings once for the whole batch: the retention expiry for
// uploaded originals, and the effective per-file upload cap (the workspace's
// soft cap clamped to the server-wide hard limit).
let settings = workspace.settings.or_default();
let expires_at = settings
.retention
.original_documents
.expires_at(jiff::Timestamp::now());
let max_upload_bytes = settings.effective_max_upload_bytes(upload.max_file_bytes());
(uploaded_by, expires_at, max_upload_bytes)
};

let ctx = FileUploadContext {
workspace_id: workspace.id,
Expand All @@ -343,16 +349,20 @@ async fn upload_file(
max_upload_bytes,
};

// Stream every file to storage first, then persist all their rows and events
// in one transaction. The upload is atomic: it either records every file or,
// on any failure, records none and reclaims every staged object — never a
// partial batch, and never an object left behind with no row.
// Stream every file to storage first (no DB connection held), then persist all
// their rows and events in one transaction. The upload is atomic: it either
// records every file or, on any failure, records none and reclaims every
// staged object — never a partial batch, and never an object left behind with
// no row.
let staged = ctx.stage_all(&mut multipart).await?;

if staged.is_empty() {
return Err(ErrorKind::BadRequest.with_message("No files provided in multipart request"));
}

// Re-acquire a connection only for the final commit, so the pool is free
// during the streaming above.
let mut conn = pg_client.get_connection().await?;
let origin = EventOrigin {
workspace_id: workspace.id,
account_id: auth_claims.account_id,
Expand Down
9 changes: 9 additions & 0 deletions crates/nvisy-server/src/handler/request/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;

use crate::handler::utility::FileHash;
use crate::service::{EngineService, UnknownFormatToken};

/// Request to update file metadata.
Expand Down Expand Up @@ -114,6 +115,11 @@ pub struct ListFiles {
/// Filter by modality (`text`, `tabular`, `image`, `audio`).
#[serde(skip_serializing_if = "Option::is_none")]
pub modality: Option<Vec<ModalityToken>>,
/// Filter to files whose content is exactly this SHA-256. Lets a client check
/// whether identical content already exists in the workspace before uploading
/// it.
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<FileHash>,
}

impl ListFiles {
Expand All @@ -138,6 +144,9 @@ impl ListFiles {
if let Some(extensions) = intersect_facets(formats, modality) {
filter = filter.with_extensions(extensions);
}
if let Some(hash) = &self.hash {
filter = filter.with_hash(hash.to_bytes());
}
Ok(filter)
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/nvisy-server/src/handler/response/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub struct File {
pub file_extension: String,
/// File size in bytes.
pub file_size: i64,
/// Lowercase hex-encoded SHA-256 of the file's plaintext content.
pub file_hash: String,
/// The file's role (original, redacted, audit).
pub file_kind: FileKind,
/// Account that uploaded/created the file.
Expand All @@ -50,6 +52,7 @@ impl File {
original_filename: file.original_filename,
file_extension: file.file_extension,
file_size: file.file_size_bytes,
file_hash: hex::encode(&file.file_hash_sha256),
file_kind: file.file_kind,
uploaded_by,
version_number: file.version_number,
Expand Down
107 changes: 107 additions & 0 deletions crates/nvisy-server/src/handler/utility/file_hash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! A validated file content hash for request parameters.

use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;

use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};

/// Length of a SHA-256 digest in bytes.
const SHA256_LEN: usize = 32;

/// A file content hash: a SHA-256 digest carried as a 64-character hex string.
///
/// Validates on deserialization — a value that is not exactly 32 bytes of hex is
/// rejected — so a handler holding one never has to re-check it.
#[must_use]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileHash([u8; SHA256_LEN]);

impl FileHash {
/// The digest as raw bytes, for matching against the stored column.
pub fn to_bytes(&self) -> Vec<u8> {
self.0.to_vec()
}
}

impl FromStr for FileHash {
type Err = FileHashError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut bytes = [0u8; SHA256_LEN];
hex::decode_to_slice(s, &mut bytes).map_err(|_| FileHashError)?;
Ok(Self(bytes))
}
}

impl fmt::Display for FileHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}

impl Serialize for FileHash {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}

impl<'de> Deserialize<'de> for FileHash {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}

impl JsonSchema for FileHash {
fn schema_name() -> Cow<'static, str> {
"FileHash".into()
}

fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
schemars::json_schema!({
"type": "string",
"description": "A SHA-256 content hash as a 64-character hex string.",
"pattern": "^[0-9a-fA-F]{64}$",
})
}
}

/// The `hash` value was not a valid 64-character hex SHA-256.
#[derive(Debug)]
pub struct FileHashError;

impl fmt::Display for FileHashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "hash must be a 64-character hex SHA-256")
}
}

impl std::error::Error for FileHashError {}

#[cfg(test)]
mod tests {
use super::FileHash;

#[test]
fn parses_valid_sha256_and_round_trips() {
let hex = "a".repeat(64);
let hash: FileHash = hex.parse().expect("64 hex chars is a valid sha256");
assert_eq!(hash.to_bytes().len(), 32);
assert_eq!(hash.to_string(), hex);
}

#[test]
fn rejects_bad_length_and_non_hex() {
assert!("abcd".parse::<FileHash>().is_err(), "too short");
assert!(
"a".repeat(66).parse::<FileHash>().is_err(),
"too long (33 bytes)"
);
assert!(
"zz".repeat(32).parse::<FileHash>().is_err(),
"non-hex characters"
);
}
}
2 changes: 2 additions & 0 deletions crates/nvisy-server/src/handler/utility/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
mod accounts;
mod custom_routes;
mod download;
mod file_hash;
mod sse_response;

pub use accounts::{ActorFilter, build_password_user_inputs, resolve_account_ref, resolve_actor};
pub use custom_routes::{BuiltinModule, CustomRoutes, RouterMapFn};
pub use download::{DownloadResponseExt, attachment_headers};
pub use file_hash::FileHash;
pub use sse_response::SseResponse;
2 changes: 0 additions & 2 deletions crates/nvisy-server/src/middleware/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ mod constants;
mod counting_body;
mod observability;
mod recovery;
mod route_category;
mod security;
mod specification;
mod sunset;
Expand All @@ -61,7 +60,6 @@ pub use authorization::require_admin;
pub use constants::{DEFAULT_MAX_BODY_SIZE, DEFAULT_MAX_FILE_BODY_SIZE};
pub use observability::RouterObservabilityExt;
pub use recovery::{RecoveryConfig, RouterRecoveryExt};
pub use route_category::RouteCategory;
pub use security::{
CorsConfig, FrameOptions, ReferrerPolicy, RouterSecurityExt, SecurityHeadersConfig,
UploadConfig,
Expand Down
Loading