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
13 changes: 12 additions & 1 deletion crates/nvisy-cli/src/config/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
//! ```

use clap::Args;
use nvisy_server::middleware::{CorsConfig, OpenApiConfig, RecoveryConfig};
use nvisy_server::middleware::{CorsConfig, OpenApiConfig, RecoveryConfig, UploadConfig};

use super::TRACING_TARGET_CONFIG;

Expand All @@ -28,6 +28,10 @@ pub struct MiddlewareConfig {
#[clap(flatten)]
pub cors: CorsConfig,

/// Request body size limits.
#[clap(flatten)]
pub upload: UploadConfig,

/// OpenAPI documentation configuration.
#[clap(flatten)]
pub openapi: OpenApiConfig,
Expand All @@ -47,6 +51,13 @@ impl MiddlewareConfig {
"CORS configuration"
);

tracing::info!(
target: TRACING_TARGET_CONFIG,
max_body_bytes = self.upload.max_body_bytes,
max_file_body_bytes = self.upload.max_file_body_bytes,
"Upload configuration"
);

tracing::info!(
target: TRACING_TARGET_CONFIG,
openapi_path = %self.openapi.open_api_json,
Expand Down
1 change: 1 addition & 0 deletions crates/nvisy-cli/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ impl Cli {
service.health,
service.sync,
webhook,
self.middleware.upload.clone(),
)
.await?)
}
Expand Down
5 changes: 3 additions & 2 deletions crates/nvisy-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ async fn run() -> anyhow::Result<()> {

/// Creates the router with all middleware layers applied.
fn create_router(state: ServiceState, middleware: &MiddlewareConfig) -> Router {
let api_routes = routes(CustomRoutes::new(), state.clone()).with_state(state);
let api_routes =
routes(CustomRoutes::new(), state.clone(), &middleware.upload).with_state(state);

api_routes
.with_open_api(&middleware.openapi)
.with_metrics()
.with_security(&middleware.cors, &Default::default())
.with_security(&middleware.cors, &middleware.upload, &Default::default())
.with_observability()
.with_recovery(&middleware.recovery)
}
121 changes: 65 additions & 56 deletions crates/nvisy-postgres/src/query/workspace_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,6 @@ pub trait WorkspaceFileRepository {
fn delete_workspace_file(&mut self, file_id: Uuid)
-> impl Future<Output = PgResult<()>> + Send;

/// Soft deletes multiple workspace files by setting deletion timestamps.
///
/// Returns the number of files deleted.
fn delete_workspace_files(
&mut self,
workspace_id: Uuid,
file_ids: &[Uuid],
) -> impl Future<Output = PgResult<usize>> + Send;

/// Lists all files in a workspace with sorting and filtering options.
///
/// Supports filtering by file format and sorting by name, date, or size.
Expand Down Expand Up @@ -206,9 +197,21 @@ pub trait WorkspaceFileRepository {
account_id: Uuid,
) -> impl Future<Output = PgResult<BigDecimal>> + Send;

/// Finds multiple workspace files by their IDs.
fn find_workspace_files_by_ids(
/// Soft-deletes the live files among `file_ids` that belong to
/// `workspace_id`, returning the rows it actually transitioned.
///
/// Resolution and deletion are one atomic step: the `UPDATE ... RETURNING`
/// guarded on `deleted_at IS NULL` transitions and returns only rows it
/// changed, so a row concurrently deleted by another request is absent from
/// the result and never double-reported. Ids that are unknown, already
/// deleted, or in another workspace are simply absent rather than an error.
/// Also drops each returned file's import-origin row so re-import is never
/// blocked (see [`delete_workspace_file`]).
///
/// [`delete_workspace_file`]: WorkspaceFileRepository::delete_workspace_file
fn delete_files_in_workspace(
&mut self,
workspace_id: Uuid,
file_ids: &[Uuid],
) -> impl Future<Output = PgResult<Vec<WorkspaceFile>>> + Send;

Expand Down Expand Up @@ -619,41 +622,6 @@ impl WorkspaceFileRepository for PgConnection {
.await
}

async fn delete_workspace_files(
&mut self,
workspace_id: Uuid,
file_ids: &[Uuid],
) -> PgResult<usize> {
use diesel_async::AsyncConnection;
use schema::{workspace_file_imports, workspace_files};

let ids = file_ids.to_vec();
self.transaction(async |conn| {
let count = diesel::update(
workspace_files::table
.filter(workspace_files::id.eq_any(&ids))
.filter(workspace_files::workspace_id.eq(workspace_id))
.filter(workspace_files::deleted_at.is_null()),
)
.set(workspace_files::deleted_at.eq(diesel::dsl::now))
.execute(conn)
.await
.map_err(PgError::from)?;

// Drop import-origin rows so re-import is never blocked (see
// `delete_workspace_file`).
diesel::delete(
workspace_file_imports::table.filter(workspace_file_imports::file_id.eq_any(&ids)),
)
.execute(conn)
.await
.map_err(PgError::from)?;

Ok::<_, PgError>(count)
})
.await
}

async fn offset_list_workspace_files(
&mut self,
workspace_id: Uuid,
Expand Down Expand Up @@ -864,21 +832,62 @@ impl WorkspaceFileRepository for PgConnection {
Ok(usage.unwrap_or_else(|| BigDecimal::from(0)))
}

async fn find_workspace_files_by_ids(
async fn delete_files_in_workspace(
&mut self,
workspace_id: Uuid,
file_ids: &[Uuid],
) -> PgResult<Vec<WorkspaceFile>> {
use schema::workspace_files::{self, dsl};
use diesel::dsl::{exists, not};
use schema::workspace_detections::dsl as detections;
use schema::{workspace_detections, workspace_file_imports, workspace_files};

// A detection still analyzing (pending or executing) needs its input
// document and audit blob, so a file either references is held back from
// deletion — otherwise purging it would strand the in-flight detection with
// no source or analysis. This mirrors the expiry sweep's hold
// (`files_due_for_expiry`); a held file is simply not transitioned, so it
// is absent from the result and the caller reports it as skipped.
let active_detection_holds_file = exists(
workspace_detections::table.filter(
detections::status.eq_any(DetectionStatus::IN_PROGRESS).and(
detections::input_file_id
.eq(workspace_files::id)
.or(detections::audit_file_id.eq(workspace_files::id.nullable())),
),
),
);

let files = workspace_files::table
.filter(dsl::id.eq_any(file_ids))
.filter(dsl::deleted_at.is_null())
.select(WorkspaceFile::as_select())
.load(self)
.await
.map_err(PgError::from)?;
// Transition and return only the live, non-held rows in this workspace, in
// one atomic statement. The `deleted_at IS NULL` guard means a row a
// concurrent request already deleted is not returned here, so it is never
// double-counted or double-emitted. This runs on the caller's connection
// (not its own transaction) so the caller can commit the deletion together
// with the events it emits for the returned rows.
let deleted: Vec<WorkspaceFile> = diesel::update(
workspace_files::table
.filter(workspace_files::id.eq_any(file_ids))
.filter(workspace_files::workspace_id.eq(workspace_id))
.filter(workspace_files::deleted_at.is_null())
.filter(not(active_detection_holds_file)),
)
.set(workspace_files::deleted_at.eq(diesel::dsl::now))
.returning(WorkspaceFile::as_returning())
.get_results(self)
.await
.map_err(PgError::from)?;

// Drop the import-origin rows of exactly the files just deleted, so
// re-import is never blocked (see `delete_workspace_file`).
let deleted_ids: Vec<Uuid> = deleted.iter().map(|file| file.id).collect();
diesel::delete(
workspace_file_imports::table
.filter(workspace_file_imports::file_id.eq_any(&deleted_ids)),
)
.execute(self)
.await
.map_err(PgError::from)?;

Ok(files)
Ok(deleted)
}

async fn list_workspace_file_versions(
Expand Down
2 changes: 1 addition & 1 deletion crates/nvisy-postgres/src/types/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ pub use retention::{Retention, RetentionScope, RetentionSettings};
pub use typed_json::Json;
pub use webhook_headers::{InvalidHeader, WebhookHeaders};
pub use workspace_metadata::WorkspaceMetadata;
pub use workspace_settings::{OcrPolicy, WorkspaceSettings};
pub use workspace_settings::{RasterPolicy, WorkspaceSettings};
115 changes: 81 additions & 34 deletions crates/nvisy-postgres/src/types/json/workspace_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,67 @@ use serde::{Deserialize, Serialize};

use super::retention::RetentionSettings;

/// Whether processed files require approval before becoming visible, on by
/// default (the safe choice for a redaction workflow).
#[must_use]
const fn default_require_approval() -> bool {
true
}

/// How a workspace's documents are turned into images for OCR during detection.
/// How a workspace's document pages are rasterised to images for OCR during
/// detection.
///
/// A workspace-level policy over the engine's per-run OCR mode: `Auto` lets the
/// engine decide from the text layer, `Force` always renders every page (for
/// A workspace-level policy over the engine's per-run raster mode: `Auto` lets
/// the engine decide from the text layer, `Always` renders every page (for
/// documents with unreliable text layers — scans, watermarks), and `Never`
/// relies on the text layer only.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OcrPolicy {
/// Extract the text layer where present and OCR only pages that lack it.
pub enum RasterPolicy {
/// Extract the text layer where present and rasterise only pages that lack it.
#[default]
Auto,
/// Always render every page to images for OCR, ignoring any text layer.
Force,
/// Rely on the text layer only; never render pages for OCR.
/// Always render every page to images, ignoring any text layer.
#[serde(alias = "force")]
Always,
/// Rely on the text layer only; never rasterise pages.
Never,
}

/// Typed workspace settings, the JSON stored in the `workspaces.settings` column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct WorkspaceSettings {
/// Whether approval is required before processed files become visible.
pub require_approval: bool,
/// How documents are rendered for OCR during detection.
pub ocr: OcrPolicy,
/// How document pages are rasterised for OCR during detection.
#[serde(alias = "ocr")]
pub raster: RasterPolicy,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Data-retention rules for the workspace.
pub retention: RetentionSettings,
/// A soft per-file upload cap in bytes: an upload larger than this is
/// rejected for this workspace. `None` imposes no workspace-specific cap.
///
/// The server-wide hard limit still applies regardless; the effective cap is
/// the smaller of the two.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_upload_bytes: Option<u64>,
}

impl Default for WorkspaceSettings {
fn default() -> Self {
Self {
require_approval: default_require_approval(),
ocr: OcrPolicy::Auto,
retention: RetentionSettings::default(),
}
impl WorkspaceSettings {
/// The effective per-file upload limit in bytes: the smaller of this
/// workspace's own soft cap (the hard limit when it set none) and the
/// server-wide `hard_max_upload_bytes`. Always a concrete number a client can
/// enforce.
#[must_use]
pub fn effective_max_upload_bytes(&self, hard_max_upload_bytes: u64) -> u64 {
self.max_upload_bytes.map_or(hard_max_upload_bytes, |soft| {
soft.min(hard_max_upload_bytes)
})
}

/// Returns these settings with `max_upload_bytes` replaced by the effective
/// per-file limit for `hard_max_upload_bytes`, so a response always exposes a
/// single concrete cap a client can enforce rather than the raw soft value.
#[must_use]
pub fn resolved(mut self, hard_max_upload_bytes: u64) -> Self {
self.max_upload_bytes = Some(self.effective_max_upload_bytes(hard_max_upload_bytes));
self
}
}

Expand All @@ -75,33 +88,67 @@ mod tests {
#[test]
fn empty_settings_blob_is_default() {
let settings = column(json!({})).or_default();
assert!(settings.require_approval);
assert_eq!(settings.raster, RasterPolicy::Auto);
assert!(settings.retention.is_noop());
}

#[test]
fn malformed_settings_blob_falls_back_to_default() {
let settings = column(json!({ "retention": "nonsense" })).or_default();
assert!(settings.require_approval);
assert_eq!(settings.raster, RasterPolicy::Auto);
assert!(settings.retention.is_noop());
}

#[test]
fn require_approval_survives_round_trip() {
fn raster_policy_defaults_to_auto_and_round_trips() {
assert_eq!(column(json!({})).or_default().raster, RasterPolicy::Auto);
let settings = WorkspaceSettings {
require_approval: false,
raster: RasterPolicy::Always,
..Default::default()
};
assert_eq!(Json::encode(&settings).or_default(), settings);
}

#[test]
fn ocr_policy_defaults_to_auto_and_round_trips() {
assert_eq!(column(json!({})).or_default().ocr, OcrPolicy::Auto);
fn max_upload_bytes_round_trips() {
let settings = WorkspaceSettings {
ocr: OcrPolicy::Force,
max_upload_bytes: Some(25 * 1024 * 1024),
..Default::default()
};
assert_eq!(Json::encode(&settings).or_default(), settings);
}

#[test]
fn legacy_ocr_field_and_force_variant_still_deserialize() {
// Settings persisted before the `ocr`/`force` rename must keep their
// behaviour: the `ocr` key aliases `raster`, and `force` aliases `always`.
let settings = column(json!({ "ocr": "force" })).or_default();
assert_eq!(settings.raster, RasterPolicy::Always);

let settings = column(json!({ "ocr": "never" })).or_default();
assert_eq!(settings.raster, RasterPolicy::Never);
}

#[test]
fn effective_max_upload_bytes_is_the_smaller_of_soft_and_hard() {
let hard = 12 * 1024 * 1024;

// No soft cap: the hard limit governs.
let settings = WorkspaceSettings::default();
assert_eq!(settings.effective_max_upload_bytes(hard), hard);

// Soft cap below the hard limit wins.
let settings = WorkspaceSettings {
max_upload_bytes: Some(8 * 1024 * 1024),
..Default::default()
};
assert_eq!(settings.effective_max_upload_bytes(hard), 8 * 1024 * 1024);

// Soft cap above the hard limit is clamped to it.
let settings = WorkspaceSettings {
max_upload_bytes: Some(64 * 1024 * 1024),
..Default::default()
};
assert_eq!(settings.effective_max_upload_bytes(hard), hard);
}
}
Loading