This commit is contained in:
2025-10-10 09:39:58 +02:00
parent 4a428b9af6
commit ddce0e39b3
36 changed files with 6410 additions and 1419 deletions
+19
View File
@@ -0,0 +1,19 @@
# Paperless-NEO
## Backend Integration Tests
Integration tests require a running Postgres instance. The repository includes a lightweight compose file for local runs:
```bash
docker compose -f docker-compose.test.yml up -d
export TEST_DATABASE_URL=postgres://paperless:paperless_test@localhost:5433/paperless_test
cargo test
```
Stop the database when you are done:
```bash
docker compose -f docker-compose.test.yml down
```
The compose service uses tmpfs storage, giving each test run a clean database.
+218
View File
@@ -692,12 +692,24 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -775,6 +787,26 @@ dependencies = [
"cc",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -1103,6 +1135,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.12.1"
@@ -1119,6 +1160,16 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
[[package]]
name = "flate2"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1652,6 +1703,21 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "2.11.4"
@@ -1796,6 +1862,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1839,6 +1911,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1852,6 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
@@ -1865,6 +1948,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cc7d85f3d741164e8972ad355e26ac6e51b20fcae5f911c7da8f2d8bbbb3f33"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1993,8 +2086,11 @@ dependencies = [
"hex",
"http-body-util",
"hyper 1.7.0",
"image",
"jsonwebtoken",
"mime_guess",
"once_cell",
"pdfium-render",
"rand 0.8.5",
"serde",
"serde_json",
@@ -2042,6 +2138,32 @@ dependencies = [
"subtle",
]
[[package]]
name = "pdfium-render"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8fa6e8ceaad45c9fed63db09045c45f2cccd4a8b92a826bd3f8e901a98b14c6"
dependencies = [
"bitflags",
"bytemuck",
"bytes",
"chrono",
"console_error_panic_hook",
"console_log",
"image",
"itertools",
"js-sys",
"libloading",
"log",
"maybe-owned",
"once_cell",
"utf16string",
"vecmath",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "pem"
version = "3.0.5"
@@ -2090,6 +2212,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piston-float"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590"
[[package]]
name = "pkcs8"
version = "0.9.0"
@@ -2106,6 +2234,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -2160,6 +2301,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]]
name = "quote"
version = "1.0.41"
@@ -2658,6 +2808,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "simple_asn1"
version = "0.6.3"
@@ -3084,6 +3240,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-ident"
version = "1.0.19"
@@ -3114,6 +3276,15 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf16string"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216"
dependencies = [
"byteorder",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3144,6 +3315,15 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vecmath"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a"
dependencies = [
"piston-float",
]
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3216,6 +3396,19 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
@@ -3248,6 +3441,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -3590,3 +3793,18 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
+3
View File
@@ -34,6 +34,9 @@ sha2 = "0.10"
hex = "0.4"
bytes = "1.5"
async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
pdfium-render = "0.8"
mime_guess = "2.0"
# Error handling
thiserror = "1.0"
Binary file not shown.
@@ -0,0 +1,5 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
DROP INDEX IF EXISTS idx_jobs_job_type;
DROP INDEX IF EXISTS idx_jobs_status_run_after;
DROP TABLE IF EXISTS jobs;
@@ -0,0 +1,28 @@
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_document_assets_type;
DROP INDEX IF EXISTS idx_document_assets_version;
DROP TABLE IF EXISTS document_assets;
@@ -0,0 +1,15 @@
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
s3_key TEXT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER,
height INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
);
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
ALTER TABLE folders
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
@@ -0,0 +1,5 @@
ALTER TABLE folders
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
+42
View File
@@ -0,0 +1,42 @@
use std::{sync::Arc, time::Duration};
use tokio::signal;
use tracing_subscriber::EnvFilter;
use paperless_backend::{
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
state::AppState, storage::S3Storage, Worker,
};
#[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 = Arc::new(AppState::new(pool, config, storage, jwt));
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
tokio::select! {
_ = worker.run() => {}
_ = signal::ctrl_c() => {
tracing::info!("worker received shutdown signal");
}
}
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();
}
+127
View File
@@ -0,0 +1,127 @@
use std::time::Duration;
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use diesel::pg::PgConnection;
use diesel::prelude::*;
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
use crate::models::{Job, NewJob};
use crate::schema::jobs;
pub const STATUS_QUEUED: &str = "queued";
pub const STATUS_PROCESSING: &str = "processing";
pub const STATUS_SUCCEEDED: &str = "succeeded";
pub const STATUS_FAILED: &str = "failed";
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
#[derive(Debug, Error)]
pub enum JobQueueError {
#[error("database error: {0}")]
Database(#[from] diesel::result::Error),
}
pub type JobQueueResult<T> = Result<T, JobQueueError>;
pub fn enqueue_job(
conn: &mut PgConnection,
job_type: &str,
payload: Value,
run_after: Option<NaiveDateTime>,
) -> JobQueueResult<Job> {
let new_job = NewJob {
id: Uuid::new_v4(),
job_type: job_type.to_string(),
payload,
status: STATUS_QUEUED.to_string(),
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
};
diesel::insert_into(jobs::table)
.values(&new_job)
.execute(conn)?;
let job = jobs::table.find(new_job.id).first(conn)?;
Ok(job)
}
pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult<Option<Job>> {
let now = Utc::now().naive_utc();
conn.transaction(|conn| {
let job_opt = jobs::table
.filter(jobs::status.eq(STATUS_QUEUED))
.filter(jobs::run_after.le(now))
.filter(jobs::job_type.eq_any(job_types))
.order(jobs::run_after.asc())
.for_update()
.skip_locked()
.first::<Job>(conn)
.optional()?;
if let Some(job) = job_opt {
diesel::update(jobs::table.find(job.id))
.set((
jobs::status.eq(STATUS_PROCESSING),
jobs::attempts.eq(job.attempts + 1),
jobs::updated_at.eq(now),
))
.execute(conn)?;
let refreshed = jobs::table.find(job.id).first(conn)?;
Ok::<Option<Job>, diesel::result::Error>(Some(refreshed))
} else {
Ok::<Option<Job>, diesel::result::Error>(None)
}
})
.map_err(JobQueueError::from)
}
pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> {
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_SUCCEEDED),
jobs::last_error.eq::<Option<String>>(None),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
pub fn retry_job_after(
conn: &mut PgConnection,
job_id: Uuid,
delay: Duration,
error_message: &str,
) -> JobQueueResult<()> {
let next_run = Utc::now()
+ ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30));
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_QUEUED),
jobs::run_after.eq(next_run.naive_utc()),
jobs::last_error.eq(Some(error_message.to_string())),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
pub fn mark_job_failed(
conn: &mut PgConnection,
job_id: Uuid,
error_message: &str,
) -> JobQueueResult<()> {
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_FAILED),
jobs::last_error.eq(Some(error_message.to_string())),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
+3
View File
@@ -2,9 +2,12 @@ pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod jobs;
pub mod models;
pub mod routes;
pub mod s3;
pub mod schema;
pub mod state;
pub mod storage;
pub mod workers;
pub use workers::{default_handlers, Worker};
+52
View File
@@ -98,6 +98,58 @@ pub struct NewDocumentVersion {
pub operations_summary: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_assets)]
#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))]
pub struct DocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_assets)]
pub struct NewDocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = jobs)]
pub struct Job {
pub id: Uuid,
pub job_type: String,
pub payload: serde_json::Value,
pub status: String,
pub attempts: i32,
pub run_after: NaiveDateTime,
pub last_error: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = jobs)]
pub struct NewJob {
pub id: Uuid,
pub job_type: String,
pub payload: serde_json::Value,
pub status: String,
pub run_after: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = tags)]
pub struct Tag {
+451 -120
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::time::Duration;
use std::{collections::HashMap, time::Duration};
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
@@ -8,16 +7,18 @@ use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{
Document, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
};
use crate::schema::{document_tags, document_versions, documents, folders, tags};
use crate::schema::{document_assets, document_tags, document_versions, documents, folders, tags};
use crate::state::AppState;
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
@@ -29,6 +30,12 @@ pub struct DocumentListQuery {
pub include_deleted: bool,
}
#[derive(Deserialize)]
pub struct AssetRequestQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Serialize)]
pub struct TagResponse {
pub id: Uuid,
@@ -59,9 +66,10 @@ pub struct DocumentResponse {
pub deleted_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
pub thumbnail: Option<DocumentAssetResponse>,
}
#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
@@ -72,10 +80,22 @@ pub struct DocumentVersionResponse {
pub operations_summary: Value,
}
#[derive(Serialize, Clone)]
pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub url: String,
pub created_at: String,
}
#[derive(Serialize)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
pub current_version: DocumentVersionResponse,
pub assets: Vec<DocumentAssetResponse>,
}
#[derive(Serialize)]
@@ -87,6 +107,24 @@ pub struct DocumentDownloadResponse {
pub size_bytes: i64,
}
#[derive(Serialize)]
pub struct BulkReanalyzeResponse {
pub queued: usize,
}
struct UploadRequest {
bytes: Vec<u8>,
original_name: String,
content_type: Option<String>,
folder_id: Option<Uuid>,
metadata: Value,
}
struct UploadOutcome {
detail: DocumentDetailResponse,
created: bool,
}
#[derive(Deserialize)]
pub struct MoveDocumentRequest {
pub folder_id: Option<Uuid>,
@@ -124,12 +162,16 @@ pub async fn list_documents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
@@ -153,10 +195,19 @@ pub async fn get_document(
.first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
let version_id = current_version.id;
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let thumbnail = assets
.iter()
.find(|asset| asset.asset_type == "thumbnail")
.cloned();
Ok(Json(DocumentDetailResponse {
document: to_document_response(doc, tags_map.get(&document_id).cloned()),
document: to_document_response(doc, tags_map.get(&document_id).cloned(), thumbnail),
current_version: to_version_response(current_version),
assets,
}))
}
@@ -164,18 +215,18 @@ pub async fn upload_document(
State(state): State<AppState>,
_user: AuthenticatedUser,
mut multipart: Multipart,
) -> AppResult<Json<DocumentDetailResponse>> {
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None;
let mut folder_id: Option<Uuid> = None;
let mut metadata: Value = Value::Object(Default::default());
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::bad_request(format!("invalid multipart data: {err}")))?
{
while let Some(field) = multipart.next_field().await.map_err(|err| {
let msg = format!("invalid multipart data: {err}");
warn!(error = %err, "invalid multipart data");
AppError::bad_request(msg)
})? {
let name = field.name().map(|n| n.to_string());
match name.as_deref() {
Some("file") => {
@@ -183,15 +234,18 @@ pub async fn upload_document(
original_name = file_name.clone();
content_type = field.content_type().map(|mime| mime.to_string());
let data = field.bytes().await.map_err(|err| {
AppError::bad_request(format!("failed to read file bytes: {err}"))
let msg = format!("failed to read file bytes: {err}");
warn!(error = %err, "failed to read file bytes");
AppError::bad_request(msg)
})?;
file_bytes = Some(data.to_vec());
}
Some("folder_id") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid folder id: {err}")))?;
let value = field.text().await.map_err(|err| {
let msg = format!("invalid folder id: {err}");
warn!(error = %err, "invalid folder id");
AppError::bad_request(msg)
})?;
if !value.trim().is_empty() {
let parsed = Uuid::parse_str(value.trim())
.map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?;
@@ -199,127 +253,138 @@ pub async fn upload_document(
}
}
Some("metadata") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid metadata: {err}")))?;
let value = field.text().await.map_err(|err| {
let msg = format!("invalid metadata: {err}");
warn!(error = %err, "invalid metadata payload");
AppError::bad_request(msg)
})?;
metadata = serde_json::from_str(&value).map_err(|err| {
AppError::bad_request(format!("metadata must be valid JSON: {err}"))
let msg = format!("metadata must be valid JSON: {err}");
warn!(error = %err, "metadata parse failure");
AppError::bad_request(msg)
})?;
}
_ => {}
}
}
let file_bytes = file_bytes.ok_or_else(|| AppError::bad_request("file field is required"))?;
let file_bytes = file_bytes.ok_or_else(|| {
warn!("upload rejected: missing file field");
AppError::bad_request("file field is required")
})?;
let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string());
if let Some(folder_id) = folder_id {
ensure_folder_exists(&state, folder_id)?;
}
let request = UploadRequest {
bytes: file_bytes,
original_name,
content_type,
folder_id,
metadata,
};
let doc_id = Uuid::new_v4();
let version_id = Uuid::new_v4();
let version_number = 1;
let stored_filename = original_name.clone();
let checksum = Sha256::digest(&file_bytes);
let checksum_hex = hex::encode(checksum);
let size_bytes = file_bytes.len() as i64;
let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}");
{
let mut conn = state.db()?;
let existing = documents::table
.inner_join(
document_versions::table.on(document_versions::document_id
.eq(documents::id)
.and(document_versions::version_number.eq(documents::current_version))),
)
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn)
.optional()?;
if let Some((mut document, version)) = existing {
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
.set((
documents::deleted_at.eq(None::<NaiveDateTime>),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.deleted_at = None;
document.updated_at = now;
}
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let tags = tags_map.get(&document.id).cloned();
return Ok(Json(DocumentDetailResponse {
document: to_document_response(document, tags),
current_version: to_version_response(version),
}));
let outcome = match process_upload(&state, request).await {
Ok(outcome) => outcome,
Err(err) => {
error!(error = ?err, "document upload failed");
return Err(err);
}
};
info!(
document_id = %outcome.detail.document.id,
original_name = %outcome.detail.document.original_name,
created = outcome.created,
"document upload succeeded"
);
let status = if outcome.created {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((status, Json(outcome.detail)))
}
pub async fn request_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
state
.storage
.put_object(&s3_key, file_bytes.clone(), content_type.clone())
.await
.map_err(|err| AppError::internal(format!("failed to store document: {err}")))?;
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(document.current_version))
.first(&mut conn)?;
let metadata_value = if metadata.is_null() {
Value::Object(Default::default())
} else {
metadata
};
enqueue_job(
&mut conn,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
"document_version_id": version.id,
"force": query.force,
}),
None,
)
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
let (document, version) = {
let mut conn = state.db()?;
conn.transaction(|conn| {
let new_document = NewDocument {
id: doc_id,
filename: stored_filename.clone(),
original_name: original_name.clone(),
content_type: content_type.clone(),
folder_id,
current_version: version_number,
metadata: metadata_value.clone(),
};
diesel::insert_into(documents::table)
.values(&new_document)
.execute(conn)?;
Ok(StatusCode::ACCEPTED)
}
let new_version = NewDocumentVersion {
id: version_id,
document_id: doc_id,
version_number,
s3_key: s3_key.clone(),
size_bytes,
checksum: checksum_hex.clone(),
operations_summary: Value::Object(Default::default()),
};
pub async fn reanalyze_all_documents(
State(state): State<AppState>,
_user: AuthenticatedUser,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let mut conn = state.db()?;
diesel::insert_into(document_versions::table)
.values(&new_version)
.execute(conn)?;
let targets: Vec<(Uuid, Uuid)> = document_versions::table
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
.filter(documents::deleted_at.is_null())
.filter(document_versions::version_number.eq(documents::current_version))
.select((documents::id, document_versions::id))
.load(&mut conn)?;
let document: Document = documents::table.find(doc_id).first(conn)?;
let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?;
let mut queued = 0usize;
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
"document_version_id": version_id,
"force": true,
}),
None,
)
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
queued += 1;
}
Ok::<_, diesel::result::Error>((document, version))
})?
};
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
}
let response = DocumentDetailResponse {
document: to_document_response(document, None),
current_version: to_version_response(version),
};
pub async fn list_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<Json<Vec<DocumentAssetResponse>>> {
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
Ok(Json(response))
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(document.current_version))
.first(&mut conn)?;
drop(conn);
let assets = load_asset_responses(&state, version.id).await?;
Ok(Json(assets))
}
pub async fn download_document(
@@ -449,6 +514,157 @@ pub async fn remove_tag(
Ok(StatusCode::NO_CONTENT)
}
async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<UploadOutcome> {
let UploadRequest {
bytes,
original_name,
content_type,
folder_id,
metadata,
} = request;
if let Some(folder) = folder_id {
ensure_folder_exists(state, folder)?;
}
let doc_id = Uuid::new_v4();
let version_id = Uuid::new_v4();
let version_number = 1;
let stored_filename = original_name.clone();
let checksum = Sha256::digest(&bytes);
let checksum_hex = hex::encode(checksum);
let size_bytes = bytes.len() as i64;
let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}");
{
let mut conn = state.db()?;
let existing = documents::table
.inner_join(
document_versions::table.on(document_versions::document_id
.eq(documents::id)
.and(document_versions::version_number.eq(documents::current_version))),
)
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn)
.optional()?;
if let Some((mut document, version)) = existing {
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
.set((
documents::deleted_at.eq(None::<NaiveDateTime>),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.deleted_at = None;
document.updated_at = now;
}
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let tags = tags_map.get(&document.id).cloned();
drop(conn);
let assets = load_asset_responses(state, version.id).await?;
let thumbnail = assets
.iter()
.find(|asset| asset.asset_type == "thumbnail")
.cloned();
return Ok(UploadOutcome {
detail: DocumentDetailResponse {
document: to_document_response(document, tags, thumbnail),
current_version: to_version_response(version),
assets,
},
created: false,
});
}
}
state
.storage
.put_object(&s3_key, bytes.clone(), content_type.clone())
.await
.map_err(|err| {
error!(error = %err, key = %s3_key, "failed to store document");
AppError::internal(format!("failed to store document: {err}"))
})?;
let metadata_value = if metadata.is_null() {
Value::Object(Default::default())
} else {
metadata
};
let (document, version) = {
let mut conn = state.db()?;
conn.transaction(|conn| {
let new_document = NewDocument {
id: doc_id,
filename: stored_filename.clone(),
original_name: original_name.clone(),
content_type: content_type.clone(),
folder_id,
current_version: version_number,
metadata: metadata_value.clone(),
};
diesel::insert_into(documents::table)
.values(&new_document)
.execute(conn)?;
let new_version = NewDocumentVersion {
id: version_id,
document_id: doc_id,
version_number,
s3_key: s3_key.clone(),
size_bytes,
checksum: checksum_hex.clone(),
operations_summary: Value::Object(Default::default()),
};
diesel::insert_into(document_versions::table)
.values(&new_version)
.execute(conn)?;
let document: Document = documents::table.find(doc_id).first(conn)?;
let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?;
Ok::<_, diesel::result::Error>((document, version))
})?
};
let detail = DocumentDetailResponse {
document: to_document_response(document, None, None),
current_version: to_version_response(version.clone()),
assets: Vec::new(),
};
if let Ok(mut conn) = state.db() {
if let Err(err) = enqueue_job(
&mut conn,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": doc_id,
"document_version_id": version.id,
"force": false,
}),
None,
) {
warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job");
}
} else {
warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error");
}
Ok(UploadOutcome {
detail,
created: true,
})
}
fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db()?;
let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id))))
@@ -480,7 +696,82 @@ pub(crate) fn load_tags_for_documents(
Ok(map)
}
pub(crate) fn to_document_response(doc: Document, tags: Option<Vec<Tag>>) -> DocumentResponse {
pub(crate) async fn load_primary_thumbnails(
state: &AppState,
documents: &[Document],
) -> AppResult<HashMap<Uuid, DocumentAssetResponse>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
let mut current_versions: HashMap<Uuid, i32> = HashMap::new();
let mut doc_ids = Vec::with_capacity(documents.len());
for doc in documents {
current_versions.insert(doc.id, doc.current_version);
doc_ids.push(doc.id);
}
let mut conn = state.db()?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::document_id.eq_any(&doc_ids))
.load(&mut conn)?;
let mut version_id_by_doc: HashMap<Uuid, Uuid> = HashMap::new();
let mut doc_id_by_version: HashMap<Uuid, Uuid> = HashMap::new();
for version in versions {
if let Some(current_number) = current_versions.get(&version.document_id) {
if *current_number == version.version_number {
version_id_by_doc.insert(version.document_id, version.id);
doc_id_by_version.insert(version.id, version.document_id);
}
}
}
if version_id_by_doc.is_empty() {
return Ok(HashMap::new());
}
let version_ids: Vec<Uuid> = version_id_by_doc.values().copied().collect();
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq_any(&version_ids))
.filter(document_assets::asset_type.eq("thumbnail"))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.load(&mut conn)?;
drop(conn);
let mut first_assets: HashMap<Uuid, DocumentAsset> = HashMap::new();
for asset in assets {
if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) {
first_assets.entry(*doc_id).or_insert(asset);
}
}
let mut responses = HashMap::with_capacity(first_assets.len());
for (doc_id, asset) in first_assets {
let url = state
.storage
.presign_get_object(
&asset.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
responses.insert(doc_id, to_asset_response(asset, url));
}
Ok(responses)
}
pub(crate) fn to_document_response(
doc: Document,
tags: Option<Vec<Tag>>,
thumbnail: Option<DocumentAssetResponse>,
) -> DocumentResponse {
DocumentResponse {
id: doc.id,
filename: doc.filename,
@@ -497,6 +788,7 @@ pub(crate) fn to_document_response(doc: Document, tags: Option<Vec<Tag>>) -> Doc
.into_iter()
.map(TagResponse::from)
.collect(),
thumbnail,
}
}
@@ -512,6 +804,45 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
}
}
fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
width: asset.width,
height: asset.height,
url,
created_at: to_iso(asset.created_at),
}
}
async fn load_asset_responses(
state: &AppState,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db()?;
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(version_id))
.order(document_assets::created_at.asc())
.load(&mut conn)?;
drop(conn);
let mut responses = Vec::with_capacity(assets.len());
for asset in assets {
let url = state
.storage
.presign_get_object(
&asset.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
responses.push(to_asset_response(asset, url));
}
Ok(responses)
}
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}
+84 -3
View File
@@ -12,7 +12,10 @@ use crate::models::{Document, Folder, NewFolder};
use crate::schema::{document_tags, documents, folders};
use crate::state::AppState;
use super::documents::{load_tags_for_documents, to_document_response, to_iso, DocumentResponse};
use super::documents::{
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso,
DocumentResponse,
};
#[derive(Deserialize)]
pub struct CreateFolderRequest {
@@ -20,6 +23,12 @@ pub struct CreateFolderRequest {
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct EnsureFolderPathRequest {
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize)]
pub struct FolderResponse {
pub folder: FolderInfo,
@@ -48,6 +57,70 @@ pub struct FolderInfo {
pub updated_at: String,
}
pub async fn ensure_folder_path(
State(state): State<AppState>,
Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = raw_name.trim();
if name.is_empty() {
return Err(AppError::bad_request("folder names must not be empty"));
}
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let path_cache = build_path_cache(conn, current_parent, name)?;
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
path_cache,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(conn)?;
folders::table.find(new_folder.id).first(conn)?
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_folder),
}))
}
pub async fn create_folder(
State(state): State<AppState>,
Json(payload): Json<CreateFolderRequest>,
@@ -127,12 +200,16 @@ pub async fn list_folder_contents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let documents = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
@@ -228,11 +305,15 @@ pub async fn search_documents(
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
let thumbnail = thumbnails.get(&doc.id).cloned();
to_document_response(doc, tags, thumbnail)
})
.collect();
+6
View File
@@ -30,17 +30,23 @@ pub fn create_router(state: AppState) -> Router<()> {
"/",
get(documents::list_documents).post(documents::upload_document),
)
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route(
"/:id",
get(documents::get_document).delete(documents::delete_document),
)
.route("/:id/download", get(documents::download_document))
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
)
.route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag));
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path))
.route("/:id", delete(folders::delete_folder))
.route("/:id/contents", get(folders::list_folder_contents))
.route("/:id/documents", get(folders::search_documents));
+31
View File
@@ -1,5 +1,19 @@
// @generated automatically by Diesel CLI.
diesel::table! {
document_assets (id) {
id -> Uuid,
document_version_id -> Uuid,
asset_type -> Text,
s3_key -> Text,
mime_type -> Text,
width -> Nullable<Int4>,
height -> Nullable<Int4>,
metadata -> Jsonb,
created_at -> Timestamptz,
}
}
diesel::table! {
document_tags (document_id, tag_id) {
document_id -> Uuid,
@@ -55,6 +69,20 @@ diesel::table! {
}
}
diesel::table! {
jobs (id) {
id -> Uuid,
job_type -> Text,
payload -> Jsonb,
status -> Text,
attempts -> Int4,
run_after -> Timestamptz,
last_error -> Nullable<Text>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
tags (id) {
id -> Uuid,
@@ -80,6 +108,7 @@ diesel::table! {
}
}
diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by));
@@ -87,10 +116,12 @@ diesel::joinable!(document_versions -> documents (document_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::allow_tables_to_appear_in_same_query!(
document_assets,
document_tags,
document_versions,
documents,
folders,
jobs,
tags,
users,
);
+23
View File
@@ -16,6 +16,8 @@ pub trait ObjectStorage: Send + Sync + 'static {
) -> Result<()>;
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
}
pub struct S3Storage {
@@ -76,4 +78,25 @@ impl ObjectStorage for S3Storage {
Ok(presigned.uri().to_string())
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let response = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.context("failed to download object from S3")?;
let bytes = response
.body
.collect()
.await
.context("failed to read object stream")?
.into_bytes()
.to_vec();
Ok(bytes)
}
}
+165
View File
@@ -0,0 +1,165 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait;
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tokio::task;
use tracing::{error, warn};
use uuid::Uuid;
use crate::{
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_GENERATE_THUMBNAILS},
models::{Document, DocumentVersion},
schema::{document_versions, documents},
state::AppState,
};
use super::{JobExecution, JobHandler};
#[derive(Debug, Deserialize)]
struct AnalyzePayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
pub struct AnalyzeDocumentJob;
impl AnalyzeDocumentJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for AnalyzeDocumentJob {
fn job_type(&self) -> &'static str {
JOB_ANALYZE_DOCUMENT
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid analyze payload: {err}"),
}
}
};
let state_clone = state.clone();
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await {
Ok(Ok(execution)) => execution,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "analyze job will retry");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
}
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "analyze task panicked");
JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
}
}
}
}
}
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let (supported, reason) = determine_thumbnail_support(&document);
let mut summary_map = match version.operations_summary {
Value::Object(map) => map,
_ => Map::new(),
};
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
if let Some(reason) = reason {
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
} else {
summary_map.remove("thumbnail_reason");
}
diesel::update(document_versions::table.find(version.id))
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if supported {
let enqueue_result = enqueue_job(
&mut conn,
JOB_GENERATE_THUMBNAILS,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
"force": payload.force,
}),
None,
);
if let Err(err) = enqueue_result {
return Err(err.to_string());
}
}
Ok(JobExecution::Success)
}
pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<String>) {
let supported_mimes: HashSet<&'static str> = [
"image/jpeg",
"image/png",
"image/gif",
"image/tiff",
"image/bmp",
"image/webp",
"application/pdf",
]
.into_iter()
.collect();
if let Some(ref content_type) = document.content_type {
if supported_mimes.contains(content_type.as_str()) {
return (true, None);
}
}
if let Some(ext) = document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.to_ascii_lowercase())
{
let supported_exts = [
"jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf",
];
if supported_exts.contains(&ext.as_str()) {
return (true, None);
}
}
(
false,
Some("content type not supported for thumbnails".into()),
)
}
+131
View File
@@ -0,0 +1,131 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use async_trait::async_trait;
use tokio::time::sleep;
use tracing::{error, info, warn};
use crate::{
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
models::Job,
state::AppState,
};
pub mod analyze;
pub mod thumbnails;
#[derive(Debug)]
pub enum JobExecution {
Success,
Retry { delay: Duration, error: String },
Failed { error: String },
}
#[async_trait]
pub trait JobHandler: Send + Sync {
fn job_type(&self) -> &'static str;
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution;
}
pub struct Worker {
state: Arc<AppState>,
handlers: HashMap<&'static str, Arc<dyn JobHandler>>,
poll_interval: Duration,
}
impl Worker {
pub fn new(
state: Arc<AppState>,
handlers: Vec<Arc<dyn JobHandler>>,
poll_interval: Duration,
) -> Self {
let map = handlers
.into_iter()
.map(|handler| (handler.job_type(), handler))
.collect();
Self {
state,
handlers: map,
poll_interval,
}
}
pub async fn run(&self) {
info!("worker started");
loop {
match self.tick().await {
Ok(true) => {}
Ok(false) => sleep(self.poll_interval).await,
Err(err) => {
error!(error = %err, "worker tick failed");
sleep(self.poll_interval).await;
}
}
}
}
async fn tick(&self) -> Result<bool, JobQueueError> {
let job_types: Vec<&str> = self.handlers.keys().copied().collect();
if job_types.is_empty() {
return Ok(false);
}
let mut conn = match self.state.db() {
Ok(conn) => conn,
Err(err) => {
error!(?err, "failed to obtain database connection in worker");
return Ok(false);
}
};
let job_opt = reserve_job(&mut conn, &job_types)?;
drop(conn);
if let Some(job) = job_opt {
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
let result = handler.handle(self.state.clone(), job.clone()).await;
match result {
JobExecution::Success => {
if let Ok(mut conn) = self.state.db() {
mark_job_succeeded(&mut conn, job.id)?;
} else {
error!("failed to mark job succeeded due to pool error");
}
}
JobExecution::Retry { delay, error } => {
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
if let Ok(mut conn) = self.state.db() {
retry_job_after(&mut conn, job.id, delay, &error)?;
} else {
error!("failed to requeue job for retry due to pool error");
}
}
JobExecution::Failed { error } => {
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
if let Ok(mut conn) = self.state.db() {
mark_job_failed(&mut conn, job.id, &error)?;
} else {
error!("failed to mark job failed due to pool error");
}
}
}
} else {
error!(job_type = %job.job_type, "no handler registered for job type");
if let Ok(mut conn) = self.state.db() {
mark_job_failed(&mut conn, job.id, "no handler registered")?;
} else {
error!("failed to mark job failed for missing handler due to pool error");
}
}
Ok(true)
} else {
Ok(false)
}
}
}
pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
vec![
Arc::new(analyze::AnalyzeDocumentJob::new()),
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
]
}
+321
View File
@@ -0,0 +1,321 @@
use std::{io::Cursor, panic, sync::Arc, time::Duration};
use async_trait::async_trait;
use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*};
use image::{
codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader,
};
use pdfium_render::prelude::*;
use serde::Deserialize;
use serde_json::json;
use tokio::task;
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::{
jobs::JOB_GENERATE_THUMBNAILS,
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::{document_assets, document_versions, documents},
state::AppState,
};
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
const THUMBNAIL_WIDTH: u32 = 512;
const THUMBNAIL_HEIGHT: u32 = 512;
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
#[derive(Debug, Deserialize)]
struct ThumbnailPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
pub struct GenerateThumbnailsJob;
impl GenerateThumbnailsJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for GenerateThumbnailsJob {
fn job_type(&self) -> &'static str {
JOB_GENERATE_THUMBNAILS
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
Ok(p) => p,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid thumbnail payload: {err}"),
}
}
};
let state_clone = state.clone();
let initial =
match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if initial.skip {
info!(job_id = %job.id, "thumbnails already exist; skipping");
return JobExecution::Success;
}
let bytes = match state.storage.get_object(&initial.version.s3_key).await {
Ok(bytes) => bytes,
Err(err) => {
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
};
let generation = match generate_thumbnail(&initial.document, &bytes) {
Ok(result) => result,
Err(err) => {
return JobExecution::Failed { error: err };
}
};
if let Err(err) = state
.storage
.put_object(
&generation.s3_key,
generation.image_bytes.clone(),
Some("image/png".into()),
)
.await
{
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_thumbnail_metadata(state_clone, &initial, &generation)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "failed to persist thumbnail metadata; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "thumbnail metadata update panicked");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("metadata update panic: {join_err}"),
};
}
}
JobExecution::Success
}
}
struct ThumbnailContext {
document: Document,
version: DocumentVersion,
skip: bool,
}
struct GeneratedThumbnail {
image_bytes: Vec<u8>,
width: Option<i32>,
height: Option<i32>,
s3_key: String,
}
fn load_thumbnail_context(
state: Arc<AppState>,
payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE))
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let (supported, _) = determine_thumbnail_support(&document);
if !supported {
return Err("thumbnail generation not supported for this document".into());
}
let skip = existing.is_some() && !payload.force;
Ok(ThumbnailContext {
document,
version,
skip,
})
}
fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThumbnail, String> {
let is_pdf = document
.content_type
.as_deref()
.map(|mime| mime == "application/pdf")
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
});
let (png_bytes, width, height) = if is_pdf {
generate_pdf_thumbnail(bytes)?
} else {
generate_image_thumbnail(bytes)?
};
let s3_key = format!("thumbnails/{}/{}.png", document.id, Uuid::new_v4());
Ok(GeneratedThumbnail {
image_bytes: png_bytes,
width,
height,
s3_key,
})
}
fn generate_image_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|err| err.to_string())?;
let mut image = reader.decode().map_err(|err| err.to_string())?;
if image.width() > THUMBNAIL_WIDTH || image.height() > THUMBNAIL_HEIGHT {
image = image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
}
let (width, height) = image.dimensions();
let mut cursor = Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|err| err.to_string())?;
let buffer = cursor.into_inner();
Ok((buffer, Some(width as i32), Some(height as i32)))
}
fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
let pdfium = panic::catch_unwind(|| Pdfium::default())
.map_err(|_| "failed to initialize PDFium".to_string())?;
let document = pdfium
.load_pdf_from_byte_slice(bytes, None)
.map_err(|err| format!("load pdf: {err}"))?;
let page = document
.pages()
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new()
.set_target_width(THUMBNAIL_WIDTH as i32)
.set_maximum_height(THUMBNAIL_HEIGHT as i32)
.render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true);
let bitmap = page
.render_with_config(&render_config)
.map_err(|err| format!("render pdf page: {err}"))?;
let image = bitmap.as_image().to_rgb8();
let (width, height) = image.dimensions();
let mut cursor = Cursor::new(Vec::new());
PngEncoder::new(&mut cursor)
.write_image(image.as_raw(), width, height, ColorType::Rgb8.into())
.map_err(|err| format!("encode pdf thumbnail: {err}"))?;
Ok((cursor.into_inner(), Some(width as i32), Some(height as i32)))
}
fn persist_thumbnail_metadata(
state: Arc<AppState>,
context: &ThumbnailContext,
generated: &GeneratedThumbnail,
) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let new_asset = NewDocumentAsset {
id: Uuid::new_v4(),
document_version_id: context.version.id,
asset_type: THUMBNAIL_ASSET_TYPE.to_string(),
s3_key: generated.s3_key.clone(),
mime_type: "image/png".to_string(),
width: generated.width,
height: generated.height,
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
}),
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::width.eq(excluded(document_assets::width)),
document_assets::height.eq(excluded(document_assets::height)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
+1 -4
View File
@@ -14,10 +14,7 @@ struct AuthenticatedUser {
#[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await;
let Some(app) = TestApp::new().await? else {
eprintln!("skipping test: TEST_DATABASE_URL not set");
return Ok(());
};
let app = TestApp::new().await?;
let password = "s3cret";
app.insert_user("alice", password, "admin").await?;
+42 -8
View File
@@ -17,7 +17,7 @@ use once_cell::sync::Lazy;
use paperless_backend::auth::jwt::JwtService;
use paperless_backend::config::AppConfig;
use paperless_backend::db::{self, PgPool};
use paperless_backend::models::NewUser;
use paperless_backend::models::{Job, NewUser};
use paperless_backend::routes;
use paperless_backend::state::AppState;
use paperless_backend::storage::ObjectStorage;
@@ -70,6 +70,14 @@ impl ObjectStorage for FakeStorage {
expires_in.as_secs()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
}
impl FakeStorage {
@@ -93,11 +101,9 @@ pub struct TestApp {
}
impl TestApp {
pub async fn new() -> Result<Option<Self>> {
let database_url = match env::var("TEST_DATABASE_URL") {
Ok(url) => url,
Err(_) => return Ok(None),
};
pub async fn new() -> Result<Self> {
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let config = AppConfig {
database_url: database_url.clone(),
@@ -123,11 +129,11 @@ impl TestApp {
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
Ok(Some(Self {
Ok(Self {
state,
router,
storage,
}))
})
}
pub async fn cleanup(&self) -> Result<()> {
@@ -199,6 +205,34 @@ impl TestApp {
Ok(parsed.access_token)
}
#[allow(dead_code)]
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use paperless_backend::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
#[allow(dead_code)]
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use paperless_backend::schema::jobs::dsl::{
job_type as job_type_col, jobs as jobs_table,
};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
path: &str,
+107 -9
View File
@@ -11,6 +11,7 @@ use uuid::Uuid;
struct DocumentDetail {
document: DocumentInfo,
current_version: DocumentVersion,
assets: Vec<DocumentAssetInfo>,
}
#[derive(Deserialize)]
@@ -24,10 +25,17 @@ struct DocumentInfo {
#[derive(Deserialize)]
struct DocumentVersion {
id: Uuid,
s3_key: String,
size_bytes: i64,
}
#[derive(Deserialize)]
struct DocumentAssetInfo {
id: Uuid,
asset_type: String,
}
#[derive(Deserialize)]
struct DocumentListItem {
id: Uuid,
@@ -40,13 +48,23 @@ struct DocumentDownload {
filename: String,
}
#[derive(Deserialize)]
struct BulkReanalyze {
queued: usize,
}
#[derive(Deserialize)]
struct AnalyzeJobPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
#[tokio::test]
async fn upload_and_list_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let Some(app) = TestApp::new().await? else {
eprintln!("skipping test: TEST_DATABASE_URL not set");
return Ok(());
};
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("dana", password, "admin").await?;
@@ -63,7 +81,7 @@ async fn upload_and_list_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::OK);
assert_eq!(upload.status(), StatusCode::CREATED);
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
@@ -72,6 +90,7 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.tags.is_empty());
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
assert!(detail.assets.is_empty());
let stored = app
.storage()
@@ -79,6 +98,7 @@ async fn upload_and_list_document() -> Result<()> {
.await
.expect("object stored");
assert_eq!(stored.bytes, file_bytes);
assert_eq!(app.storage().object_count().await, 1);
let response = app.get("/api/documents", Some(&token)).await?;
assert_eq!(response.status(), StatusCode::OK);
@@ -108,10 +128,7 @@ async fn upload_and_list_document() -> Result<()> {
#[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let Some(app) = TestApp::new().await? else {
eprintln!("skipping test: TEST_DATABASE_URL not set");
return Ok(());
};
let app = TestApp::new().await?;
let password = "pass1234";
app.insert_user("sam", password, "admin").await?;
@@ -128,6 +145,7 @@ async fn duplicate_and_restore_document() -> Result<()> {
&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)?;
@@ -147,6 +165,8 @@ async fn duplicate_and_restore_document() -> Result<()> {
assert_eq!(first_detail.document.id, second_detail.document.id);
assert_eq!(second_detail.document.deleted_at, None);
assert!(second_detail.assets.is_empty());
assert_eq!(app.storage().object_count().await, 1);
let delete = app
.delete(
@@ -166,11 +186,89 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(third.status(), StatusCode::OK);
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
assert_eq!(third_detail.document.id, first_detail.document.id);
assert_eq!(third_detail.document.deleted_at, None);
assert_eq!(app.storage().object_count().await, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkpass";
app.insert_user("alex", password, "admin").await?;
let token = app.login_token("alex", password).await?;
app.clear_jobs().await?;
let first_bytes = b"first doc";
let first = app
.upload_document(
"/api/documents",
"first.txt",
"text/plain",
first_bytes,
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_bytes = b"second doc";
let second = app
.upload_document(
"/api/documents",
"second.txt",
"text/plain",
second_bytes,
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)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/reanalyze",
&serde_json::json!({}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
let mut expected = vec![
(first_detail.document.id, first_detail.current_version.id),
(second_detail.document.id, second_detail.current_version.id),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
+84 -4
View File
@@ -33,6 +33,12 @@ struct CreateFolder<'a> {
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct EnsureFolderPath<'a> {
parent_id: Option<Uuid>,
segments: &'a [&'a str],
}
#[derive(Serialize)]
struct MoveDocumentRequest {
folder_id: Option<Uuid>,
@@ -46,10 +52,7 @@ struct DocumentDetail {
#[tokio::test]
async fn folder_move_and_delete_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let Some(app) = TestApp::new().await? else {
eprintln!("skipping test: TEST_DATABASE_URL not set");
return Ok(());
};
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-admin", password, "admin").await?;
@@ -135,3 +138,80 @@ async fn folder_move_and_delete_flow() -> Result<()> {
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", password, "admin").await?;
let token = app.login_token("path-admin", password).await?;
let base_path = EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering", "Backend"],
};
let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(first_resp.status(), StatusCode::OK);
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(second_resp.status(), StatusCode::OK);
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id);
let engineering_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering"],
},
Some(&token),
)
.await?;
assert_eq!(engineering_resp.status(), StatusCode::OK);
let engineering_body = body_to_vec(engineering_resp.into_body()).await?;
let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?;
assert_ne!(engineering_folder.folder.id, first_folder.folder.id);
let infra_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_resp.status(), StatusCode::OK);
let infra_body = body_to_vec(infra_resp.into_body()).await?;
let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?;
assert_ne!(infra_folder.folder.id, engineering_folder.folder.id);
let infra_dupe_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_dupe_resp.status(), StatusCode::OK);
let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?;
let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?;
assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id);
app.cleanup().await?;
Ok(())
}
+2 -4
View File
@@ -36,10 +36,7 @@ struct AssignTagsRequest {
#[tokio::test]
async fn tag_assignment_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let Some(app) = TestApp::new().await? else {
eprintln!("skipping test: TEST_DATABASE_URL not set");
return Ok(());
};
let app = TestApp::new().await?;
let password = "tagpass";
app.insert_user("tagger", password, "admin").await?;
@@ -55,6 +52,7 @@ async fn tag_assignment_flow() -> Result<()> {
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
+16
View File
@@ -0,0 +1,16 @@
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless_test
POSTGRES_DB: paperless_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless"]
interval: 5s
timeout: 5s
retries: 5
+27
View File
@@ -0,0 +1,27 @@
# Integration Test Setup
The backend integration tests talk to a real Postgres database. To spin up an ephemeral instance locally, use the dedicated compose file:
```bash
docker compose -f docker-compose.test.yml up -d
```
This starts Postgres on port `5433` with the database/user both named `paperless` and password `paperless_test`. Point the test harness at it:
```bash
export TEST_DATABASE_URL=postgres://paperless:paperless_test@localhost:5433/paperless_test
```
Run the tests as usual:
```bash
cargo test
```
When you are done, stop the container:
```bash
docker compose -f docker-compose.test.yml down
```
The compose file uses a tmpfs volume, so each run starts with a clean database.
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: 'defaults',
},
],
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
],
};
+2248 -1
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -9,9 +9,15 @@
"lint": "echo \"No linting configured\""
},
"dependencies": {
"axios": "1.7.7"
"axios": "1.7.7",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"babel-loader": "9.2.1",
"css-loader": "7.1.2",
"dotenv": "16.4.5",
"html-webpack-plugin": "5.6.3",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+460 -170
View File
@@ -1,122 +1,224 @@
:root {
color-scheme: light dark;
--bg: #f3f4f6;
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: rgba(255, 255, 255, 0.95);
--fg: #1f2937;
--muted: #6b7280;
--border: #d1d5db;
--accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.12);
--accent-strong: rgba(37, 99, 235, 0.2);
--danger: #dc2626;
--success: #10b981;
color-scheme: light;
--bg: #f4f5f7;
--surface: #ffffff;
--surface-subtle: #f8f9fb;
--fg: #1b1f24;
--muted: #5f6673;
--border: #d0d5dd;
--accent: #2b5cff;
--accent-soft: rgba(43, 92, 255, 0.12);
--danger: #d92c20;
--success: #12805a;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
overflow: hidden;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 2rem clamp(1rem, 3vw, 3rem);
box-sizing: border-box;
}
header.global-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
}
header.global-header h1 {
margin: 0;
font-size: 1.9rem;
height: 100%;
background: var(--bg);
}
button {
font: inherit;
border-radius: 8px;
border: none;
padding: 0.55rem 1.1rem;
border-radius: 2px;
border: 1px solid transparent;
padding: 0.35rem 0.85rem;
background: var(--accent);
color: white;
color: #fff;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.15s ease;
font-weight: 500;
transition: background 0.15s ease, border-color 0.15s ease;
}
button[disabled] {
opacity: 0.6;
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 10px 20px var(--accent-strong);
background: #2047d9;
}
button.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
color: var(--fg);
border-color: var(--border);
}
button.secondary:hover:not([disabled]) {
background: var(--surface-subtle);
}
button.danger {
background: var(--danger);
color: #fff;
box-shadow: none;
background: transparent;
color: var(--danger);
border-color: rgba(217, 44, 32, 0.45);
}
button.danger:hover:not([disabled]) {
box-shadow: 0 8px 16px rgba(220, 38, 38, 0.2);
border-color: var(--danger);
background: rgba(217, 44, 32, 0.08);
}
button.icon-button {
padding: 0.35rem 0.65rem;
padding: 0.25rem 0.55rem;
min-width: auto;
}
button.icon-button:hover:not([disabled]) {
transform: none;
button.icon-button.ghost {
border: none;
background: transparent;
color: var(--muted);
padding: 0.2rem;
}
.dashboard-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 1.5rem;
align-items: start;
flex: 1;
button.icon-button.ghost:hover:not([disabled]) {
color: var(--danger);
background: transparent;
}
.sidebar,
.table-panel,
.detail-panel {
.with-icon {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-inline {
width: 1rem;
height: 1rem;
}
.icon {
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon path {
stroke: currentColor;
stroke-width: 1.6;
fill: none;
}
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
color: var(--fg);
background: var(--bg);
}
.app-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.5rem;
background: var(--surface);
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.sidebar {
position: relative;
min-height: calc(100vh - 6rem);
.app-bar__meta {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.sidebar h2,
.table-panel h2,
.detail-panel h2 {
margin: 0 0 1rem;
.app-bar h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-bar__hint {
color: var(--muted);
font-size: 0.9rem;
}
.app-bar__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-container {
padding: 0.4rem 1.5rem 0;
flex-shrink: 0;
}
.app-main {
flex: 1;
display: grid;
grid-template-columns: 220px minmax(0, 1fr) 320px;
gap: 1.5rem;
padding: 0.75rem 1.5rem 1.25rem;
min-height: 0;
overflow: hidden;
}
.column {
background: transparent;
border: none;
display: flex;
flex-direction: column;
min-height: 0;
}
.column + .column {
border-left: 1px solid var(--border);
padding-left: 0.6rem;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0 0.5rem;
border-bottom: 1px solid var(--border);
}
.column-header h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
}
.column-subtitle {
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--muted);
}
.column-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.32rem;
padding: 0.35rem 0 0.55rem;
min-height: 0;
}
.column-body.scrollable {
overflow-y: auto;
}
.folder-tree {
@@ -132,11 +234,13 @@ button.icon-button:hover:not([disabled]) {
.folder-row {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.55rem;
border-radius: 8px;
gap: 0.22rem;
padding: 0.16rem 0.26rem;
border-radius: 2px;
cursor: pointer;
color: var(--fg);
transition: background 0.12s ease, color 0.12s ease;
font-size: 0.78rem;
}
.folder-row span.name {
@@ -147,28 +251,54 @@ button.icon-button:hover:not([disabled]) {
}
.folder-row:hover {
background: var(--accent-soft);
background: var(--surface-subtle);
}
.folder-row.active {
background: var(--accent);
color: white;
background: rgba(43, 92, 255, 0.12);
color: var(--fg);
}
.folder-row .toggle {
width: 1.1rem;
text-align: center;
font-size: 0.9rem;
width: 1.05rem;
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.folder-row .toggle.invisible {
visibility: hidden;
}
.toggle-icon {
width: 0.9rem;
height: 0.9rem;
transition: transform 0.18s ease;
}
.folder-row .toggle.expanded .toggle-icon {
transform: rotate(90deg);
}
.folder-row .icon-button {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
margin-left: auto;
}
.folder-row:hover .icon-button,
.folder-row:focus-within .icon-button {
opacity: 1;
pointer-events: auto;
}
.folder-children {
list-style: none;
margin: 0 0 0 1rem;
margin: 0 0 0 0.45rem;
padding: 0;
}
@@ -177,73 +307,133 @@ button.icon-button:hover:not([disabled]) {
outline-offset: 2px;
}
.table-panel {
overflow: hidden;
}
.table-panel header {
.documents-panel .column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.8rem;
gap: 1rem;
}
.header-actions {
.documents-panel .column-header .header-actions {
display: flex;
gap: 0.5rem;
}
.table-panel table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
.documents-panel .column-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.table-panel thead {
background: var(--accent-soft);
}
.table-panel th,
.table-panel td {
padding: 0.65rem 0.75rem;
text-align: left;
.documents-panel .column-toolbar {
flex-shrink: 0;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
.table-panel th.actions-column,
.table-panel td.actions {
.documents-panel .documents-scroll {
flex: 1;
overflow-y: auto;
border: none;
background: transparent;
min-height: 0;
padding-right: 0.3rem;
}
.documents-panel table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 0.4rem;
}
.documents-panel thead {
background: transparent;
border-bottom: 1px solid var(--border);
}
.documents-panel thead th {
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
padding: 0.45rem 0.6rem;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.documents-panel th,
.documents-panel td {
padding: 0.45rem 0.6rem;
text-align: left;
}
.documents-panel th.thumb-column,
.documents-panel td.thumb-cell {
width: 54px;
text-align: center;
}
.document-thumbnail {
width: 36px;
height: 48px;
object-fit: cover;
border-radius: 0;
box-shadow: none;
}
.thumb-placeholder {
width: 36px;
height: 48px;
border-radius: 0;
background: var(--surface-subtle);
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.documents-panel th.actions-column,
.documents-panel td.actions {
width: 1%;
white-space: nowrap;
}
.table-panel tbody tr {
background: var(--surface-strong);
.documents-panel tbody tr {
background: transparent;
border-bottom: 1px solid var(--border);
transition: background 0.15s ease;
}
.table-panel tbody tr:nth-child(every) {
background: var(--surface-strong);
.documents-panel tbody tr:last-child {
border-bottom: none;
}
.table-panel tbody tr:hover {
background: rgba(37, 99, 235, 0.08);
.documents-panel tbody tr:hover {
background: var(--surface-subtle);
}
.table-panel tbody tr.folder {
.documents-panel tbody tr.folder {
cursor: pointer;
font-weight: 600;
}
.table-panel tbody tr.document {
.documents-panel tbody tr.document {
cursor: pointer;
}
.table-panel tbody tr.document.selected {
background: rgba(37, 99, 235, 0.2);
.documents-panel tbody tr.document.selected {
background: rgba(43, 92, 255, 0.12);
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.9);
}
.table-panel tbody tr.document.dragging {
.documents-panel tbody tr.document.dragging {
opacity: 0.4;
}
@@ -251,34 +441,34 @@ button.icon-button:hover:not([disabled]) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
gap: 0.32rem;
margin-bottom: 0.5rem;
}
.filter-bar input[type='search'] {
flex: 1;
min-width: 220px;
min-width: 180px;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
gap: 0.32rem;
}
.tag-filter {
border: 1px solid var(--accent);
border: 1px solid var(--border);
background: transparent;
color: var(--accent);
padding: 0.3rem 0.7rem;
border-radius: 999px;
color: var(--fg);
padding: 0.25rem 0.6rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.tag-filter:hover {
box-shadow: 0 8px 16px var(--accent-strong);
background: var(--surface-subtle);
}
.tag-filter.active {
@@ -288,7 +478,7 @@ button.icon-button:hover:not([disabled]) {
.filter-actions {
display: flex;
gap: 0.4rem;
gap: 0.32rem;
}
.search-hint {
@@ -300,29 +490,53 @@ button.icon-button:hover:not([disabled]) {
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
margin-right: 0.35rem;
padding: 0.15rem 0.35rem;
border-radius: 2px;
background: var(--surface-subtle);
color: var(--fg);
font-size: 0.74rem;
margin-right: 0.25rem;
}
.empty-state {
border: 2px dashed var(--border);
border-radius: 12px;
border: 1px dashed var(--border);
border-radius: 0;
text-align: center;
padding: 2.5rem 1rem;
padding: 1.1rem 0.9rem;
color: var(--muted);
margin-top: 1.5rem;
margin-top: 0.75rem;
background: var(--surface-subtle);
}
.detail-panel {
position: relative;
min-height: calc(100vh - 6rem);
}
.detail-panel .column-body {
gap: 1rem;
}
.detail-panel .column-body.scrollable {
overflow-y: auto;
}
.detail-actions {
display: flex;
gap: 0.32rem;
margin: 0.6rem 0;
}
.thumbnail-preview {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.32rem;
margin: 0.4rem 0 0.6rem;
}
.thumbnail-image {
max-width: 100%;
border-radius: 0;
box-shadow: none;
}
.detail-panel .meta {
@@ -346,25 +560,26 @@ button.icon-button:hover:not([disabled]) {
.detail-panel .tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
gap: 0.3rem;
margin-top: 0.5rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--accent-soft);
color: var(--accent);
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
gap: 0.18rem;
background: var(--surface-subtle);
color: var(--fg);
padding: 0.2rem 0.45rem;
border-radius: 2px;
font-size: 0.78rem;
border: 1px solid var(--border);
}
.tag-pill button {
background: none;
border: none;
color: inherit;
color: var(--muted);
padding: 0;
cursor: pointer;
font-size: 0.85rem;
@@ -374,52 +589,61 @@ input,
textarea,
select {
font: inherit;
border-radius: 8px;
border-radius: 2px;
border: 1px solid var(--border);
padding: 0.55rem 0.6rem;
background: var(--surface-strong);
padding: 0.4rem 0.5rem;
background: var(--surface);
color: inherit;
width: 100%;
box-sizing: border-box;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
background: var(--surface);
}
textarea {
resize: vertical;
}
form.inline {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
gap: 0.32rem;
margin-top: 0.5rem;
}
.status-banner {
padding: 0.6rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
margin-bottom: 1rem;
padding: 0.45rem 0.8rem;
border-radius: 2px;
font-size: 0.85rem;
margin-bottom: 0.8rem;
}
.status-banner.info {
background: rgba(107, 114, 128, 0.12);
background: var(--surface-subtle);
color: var(--muted);
}
.status-banner.success {
background: rgba(16, 185, 129, 0.15);
background: rgba(18, 128, 90, 0.1);
color: var(--success);
}
.status-banner.error {
background: rgba(220, 38, 38, 0.15);
background: rgba(217, 44, 32, 0.1);
color: var(--danger);
}
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(37, 99, 235, 0.25);
backdrop-filter: blur(8px);
background: rgba(43, 92, 255, 0.12);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
@@ -431,17 +655,18 @@ form.inline {
}
.drop-overlay__content {
background: rgba(17, 24, 39, 0.85);
color: white;
padding: 2rem 3rem;
border-radius: 20px;
background: var(--surface);
color: var(--fg);
padding: 1.25rem 1.75rem;
border-radius: 2px;
text-align: center;
font-size: 1.2rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
font-size: 0.95rem;
box-shadow: none;
border: 1px solid var(--border);
}
@media (max-width: 1080px) {
.dashboard-layout {
.app-main {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
grid-template-rows: auto auto;
}
@@ -451,19 +676,84 @@ form.inline {
}
@media (max-width: 768px) {
#app {
padding: 1.5rem 1rem 4rem;
}
header.global-header {
.app-bar {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.dashboard-layout {
.app-main {
grid-template-columns: 1fr;
}
.sidebar,
.table-panel,
.documents-panel,
.detail-panel {
min-height: auto;
}
}
.sidebar .column-body {
gap: 0.28rem;
}
.new-folder-form {
display: flex;
gap: 0.45rem;
}
.folder-tree-scroller {
flex: 1;
overflow-y: auto;
padding-right: 0.4rem;
margin-right: -0.4rem;
min-height: 0;
}
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 2rem;
}
.login-card {
width: min(340px, 100%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
padding: 1.75rem;
box-shadow: none;
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-card h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 600;
}
.login-card p {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.login-card form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-card label {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
}
.login-card .status-banner {
margin-bottom: 0;
}
.column-toolbar .filter-bar {
margin-bottom: 0;
}
+11 -1
View File
@@ -9,7 +9,7 @@ const API_BASE_URL =
env.API_BASE_URL || process.env.API_BASE_URL || 'http://127.0.0.1:3000';
module.exports = {
entry: './src/index.js',
entry: './src/index.jsx',
output: {
filename: 'bundle.[contenthash].js',
path: path.resolve(__dirname, 'dist'),
@@ -17,6 +17,13 @@ module.exports = {
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
@@ -49,4 +56,7 @@ module.exports = {
],
},
devtool: 'source-map',
resolve: {
extensions: ['.js', '.jsx'],
},
};