1 Commits
Author SHA1 Message Date
nils 5091cd005e ollama experiment 2025-10-27 13:24:47 +01:00
111 changed files with 5679 additions and 15365 deletions
-315
View File
@@ -59,45 +59,6 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -650,7 +611,6 @@ dependencies = [
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenv",
"envy",
"futures-util", "futures-util",
"hex", "hex",
"http-body-util", "http-body-util",
@@ -665,9 +625,6 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
"serde-aux",
"serde_bytes",
"serde_cbor_2",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
@@ -680,8 +637,6 @@ dependencies = [
"url", "url",
"utoipa", "utoipa",
"uuid", "uuid",
"webauthn-rs",
"webauthn-rs-core",
] ]
[[package]] [[package]]
@@ -733,17 +688,6 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "base64urlsafedata"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -993,12 +937,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.4.9" version = "0.4.9"
@@ -1066,12 +1004,6 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "der" name = "der"
version = "0.6.1" version = "0.6.1"
@@ -1082,20 +1014,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.4" version = "0.5.4"
@@ -1257,15 +1175,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1335,21 +1244,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1524,17 +1418,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2265,74 +2148,18 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.110"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@@ -2384,12 +2211,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfium-render" name = "pdfium-render"
version = "0.8.35" version = "0.8.35"
@@ -2866,15 +2687,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.2" version = "1.1.2"
@@ -3092,48 +2904,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-aux"
version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
dependencies = [
"chrono",
"serde",
"serde-value",
"serde_json",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half",
"serde",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -3989,74 +3759,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]]
name = "webauthn-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom",
"openssl",
"openssl-sys",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_cbor_2",
"serde_json",
"thiserror 1.0.69",
"tracing",
"url",
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "webauthn-rs-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.3" version = "1.0.3"
@@ -4299,23 +4001,6 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]] [[package]]
name = "xmlparser" name = "xmlparser"
version = "0.13.6" version = "0.13.6"
-6
View File
@@ -25,8 +25,6 @@ aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities # Utilities
tracing = "0.1" tracing = "0.1"
@@ -56,9 +54,6 @@ anyhow = "1.0"
# Authentication & security # Authentication & security
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = "9"
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
serde_bytes = "0.11"
serde_cbor_2 = "0.13"
# Misc # Misc
rand = "0.8" rand = "0.8"
@@ -67,4 +62,3 @@ rand = "0.8"
once_cell = "1.19" once_cell = "1.19"
hyper = "1.2" hyper = "1.2"
http-body-util = "0.1" http-body-util = "0.1"
webauthn-rs-core = "0.5"
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
DROP COLUMN IF EXISTS operations_summary;
@@ -1,7 +0,0 @@
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
UPDATE document_correspondents SET role = 'other';
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id, role);
@@ -1,24 +0,0 @@
WITH ranked AS (
SELECT
document_id,
correspondent_id,
role,
assigned_at,
assigned_by,
tenant_id,
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
FROM document_correspondents
)
DELETE FROM document_correspondents dc
USING ranked r
WHERE dc.document_id = r.document_id
AND dc.correspondent_id = r.correspondent_id
AND dc.role = r.role
AND dc.tenant_id = r.tenant_id
AND r.rn > 1;
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents DROP COLUMN role;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id);
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
DROP TABLE IF EXISTS webdav_tokens;
@@ -1,16 +0,0 @@
CREATE TABLE webdav_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
label TEXT,
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
@@ -1,2 +0,0 @@
ALTER TABLE webdav_tokens
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE webdav_tokens
DROP COLUMN IF EXISTS scopes;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS created_by;
DROP TYPE IF EXISTS tenant_status;
@@ -1,19 +0,0 @@
ALTER TABLE tenants
DROP COLUMN IF EXISTS status;
ALTER TABLE tenants
DROP COLUMN IF EXISTS created_by;
DROP TYPE IF EXISTS tenant_status;
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
ALTER TABLE tenants
ADD COLUMN status tenant_status,
ADD COLUMN created_by UUID;
UPDATE tenants
SET status = 'active';
ALTER TABLE tenants
ALTER COLUMN status SET NOT NULL;
@@ -1 +0,0 @@
DROP TABLE IF EXISTS user_passkeys;
@@ -1,19 +0,0 @@
CREATE TABLE user_passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
credential JSONB NOT NULL,
sign_count BIGINT NOT NULL,
transports TEXT[] NOT NULL DEFAULT '{}',
aaguid UUID,
nickname TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by UUID,
revoked_reason TEXT
);
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys (user_id);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS webauthn_challenges;
@@ -1,13 +0,0 @@
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
challenge BYTEA NOT NULL,
state BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
);
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges (user_id);
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges (expires_at);
@@ -1,2 +0,0 @@
ALTER TABLE user_memberships
ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
@@ -1,2 +0,0 @@
ALTER TABLE user_memberships
DROP COLUMN role;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
RENAME COLUMN name TO slug;
ALTER TABLE tenants
RENAME CONSTRAINT tenants_name_key TO tenants_slug_key;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
RENAME COLUMN slug TO name;
ALTER TABLE tenants
RENAME CONSTRAINT tenants_slug_key TO tenants_name_key;
@@ -1,2 +0,0 @@
ALTER TABLE tenants
ADD CONSTRAINT tenants_name_key UNIQUE (name);
@@ -1,2 +0,0 @@
ALTER TABLE tenants
DROP CONSTRAINT IF EXISTS tenants_name_key;
@@ -1,6 +0,0 @@
ALTER TABLE users
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
-- Optional: remove the default if you need to reintroduce passwords later
ALTER TABLE users
ALTER COLUMN password_hash DROP DEFAULT;
@@ -1,2 +0,0 @@
ALTER TABLE users
DROP COLUMN password_hash;
-44
View File
@@ -17,8 +17,6 @@ pub struct JwtService {
download_expiry: Duration, download_expiry: Duration,
selector_audience: String, selector_audience: String,
selector_expiry: Duration, selector_expiry: Duration,
signup_audience: String,
signup_expiry: Duration,
} }
impl JwtService { impl JwtService {
@@ -33,8 +31,6 @@ impl JwtService {
download_expiry: Duration::minutes(config.download_token_expiry_minutes), download_expiry: Duration::minutes(config.download_token_expiry_minutes),
selector_audience: format!("{}:tenant-selector", config.jwt_audience), selector_audience: format!("{}:tenant-selector", config.jwt_audience),
selector_expiry: Duration::minutes(15), selector_expiry: Duration::minutes(15),
signup_audience: format!("{}:signup", config.jwt_audience),
signup_expiry: Duration::minutes(15),
}) })
} }
@@ -112,35 +108,6 @@ impl JwtService {
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?; let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_signup_token(
&self,
user_id: Uuid,
challenge_id: Uuid,
username: String,
) -> Result<String> {
let now = Utc::now();
let exp = now + self.signup_expiry;
let claims = SignupClaims {
sub: user_id,
challenge_id,
username,
iss: self.issuer.clone(),
aud: self.signup_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.signup_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -173,14 +140,3 @@ pub struct TenantSelectionClaims {
pub iat: usize, pub iat: usize,
pub exp: usize, pub exp: usize,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupClaims {
pub sub: Uuid,
pub challenge_id: Uuid,
pub username: String,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
-2
View File
@@ -1,7 +1,5 @@
pub mod jwt; pub mod jwt;
pub mod passkeys;
pub mod password; pub mod password;
pub mod webdav_tokens;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::headers::{authorization::Bearer, Authorization};
-670
View File
@@ -1,670 +0,0 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use webauthn_rs::prelude::{Credential, *};
use crate::{
config::AppConfig,
error::{AppError, AppResult},
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
};
const PURPOSE_REGISTRATION: &str = "registration";
const PURPOSE_AUTHENTICATION: &str = "authentication";
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
#[derive(Clone)]
pub struct PasskeyService {
webauthn: Arc<Webauthn>,
challenge_ttl: ChronoDuration,
}
pub struct PreparedPasskey {
pub id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
}
impl PreparedPasskey {
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
NewUserPasskey {
id: self.id,
user_id,
credential_id: self.credential_id,
public_key: self.public_key,
credential: self.credential,
sign_count: self.sign_count,
transports: self.transports,
aaguid: self.aaguid,
nickname,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationChallengeResponse {
pub challenge_id: Uuid,
#[serde(flatten)]
pub challenge: CreationChallengeResponse,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticationChallengeResponse {
pub challenge_id: Uuid,
#[serde(flatten)]
pub challenge: RequestChallengeResponse,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeySummary {
pub id: Uuid,
pub nickname: Option<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub transports: Vec<String>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_reason: Option<String>,
}
impl PasskeyService {
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
Some(rp_id) if !rp_id.is_empty() => rp_id,
_ => return Ok(None),
};
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
Some(origin) if !origin.is_empty() => origin,
_ => return Ok(None),
};
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
let builder = WebauthnBuilder::new(rp_id, &origin)
.context("failed to initialise WebAuthn builder")?
.rp_name(&config.webauthn_rp_name)
.allow_subdomains(false)
.allow_any_port(false);
let webauthn = builder
.build()
.context("failed to build WebAuthn instance")?;
Ok(Some(Self {
webauthn: Arc::new(webauthn),
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
}))
}
fn prune_expired(&self, conn: &mut PgConnection) {
let now = Utc::now().naive_utc();
let _ = diesel::delete(
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
)
.execute(conn);
}
pub fn start_registration(
&self,
conn: &mut PgConnection,
user: &User,
) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null())
.load(conn)?;
let exclude = if existing.is_empty() {
None
} else {
Some(
existing
.iter()
.map(|pk| CredentialID::from(pk.credential_id.clone()))
.collect(),
)
};
let (challenge, state) = self
.webauthn
.start_passkey_registration(user.id, &user.username, &user.username, exclude)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: Some(user.id),
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn start_signup_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, None)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: None,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn finish_registration(
&self,
conn: &mut PgConnection,
user: &User,
challenge_id: Uuid,
credential: RegisterPublicKeyCredential,
nickname: Option<String>,
) -> AppResult<UserPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if record.user_id != Some(user.id) {
return Err(AppError::unauthorized());
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(&credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
let new_passkey = NewUserPasskey {
id: Uuid::new_v4(),
user_id: user.id,
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
nickname,
};
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&new_passkey)
.execute(conn)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
let created: UserPasskey = passkey_dsl::user_passkeys
.find(new_passkey.id)
.select(UserPasskey::as_select())
.first(conn)?;
Ok(created)
}
pub fn start_authentication(
&self,
conn: &mut PgConnection,
user: &User,
) -> AppResult<AuthenticationChallengeResponse> {
self.prune_expired(conn);
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null())
.select(UserPasskey::as_select())
.load(conn)?;
if stored.is_empty() {
return Err(AppError::bad_request("no passkeys registered"));
}
let mut passkeys = Vec::with_capacity(stored.len());
for pk in &stored {
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
.context("failed to parse stored passkey")
.map_err(AppError::internal)?;
passkeys.push(passkey);
}
let (challenge, state) = self
.webauthn
.start_passkey_authentication(&passkeys)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey authentication");
AppError::internal("failed to start passkey authentication")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode authentication state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: Some(user.id),
purpose: PURPOSE_AUTHENTICATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(AuthenticationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn list_for_user(
&self,
conn: &mut PgConnection,
user_id: Uuid,
) -> AppResult<Vec<PasskeySummary>> {
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.order(passkey_dsl::created_at.asc())
.select(UserPasskey::as_select())
.load(conn)?;
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
}
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
let count: i64 = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::revoked_at.is_null())
.select(count_star())
.first(conn)?;
Ok(count)
}
pub fn consume_signup_challenge(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: &RegisterPublicKeyCredential,
) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(),
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
})
}
pub fn revoke_passkey(
&self,
conn: &mut PgConnection,
user_id: Uuid,
passkey_id: Uuid,
reason: Option<String>,
) -> AppResult<()> {
let now = Utc::now().naive_utc();
let updated = diesel::update(
passkey_dsl::user_passkeys
.filter(passkey_dsl::id.eq(passkey_id))
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::revoked_at.is_null()),
)
.set((
passkey_dsl::revoked_at.eq(Some(now)),
passkey_dsl::revoked_reason.eq(reason),
passkey_dsl::updated_at.eq(now),
))
.execute(conn)?;
if updated == 0 {
return Err(AppError::not_found());
}
Ok(())
}
pub fn finish_authentication(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: PublicKeyCredential,
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_AUTHENTICATION {
return Err(AppError::bad_request("challenge is not for authentication"));
}
let user_id = record
.user_id
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
.context("failed to decode authentication state")
.map_err(AppError::internal)?;
let auth_result = self
.webauthn
.finish_passkey_authentication(&credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey authentication failed");
AppError::unauthorized()
})?;
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.filter(passkey_dsl::revoked_at.is_null())
.select(UserPasskey::as_select())
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::unauthorized()
} else {
AppError::from(err)
}
})?;
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
.context("failed to parse stored passkey")
.map_err(AppError::internal)?;
if auth_result.needs_update() {
let _ = passkey_model.update_credential(&auth_result);
}
let credential_struct: Credential = passkey_model.clone().into();
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let credential_json = serde_json::to_value(&passkey_model)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
let now = Utc::now().naive_utc();
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
.set((
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
passkey_dsl::transports.eq(&transports),
passkey_dsl::credential.eq(credential_json.clone()),
passkey_dsl::public_key.eq(public_key_bytes),
passkey_dsl::last_used_at.eq(Some(now)),
passkey_dsl::updated_at.eq(now),
))
.execute(conn)?;
passkey.sign_count = auth_result.counter() as i64;
passkey.transports = transports;
passkey.credential = credential_json;
passkey.last_used_at = Some(now);
passkey.updated_at = now;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
let user = crate::schema::users::table
.find(user_id)
.first::<User>(conn)?;
Ok((user, passkey, auth_result))
}
}
impl From<UserPasskey> for PasskeySummary {
fn from(passkey: UserPasskey) -> Self {
let transports = passkey
.transports
.into_iter()
.filter_map(|value| value)
.collect();
Self {
id: passkey.id,
nickname: passkey.nickname,
created_at: passkey.created_at,
last_used_at: passkey.last_used_at,
transports,
revoked_at: passkey.revoked_at,
revoked_reason: passkey.revoked_reason,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationFinishPayload {
pub challenge_id: Uuid,
pub credential: RegisterPublicKeyCredential,
#[serde(default)]
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginStartPayload {
pub username: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginFinishPayload {
pub challenge_id: Uuid,
pub credential: PublicKeyCredential,
}
+1 -10
View File
@@ -1,9 +1,8 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{PasswordHash, PasswordVerifier},
Argon2, Argon2,
}; };
use rand::rngs::OsRng;
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
@@ -11,11 +10,3 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
.verify_password(password.as_bytes(), &parsed_hash) .verify_password(password.as_bytes(), &parsed_hash)
.is_ok()) .is_ok())
} }
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?;
Ok(hash.to_string())
}
-190
View File
@@ -1,190 +0,0 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use chrono::{NaiveDateTime, Utc};
use diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use uuid::Uuid;
use crate::{
error::AppError,
models::{NewWebdavToken, WebdavToken},
schema::webdav_tokens,
state::PgPooledConnection,
};
const TOKEN_PREFIX_LENGTH: usize = 12;
const TOKEN_SECRET_LENGTH: usize = 32;
pub struct IssuedWebdavToken {
pub token: String,
pub record: WebdavToken,
}
pub fn create_webdav_token(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Uuid,
label: Option<String>,
expires_at: Option<NaiveDateTime>,
) -> Result<IssuedWebdavToken, AppError> {
let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
let token_hash = hash_secret(&raw_secret)?;
let new_token = NewWebdavToken {
id: Uuid::new_v4(),
user_id,
tenant_id,
token_prefix,
token_hash,
label,
expires_at,
};
let record = diesel::insert_into(webdav_tokens::table)
.values(&new_token)
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record,
})
}
pub fn list_webdav_tokens(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<Vec<WebdavToken>, AppError> {
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let tokens = query
.order(webdav_tokens::created_at.asc())
.load::<WebdavToken>(conn)?;
Ok(tokens)
}
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
secret: &str,
) -> Result<Option<WebdavToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None);
}
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.filter(webdav_tokens::token_prefix.eq(prefix))
.filter(webdav_tokens::revoked_at.is_null())
.into_boxed();
let now = Utc::now().naive_utc();
query = query.filter(
webdav_tokens::expires_at
.is_null()
.or(webdav_tokens::expires_at.gt(now)),
);
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let candidates = query.load::<WebdavToken>(conn)?;
for token in candidates {
if verify_token_secret(secret, &token.token_hash)? {
return Ok(Some(token));
}
}
Ok(None)
}
pub fn revoke_webdav_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let affected = diesel::update(
webdav_tokens::table
.filter(webdav_tokens::id.eq(token_id))
.filter(webdav_tokens::user_id.eq(user_id)),
)
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
if affected == 0 {
return Err(AppError::not_found());
}
Ok(())
}
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
tracing::error!(error = ?err, "failed to verify token");
AppError::internal("failed to verify token")
})
}
fn generate_secret() -> Result<String, AppError> {
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
tracing::error!(error = ?err, "failed to generate token");
AppError::internal("failed to generate token")
})?;
Ok(hex::encode(buffer))
}
fn hash_secret(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map_err(|err| {
tracing::error!(error = ?err, "failed to hash token");
AppError::internal("failed to hash token")
})?;
Ok(hash.to_string())
}
fn _ensure_constants() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_secret_has_expected_length() {
let secret = generate_secret().unwrap();
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
}
#[test]
fn hash_and_verify_secret_round_trip() {
let secret = generate_secret().unwrap();
let hash = hash_secret(&secret).unwrap();
assert!(verify_token_secret(&secret, &hash).unwrap());
assert!(!verify_token_secret("wrong", &hash).unwrap());
}
}
+290 -140
View File
@@ -2,81 +2,115 @@ use std::env;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use diesel::{dsl::exists, prelude::*, select}; use diesel::{dsl::exists, prelude::*, select};
use once_cell::sync::Lazy;
use reqwest::{Client, Method, StatusCode}; use reqwest::{Client, Method, StatusCode};
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use backend::{ use backend::{
config::AppConfig, config::AppConfig,
db::{self, PgPool}, db::{self, PgPool},
documents::search::ensure_quickwit_index,
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}, jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
models::{ models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
},
s3, s3,
schema::{ schema::{
document_asset_objects, document_assets, documents, tenants, user_memberships, users, document_asset_objects, document_assets, documents, tenants, user_memberships, users,
}, },
storage::{ObjectStorage, S3Storage, TenantStorage}, storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService,
utils::tracing::init_tracing, utils::tracing::init_tracing,
}; };
use rand::rngs::OsRng;
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
json!({
"version": "0.8",
"index_id": "documents",
"doc_mapping": {
"tokenizers": [
{
"name": "substring",
"type": "ngram",
"min_gram": 2,
"max_gram": 20,
"prefix_only": false
}
],
"field_mappings": [
{ "name": "tenant_id", "type": "text", "stored": true },
{ "name": "document_id", "type": "text", "stored": true },
{ "name": "version_id", "type": "text", "stored": true },
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
]
},
"search_settings": {
"default_search_fields": ["title", "text"]
}
})
});
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
CreateUser { CreateUser {
username: String, username: String,
password: String,
},
SetPassword {
username: String,
password: String,
}, },
ListUsers, ListUsers,
DeleteUser { DeleteUser {
username: String, username: String,
}, },
CreateTenant { CreateTenant {
name: String, slug: String,
storage_root: Option<String>, storage_root: Option<String>,
quickwit_index: Option<String>, quickwit_index: Option<String>,
}, },
DeleteTenant { DeleteTenant {
tenant_id: Uuid, slug: String,
}, },
AddUserToTenant { AddUserToTenant {
username: String, username: String,
tenant_id: Uuid, slug: String,
role: Option<String>,
}, },
RemoveUserFromTenant { RemoveUserFromTenant {
username: String, username: String,
tenant_id: Uuid, slug: String,
}, },
ReanalyzeDocuments { ReanalyzeDocuments {
tenant_id: Uuid, slug: String,
}, },
ListTenants, ListTenants,
DeleteAssets(Uuid), DeleteAssets(String),
QuickwitCreate(Uuid), QuickwitCreate(String),
QuickwitDelete(Uuid), QuickwitDelete(String),
} }
impl Command { impl Command {
fn usage() -> &'static str { fn usage() -> &'static str {
"Usage: admin\n\ "Usage: admin\n\
create-user <username>\n\ create-user <username> <password>\n\
set-password <username> <password>\n\
list-users\n\ list-users\n\
delete-user <username>\n\ delete-user <username>\n\
create-tenant <name> [storage_root] [quickwit_index]\n\ create-tenant <slug> [storage_root] [quickwit_index]\n\
delete-tenant <tenant-id>\n\ delete-tenant <slug>\n\
add-user-to-tenant <username> <tenant-id>\n\ add-user-to-tenant <username> <slug> [role]\n\
remove-user-from-tenant <username> <tenant-id>\n\ remove-user-from-tenant <username> <slug>\n\
reanalyze-documents <tenant-id>\n\ reanalyze-documents <slug>\n\
list-tenants\n\ list-tenants\n\
delete-assets <tenant-id>\n\ delete-assets <slug>\n\
quickwit-create-index <tenant-id>\n\ quickwit-create-index <slug>\n\
quickwit-delete-index <tenant-id>" quickwit-delete-index <slug>"
}
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
} }
fn parse() -> Result<Self> { fn parse() -> Result<Self> {
@@ -84,38 +118,46 @@ impl Command {
match args.next().as_deref() { match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser { Some("create-user") => Ok(Self::CreateUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?, username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}),
Some("set-password") => Ok(Self::SetPassword {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}), }),
Some("list-users") => Ok(Self::ListUsers), Some("list-users") => Ok(Self::ListUsers),
Some("delete-user") => Ok(Self::DeleteUser { Some("delete-user") => Ok(Self::DeleteUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?, username: args.next().ok_or_else(|| anyhow!("username required"))?,
}), }),
Some("create-tenant") => Ok(Self::CreateTenant { Some("create-tenant") => Ok(Self::CreateTenant {
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
storage_root: args.next(), storage_root: args.next(),
quickwit_index: args.next(), quickwit_index: args.next(),
}), }),
Some("delete-tenant") => Ok(Self::DeleteTenant { Some("delete-tenant") => Ok(Self::DeleteTenant {
tenant_id: Self::parse_tenant_id(args.next())?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}), }),
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant { Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?, username: args.next().ok_or_else(|| anyhow!("username required"))?,
tenant_id: Self::parse_tenant_id(args.next())?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
role: args.next(),
}), }),
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant { Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?, username: args.next().ok_or_else(|| anyhow!("username required"))?,
tenant_id: Self::parse_tenant_id(args.next())?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}), }),
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments { Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
tenant_id: Self::parse_tenant_id(args.next())?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}), }),
Some("list-tenants") => Ok(Self::ListTenants), Some("list-tenants") => Ok(Self::ListTenants),
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)), Some("delete-assets") => Ok(Self::DeleteAssets(
Some("quickwit-create-index") => { args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?)) )),
} Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
Some("quickwit-delete-index") => { args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?)) )),
} Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
_ => Err(anyhow!(Self::usage())), _ => Err(anyhow!(Self::usage())),
} }
} }
@@ -129,43 +171,45 @@ async fn main() -> Result<()> {
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match command { match command {
Command::CreateUser { username } => create_user(&pool, &username)?, Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
Command::ListUsers => list_users(&pool)?, Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?, Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant { Command::CreateTenant {
name, slug,
storage_root, storage_root,
quickwit_index, quickwit_index,
} => create_tenant(&pool, &name, storage_root, quickwit_index)?, } => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?, Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
Command::AddUserToTenant { Command::AddUserToTenant {
username, username,
tenant_id, slug,
} => add_user_to_tenant(&pool, &username, tenant_id)?, role,
Command::RemoveUserFromTenant { } => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
username, Command::RemoveUserFromTenant { username, slug } => {
tenant_id, remove_user_from_tenant(&pool, &username, &slug)?
} => remove_user_from_tenant(&pool, &username, tenant_id)?, }
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
Command::ListTenants => list_tenants(&pool)?, Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets(tenant_id) => { Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
delete_assets_for_tenant(&config, &pool, tenant_id).await? Command::QuickwitCreate(slug) => {
quickwit_index(&config, &pool, &slug, Method::POST).await?
} }
Command::QuickwitCreate(tenant_id) => { Command::QuickwitDelete(slug) => {
quickwit_index(&config, &pool, tenant_id, Method::POST).await? quickwit_index(&config, &pool, &slug, Method::DELETE).await?
}
Command::QuickwitDelete(tenant_id) => {
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
} }
} }
Ok(()) Ok(())
} }
fn create_user(pool: &PgPool, username: &str) -> Result<()> { fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if username.trim().is_empty() { if username.trim().is_empty() {
bail!("username must not be empty"); bail!("username must not be empty");
} }
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool = let exists: bool =
@@ -174,9 +218,11 @@ fn create_user(pool: &PgPool, username: &str) -> Result<()> {
bail!("user '{}' already exists", username); bail!("user '{}' already exists", username);
} }
let password_hash = hash_password(password)?;
let new_user = NewUser { let new_user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username: username.to_string(), username: username.to_string(),
password_hash,
}; };
diesel::insert_into(users::table) diesel::insert_into(users::table)
@@ -187,6 +233,34 @@ fn create_user(pool: &PgPool, username: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let password_hash = hash_password(password)?;
let updated = diesel::update(users::table.filter(users::username.eq(username)))
.set(users::password_hash.eq(password_hash))
.execute(&mut conn)?;
if updated == 0 {
bail!("user '{}' not found", username);
}
println!("updated password for '{}'", username);
Ok(())
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?;
Ok(hash.to_string())
}
fn list_users(pool: &PgPool) -> Result<()> { fn list_users(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
@@ -197,22 +271,21 @@ fn list_users(pool: &PgPool) -> Result<()> {
} }
for user in users_list { for user in users_list {
let memberships: Vec<String> = user_memberships::table let memberships: Vec<(Uuid, String, String)> = user_memberships::table
.inner_join(tenants::table) .inner_join(tenants::table)
.filter(user_memberships::user_id.eq(user.id)) .filter(user_memberships::user_id.eq(user.id))
.select(tenants::name) .select((tenants::id, tenants::slug, user_memberships::role))
.order(tenants::name.asc()) .order((tenants::slug.asc(), user_memberships::role.asc()))
.load(&mut conn)?; .load(&mut conn)?;
if memberships.is_empty() { if memberships.is_empty() {
println!("{} ({})", user.username, user.id); println!("{} ({})", user.username, user.id);
} else { } else {
println!( let details: Vec<String> = memberships
"{} ({}) -> {}", .into_iter()
user.username, .map(|(_, slug, role)| format!("{}: {}", slug, role))
user.id, .collect();
memberships.join(", ") println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
);
} }
} }
@@ -238,59 +311,77 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
fn create_tenant( fn create_tenant(
pool: &PgPool, pool: &PgPool,
name: &str, slug: &str,
storage_root_arg: Option<String>, storage_root_arg: Option<String>,
quickwit_index_arg: Option<String>, quickwit_index_arg: Option<String>,
) -> Result<()> { ) -> Result<()> {
let service = TenantService::new(pool.clone()); if slug.trim().is_empty() {
let tenant = service bail!("tenant slug must not be empty");
.create_tenant( }
name,
storage_root_arg.as_deref(),
quickwit_index_arg.as_deref(),
TenantStatus::Creating,
&[],
None,
)
.map_err(|err| anyhow!(format!("{err:?}")))?;
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>"); let mut conn = pool.get().context("failed to get database connection")?;
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>"); let exists: bool =
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
if exists {
bail!("tenant '{}' already exists", slug);
}
let id = Uuid::new_v4();
let storage_root = storage_root_arg
.map(|mut s| {
if s.is_empty() {
format!("tenants/{}/", id)
} else {
if !s.ends_with('/') {
s.push('/');
}
s
}
})
.unwrap_or_else(|| format!("tenants/{}/", id));
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(id),
tenants::slug.eq(slug),
tenants::storage_root.eq(Some(storage_root.clone())),
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
tenants::status.eq("active"),
tenants::config.eq(serde_json::json!({})),
))
.execute(&mut conn)?;
println!( println!(
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'", "created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
tenant.name, slug, id, storage_root, quickwit_index
tenant.id,
storage_root,
quickwit_index,
tenant.status.as_str()
); );
Ok(()) Ok(())
} }
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> { fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let member_exists: bool = select(exists( let member_exists: bool = select(exists(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)), user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
)) ))
.get_result(&mut conn)?; .get_result(&mut conn)?;
if member_exists { if member_exists {
bail!("tenant '{}' still has user memberships", tenant.name); bail!("tenant '{}' still has user memberships", slug);
} }
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?; diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
println!("deleted tenant '{}'", tenant.name); println!("deleted tenant '{}'", slug);
Ok(()) Ok(())
} }
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table let user: User = users::table
@@ -300,28 +391,33 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<
.ok_or_else(|| anyhow!("user '{}' not found", username))?; .ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let membership = NewUserMembership { let membership = NewUserMembership {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id, user_id: user.id,
tenant_id: tenant.id, tenant_id: tenant.id,
role: role.unwrap_or("user").to_string(),
}; };
diesel::insert_into(user_memberships::table) diesel::insert_into(user_memberships::table)
.values(&membership) .values(&membership)
.on_conflict((user_memberships::user_id, user_memberships::tenant_id)) .on_conflict((user_memberships::user_id, user_memberships::tenant_id))
.do_nothing() .do_update()
.set(user_memberships::role.eq(&membership.role))
.execute(&mut conn)?; .execute(&mut conn)?;
println!("added user '{}' to tenant '{}'", username, tenant.name); println!(
"added user '{}' to tenant '{}' with role '{}'",
username, slug, membership.role
);
Ok(()) Ok(())
} }
fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table let user: User = users::table
@@ -331,10 +427,10 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Re
.ok_or_else(|| anyhow!("user '{}' not found", username))?; .ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let removed = diesel::delete( let removed = diesel::delete(
user_memberships::table user_memberships::table
@@ -344,24 +440,21 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Re
.execute(&mut conn)?; .execute(&mut conn)?;
if removed == 0 { if removed == 0 {
println!( println!("user '{}' was not a member of tenant '{}'", username, slug);
"user '{}' was not a member of tenant '{}'",
username, tenant.name
);
} else { } else {
println!("removed user '{}' from tenant '{}'", username, tenant.name); println!("removed user '{}' from tenant '{}'", username, slug);
} }
Ok(()) Ok(())
} }
fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> { fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let targets: Vec<(Uuid, Uuid)> = documents::table let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::tenant_id.eq(tenant.id)) .filter(documents::tenant_id.eq(tenant.id))
@@ -370,7 +463,7 @@ fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
.load(&mut conn)?; .load(&mut conn)?;
if targets.is_empty() { if targets.is_empty() {
println!("tenant '{}' has no active documents", tenant.name); println!("tenant '{}' has no active documents", slug);
return Ok(()); return Ok(());
} }
@@ -393,7 +486,7 @@ fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
println!( println!(
"queued {} documents for re-analysis in tenant '{}'", "queued {} documents for re-analysis in tenant '{}'",
queued, tenant.name queued, slug
); );
Ok(()) Ok(())
} }
@@ -401,7 +494,7 @@ fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
fn list_tenants(pool: &PgPool) -> Result<()> { fn list_tenants(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenants: Vec<Tenant> = tenants::table let tenants: Vec<Tenant> = tenants::table
.order(tenants::name.asc()) .order(tenants::slug.asc())
.load(&mut conn) .load(&mut conn)
.context("failed to load tenants")?; .context("failed to load tenants")?;
@@ -411,7 +504,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
} }
for tenant in tenants { for tenant in tenants {
println!("{} {}", tenant.id, tenant.name); println!("{} ({})", tenant.slug, tenant.id);
} }
Ok(()) Ok(())
@@ -420,7 +513,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
async fn delete_assets_for_tenant( async fn delete_assets_for_tenant(
config: &AppConfig, config: &AppConfig,
pool: &PgPool, pool: &PgPool,
tenant_id: Uuid, tenant_slug: &str,
) -> Result<()> { ) -> Result<()> {
let s3_client = s3::build_client(config).await?; let s3_client = s3::build_client(config).await?;
let storage: Arc<dyn ObjectStorage> = let storage: Arc<dyn ObjectStorage> =
@@ -428,27 +521,28 @@ async fn delete_assets_for_tenant(
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(tenant_slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .context("failed to load tenant")?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?; .with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
let assets: Vec<DocumentAsset> = document_assets::table let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::tenant_id.eq(tenant.id)) .filter(document_assets::tenant_id.eq(tenant.id))
.load(&mut conn) .load(&mut conn)
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?; .with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?;
if assets.is_empty() { if assets.is_empty() {
println!("Tenant {}: no assets", tenant.name); println!("Tenant {}: no assets", tenant.slug);
return Ok(()); return Ok(());
} }
println!( println!(
"Tenant {} ({}): deleting {} assets…", "Tenant {} ({}): deleting {} assets…",
tenant.name, tenant.slug,
tenant.id, tenant.id,
assets.len() assets.len()
); );
@@ -459,13 +553,13 @@ async fn delete_assets_for_tenant(
.filter(document_asset_objects::tenant_id.eq(tenant.id)) .filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)) .filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn) .load(&mut conn)
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?; .with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
for object in &objects { for object in &objects {
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await { if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
eprintln!( eprintln!(
"Failed to delete object {} (tenant {}): {err}", "Failed to delete object {} (tenant {}): {err}",
object.s3_key, tenant.name object.s3_key, tenant.slug
); );
} }
} }
@@ -476,20 +570,20 @@ async fn delete_assets_for_tenant(
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)), .filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
) )
.execute(&mut conn) .execute(&mut conn)
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?; .with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id))) diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
.execute(&mut conn) .execute(&mut conn)
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?; .with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?;
println!("Tenant {}: asset records deleted.", tenant.name); println!("Tenant {}: asset records deleted.", tenant.slug);
Ok(()) Ok(())
} }
async fn quickwit_index( async fn quickwit_index(
config: &AppConfig, config: &AppConfig,
pool: &PgPool, pool: &PgPool,
tenant_id: Uuid, slug: &str,
method: Method, method: Method,
) -> Result<()> { ) -> Result<()> {
let endpoint = config let endpoint = config
@@ -499,10 +593,11 @@ async fn quickwit_index(
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
.find(tenant_id) .filter(tenants::slug.eq(slug))
.first(&mut conn) .first(&mut conn)
.optional()? .optional()
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .context("failed to query tenants")?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let client = Client::new(); let client = Client::new();
let index_id = format!("documents-{}", tenant.id); let index_id = format!("documents-{}", tenant.id);
@@ -510,19 +605,63 @@ async fn quickwit_index(
match method { match method {
Method::POST => { Method::POST => {
ensure_quickwit_index(&client, base_endpoint, &index_id) let payload = render_index_template(&index_id);
let response = client
.post(format!("{}/api/v1/indexes", base_endpoint))
.header("content-type", "application/json")
.body(payload)
.send()
.await .await
.context("failed to ensure quickwit index")?; .context("failed to send create index request")?;
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) match response.status() {
.set(tenants::quickwit_index.eq(Some(index_id.clone()))) status if status.is_success() => {
.execute(&mut conn) diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.context("failed to update tenant quickwit_index")?; .set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!( println!(
"Tenant '{}' quickwit index set to '{}'.", "Tenant '{}' quickwit index set to '{}'.",
tenant.name, index_id tenant.slug, index_id
); );
}
StatusCode::CONFLICT => {
let lookup = client
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to verify existing quickwit index")?;
let lookup_status = lookup.status();
if !lookup_status.is_success() {
let body = lookup.text().await.unwrap_or_default();
bail!(
"quickwit reported conflict but index lookup failed with status {}: {}",
lookup_status,
body
);
}
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.slug, index_id
);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit create index failed with status {}: {}",
status,
body
);
}
}
} }
Method::DELETE => { Method::DELETE => {
let response = client let response = client
@@ -538,7 +677,7 @@ async fn quickwit_index(
.execute(&mut conn) .execute(&mut conn)
.context("failed to clear tenant quickwit_index")?; .context("failed to clear tenant quickwit_index")?;
println!("Tenant '{}' quickwit index cleared.", tenant.name); println!("Tenant '{}' quickwit index cleared.", tenant.slug);
} }
status => { status => {
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
@@ -555,3 +694,14 @@ async fn quickwit_index(
Ok(()) Ok(())
} }
fn render_index_template(index_id: &str) -> String {
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
if let Some(obj) = template.as_object_mut() {
obj.insert(
"index_id".to_string(),
serde_json::Value::String(index_id.to_string()),
);
}
template.to_string()
}
+78 -132
View File
@@ -1,69 +1,36 @@
use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use url::Url; use url::Url;
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_bool_from_anything;
use crate::db::DEFAULT_MAX_POOL_SIZE; use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug)]
pub struct AppConfig { pub struct AppConfig {
pub database_url: String, pub database_url: String,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32, pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String, pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16, pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String, pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16, pub webdav_port: u16,
pub jwt_secret: String, pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String, pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String, pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64, pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String, pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64, pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64, pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>, pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>, pub cors_allowed_origin: Option<String>,
#[serde(default)]
pub aws_endpoint_url: Option<String>, pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String, pub aws_region: String,
pub s3_bucket: String, pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
#[serde(default = "default_worker_max_document_bytes")] pub default_tenant_slug: String,
pub worker_max_document_bytes: u64,
#[serde(default = "default_upload_body_limit_bytes")]
pub upload_body_limit_bytes: u64,
#[serde(default)]
pub webauthn_rp_id: Option<String>,
#[serde(default)]
pub webauthn_origin: Option<String>,
#[serde(default = "default_webauthn_rp_name")]
pub webauthn_rp_name: String,
} }
impl AppConfig { impl AppConfig {
@@ -75,19 +42,87 @@ impl AppConfig {
database_url = %config.redacted_database_url(), database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size, pool_size = config.database_max_pool_size,
quickwit_enabled = config.quickwit_endpoint.is_some(), quickwit_enabled = config.quickwit_endpoint.is_some(),
passkeys_enabled = config.webauthn_origin.is_some(),
s3_bucket = %config.s3_bucket, s3_bucket = %config.s3_bucket,
worker_max_document_bytes = config.worker_max_document_bytes,
upload_body_limit_bytes = config.upload_body_limit_bytes,
"loaded backend configuration" "loaded backend configuration"
); );
Ok(config) Ok(config)
} }
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let config: AppConfig = envy::from_env() let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
.context("failed to parse application configuration from environment")?; let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
Ok(config.normalize()) .ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
default_tenant_slug,
})
} }
pub fn redacted_database_url(&self) -> String { pub fn redacted_database_url(&self) -> String {
@@ -95,95 +130,6 @@ impl AppConfig {
} }
} }
impl AppConfig {
fn normalize(mut self) -> Self {
if self.webdav_host.is_empty() {
self.webdav_host = self.server_host.clone();
}
if self.webauthn_rp_id.is_none() {
self.webauthn_rp_id = Some(self.server_host.clone());
}
if self.webauthn_origin.is_none() {
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
"http"
} else {
"https"
};
self.webauthn_origin = Some(format!(
"{scheme}://{}:{}",
self.server_host, self.server_port
));
}
self
}
}
fn default_database_max_pool_size() -> u32 {
DEFAULT_MAX_POOL_SIZE
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
fn default_webdav_host() -> String {
String::new()
}
fn default_webdav_port() -> u16 {
3001
}
fn default_jwt_issuer() -> String {
"papercrate".to_string()
}
fn default_jwt_audience() -> String {
"papercrate-clients".to_string()
}
fn default_jwt_expiry_minutes() -> i64 {
60
}
fn default_download_token_audience() -> String {
"papercrate-download".to_string()
}
fn default_download_token_expiry_minutes() -> i64 {
60
}
fn default_refresh_token_expiry_days() -> i64 {
30
}
fn default_refresh_cookie_secure() -> bool {
false
}
fn default_aws_region() -> String {
"us-east-1".to_string()
}
fn default_worker_max_document_bytes() -> u64 {
200 * 1024 * 1024
}
fn default_upload_body_limit_bytes() -> u64 {
128 * 1024 * 1024
}
fn default_webauthn_rp_name() -> String {
"Papercrate".to_string()
}
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
-279
View File
@@ -1,279 +0,0 @@
use std::collections::HashMap;
use std::path::Path as FsPath;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::AppState;
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Serialize, ToSchema)]
pub struct DocumentAssetDetailResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub metadata: Value,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetResponse>,
pub download_path: String,
}
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| {
tracing::error!(error = ?err, "failed to generate download token");
AppError::internal("failed to generate download token")
})
}
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> {
let mut conn = state.db_for_tenant(tenant_id)?;
diesel::delete(
document_assets::table
.filter(document_assets::id.eq(asset_id))
.filter(document_assets::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
Ok(())
}
pub async fn load_asset_responses(
state: &AppState,
tenant_id: Uuid,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db_for_tenant(tenant_id)?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
Ok(assets
.into_iter()
.map(|(asset, _)| to_asset_summary(asset))
.collect())
}
pub fn load_primary_assets(
state: &AppState,
tenant_id: Uuid,
documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
for doc in documents {
doc_to_version.insert(doc.id, doc.current_version_id);
version_ids.push(doc.current_version_id);
}
version_ids.sort();
version_ids.dedup();
let mut conn = state.db_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
version_map.insert(version.id, version);
}
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq_any(&version_ids))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_summary(asset);
assets_by_version
.entry(version_id)
.or_default()
.push(response);
}
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
HashMap::with_capacity(doc_to_version.len());
for (doc_id, version_id) in doc_to_version {
if let Some(version) = version_map.remove(&version_id) {
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
result.insert(doc_id, (to_version_response(version), assets));
}
}
Ok(result)
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
-120
View File
@@ -1,120 +0,0 @@
use std::collections::HashMap;
use chrono::Utc;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
use crate::schema::{correspondents, document_correspondents, documents};
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub assigned_at: String,
}
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one correspondent",
));
}
Ok(unique)
}
pub fn insert_document_correspondents(
conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid,
user_id: Uuid,
correspondent_ids: &[Uuid],
) -> AppResult<usize> {
let ids = normalize_correspondent_ids(correspondent_ids)?;
let existing: Vec<Uuid> = correspondents::table
.filter(correspondents::id.eq_any(&ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.select(correspondents::id)
.load(conn)?;
if existing.len() != ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
let new_rows: Vec<NewDocumentCorrespondent> = ids
.into_iter()
.map(|correspondent_id| NewDocumentCorrespondent {
document_id,
correspondent_id,
assigned_by: Some(user_id),
tenant_id,
})
.collect();
if new_rows.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?;
if inserted > 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(inserted)
}
pub fn load_correspondents_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
.inner_join(correspondents::table)
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
for (assignment, correspondent) in rows {
map.entry(assignment.document_id)
.or_default()
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
}
Ok(map)
}
-21
View File
@@ -1,21 +0,0 @@
use diesel::dsl::exists;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::AppResult;
use crate::schema::folders;
use crate::utils::validation::ensure_exists;
pub fn ensure_folder_exists_on_conn(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
ensure_exists(exists, "folder")
}
-52
View File
@@ -1,52 +0,0 @@
use serde_json::{map::Entry, Map, Value};
use crate::error::{AppError, AppResult};
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
let mut base = match existing {
Value::Object(map) => map,
Value::Null => Map::new(),
_ => {
return Err(AppError::bad_request(
"existing metadata is not an object; set replace=true to overwrite",
));
}
};
let incoming = match updates {
Value::Object(map) => map,
_ => {
return Err(AppError::bad_request(
"metadata value must be a JSON object when replace is false",
));
}
};
merge_metadata_maps(&mut base, incoming);
Ok(Value::Object(base))
}
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
for (key, value) in updates {
match target.entry(key) {
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
match value {
Value::Object(update_map) => {
if let Value::Object(existing_map) = existing {
merge_metadata_maps(existing_map, update_map);
} else {
*existing = Value::Object(update_map);
}
}
other => {
*existing = other;
}
}
}
Entry::Vacant(entry) => {
entry.insert(value);
}
}
}
}
-6
View File
@@ -1,6 +0,0 @@
pub mod asset;
pub mod correspondents;
pub mod folders;
pub mod metadata;
pub mod search;
pub mod tags;
-290
View File
@@ -1,290 +0,0 @@
use std::collections::HashSet;
use anyhow::{anyhow, bail, Result};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{debug, error};
use uuid::Uuid;
use crate::models::{Document, DocumentVersion};
pub const QUICKWIT_MAX_HITS: usize = 200;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|token| !token.is_empty())
.map(|token| {
let normalized = token.to_lowercase();
escape_quickwit_token(&normalized)
})
.collect();
if tokens.is_empty() {
return None;
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| format!("(title:{token} OR text:{token})"))
.collect();
Some(parts.join(" AND "))
}
pub fn escape_quickwit_token(token: &str) -> String {
let mut escaped = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
pub async fn quickwit_search(
endpoint: &str,
index: &str,
tenant_id: Uuid,
query: &str,
) -> Result<Vec<Uuid>> {
let tenant_clause = format!("tenant_id:{}", tenant_id);
let quickwit_query = match build_quickwit_query(query) {
Some(q) => {
debug!(%query, quickwit_query = %q, "built quickwit search query");
format!("{} AND ({})", tenant_clause, q)
}
None => {
debug!(%query, "quickwit search skipped because query produced no tokens");
return Ok(vec![]);
}
};
let client = Client::new();
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
let payload = json!({
"query": quickwit_query,
"max_hits": QUICKWIT_MAX_HITS,
});
debug!(%url, payload = %payload, "sending quickwit search request");
let response = client.post(url).json(&payload).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, body = %body, "quickwit search request failed");
return Err(anyhow!(
"quickwit search failed with status {status}: {body}"
));
}
let data: QuickwitSearchResponse = response.json().await?;
debug!("quickwit search response parsed successfully");
let QuickwitSearchResponse { hits } = data;
let total_hits = hits.len();
let mut seen = HashSet::new();
let mut doc_ids = Vec::with_capacity(total_hits);
for hit in hits {
if let Some(doc_id) = extract_document_id(&hit) {
if seen.insert(doc_id) {
doc_ids.push(doc_id);
}
}
}
debug!(
total_hits = total_hits,
unique_ids = doc_ids.len(),
"quickwit search completed"
);
Ok(doc_ids)
}
pub fn quickwit_index_template(index_id: &str) -> Value {
json!({
"version": "0.8",
"index_id": index_id,
"doc_mapping": {
"tokenizers": [
{
"name": "substring",
"type": "ngram",
"min_gram": 2,
"max_gram": 20,
"prefix_only": false
}
],
"field_mappings": [
{ "name": "tenant_id", "type": "text", "stored": true },
{ "name": "document_id", "type": "text", "stored": true },
{ "name": "version_id", "type": "text", "stored": true },
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
]
},
"search_settings": {
"default_search_fields": ["title", "text"]
}
})
}
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
let base = endpoint.trim_end_matches('/');
let create_url = format!("{}/api/v1/indexes", base);
let payload = quickwit_index_template(index_id);
let response = client.post(&create_url).json(&payload).send().await?;
match response.status() {
status if status.is_success() => Ok(()),
StatusCode::CONFLICT => {
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
let lookup = client.get(&lookup_url).send().await?;
if lookup.status().is_success() {
Ok(())
} else {
let status = lookup.status();
let body = lookup.text().await.unwrap_or_default();
bail!("quickwit index lookup failed with status {status}: {body}");
}
}
status => {
let body = response.text().await.unwrap_or_default();
bail!("quickwit create index failed with status {status}: {body}");
}
}
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
}
if let Some(value) = hit.get("document_id") {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
None
}
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
if let Some(obj) = value.as_object() {
if let Some(inner) = obj.get("document_id") {
return parse_uuid_value(inner);
}
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = extract_uuid_from_value(item) {
return Some(uuid);
}
}
}
parse_uuid_value(value)
}
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
if let Some(s) = value.as_str() {
return Uuid::parse_str(s).ok();
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = parse_uuid_value(item) {
return Some(uuid);
}
}
}
None
}
#[derive(Deserialize)]
struct QuickwitSearchResponse {
#[serde(default)]
hits: Vec<Value>,
}
#[derive(Serialize)]
pub struct QuickwitIngestRecord {
pub document_id: Uuid,
pub version_id: Uuid,
pub tenant_id: Uuid,
pub title: String,
pub text: String,
}
pub fn build_quickwit_ingest_record(
document: &Document,
version: &DocumentVersion,
tenant_id: Uuid,
text: &str,
) -> QuickwitIngestRecord {
QuickwitIngestRecord {
document_id: document.id,
version_id: version.id,
tenant_id,
title: document.title.to_lowercase(),
text: text.to_lowercase(),
}
}
pub async fn quickwit_ingest(
client: &Client,
endpoint: &str,
index: &str,
records: &[QuickwitIngestRecord],
) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let url = format!(
"{}/api/v1/{}/ingest?commit=auto",
endpoint.trim_end_matches('/'),
index
);
let mut body = String::new();
for record in records {
let line = serde_json::to_string(record)?;
body.push_str(&line);
body.push('\n');
}
debug!(%url, lines = records.len(), "sending quickwit ingest request");
let response = client
.post(url)
.header("content-type", "application/x-ndjson")
.body(body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, %body, "quickwit ingest request failed");
return Err(anyhow!(
"quickwit ingest failed with status {status}: {body}"
));
}
debug!("quickwit ingest request succeeded");
Ok(())
}
-80
View File
@@ -1,80 +0,0 @@
use std::collections::HashMap;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, tags};
pub fn assign_tags(
conn: &mut PgConnection,
tenant_id: Uuid,
document: &Document,
raw_tag_ids: &[Uuid],
assigned_by: Option<Uuid>,
) -> AppResult<usize> {
if raw_tag_ids.is_empty() {
return Ok(0);
}
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
tag_ids.sort_unstable();
tag_ids.dedup();
if tag_ids.is_empty() {
return Ok(0);
}
let existing: Vec<Uuid> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.select(tags::id)
.load(conn)?;
if existing.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = tag_ids
.into_iter()
.map(|tag_id| NewDocumentTag {
document_id: document.id,
tag_id,
assigned_by,
tenant_id,
})
.collect();
if new_tags.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(conn)?;
Ok(inserted)
}
pub fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
+1 -4
View File
@@ -72,10 +72,7 @@ impl From<diesel::result::Error> for AppError {
fn from(value: diesel::result::Error) -> Self { fn from(value: diesel::result::Error) -> Self {
match value { match value {
diesel::result::Error::NotFound => AppError::not_found(), diesel::result::Error::NotFound => AppError::not_found(),
other => { _ => AppError::internal(value),
tracing::error!(error = ?other, "database operation failed");
AppError::internal("database operation failed")
}
} }
} }
} }
-1
View File
@@ -19,7 +19,6 @@ pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text"; pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum JobQueueError { pub enum JobQueueError {
-1
View File
@@ -1,7 +1,6 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod documents;
pub mod error; pub mod error;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
+12 -161
View File
@@ -1,16 +1,7 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::deserialize::FromSql;
use diesel::pg::{Pg, PgValue};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::serialize::{IsNull, Output, ToSql};
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
use serde_json::Value;
use std::fmt;
use std::io::Write;
use std::str;
use uuid::Uuid; use uuid::Uuid;
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
use crate::schema::*; use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -21,6 +12,7 @@ pub struct UserMembership {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
pub role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -31,65 +23,7 @@ pub struct NewUserMembership {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} pub role: String,
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = TenantStatusSql)]
pub enum TenantStatus {
Creating,
Active,
Suspended,
Deleting,
Error,
}
impl TenantStatus {
pub fn as_str(&self) -> &'static str {
match self {
TenantStatus::Creating => "creating",
TenantStatus::Active => "active",
TenantStatus::Suspended => "suspended",
TenantStatus::Deleting => "deleting",
TenantStatus::Error => "error",
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"creating" => Some(TenantStatus::Creating),
"active" => Some(TenantStatus::Active),
"suspended" => Some(TenantStatus::Suspended),
"deleting" => Some(TenantStatus::Deleting),
"error" => Some(TenantStatus::Error),
_ => None,
}
}
}
impl fmt::Display for TenantStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No)
}
}
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
let value = str::from_utf8(bytes.as_bytes())
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
TenantStatus::from_str(value).ok_or_else(|| {
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tenant status '{value}'"),
))
})
}
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -97,14 +31,13 @@ impl FromSql<TenantStatusSql, Pg> for TenantStatus {
#[diesel(primary_key(id))] #[diesel(primary_key(id))]
pub struct Tenant { pub struct Tenant {
pub id: Uuid, pub id: Uuid,
pub name: String, pub slug: String,
pub storage_root: Option<String>, pub storage_root: Option<String>,
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub config: Value, pub status: String,
pub config: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub status: TenantStatus,
pub created_by: Option<Uuid>,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -112,6 +45,7 @@ pub struct Tenant {
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -121,94 +55,7 @@ pub struct User {
pub struct NewUser { pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
} pub password_hash: String,
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
#[diesel(table_name = user_passkeys)]
#[diesel(belongs_to(User))]
pub struct UserPasskey {
pub id: Uuid,
pub user_id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_by: Option<Uuid>,
pub revoked_reason: Option<String>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_passkeys)]
pub struct NewUserPasskey {
pub id: Uuid,
pub user_id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = webauthn_challenges)]
#[diesel(belongs_to(User))]
pub struct WebauthnChallenge {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
pub created_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = webauthn_challenges)]
pub struct NewWebauthnChallenge {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
pub expires_at: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = webdav_tokens)]
#[diesel(belongs_to(User))]
#[diesel(belongs_to(Tenant))]
pub struct WebdavToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = webdav_tokens)]
pub struct NewWebdavToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub expires_at: Option<NaiveDateTime>,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -276,6 +123,7 @@ pub struct DocumentVersion {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -289,6 +137,7 @@ pub struct NewDocumentVersion {
pub s3_key: String, pub s3_key: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -434,10 +283,11 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)] #[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))] #[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id))] #[diesel(primary_key(document_id, correspondent_id, role))]
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
@@ -448,6 +298,7 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent { pub struct NewDocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
+46 -292
View File
@@ -8,23 +8,17 @@ use uuid::Uuid;
paths( paths(
doc::health_check, doc::health_check,
doc::login, doc::login,
doc::signup_start,
doc::signup_finish,
doc::refresh, doc::refresh,
doc::logout, doc::logout,
doc::me, doc::me,
doc::list_tenants,
doc::select_tenant, doc::select_tenant,
doc::passkey_register_start,
doc::passkey_register_finish,
doc::passkey_login_start,
doc::passkey_login_finish,
doc::list_documents, doc::list_documents,
doc::check_document, doc::check_document,
doc::upload_document, doc::upload_document,
doc::get_document, doc::get_document,
doc::update_document, doc::update_document,
doc::delete_document, doc::delete_document,
doc::download_document,
doc::download_with_token, doc::download_with_token,
doc::move_document, doc::move_document,
doc::assign_tags, doc::assign_tags,
@@ -52,39 +46,24 @@ use uuid::Uuid;
doc::create_correspondent, doc::create_correspondent,
doc::update_correspondent, doc::update_correspondent,
doc::delete_correspondent, doc::delete_correspondent,
doc::list_webdav_tokens,
doc::create_webdav_token,
doc::delete_webdav_token,
doc::list_passkeys,
doc::delete_passkey,
), ),
components( components(
schemas( schemas(
schemas::LoginRequest, schemas::LoginRequest,
schemas::SignupStartRequest,
schemas::SignupStartResponse,
schemas::SignupFinishRequest,
schemas::AccessTokenResponse, schemas::AccessTokenResponse,
schemas::TenantSnippet, schemas::TenantSummary,
schemas::TenantSelectionResponse, schemas::TenantSelectionResponse,
schemas::TenantSelectionRequest, schemas::TenantSelectionRequest,
schemas::TenantListResponse,
schemas::LoginResponseVariants, schemas::LoginResponseVariants,
schemas::RegistrationChallengeResponse,
schemas::AuthenticationChallengeResponse,
schemas::PasskeySummary,
schemas::PasskeyRegistrationFinishPayload,
schemas::PasskeyLoginStartPayload,
schemas::PasskeyLoginFinishPayload,
schemas::DocumentResponse, schemas::DocumentResponse,
schemas::DocumentDetailResponse, schemas::DocumentDetailResponse,
schemas::DocumentVersionResponse, schemas::DocumentVersion,
schemas::DocumentVersionDetailResponse,
schemas::DocumentAssetSummary, schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail, schemas::DocumentAssetDetail,
schemas::DocumentAssetObject, schemas::DocumentAssetObject,
schemas::DocumentCorrespondent, schemas::DocumentCorrespondent,
schemas::DocumentTag, schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest, schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest, schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse, schemas::BulkMoveDocumentsResponse,
@@ -98,6 +77,7 @@ use uuid::Uuid;
schemas::BulkCorrespondentsResponse, schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction, schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest, schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest, schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse, schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams, schemas::DocumentAssetRequestParams,
@@ -116,13 +96,9 @@ use uuid::Uuid;
schemas::TagCatalogEntry, schemas::TagCatalogEntry,
schemas::CreateTagRequest, schemas::CreateTagRequest,
schemas::UpdateTagRequest, schemas::UpdateTagRequest,
schemas::CorrespondentUsage,
schemas::CorrespondentCatalogEntry, schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest, schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest, schemas::UpdateCorrespondentRequest,
schemas::WebdavTokenResponse,
schemas::WebdavTokenCreatedResponse,
schemas::CreateWebdavTokenRequest,
) )
), ),
tags( tags(
@@ -132,8 +108,7 @@ use uuid::Uuid;
(name = "Assets", description = "Document assets"), (name = "Assets", description = "Document assets"),
(name = "Folders", description = "Folder management"), (name = "Folders", description = "Folder management"),
(name = "Tags", description = "Tag catalog"), (name = "Tags", description = "Tag catalog"),
(name = "Correspondents", description = "Correspondent catalog"), (name = "Correspondents", description = "Correspondent catalog")
(name = "Profile", description = "User profile and WebDAV tokens")
) )
)] )]
pub struct ApiDoc; pub struct ApiDoc;
@@ -168,32 +143,6 @@ mod doc {
)] )]
pub(super) fn login() {} pub(super) fn login() {}
#[utoipa::path(
post,
path = "/api/auth/signup/start",
request_body = SignupStartRequest,
responses(
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
(status = 400, description = "Invalid signup request"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub(super) fn signup_start() {}
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub(super) fn signup_finish() {}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/refresh", path = "/api/auth/refresh",
@@ -221,14 +170,6 @@ mod doc {
)] )]
pub(super) fn me() {} pub(super) fn me() {}
#[utoipa::path(
get,
path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
tag = "Auth"
)]
pub(super) fn list_tenants() {}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/select-tenant", path = "/api/auth/select-tenant",
@@ -238,44 +179,6 @@ mod doc {
)] )]
pub(super) fn select_tenant() {} pub(super) fn select_tenant() {}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/start",
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
tag = "Auth"
)]
pub(super) fn passkey_register_start() {}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/finish",
request_body = PasskeyRegistrationFinishPayload,
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
tag = "Auth"
)]
pub(super) fn passkey_register_finish() {}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/start",
request_body = PasskeyLoginStartPayload,
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
tag = "Auth"
)]
pub(super) fn passkey_login_start() {}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/finish",
request_body = PasskeyLoginFinishPayload,
responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed")
),
tag = "Auth"
)]
pub(super) fn passkey_login_finish() {}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/documents", path = "/api/documents",
@@ -337,34 +240,12 @@ mod doc {
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/documents/{id}/versions", path = "/api/documents/{id}/download",
params(("id" = Uuid, Path, description = "Document ID")), params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
tag = "Documents" tag = "Documents"
)] )]
pub(super) fn list_document_versions() {} pub(super) fn download_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document_version() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub(super) fn restore_document() {}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -449,7 +330,8 @@ mod doc {
path = "/api/documents/{id}/correspondents/{correspondent_id}", path = "/api/documents/{id}/correspondents/{correspondent_id}",
params( params(
("id" = Uuid, Path, description = "Document ID"), ("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID") ("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
RemoveCorrespondentParams
), ),
responses((status = 204, description = "Correspondent removed")), responses((status = 204, description = "Correspondent removed")),
tag = "Documents" tag = "Documents"
@@ -502,10 +384,7 @@ mod doc {
post, post,
path = "/api/folders", path = "/api/folders",
request_body = CreateFolderRequest, request_body = CreateFolderRequest,
responses( responses((status = 200, description = "Folder created", body = FolderResponse)),
(status = 201, description = "Folder created", body = FolderResponse),
(status = 200, description = "Folder already existed", body = FolderResponse)
),
tag = "Folders" tag = "Folders"
)] )]
pub(super) fn create_folder() {} pub(super) fn create_folder() {}
@@ -630,52 +509,6 @@ mod doc {
tag = "Correspondents" tag = "Correspondents"
)] )]
pub(super) fn delete_correspondent() {} pub(super) fn delete_correspondent() {}
#[utoipa::path(
get,
path = "/api/profile/webdav-tokens",
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
tag = "Profile"
)]
pub(super) fn list_webdav_tokens() {}
#[utoipa::path(
post,
path = "/api/profile/webdav-tokens",
request_body = CreateWebdavTokenRequest,
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
tag = "Profile"
)]
pub(super) fn create_webdav_token() {}
#[utoipa::path(
delete,
path = "/api/profile/webdav-tokens/{id}",
params(("id" = Uuid, Path, description = "WebDAV token ID")),
responses((status = 204, description = "WebDAV token revoked")),
tag = "Profile"
)]
pub(super) fn delete_webdav_token() {}
#[utoipa::path(
get,
path = "/api/profile/passkeys",
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
tag = "Profile"
)]
pub(super) fn list_passkeys() {}
#[utoipa::path(
delete,
path = "/api/profile/passkeys/{id}",
params(
("id" = Uuid, Path, description = "Passkey ID"),
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
),
responses((status = 204, description = "Passkey revoked")),
tag = "Profile"
)]
pub(super) fn delete_passkey() {}
} }
#[cfg(test)] #[cfg(test)]
@@ -693,37 +526,12 @@ mod tests {
pub mod schemas { pub mod schemas {
use super::*; use super::*;
pub use crate::auth::passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
};
use webauthn_rs::prelude::RegisterPublicKeyCredential;
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[schema(nullable)] #[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>, pub preferred_tenant_slug: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -734,16 +542,22 @@ pub mod schemas {
pub tenant: TenantSnippet, pub tenant: TenantSnippet,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet { pub struct TenantSnippet {
pub id: Uuid, pub id: Uuid,
pub name: String, pub slug: String,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse { pub struct TenantSelectionResponse {
pub access_token: String, pub access_token: String,
pub tenants: Vec<TenantSnippet>, pub tenants: Vec<TenantSummary>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -751,11 +565,6 @@ pub mod schemas {
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)] #[serde(untagged)]
pub enum LoginResponseVariants { pub enum LoginResponseVariants {
@@ -767,26 +576,11 @@ pub mod schemas {
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct DocumentListQuery { pub struct DocumentListQuery {
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
#[schema(nullable)] pub include_deleted: Option<bool>,
pub include_descendants: Option<bool>, pub include_descendants: Option<bool>,
pub query: Option<String>, pub query: Option<String>,
pub tags: Option<String>, pub tags: Option<String>,
pub correspondents: Option<String>, pub correspondents: Option<String>,
#[serde(default = "default_document_status_filter")]
#[schema(default = "active")]
pub status: DocumentStatusFilter,
}
fn default_document_status_filter() -> DocumentStatusFilter {
DocumentStatusFilter::Active
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum DocumentStatusFilter {
Active,
Deleted,
All,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -831,21 +625,17 @@ pub mod schemas {
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersionResponse { pub struct DocumentVersion {
pub id: Uuid, pub id: Uuid,
pub version_number: i32, pub version_number: i32,
pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub size_bytes: i64,
pub created_at: String, pub created_at: String,
pub metadata: Value, pub metadata: Value,
} #[schema(nullable)]
pub operations_summary: Option<Value>,
#[derive(Serialize, Deserialize, ToSchema)] #[schema(nullable)]
pub struct DocumentVersionDetailResponse { pub assets: Option<Vec<DocumentAssetSummary>>,
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetSummary>,
pub download_path: String, pub download_path: String,
} }
@@ -853,6 +643,7 @@ pub mod schemas {
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub role: String,
pub metadata: Value, pub metadata: Value,
pub assigned_at: String, pub assigned_at: String,
} }
@@ -875,10 +666,10 @@ pub mod schemas {
pub issued_at: Option<String>, pub issued_at: Option<String>,
pub metadata: Value, pub metadata: Value,
pub tags: Vec<DocumentTag>, pub tags: Vec<DocumentTag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondent>,
#[schema(nullable)] #[schema(nullable)]
pub current_version: Option<DocumentVersionDetailResponse>, pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -887,27 +678,19 @@ pub mod schemas {
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentMetadataUpdate { pub struct DocumentDownloadResponse {
pub value: Value, pub url: String,
#[serde(default)] pub expires_in: u64,
#[schema(default = false)] pub filename: String,
pub replace: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct RestoreDocumentRequest {
#[schema(nullable)] #[schema(nullable)]
pub folder_id: Option<Uuid>, pub content_type: Option<String>,
pub size_bytes: i64,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateDocumentRequest { pub struct UpdateDocumentRequest {
#[schema(nullable)] #[schema(nullable)]
pub title: Option<String>, pub title: Option<String>,
#[schema(nullable, value_type = Option<String>)]
pub issued_at: Option<Value>,
#[schema(nullable)]
pub metadata: Option<DocumentMetadataUpdate>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -956,6 +739,7 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment { pub struct CorrespondentAssignment {
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -990,6 +774,12 @@ pub mod schemas {
pub removed: usize, pub removed: usize,
} }
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest { pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>, pub document_ids: Vec<Uuid>,
@@ -1143,19 +933,12 @@ pub mod schemas {
pub color: Option<Option<String>>, pub color: Option<Option<String>>,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry { pub struct CorrespondentCatalogEntry {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub metadata: Value, pub metadata: Value,
pub created_at: String, pub role_counts: Value,
pub updated_at: String,
pub usage: CorrespondentUsage,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -1172,33 +955,4 @@ pub mod schemas {
#[schema(nullable)] #[schema(nullable)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct WebdavTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct WebdavTokenCreatedResponse {
pub token: String,
pub token_info: WebdavTokenResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateWebdavTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable, example = "2025-01-01T00:00:00Z")]
pub expires_at: Option<String>,
}
} }
+76 -281
View File
@@ -16,24 +16,17 @@ use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::{ auth::{password, AuthenticatedUser},
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser,
},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
schema::{ schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
user_passkeys::dsl as passkey_dsl, users::dsl, users::dsl,
}, },
state::AppState, state::AppState,
}; };
use crate::schema::refresh_tokens::dsl as refresh_dsl; use crate::schema::refresh_tokens::dsl as refresh_dsl;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
const REFRESH_COOKIE_NAME: &str = "refresh_token"; const REFRESH_COOKIE_NAME: &str = "refresh_token";
@@ -42,10 +35,10 @@ pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default)] #[serde(default)]
pub preferred_tenant_id: Option<Uuid>, pub preferred_tenant_slug: Option<String>,
} }
#[derive(Deserialize, Serialize)] #[derive(Serialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub access_token: String, pub access_token: String,
pub token_type: String, pub token_type: String,
@@ -53,16 +46,22 @@ pub struct LoginResponse {
pub tenant: TenantSnippet, pub tenant: TenantSnippet,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSnippet { pub struct TenantSnippet {
pub id: Uuid, pub id: Uuid,
pub name: String, pub slug: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct TenantSelectionResponse { pub struct TenantSelectionResponse {
pub access_token: String, pub access_token: String,
pub tenants: Vec<TenantSnippet>, pub tenants: Vec<TenantSummary>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -75,120 +74,69 @@ pub struct TenantSelectionRequest {
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
#[derive(Deserialize)] pub async fn login(
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize)]
pub struct SignupFinishRequest {
pub signup_token: String,
pub credential: RegisterPublicKeyCredential,
pub nickname: Option<String>,
}
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
Err(AppError::bad_request(
"password authentication is no longer supported",
))
}
pub async fn signup_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Json<SignupStartResponse>> {
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username)?;
let signup_token = state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?;
Ok(Json(SignupStartResponse {
signup_token,
challenge,
}))
}
pub async fn signup_finish(
State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let claims = state let mut conn = state.db_unscoped()?;
.jwt
.verify_signup_token(&payload.signup_token) let user: User = dsl::users
.filter(dsl::username.eq(&payload.username))
.first(&mut conn)?;
let valid = password::verify_password(&payload.password, &user.password_hash)
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?; if !valid {
return Err(AppError::unauthorized());
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
} }
let service = state let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.passkeys .inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(&mut conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
let preferred_slug = payload
.preferred_tenant_slug
.as_ref() .as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; .map(|slug| slug.trim().to_string())
.filter(|slug| !slug.is_empty());
let prepared_passkey = if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?; memberships
.iter()
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
}) {
return issue_session(&state, &mut conn, &user, tenant.1.id);
}
let state_clone = state.clone(); if memberships.len() == 1 {
let response = conn.transaction::<Response, AppError, _>(|conn| { let tenant_id = memberships[0].1.id;
insert_user(conn, claims.sub, &claims.username)?; return issue_session(&state, &mut conn, &user, tenant_id);
}
let tenant = state_clone.tenants.create_tenant_with_conn( let selection_token = state
conn, .jwt
&claims.username, .generate_tenant_selector_token(user.id)
None, .map_err(AppError::from)?;
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert = let tenants = memberships
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); .into_iter()
.map(|(_, tenant)| TenantSummary {
tenant_id: tenant.id,
slug: tenant.slug,
})
.collect();
diesel::insert_into(passkey_dsl::user_passkeys) let response = Json(TenantSelectionResponse {
.values(&passkey_insert) access_token: selection_token,
.execute(conn) tenants,
.map_err(AppError::from)?; })
.into_response();
let user: User = dsl::users.find(claims.sub).first(conn)?;
issue_session(&state_clone, conn, &user, tenant.id)
})?;
Ok(response) Ok(response)
} }
@@ -233,37 +181,20 @@ pub async fn refresh(
issue_session(&state, &mut conn, &user, token.tenant_id) issue_session(&state, &mut conn, &user, token.tenant_id)
} }
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
};
diesel::insert_into(dsl::users)
.values(&new_user)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub async fn select_tenant( pub async fn select_tenant(
State(state): State<AppState>, State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>, Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) { let claims = state
Ok(claims) => claims.sub, .jwt
Err(_) => state .verify_tenant_selector_token(bearer.token())
.jwt .map_err(|_| AppError::unauthorized())?;
.verify_token(bearer.token())
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants) .inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id) .select(memberships_dsl::id)
@@ -275,7 +206,7 @@ pub async fn select_tenant(
} }
let user: User = dsl::users let user: User = dsl::users
.find(user_id) .find(claims.sub)
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
@@ -354,151 +285,15 @@ pub async fn list_tenants(
let tenants = memberships_dsl::user_memberships let tenants = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants) .inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::name)) .select((tenant_dsl::id, tenant_dsl::slug))
.load::<(Uuid, String)>(&mut conn)? .load::<(Uuid, String)>(&mut conn)?
.into_iter() .into_iter()
.map(|(id, name)| TenantSnippet { id, name }) .map(|(id, slug)| TenantSnippet { id, slug })
.collect(); .collect();
Ok(Json(TenantListResponse { tenants })) Ok(Json(TenantListResponse { tenants }))
} }
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<Json<RegistrationChallengeResponse>> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
Ok(Json(challenge))
}
pub async fn passkey_register_finish(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<Json<PasskeySummary>> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let PasskeyRegistrationFinishPayload {
challenge_id,
credential,
nickname,
} = payload;
let passkey = service.finish_registration(
&mut conn,
&current_user,
challenge_id,
credential,
nickname,
)?;
Ok(Json(PasskeySummary::from(passkey)))
}
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<Json<AuthenticationChallengeResponse>> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(username))
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
Ok(Json(challenge))
}
pub async fn passkey_login_finish(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>,
) -> AppResult<Response> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let (user, _passkey, auth_result) =
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
if !auth_result.user_verified() {
return Err(AppError::unauthorized());
}
complete_login(&state, &mut conn, &user, None)
}
fn complete_login(
state: &AppState,
conn: &mut PgConnection,
user: &User,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
if let Some(preferred_id) = preferred_tenant_id {
if let Some((_, tenant)) = memberships
.iter()
.find(|(_, tenant)| tenant.id == preferred_id)
{
return issue_session(state, conn, user, tenant.id);
}
}
if memberships.len() == 1 {
return issue_session(state, conn, user, memberships[0].1.id);
}
let selection_token = state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let tenants = memberships
.into_iter()
.map(|(_, tenant)| TenantSnippet {
id: tenant.id,
name: tenant.name,
})
.collect();
Ok(Json(TenantSelectionResponse {
access_token: selection_token,
tenants,
})
.into_response())
}
fn issue_session( fn issue_session(
state: &AppState, state: &AppState,
conn: &mut PgConnection, conn: &mut PgConnection,
@@ -511,9 +306,9 @@ fn issue_session(
.generate_token(user.id, tenant_id, &user.username) .generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants let tenant_slug: String = tenant_dsl::tenants
.find(tenant_id) .find(tenant_id)
.select(tenant_dsl::name) .select(tenant_dsl::slug)
.first(conn) .first(conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
@@ -540,7 +335,7 @@ fn issue_session(
expires_in: state.config.jwt_expiry_minutes * 60, expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet { tenant: TenantSnippet {
id: tenant_id, id: tenant_id,
name: tenant_name, slug: tenant_slug,
}, },
}) })
.into_response(); .into_response();
+41 -17
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc; use chrono::Utc;
@@ -21,6 +21,8 @@ use crate::{
#[derive(Serialize)] #[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -65,21 +67,31 @@ pub async fn list_correspondents(
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::correspondent_id) .group_by((
.select((document_correspondents::correspondent_id, count_star())) document_correspondents::correspondent_id,
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut usage_map: HashMap<Uuid, i64> = HashMap::new(); let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, count) in usage_rows { for (correspondent_id, role, count) in usage_rows {
usage_map.insert(correspondent_id, count); usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
} }
let mut response = Vec::with_capacity(correspondents_list.len()); let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list { for correspondent in correspondents_list {
let total = usage_map.remove(&correspondent.id).unwrap_or(0); let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, total)); response.push(build_summary(correspondent, role_counts));
} }
response.into_json() response.into_json()
@@ -124,7 +136,7 @@ pub async fn create_correspondent(
.first(&mut conn) .first(&mut conn)
.one()?; .one()?;
build_summary(correspondent, 0).into_json() build_summary(correspondent, BTreeMap::new()).into_json()
} }
pub async fn update_correspondent( pub async fn update_correspondent(
@@ -233,14 +245,21 @@ pub async fn delete_correspondent(
no_content() no_content()
} }
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary { fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary { CorrespondentSummary {
id: correspondent.id, id: correspondent.id,
name: correspondent.name, name: correspondent.name,
metadata: correspondent.metadata, metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at), created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at), updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage { total }, usage: CorrespondentUsage {
total,
by_role: role_counts,
},
} }
} }
@@ -255,12 +274,17 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid, tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<i64> { ) -> AppResult<BTreeMap<String, i64>> {
let total: i64 = document_correspondents::table let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.select(count_star()) .group_by(document_correspondents::role)
.get_result(conn)?; .select((document_correspondents::role, count_star()))
.load(conn)?;
Ok(total) let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
} }
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
use std::path::Path as FsPath;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::state::AppState;
use crate::utils::time::to_iso;
use super::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionResponse,
};
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
operations_summary: if include_operations_summary {
Some(version.operations_summary)
} else {
None
},
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
@@ -0,0 +1,61 @@
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
@@ -0,0 +1,91 @@
use serde_json::Value;
use uuid::Uuid;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|token| !token.is_empty())
.map(|token| {
let normalized = token.to_lowercase();
escape_quickwit_token(&normalized)
})
.collect();
if tokens.is_empty() {
return None;
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| format!("(title:{token} OR text:{token})"))
.collect();
Some(parts.join(" AND "))
}
pub fn escape_quickwit_token(token: &str) -> String {
let mut escaped = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
}
if let Some(value) = hit.get("document_id") {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
None
}
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
if let Some(obj) = value.as_object() {
if let Some(inner) = obj.get("document_id") {
return parse_uuid_value(inner);
}
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = extract_uuid_from_value(item) {
return Some(uuid);
}
}
}
parse_uuid_value(value)
}
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
if let Some(s) = value.as_str() {
return Uuid::parse_str(s).ok();
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = parse_uuid_value(item) {
return Some(uuid);
}
}
}
None
}
+24 -40
View File
@@ -15,10 +15,9 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{to_document_response, DocumentResponse}; use super::documents::{
use crate::documents::{ load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
asset::load_primary_assets, correspondents::load_correspondents_for_documents, to_document_response, DocumentResponse,
tags::load_tags_for_documents,
}; };
use crate::utils::{ use crate::utils::{
json::{classify_nullable, NullableValue}, json::{classify_nullable, NullableValue},
@@ -164,7 +163,7 @@ pub async fn ensure_folder_path(
last_folder = Some(folder); last_folder = Some(folder);
} }
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
})?; })?;
Ok(Json(FolderResponse { Ok(Json(FolderResponse {
@@ -179,7 +178,7 @@ pub async fn create_folder(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<(StatusCode, Json<FolderResponse>)> { ) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
} }
@@ -202,8 +201,8 @@ pub async fn create_folder(
.optional()? .optional()?
}; };
let (folder, created): (Folder, bool) = if let Some(folder) = existing { let folder: Folder = if let Some(folder) = existing {
(folder, false) folder
} else { } else {
let new_folder = NewFolder { let new_folder = NewFolder {
id: Uuid::new_v4(), id: Uuid::new_v4(),
@@ -220,43 +219,28 @@ pub async fn create_folder(
.optional()?; .optional()?;
if let Some(id) = inserted_id { if let Some(id) = inserted_id {
( folders::table
folders::table .find(id)
.find(id) .filter(folders::tenant_id.eq(tenant_id))
.filter(folders::tenant_id.eq(tenant_id)) .first(&mut conn)?
.first(&mut conn)?,
true,
)
} else if let Some(parent_id) = payload.parent_id { } else if let Some(parent_id) = payload.parent_id {
( folders::table
folders::table .filter(folders::tenant_id.eq(tenant_id))
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name))
.filter(folders::name.eq(name)) .first(&mut conn)?
.first(&mut conn)?,
false,
)
} else { } else {
( folders::table
folders::table .filter(folders::tenant_id.eq(tenant_id))
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.is_null())
.filter(folders::parent_id.is_null()) .filter(folders::name.eq(name))
.filter(folders::name.eq(name)) .first(&mut conn)?
.first(&mut conn)?,
false,
)
} }
}; };
let response = Json(FolderResponse { Ok(Json(FolderResponse {
folder: folder_to_info(folder), folder: folder_to_info(folder),
}); }))
if created {
Ok((StatusCode::CREATED, response))
} else {
Ok((StatusCode::OK, response))
}
} }
pub async fn list_folder_contents( pub async fn list_folder_contents(
@@ -325,7 +309,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?; let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
+3 -44
View File
@@ -7,10 +7,7 @@ use axum::{
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::{ use tower_http::cors::{AllowOrigin, CorsLayer};
cors::{AllowOrigin, CorsLayer},
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
@@ -20,7 +17,6 @@ pub mod correspondents;
pub mod documents; pub mod documents;
pub mod folders; pub mod folders;
pub mod health; pub mod health;
pub mod profile;
pub mod tags; pub mod tags;
pub mod webdav; pub mod webdav;
@@ -54,23 +50,11 @@ pub fn create_router(state: AppState) -> Router<()> {
}; };
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/signup/start", post(auth::signup_start))
.route("/signup/finish", post(auth::signup_finish))
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/refresh", post(auth::refresh)) .route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant)) .route("/select-tenant", post(auth::select_tenant))
.route("/tenants", get(auth::list_tenants)) .route("/tenants", get(auth::list_tenants))
.route(
"/passkeys/register/start",
post(auth::passkey_register_start),
)
.route(
"/passkeys/register/finish",
post(auth::passkey_register_finish),
)
.route("/passkeys/login/start", post(auth::passkey_login_start))
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
@@ -95,17 +79,12 @@ pub fn create_router(state: AppState) -> Router<()> {
.delete(documents::delete_document) .delete(documents::delete_document)
.patch(documents::update_document), .patch(documents::update_document),
) )
.route("/:id/download", get(documents::download_document))
.route( .route(
"/:id/assets", "/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets), get(documents::list_document_assets).post(documents::request_document_assets),
) )
.route("/:id/folder", patch(documents::move_document)) .route("/:id/folder", patch(documents::move_document))
.route("/:id/versions", get(documents::list_document_versions))
.route(
"/:id/versions/:version_id",
get(documents::get_document_version),
)
.route("/:id/restore", post(documents::restore_document))
.route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag)) .route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route( .route(
@@ -143,15 +122,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.delete(correspondents::delete_correspondent), .delete(correspondents::delete_correspondent),
); );
let profile_routes = Router::new()
.route(
"/webdav-tokens",
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
)
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
.route("/passkeys", get(profile::list_passkeys))
.route("/passkeys/:id", delete(profile::delete_passkey));
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
@@ -160,7 +130,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/folders", folders_routes) .nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes) .nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes) .nest("/api/correspondents", correspondents_routes)
.nest("/api/profile", profile_routes)
.nest("/api/assets", assets_routes) .nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
@@ -176,8 +145,6 @@ pub fn create_router(state: AppState) -> Router<()> {
}), }),
); );
let upload_limit = state.config.upload_body_limit_bytes;
Router::new() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
@@ -186,13 +153,5 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(1024 * 1024 * 512))
usize::try_from(upload_limit).unwrap_or(usize::MAX),
))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
)
} }
-160
View File
@@ -1,160 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use chrono::{DateTime, NaiveDateTime};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{
passkeys::PasskeySummary,
webdav_tokens::{
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
revoke_webdav_token as revoke_token,
},
TenantScopedConn,
};
use crate::error::{AppError, AppResult};
use crate::models::WebdavToken;
use crate::state::AppState;
use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize)]
pub struct WebdavTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
pub label: Option<String>,
pub created_at: String,
pub last_used_at: Option<String>,
pub expires_at: Option<String>,
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WebdavTokenCreatedResponse {
pub token: String,
pub token_info: WebdavTokenResponse,
}
#[derive(Debug, Deserialize)]
pub struct CreateWebdavTokenRequest {
pub label: Option<String>,
pub expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RevokePasskeyQuery {
#[serde(default)]
pub reason: Option<String>,
}
pub async fn list_passkeys(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
) -> AppResult<Json<Vec<PasskeySummary>>> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(&mut conn, user_id)?;
Ok(Json(passkeys))
}
pub async fn list_webdav_tokens(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
Ok(Json(responses))
}
pub async fn create_webdav_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateWebdavTokenRequest>,
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
let expires_at = match payload.expires_at {
Some(ref value) => Some(parse_timestamp(value)?),
None => None,
};
let issued = issue_token(
&mut conn,
user_id,
tenant_id,
payload.label.clone(),
expires_at,
)?;
let response = WebdavTokenCreatedResponse {
token: issued.token,
token_info: webdav_token_to_response(issued.record),
};
Ok((StatusCode::CREATED, Json(response)))
}
pub async fn delete_webdav_token(
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> {
revoke_token(&mut conn, token_id, user_id)?;
no_content()
}
pub async fn delete_passkey(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(passkey_id): Path<Uuid>,
Query(query): Query<RevokePasskeyQuery>,
) -> AppResult<StatusCode> {
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let active_count = service.active_passkey_count(&mut conn, user_id)?;
if active_count <= 1 {
return Err(AppError::bad_request(
"cannot revoke the last remaining passkey",
));
}
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
no_content()
}
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
WebdavTokenResponse {
id: token.id,
tenant_id: token.tenant_id,
label: token.label,
created_at: to_iso(token.created_at),
last_used_at: token.last_used_at.map(to_iso),
expires_at: token.expires_at.map(to_iso),
revoked_at: token.revoked_at.map(to_iso),
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
+135 -72
View File
@@ -8,7 +8,6 @@ use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection; use diesel::PgConnection;
use futures_util::StreamExt; use futures_util::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
@@ -16,7 +15,7 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer; use quick_xml::Writer;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token}; use crate::auth::password;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
@@ -25,16 +24,22 @@ use crate::schema::{
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; use crate::utils::{http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct WebDavContext { struct TenantEntry {
tenant_id: Uuid, tenant_id: Uuid,
slug: String,
}
#[derive(Clone, Debug)]
struct WebDavContext {
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
tenants: Vec<TenantEntry>,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -84,20 +89,35 @@ async fn handle_propfind(
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let tenant_id = context.tenant_id;
let resources = if segments.is_empty() { let resources = if segments.is_empty() {
let contents = fetch_folder_contents(state, tenant_id, None)?; build_account_root_resources(&context.tenants, depth)
build_resources_for_folder(None, &[], &contents, depth)
} else { } else {
let resolution = match resolve_path(state, tenant_id, &segments)? { let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
match resolution { match resolution {
ResolvedPath::TenantRoot { chain } => {
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
build_resources_for_folder(None, &chain, &contents, depth)
}
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?; let contents =
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth) build_resources_for_folder(Some(&folder), &chain, &contents, depth)
} }
ResolvedPath::Document { ResolvedPath::Document {
@@ -108,10 +128,8 @@ async fn handle_propfind(
} }
}; };
let body = render_multistatus(&resources).map_err(|err| { let body = render_multistatus(&resources)
tracing::error!(error = ?err, "failed to render WebDAV response"); .map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
AppError::internal("failed to render WebDAV response")
})?;
let response = Response::builder() let response = Response::builder()
.status(multi_status()) .status(multi_status())
@@ -133,13 +151,29 @@ async fn handle_get_or_head(
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
let tenant_id = context.tenant_id;
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
if segments.is_empty() { let (requested_slug, remainder) = match segments.split_first() {
Some(values) => values,
None => return Ok(method_not_allowed()),
};
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
if remainder.is_empty() {
return Ok(method_not_allowed()); return Ok(method_not_allowed());
} }
let resolution = match resolve_path(state, tenant_id, &segments)? { let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -322,7 +356,7 @@ async fn stream_document(
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
) )
.await .await
.storage_context("failed to presign document download")?; .map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut request = client.request(method.clone(), url.clone()); let mut request = client.request(method.clone(), url.clone());
@@ -331,17 +365,18 @@ async fn stream_document(
request = request.header(header::RANGE, range.clone()); request = request.header(header::RANGE, range.clone());
} }
let upstream = request.send().await.map_err(|err| { let upstream = request
tracing::error!(error = ?err, "failed to fetch document stream"); .send()
AppError::internal("failed to fetch document stream") .await
})?; .map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
let status = let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
tracing::error!(status = %status, "upstream download returned error status"); return Err(AppError::internal(format!(
return Err(AppError::internal("failed to fetch document stream")); "upstream download returned status {status}"
)));
} }
let mut builder = Response::builder().status(status); let mut builder = Response::builder().status(status);
@@ -369,10 +404,9 @@ async fn stream_document(
builder = builder.header(header::ETAG, format!("\"{}\"", version.id)); builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
if method == Method::HEAD { if method == Method::HEAD {
return builder.body(Body::empty()).map_err(|err| { return builder
tracing::error!(error = ?err, "failed to build WebDAV response"); .body(Body::empty())
AppError::internal("failed to build WebDAV response") .map_err(|err| AppError::internal(format!("failed to build response: {err}")));
});
} }
let stream = upstream let stream = upstream
@@ -380,10 +414,9 @@ async fn stream_document(
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream); let body = Body::from_stream(stream);
builder.body(body).map_err(|err| { builder
tracing::error!(error = ?err, "failed to build WebDAV response"); .body(body)
AppError::internal("failed to build WebDAV response") .map_err(|err| AppError::internal(format!("failed to build response: {err}")))
})
} }
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> { fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
@@ -425,8 +458,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
let (username, secret) = match credential_str.split_once(':') { let (username, password) = match credential_str.split_once(':') {
Some((username, secret)) if !username.is_empty() => (username, secret), Some((username, password)) if !username.is_empty() => (username, password),
_ => return Ok(None), _ => return Ok(None),
}; };
@@ -445,46 +478,35 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
}; };
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? { let valid = password::verify_password(password, &user.password_hash)
Some(token) => token, .map_err(|_| AppError::internal("failed to verify password"))?;
None => {
tracing::warn!(%username, "webdav token invalid or expired");
return Ok(None);
}
};
let tenant_row = memberships_dsl::user_memberships if !valid {
tracing::warn!(%username, "webdav password invalid");
return Ok(None);
}
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants) .inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id)) .select((tenant_dsl::id, tenant_dsl::slug))
.select((tenant_dsl::id, tenant_dsl::name)) .load(&mut conn)?;
.first::<(Uuid, String)>(&mut conn)
.optional()?;
let (tenant_id, _name) = match tenant_row { if tenant_rows.is_empty() {
Some(row) => row, tracing::warn!(%username, "webdav user has no tenant memberships");
None => { return Ok(None);
tracing::warn!( }
%username,
tenant_id = %token.tenant_id,
"webdav token tenant membership missing"
);
return Ok(None);
}
};
touch_webdav_token(&mut conn, token.id)?; let tenants: Vec<TenantEntry> = tenant_rows
.into_iter()
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
.collect();
tracing::debug!( tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
%username,
tenant_id = %tenant_id,
token_id = %token.id,
"webdav token login success"
);
Ok(Some(WebDavContext { Ok(Some(WebDavContext {
tenant_id,
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
tenants,
})) }))
} }
@@ -542,6 +564,37 @@ fn build_resources_for_folder(
resources resources
} }
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
let mut resources = Vec::new();
resources.push(DavResource {
href: "/".to_string(),
display_name: "/".to_string(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
if depth == 0 {
return resources;
}
for tenant in tenants {
let href = build_href(&[tenant.slug.clone()], true);
resources.push(DavResource {
href,
display_name: tenant.slug.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
}
resources
}
fn build_resources_for_document( fn build_resources_for_document(
chain: &[String], chain: &[String],
document: &Document, document: &Document,
@@ -664,6 +717,9 @@ struct DavResource {
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
TenantRoot {
chain: Vec<String>,
},
Folder { Folder {
folder: Folder, folder: Folder,
chain: Vec<String>, chain: Vec<String>,
@@ -677,18 +733,23 @@ enum ResolvedPath {
fn resolve_path( fn resolve_path(
state: &AppState, state: &AppState,
tenant_id: Uuid, tenant: &TenantEntry,
segments: &[String], segments: &[String],
) -> AppResult<Option<ResolvedPath>> { ) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_tenant(tenant_id)?; let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new(); let mut chain: Vec<String> = vec![tenant.slug.clone()];
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain }));
}
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? { if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
{
chain.push(folder.name.clone()); chain.push(folder.name.clone());
if is_last { if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
@@ -700,7 +761,7 @@ fn resolve_path(
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)? find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -712,7 +773,7 @@ fn resolve_path(
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? { if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -725,7 +786,9 @@ fn resolve_path(
continue; continue;
} }
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? { if let Some((document, version)) =
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
{
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
+9 -67
View File
@@ -1,11 +1,5 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
pub mod sql_types {
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "tenant_status"))]
pub struct TenantStatus;
}
diesel::table! { diesel::table! {
correspondents (id) { correspondents (id) {
id -> Uuid, id -> Uuid,
@@ -43,9 +37,11 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
document_correspondents (document_id, correspondent_id) { document_correspondents (document_id, correspondent_id, role) {
document_id -> Uuid, document_id -> Uuid,
correspondent_id -> Uuid, correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid, tenant_id -> Uuid,
@@ -73,6 +69,7 @@ diesel::table! {
#[max_length = 64] #[max_length = 64]
checksum -> Varchar, checksum -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
@@ -154,19 +151,15 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
use diesel::sql_types::*;
use super::sql_types::TenantStatus;
tenants (id) { tenants (id) {
id -> Uuid, id -> Uuid,
name -> Text, slug -> Text,
storage_root -> Nullable<Text>, storage_root -> Nullable<Text>,
quickwit_index -> Nullable<Text>, quickwit_index -> Nullable<Text>,
status -> Text,
config -> Jsonb, config -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
status -> TenantStatus,
created_by -> Nullable<Uuid>,
} }
} }
@@ -175,68 +168,24 @@ diesel::table! {
id -> Uuid, id -> Uuid,
user_id -> Uuid, user_id -> Uuid,
tenant_id -> Uuid, tenant_id -> Uuid,
role -> Text,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
} }
diesel::table! {
user_passkeys (id) {
id -> Uuid,
user_id -> Uuid,
credential_id -> Bytea,
public_key -> Bytea,
credential -> Jsonb,
sign_count -> Int8,
transports -> Array<Nullable<Text>>,
aaguid -> Nullable<Uuid>,
nickname -> Nullable<Text>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>,
revoked_by -> Nullable<Uuid>,
revoked_reason -> Nullable<Text>,
}
}
diesel::table! { diesel::table! {
users (id) { users (id) {
id -> Uuid, id -> Uuid,
#[max_length = 100] #[max_length = 100]
username -> Varchar, username -> Varchar,
#[max_length = 255]
password_hash -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
} }
diesel::table! {
webauthn_challenges (id) {
id -> Uuid,
user_id -> Nullable<Uuid>,
purpose -> Text,
challenge -> Bytea,
state -> Bytea,
created_at -> Timestamptz,
expires_at -> Timestamptz,
}
}
diesel::table! {
webdav_tokens (id) {
id -> Uuid,
user_id -> Uuid,
tenant_id -> Uuid,
token_prefix -> Text,
token_hash -> Text,
label -> Nullable<Text>,
created_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
expires_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>,
}
}
diesel::joinable!(correspondents -> tenants (tenant_id)); diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id)); diesel::joinable!(document_asset_objects -> tenants (tenant_id));
@@ -260,10 +209,6 @@ diesel::joinable!(refresh_tokens -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_memberships -> users (user_id));
diesel::joinable!(user_passkeys -> users (user_id));
diesel::joinable!(webauthn_challenges -> users (user_id));
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
diesel::joinable!(webdav_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
@@ -279,8 +224,5 @@ diesel::allow_tables_to_appear_in_same_query!(
tags, tags,
tenants, tenants,
user_memberships, user_memberships,
user_passkeys,
users, users,
webauthn_challenges,
webdav_tokens,
); );
+6 -19
View File
@@ -7,7 +7,7 @@ use diesel::{
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::{jwt::JwtService, passkeys::PasskeyService}, auth::jwt::JwtService,
config::AppConfig, config::AppConfig,
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
@@ -24,7 +24,6 @@ pub struct AppState {
storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
pub jwt: JwtService, pub jwt: JwtService,
pub tenants: TenantService, pub tenants: TenantService,
pub passkeys: Option<PasskeyService>,
} }
impl AppState { impl AppState {
@@ -53,21 +52,12 @@ impl AppState {
let config = Arc::new(config); let config = Arc::new(config);
let tenants = TenantService::new(pool.clone()); let tenants = TenantService::new(pool.clone());
let passkeys = match PasskeyService::try_new(&config) {
Ok(service) => service,
Err(err) => {
tracing::warn!(error = ?err, "passkey service disabled due to configuration");
None
}
};
Self { Self {
pool, pool,
config, config,
storage, storage,
jwt, jwt,
tenants, tenants,
passkeys,
} }
} }
@@ -79,17 +69,14 @@ impl AppState {
} }
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> { pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool.get().map_err(|err| { self.pool
tracing::error!(error = ?err, "database pool error"); .get()
AppError::internal("database pool error") .map_err(|err| AppError::internal(format!("database pool error: {err}")))
})
} }
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> { pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
let tenant = self.tenants.get_by_id(tenant_id)?; let tenant = self.tenants.get_by_id(tenant_id)?;
TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| { TenantStorage::new(self.storage.clone(), &tenant)
tracing::error!(error = ?err, "tenant storage error"); .map_err(|err| AppError::internal(format!("tenant storage error: {err}")))
AppError::internal("tenant storage error")
})
} }
} }
+28 -93
View File
@@ -1,13 +1,13 @@
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use diesel::{pg::PgConnection, prelude::*, sql_types::Text}; use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
jobs::{enqueue_job, JOB_PROVISION_TENANT}, models::Tenant,
models::{Tenant, TenantStatus},
schema::tenants::dsl, schema::tenants::dsl,
state::AppState,
}; };
pub struct TenantRepository; pub struct TenantRepository;
@@ -17,9 +17,9 @@ impl TenantRepository {
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into) dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
} }
pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult<Tenant> { pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
dsl::tenants dsl::tenants
.filter(dsl::name.eq(name)) .filter(dsl::slug.eq(slug))
.first(conn) .first(conn)
.map_err(Into::into) .map_err(Into::into)
} }
@@ -40,91 +40,27 @@ impl TenantService {
Ok(tenant) Ok(tenant)
} }
pub fn get_by_name(&self, name: &str) -> AppResult<Tenant> { pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
let name_owned = name.to_owned(); let slug_owned = slug.to_owned();
let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?; let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
Ok(tenant) Ok(tenant)
} }
pub fn create_tenant( pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
&self, Ok(self.get_by_slug(slug)?.id)
name: &str,
storage_root: Option<&str>,
quickwit_index: Option<&str>,
status: TenantStatus,
initial_members: &[Uuid],
created_by: Option<Uuid>,
) -> AppResult<Tenant> {
let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error")
})?;
self.create_tenant_with_conn(
&mut conn,
name,
storage_root,
quickwit_index,
status,
initial_members,
created_by,
)
} }
fn load<F>(&self, loader: F) -> AppResult<Tenant> fn load<F>(&self, loader: F) -> AppResult<Tenant>
where where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>, F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{ {
let mut conn = self.pool.get().map_err(|err| { let mut conn = self
tracing::error!(error = ?err, "database pool error"); .pool
AppError::internal("database pool error") .get()
})?; .map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
let tenant = loader(&mut conn)?; let tenant = loader(&mut conn)?;
Ok(tenant) Ok(tenant)
} }
pub fn create_tenant_with_conn(
&self,
conn: &mut PgConnection,
name: &str,
storage_root: Option<&str>,
quickwit_index: Option<&str>,
status: TenantStatus,
initial_members: &[Uuid],
created_by: Option<Uuid>,
) -> AppResult<Tenant> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::bad_request("tenant name must not be empty"));
}
let id = Uuid::new_v4();
let storage_root = normalize_storage_root(storage_root, id);
let quickwit_index = normalize_quickwit_index(quickwit_index, id);
diesel::insert_into(dsl::tenants)
.values((
dsl::id.eq(id),
dsl::name.eq(name),
dsl::storage_root.eq(Some(storage_root.clone())),
dsl::quickwit_index.eq(Some(quickwit_index.clone())),
dsl::config.eq(json!({})),
dsl::status.eq(status),
dsl::created_by.eq(created_by),
))
.execute(conn)?;
if status == TenantStatus::Creating {
let payload = json!({
"members": initial_members,
});
enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
AppError::internal("failed to enqueue tenant provisioning job")
})?;
}
TenantRepository::get_by_id(conn, id)
}
} }
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
@@ -135,22 +71,21 @@ pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<(
.map_err(AppError::from) .map_err(AppError::from)
} }
fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String { pub struct TenantContext {
match raw.map(str::trim) { pub tenant: Tenant,
Some(root) if !root.is_empty() => {
let mut owned = root.to_owned();
if !owned.ends_with('/') {
owned.push('/');
}
owned
}
_ => format!("tenants/{tenant_id}/"),
}
} }
fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String { #[async_trait]
match raw.map(str::trim) { impl FromRequestParts<AppState> for TenantContext {
Some(value) if !value.is_empty() => value.to_owned(), type Rejection = AppError;
_ => format!("documents-{tenant_id}"),
async fn from_request_parts(
_parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tenant = state
.tenants
.get_by_slug(&state.config.default_tenant_slug)?;
Ok(Self { tenant })
} }
} }
-32
View File
@@ -1,32 +0,0 @@
use diesel::result::Error as DieselError;
use crate::error::{AppError, AppResult};
pub trait DbResultExt<T> {
fn db_context(self, context: &'static str) -> AppResult<T>;
}
impl<T> DbResultExt<T> for Result<T, DieselError> {
fn db_context(self, context: &'static str) -> AppResult<T> {
self.map_err(|err| match err {
DieselError::NotFound => AppError::not_found(),
other => {
tracing::error!(error = ?other, "{context}");
AppError::internal(context)
}
})
}
}
pub trait StorageResultExt<T> {
fn storage_context(self, context: &'static str) -> AppResult<T>;
}
impl<T> StorageResultExt<T> for Result<T, anyhow::Error> {
fn storage_context(self, context: &'static str) -> AppResult<T> {
self.map_err(|err| {
tracing::error!(error = ?err, "{context}");
AppError::internal(context)
})
}
}
-1
View File
@@ -1,6 +1,5 @@
pub mod bootstrap; pub mod bootstrap;
pub mod db; pub mod db;
pub mod error;
pub mod http; pub mod http;
pub mod json; pub mod json;
pub mod storage_paths; pub mod storage_paths;
+28 -2
View File
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::{json, Map, Value};
use tokio::task; use tokio::task;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -104,7 +104,7 @@ fn analyze_document(
let tenant_id = document.tenant_id; let tenant_id = document.tenant_id;
let (supported, _reason) = determine_thumbnail_support(&document); let (supported, reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
@@ -117,6 +117,32 @@ fn analyze_document(
let skip_ocr = existing_ocr.is_some() && !payload.force; let skip_ocr = existing_ocr.is_some() && !payload.force;
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");
}
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
if ocr_supported {
summary_map.remove("ocr_reason");
} else {
summary_map.insert(
"ocr_reason".to_string(),
Value::String("document is not a PDF".into()),
);
}
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 { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
+51 -25
View File
@@ -5,12 +5,12 @@ use async_trait::async_trait;
use diesel::prelude::*; use diesel::prelude::*;
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use tokio::task; use tokio::task;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
documents::search::{build_quickwit_ingest_record, quickwit_ingest},
jobs::JOB_INDEX_DOCUMENT_TEXT, jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentVersion}, models::{Document, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions, documents},
@@ -18,9 +18,7 @@ use crate::{
storage::TenantStorage, storage::TenantStorage,
}; };
use super::{ use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
fetch_version_object, handle_fetch_error, ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct IndexPayload { struct IndexPayload {
@@ -116,23 +114,21 @@ impl JobHandler for IndexDocumentTextJob {
} }
let s3_key = context.text_s3_key.unwrap(); let s3_key = context.text_s3_key.unwrap();
let bytes = match fetch_version_object( let text = match storage.get_object(&s3_key).await {
&context.version, Ok(bytes) => match String::from_utf8(bytes) {
&storage, Ok(text) => text,
&s3_key, Err(err) => {
state.config.worker_max_document_bytes, warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
) return JobExecution::Failed {
.await error: "ocr text not valid UTF-8".into(),
{ };
Ok(bytes) => bytes, }
Err(err) => return handle_fetch_error(&job, err, "failed to download ocr text"), },
};
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8"); warn!(job_id = %job.id, error = %err, "failed to download ocr text");
return JobExecution::Failed { return JobExecution::Retry {
error: "ocr text not valid UTF-8".into(), delay: Duration::from_secs(30),
error: err.to_string(),
}; };
} }
}; };
@@ -144,13 +140,43 @@ impl JobHandler for IndexDocumentTextJob {
}; };
} }
let record = let client = client;
build_quickwit_ingest_record(&context.document, &context.version, job.tenant_id, &text); let url = format!(
"{}/api/v1/{}/ingest?commit=auto",
quickwit_endpoint, quickwit_index
);
let payload = json!({
"document_id": context.document.id,
"version_id": context.version.id,
"tenant_id": job.tenant_id,
"title": context.document.title.to_lowercase(),
"text": text.to_lowercase()
});
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await { let body = serde_json::to_string(&payload).unwrap();
Ok(()) => JobExecution::Success,
match client
.post(&url)
.header("content-type", "application/x-ndjson")
.body(format!("{}\n", body))
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
JobExecution::Success
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!(job_id = %job.id, %status, %body, "quickwit ingest failed");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("quickwit ingest failed with status {status}"),
}
}
}
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "quickwit ingest failed"); warn!(job_id = %job.id, error = %err, "quickwit request failed");
JobExecution::Retry { JobExecution::Retry {
delay: Duration::from_secs(30), delay: Duration::from_secs(30),
error: err.to_string(), error: err.to_string(),
-69
View File
@@ -1,6 +1,5 @@
use std::{collections::HashMap, sync::Arc, time::Duration}; use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::Error as AnyhowError;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -15,11 +14,8 @@ use crate::{
pub mod analyze; pub mod analyze;
pub mod index; pub mod index;
pub mod ocr; pub mod ocr;
pub mod tenants;
pub mod thumbnails; pub mod thumbnails;
use tenants::ProvisionTenantJob;
#[derive(Debug)] #[derive(Debug)]
pub enum JobExecution { pub enum JobExecution {
Success, Success,
@@ -149,70 +145,5 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
Arc::new(thumbnails::GenerateThumbnailsJob::new()), Arc::new(thumbnails::GenerateThumbnailsJob::new()),
Arc::new(ocr::GenerateOcrTextJob::new()), Arc::new(ocr::GenerateOcrTextJob::new()),
Arc::new(index::IndexDocumentTextJob::new()), Arc::new(index::IndexDocumentTextJob::new()),
Arc::new(ProvisionTenantJob::new()),
] ]
} }
pub(crate) fn check_worker_document_limit(
size_bytes: i64,
limit_bytes: u64,
) -> Result<(), (u64, u64)> {
let size = size_bytes.max(0) as u64;
if size > limit_bytes {
Err((size, limit_bytes))
} else {
Ok(())
}
}
pub(crate) enum FetchVersionError {
TooLarge { size: u64, limit: u64 },
Storage(AnyhowError),
}
pub(crate) async fn fetch_version_object(
version: &crate::models::DocumentVersion,
storage: &TenantStorage,
s3_key: &str,
limit_bytes: u64,
) -> Result<Vec<u8>, FetchVersionError> {
check_worker_document_limit(version.size_bytes, limit_bytes)
.map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?;
storage
.get_object(s3_key)
.await
.map_err(FetchVersionError::Storage)
}
pub(crate) fn handle_fetch_error(
job: &crate::models::Job,
err: FetchVersionError,
message: &str,
) -> JobExecution {
match err {
FetchVersionError::TooLarge { size, limit } => {
warn!(
job_id = %job.id,
size_bytes = size,
limit_bytes = limit,
"document exceeds worker size limit"
);
JobExecution::Failed {
error: format!("document size {size} bytes exceeds worker limit of {limit} bytes"),
}
}
FetchVersionError::Storage(err) => {
warn!(
job_id = %job.id,
error = %err,
context = message,
"failed to fetch object for worker"
);
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
}
}
}
}
+11 -30
View File
@@ -18,7 +18,6 @@ use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
documents::asset::delete_asset,
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{ models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
@@ -30,7 +29,7 @@ use crate::{
utils::storage_paths::document_asset_object_prefix, utils::storage_paths::document_asset_object_prefix,
}; };
use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler}; use super::{JobExecution, JobHandler};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50; const MIN_TEXT_LENGTH: usize = 50;
@@ -99,16 +98,15 @@ impl JobHandler for GenerateOcrTextJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match fetch_version_object( let bytes = match storage.get_object(&context.version.s3_key).await {
&context.version,
&storage,
&context.version.s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => return handle_fetch_error(&job, err, "failed to fetch document for ocr"), Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
}; };
let doc_meta = PdfDocumentMeta { let doc_meta = PdfDocumentMeta {
@@ -135,29 +133,12 @@ impl JobHandler for GenerateOcrTextJob {
}; };
}; };
if let Some(existing_asset) = &context.existing_asset { if context.existing_asset.is_some() {
for object in &context.existing_objects { for object in &context.existing_objects {
if let Err(err) = storage.delete_object(&object.s3_key).await { if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object"); warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
} }
} }
let tenant_id = context.document.tenant_id;
let asset_id = existing_asset.id;
let state_clone = state.clone();
match task::spawn_blocking(move || {
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = ?err, asset_id = %asset_id, "failed to remove ocr asset metadata after deletion");
}
Err(join_err) => {
warn!(job_id = %job.id, error = %join_err, asset_id = %asset_id, "failed to remove ocr asset metadata: task panicked");
}
}
} }
let asset_id = Uuid::new_v4(); let asset_id = Uuid::new_v4();
@@ -173,7 +154,7 @@ impl JobHandler for GenerateOcrTextJob {
.put_object( .put_object(
&s3_key, &s3_key,
generation.text.into_bytes(), generation.text.into_bytes(),
Some("text/plain; charset=utf-8".into()), Some("text/plain".into()),
None, None,
) )
.await .await
-174
View File
@@ -1,174 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use diesel::prelude::*;
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
use uuid::Uuid;
use crate::documents::search::ensure_quickwit_index;
use crate::jobs::JOB_PROVISION_TENANT;
use crate::models::{NewUserMembership, TenantStatus};
use crate::schema::{tenants, user_memberships};
use crate::state::AppState;
use crate::tenants::TenantRepository;
use crate::workers::{JobExecution, JobHandler};
pub struct ProvisionTenantJob;
impl ProvisionTenantJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for ProvisionTenantJob {
fn job_type(&self) -> &'static str {
JOB_PROVISION_TENANT
}
async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
_storage: crate::storage::TenantStorage,
) -> JobExecution {
let mut conn = match state.db_unscoped() {
Ok(conn) => conn,
Err(err) => {
warn!(job_id = %job.id, error = ?err, "failed to get connection for tenant provisioning");
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "database connection unavailable".into(),
};
}
};
let tenant = match TenantRepository::get_by_id(&mut conn, job.tenant_id) {
Ok(tenant) => tenant,
Err(err) => {
warn!(job_id = %job.id, error = ?err, "tenant not found for provisioning");
return JobExecution::Failed {
error: "tenant not found".into(),
};
}
};
if tenant.status == TenantStatus::Active {
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
"tenant already active; skipping provisioning"
);
return JobExecution::Success;
}
if tenant.status != TenantStatus::Creating {
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
status = %tenant.status.as_str(),
"tenant not in creating state; skipping provisioning"
);
return JobExecution::Failed {
error: format!(
"tenant status '{}' not eligible for provisioning",
tenant.status.as_str()
),
};
}
let endpoint = match &state.config.quickwit_endpoint {
Some(endpoint) => endpoint.trim_end_matches('/').to_owned(),
None => {
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
"quickwit endpoint not configured; retrying"
);
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "quickwit endpoint not configured".into(),
};
}
};
let index_id = tenant
.quickwit_index
.as_deref()
.map(str::to_owned)
.unwrap_or_else(|| format!("documents-{}", tenant.id));
let client = Client::new();
if let Err(err) = ensure_quickwit_index(&client, &endpoint, &index_id).await {
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
error = %err,
"failed to ensure quickwit index"
);
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: err.to_string(),
};
}
if let Some(members) = ProvisionPayload::from_job(&job) {
for member in members {
let new_membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: member,
tenant_id: tenant.id,
};
if let Err(err) = diesel::insert_into(user_memberships::table)
.values(&new_membership)
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
.do_nothing()
.execute(&mut conn)
{
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
user_id = %member,
error = %err,
"failed to assign initial membership"
);
}
}
}
if let Err(err) = diesel::update(tenants::table.find(tenant.id))
.set((
tenants::status.eq(TenantStatus::Active),
tenants::quickwit_index.eq(Some(index_id)),
tenants::updated_at.eq(Utc::now().naive_utc()),
))
.execute(&mut conn)
{
warn!(job_id = %job.id, error = %err, "failed to activate tenant");
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: format!("failed to update tenant status: {err}"),
};
}
JobExecution::Success
}
}
#[derive(Deserialize, Default)]
struct ProvisionPayload {
#[serde(default)]
members: Vec<Uuid>,
}
impl ProvisionPayload {
fn from_job(job: &crate::models::Job) -> Option<Vec<Uuid>> {
serde_json::from_value(job.payload.clone())
.map(|payload: ProvisionPayload| payload.members)
.ok()
}
}
+11 -70
View File
@@ -12,7 +12,6 @@ use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
documents::asset::delete_asset,
jobs::JOB_GENERATE_THUMBNAILS, jobs::JOB_GENERATE_THUMBNAILS,
models::{ models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
@@ -24,10 +23,7 @@ use crate::{
utils::storage_paths::document_asset_object_key, utils::storage_paths::document_asset_object_key,
}; };
use super::{ use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
JobHandler,
};
const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_WIDTH: u32 = 512;
const THUMBNAIL_HEIGHT: u32 = 512; const THUMBNAIL_HEIGHT: u32 = 512;
@@ -99,16 +95,15 @@ impl JobHandler for GenerateThumbnailsJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match fetch_version_object( let bytes = match storage.get_object(&initial.version.s3_key).await {
&initial.version,
&storage,
&initial.version.s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => return handle_fetch_error(&job, err, "thumbnail fetch failed; will retry"), 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_preview_and_thumbnail(&initial.document, &bytes) { let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
@@ -157,7 +152,7 @@ impl JobHandler for GenerateThumbnailsJob {
} }
} }
if let Some(existing_preview) = &initial.existing_preview { if initial.existing_preview.is_some() {
for object in &initial.existing_preview_objects { for object in &initial.existing_preview_objects {
if let Err(err) = storage.delete_object(&object.s3_key).await { if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!( warn!(
@@ -168,36 +163,9 @@ impl JobHandler for GenerateThumbnailsJob {
); );
} }
} }
let tenant_id = initial.document.tenant_id;
let asset_id = existing_preview.id;
let state_clone = state.clone();
match task::spawn_blocking(move || {
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(
job_id = %job.id,
error = ?err,
asset_id = %asset_id,
"failed to remove preview metadata after deletion"
);
}
Err(join_err) => {
warn!(
job_id = %job.id,
error = %join_err,
asset_id = %asset_id,
"failed to remove preview metadata: task panicked"
);
}
}
} }
if let Some(existing_thumbnail) = &initial.existing_thumbnail { if initial.existing_thumbnail.is_some() {
for object in &initial.existing_thumbnail_objects { for object in &initial.existing_thumbnail_objects {
if let Err(err) = storage.delete_object(&object.s3_key).await { if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!( warn!(
@@ -208,33 +176,6 @@ impl JobHandler for GenerateThumbnailsJob {
); );
} }
} }
let tenant_id = initial.document.tenant_id;
let asset_id = existing_thumbnail.id;
let state_clone = state.clone();
match task::spawn_blocking(move || {
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(
job_id = %job.id,
error = ?err,
asset_id = %asset_id,
"failed to remove thumbnail metadata after deletion"
);
}
Err(join_err) => {
warn!(
job_id = %job.id,
error = %join_err,
asset_id = %asset_id,
"failed to remove thumbnail metadata: task panicked"
);
}
}
} }
let preview_asset_id = Uuid::new_v4(); let preview_asset_id = Uuid::new_v4();
+4 -624
View File
@@ -1,73 +1,15 @@
mod common; mod common;
use anyhow::{anyhow, Context, Result}; use anyhow::Result;
use axum::http::{header::SET_COOKIE, StatusCode}; use axum::http::StatusCode;
use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary;
use backend::schema::{refresh_tokens, tenants, user_memberships, users};
use chrono::{Duration as ChronoDuration, Utc};
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
RegisterPublicKeyCredential,
};
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct LoginTenant {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct SignupStartResponse {
signup_token: String,
challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantListResponse {
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantSummary {
id: Uuid,
name: String,
}
#[tokio::test] #[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> { async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -76,9 +18,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
let password = "s3cret"; let password = "s3cret";
app.insert_user("alice", password, "admin").await?; app.insert_user("alice", password, "admin").await?;
let (login, _) = login_with_session(&app, "alice", password).await?; let token = app.login_token("alice", password).await?;
let response = app.get("/api/auth/me", Some(&login.access_token)).await?; let response = app.get("/api/auth/me", Some(&token)).await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
@@ -88,565 +30,3 @@ async fn login_and_me_roundtrip() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn login_rejects_unknown_user() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "signup-user" });
let response = app
.post_json("/api/auth/signup/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let start: SignupStartResponse = serde_json::from_slice(&body)?;
assert!(!start.signup_token.is_empty());
assert_ne!(start.challenge.challenge_id, Uuid::nil());
app.with_conn(|conn| {
let exists: bool = diesel::select(diesel::dsl::exists(
users::table.filter(users::username.eq("signup-user")),
))
.get_result(conn)?;
assert!(!exists);
Ok(())
})
.await?;
let finish_payload = json!({
"signup_token": start.signup_token,
"credential": fake_register_credential(),
});
let finish_response = app
.post_json("/api/auth/signup/finish", &finish_payload, None)
.await?;
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_start_creates_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-user", password, "admin").await?;
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
let response = app
.post_json(
"/api/auth/passkeys/register/start",
&json!({}),
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
assert_ne!(challenge.challenge_id, Uuid::nil());
let challenge_id = challenge.challenge_id;
app.with_conn(move |conn| {
use backend::schema::webauthn_challenges::dsl;
use diesel::dsl::{exists, select};
let exists: bool = select(exists(
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
))
.get_result(conn)?;
assert!(exists, "challenge not persisted");
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-register", password, "admin")
.await?;
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
let payload = PasskeyRegistrationFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_register_credential(),
nickname: None,
};
let response = app
.post_json(
"/api/auth/passkeys/register/finish",
&payload,
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_requires_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-login", password, "admin").await?;
let payload = PasskeyLoginStartPayload {
username: "passkey-login".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginStartPayload {
username: "nobody".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_authentication_credential(),
};
let response = app
.post_json("/api/auth/passkeys/login/finish", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn list_passkeys_returns_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
app.insert_passkey(user_id, Some("Laptop")).await?;
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
let response = app
.get("/api/profile/passkeys", Some(&session.access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
assert!(summaries[0].revoked_at.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_soft_revokes() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
app.insert_passkey(user_id, Some("Backup")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
app.with_conn(move |conn| {
use backend::schema::user_passkeys::dsl as passkey_dsl;
let record = passkey_dsl::user_passkeys
.find(passkey_id)
.first::<UserPasskey>(conn)?;
assert!(record.revoked_at.is_some());
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_prevents_last() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}", first_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let block_response = app
.delete(
&format!("/api/profile/passkeys/{}", last_id),
Some(&session.access_token),
)
.await?;
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
fn fake_register_credential() -> RegisterPublicKeyCredential {
RegisterPublicKeyCredential {
id: "fake-passkey".to_string(),
raw_id: vec![1, 2, 3, 4].into(),
response: AuthenticatorAttestationResponseRaw {
attestation_object: vec![5, 6, 7, 8].into(),
client_data_json: vec![9, 10, 11, 12].into(),
transports: None,
},
type_: "public-key".to_string(),
extensions: Default::default(),
}
}
fn fake_authentication_credential() -> PublicKeyCredential {
PublicKeyCredential {
id: "fake-auth".to_string(),
raw_id: vec![1, 2, 3].into(),
response: AuthenticatorAssertionResponseRaw {
authenticator_data: vec![4, 5, 6].into(),
client_data_json: vec![7, 8, 9].into(),
signature: vec![10, 11, 12].into(),
user_handle: None,
},
extensions: Default::default(),
type_: "public-key".to_string(),
}
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "valid";
app.insert_user("robin", password, "admin").await?;
let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn refresh_rotates_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rotate";
app.insert_user("rita", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
let response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let new_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
assert_eq!(refreshed.tenant.name, login.tenant.name);
let me_response = app
.get("/api/auth/me", Some(&refreshed.access_token))
.await?;
assert_eq!(me_response.status(), StatusCode::OK);
let retry = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
// new cookie should differ from old to avoid reuse
assert_ne!(new_cookie, refresh_cookie);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn logout_revokes_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "logout";
app.insert_user("logan", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
let response = app
.post_json_with_cookie(
"/api/auth/logout",
&json!({}),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let cleared_cookie = extract_refresh_cookie(response.headers())?;
assert!(cleared_cookie.ends_with("="));
let after_logout = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn me_requires_authentication() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let unauthenticated = app.get("/api/auth/me", None).await?;
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "multipass";
let user_id = app.insert_user("multipass", password, "admin").await?;
let secondary_name = "secondary".to_string();
let name_for_insert = secondary_name.clone();
let secondary_id = Uuid::new_v4();
app.with_conn(move |conn| {
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(secondary_id),
tenants::name.eq(&name_for_insert),
tenants::status.eq(TenantStatus::Active),
))
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.execute(conn)?;
Ok(())
})
.await?;
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
let tenants_response = app
.get("/api/auth/tenants", Some(&login.access_token))
.await?;
assert_eq!(tenants_response.status(), StatusCode::OK);
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
assert!(tenant_list.tenants.len() >= 2);
let secondary = tenant_list
.tenants
.iter()
.find(|tenant| tenant.name == secondary_name)
.map(|t| t.id)
.context("secondary tenant missing from listing")?;
let select_response = app
.post_json_with_cookie(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(select_response.status(), StatusCode::OK);
let select_body = body_to_vec(select_response.into_body()).await?;
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(rotated.tenant.id, secondary);
assert_eq!(rotated.tenant.name, secondary_name);
app.cleanup().await?;
Ok(())
}
async fn login_with_session(
app: &TestApp,
username: &str,
_password: &str,
) -> Result<(LoginResponse, String)> {
let username = username.to_string();
let state = app.state.clone();
app.with_conn(move |conn| {
use backend::schema::user_memberships::dsl as memberships_dsl;
use backend::schema::users::dsl as users_dsl;
let user: backend::models::User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let membership: backend::models::UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
let tenant: backend::models::Tenant =
tenants::table.find(membership.tenant_id).first(conn)?;
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant.id, &user.username)
.map_err(|err| anyhow!(err))?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id: tenant.id,
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(conn)?;
let login = LoginResponse {
access_token,
tenant: LoginTenant {
id: tenant.id,
name: tenant.name.clone(),
},
};
let cookie = format!("refresh_token={refresh_value}");
Ok((login, cookie))
})
.await
}
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
let header_value = headers
.get(SET_COOKIE)
.context("missing set-cookie header")?
.to_str()
.context("invalid set-cookie header")?;
let cookie = header_value
.split(';')
.next()
.context("set-cookie missing cookie value")?
.to_string();
Ok(cookie)
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn hash_refresh_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
+92 -120
View File
@@ -6,20 +6,15 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result}; use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Method, Request}; use axum::http::{Method, Request, StatusCode};
use axum::Router; use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
use backend::db::{self, PgPool}; use backend::db::{self, PgPool};
use backend::models::{ use backend::models::{Job, NewUser, NewUserMembership, Tenant};
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
UserMembership,
};
use backend::routes; use backend::routes;
use backend::schema::refresh_tokens::dsl as refresh_dsl;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection; use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::OptionalExtension; use diesel::OptionalExtension;
@@ -28,10 +23,8 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{self, json}; use serde_json;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
@@ -40,8 +33,6 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(())); static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
const TEST_TENANT_NAME: &str = "test_tenant";
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Clone)] #[derive(Clone)]
pub struct StoredObject { pub struct StoredObject {
@@ -149,11 +140,7 @@ impl TestApp {
s3_bucket: "test-bucket".to_string(), s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None, quickwit_endpoint: None,
quickwit_index: None, quickwit_index: None,
worker_max_document_bytes: 200 * 1024 * 1024, default_tenant_slug: "admin".to_string(),
upload_body_limit_bytes: 128 * 1024 * 1024,
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
}; };
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
@@ -199,11 +186,10 @@ impl TestApp {
#[allow(dead_code)] #[allow(dead_code)]
pub async fn storage_key_for(&self, key: &str) -> Result<String> { pub async fn storage_key_for(&self, key: &str) -> Result<String> {
self.ensure_default_tenant().await?;
let tenant = self let tenant = self
.state .state
.tenants .tenants
.get_by_name(TEST_TENANT_NAME) .get_by_slug(&self.state.config.default_tenant_slug)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?; .map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
let root = tenant let root = tenant
.storage_root .storage_root
@@ -212,13 +198,21 @@ impl TestApp {
Ok(format!("{}{}", root, key)) Ok(format!("{}{}", root, key))
} }
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> { pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
let username = username.to_string(); let username = username.to_string();
let tenant_id = self.ensure_default_tenant().await?; let password = password.to_string();
let role = role.to_string();
let tenant_id = self
.state
.tenants
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
self.with_conn(move |conn| { self.with_conn(move |conn| {
let password_hash = hash_password(&password)?;
let user = NewUser { let user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
password_hash,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(backend::schema::users::table)
.values(&user) .values(&user)
@@ -229,6 +223,7 @@ impl TestApp {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id, user_id: user.id,
tenant_id, tenant_id,
role,
}; };
diesel::insert_into(backend::schema::user_memberships::table) diesel::insert_into(backend::schema::user_memberships::table)
@@ -240,42 +235,14 @@ impl TestApp {
.await .await
} }
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
self.with_conn(move |conn| {
let credential_id = passkey_id.as_bytes().to_vec();
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
let passkey = NewUserPasskey {
id: passkey_id,
user_id,
credential_id,
public_key,
credential: json!({ "dummy": passkey_id.to_string() }),
sign_count: 0,
transports: vec![Some("usb".to_string())],
aaguid: None,
nickname,
};
diesel::insert_into(backend::schema::user_passkeys::table)
.values(&passkey)
.execute(conn)
.context("failed to insert passkey")?;
Ok(passkey_id)
})
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> { async fn ensure_default_tenant(&self) -> Result<Uuid> {
let name_value = TEST_TENANT_NAME.to_string(); let slug_value = self.state.config.default_tenant_slug.clone();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
self.with_conn(move |conn| { self.with_conn(move |conn| {
use backend::schema::tenants::dsl as tenants_dsl; use backend::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value)) .filter(tenants_dsl::slug.eq(&slug_value))
.first::<Tenant>(conn) .first::<Tenant>(conn)
.optional() .optional()
.context("failed to load default tenant")?; .context("failed to load default tenant")?;
@@ -307,10 +274,9 @@ impl TestApp {
diesel::insert_into(tenants_dsl::tenants) diesel::insert_into(tenants_dsl::tenants)
.values(( .values((
tenants_dsl::id.eq(new_id), tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value), tenants_dsl::slug.eq(&slug_value),
tenants_dsl::storage_root.eq(Some(root)), tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value), tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
)) ))
.execute(conn) .execute(conn)
.context("failed to insert default tenant")?; .context("failed to insert default tenant")?;
@@ -323,59 +289,80 @@ impl TestApp {
.await .await
} }
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> { pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
let (access_token, _, _) = self.create_session(username).await?; #[derive(Serialize)]
Ok(access_token) struct LoginPayload<'a> {
} username: &'a str,
password: &'a str,
}
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> { let response = self
let username = username.to_string(); .post_json(
let state = self.state.clone(); "/api/auth/login",
self.with_conn(move |conn| { &LoginPayload { username, password },
use backend::schema::tenants::dsl as tenants_dsl; None,
use backend::schema::user_memberships::dsl as memberships_dsl; )
use backend::schema::users::dsl as users_dsl; .await?;
let user: User = users_dsl::users ensure!(
.filter(users_dsl::username.eq(&username)) response.status() == StatusCode::OK,
.first(conn)?; "login failed with status {}",
response.status()
);
let membership: UserMembership = memberships_dsl::user_memberships let body = body_to_vec(response.into_body()).await?;
.filter(memberships_dsl::user_id.eq(user.id)) #[derive(Deserialize)]
.first(conn)?; struct LoginResponse {
access_token: String,
}
let tenant: Tenant = tenants_dsl::tenants if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
.find(membership.tenant_id) return Ok(parsed.access_token);
.first(conn)?; }
let now = Utc::now(); #[derive(Deserialize)]
let access_token = state struct TenantSummary {
.jwt tenant_id: Uuid,
.generate_token(user.id, tenant.id, &user.username) _slug: String,
.map_err(|err| anyhow!(err))?; }
let refresh_value = generate_refresh_token(); #[derive(Deserialize)]
let refresh_hash = hash_refresh_token(&refresh_value); struct TenantSelectionResponse {
let refresh_expires_at = access_token: String,
now + ChronoDuration::days(state.config.refresh_token_expiry_days); tenants: Vec<TenantSummary>,
}
let new_refresh = NewRefreshToken { let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
id: Uuid::new_v4(), ensure!(
user_id: user.id, !selection.tenants.is_empty(),
token_hash: refresh_hash, "login returned no tenant options",
issued_at: now.naive_utc(), );
expires_at: refresh_expires_at.naive_utc(),
tenant_id: tenant.id,
};
diesel::insert_into(refresh_dsl::refresh_tokens) #[derive(Serialize)]
.values(&new_refresh) struct SelectTenantPayload {
.execute(conn)?; tenant_id: Uuid,
}
let cookie = format!("refresh_token={refresh_value}"); let target_tenant = selection.tenants[0].tenant_id;
Ok((access_token, cookie, tenant.id)) let select_response = self
}) .post_json(
.await "/api/auth/select-tenant",
&SelectTenantPayload {
tenant_id: target_tenant,
},
Some(&selection.access_token),
)
.await?;
ensure!(
select_response.status() == StatusCode::OK,
"tenant selection failed with status {}",
select_response.status()
);
let select_body = body_to_vec(select_response.into_body()).await?;
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
Ok(parsed.access_token)
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -409,16 +396,6 @@ impl TestApp {
path: &str, path: &str,
payload: &T, payload: &T,
token: Option<&str>, token: Option<&str>,
) -> Result<hyper::Response<Body>> {
self.post_json_with_cookie(path, payload, token, None).await
}
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
cookie: Option<&str>,
) -> Result<hyper::Response<Body>> { ) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?; let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder() let mut builder = Request::builder()
@@ -428,9 +405,6 @@ impl TestApp {
if let Some(token) = token { if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}")); builder = builder.header("authorization", format!("Bearer {token}"));
} }
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let request = builder.body(Body::from(body))?; let request = builder.body(Body::from(body))?;
Ok(self Ok(self
.router .router
@@ -720,7 +694,6 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
jobs, \ jobs, \
refresh_tokens, \ refresh_tokens, \
tags, \ tags, \
webdav_tokens, \
user_memberships, \ user_memberships, \
users, \ users, \
tenants \ tenants \
@@ -730,14 +703,13 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
Ok(()) Ok(())
} }
fn generate_refresh_token() -> String { pub fn hash_password(password: &str) -> Result<String> {
let mut bytes = [0u8; 32]; use argon2::password_hash::{PasswordHasher, SaltString};
OsRng.fill_bytes(&mut bytes); use argon2::Argon2;
hex::encode(bytes)
}
fn hash_refresh_token(value: &str) -> String { let salt = SaltString::generate(&mut OsRng);
let mut hasher = Sha256::new(); Ok(Argon2::default()
hasher.update(value.as_bytes()); .hash_password(password.as_bytes(), &salt)
hex::encode(hasher.finalize()) .map_err(|err| anyhow!("failed to hash password: {err}"))?
.to_string())
} }
-276
View File
@@ -1,276 +0,0 @@
mod common;
use anyhow::Result;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentSummary,
}
#[derive(Deserialize)]
struct DocumentSummary {
id: Uuid,
title: String,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>,
}
#[derive(Deserialize)]
struct DocumentCorrespondentSummary {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
struct TestContext {
app: TestApp,
token: String,
document_ids: Vec<Uuid>,
sender_id: Uuid,
receiver_id: Uuid,
}
impl TestContext {
const SENDER_NAME: &'static str = "Acme Corp";
const RECEIVER_NAME: &'static str = "Bank Ltd";
async fn new(prefix: &str) -> Result<Self> {
let app = TestApp::new().await?;
let username = format!("{prefix}_user");
let password = format!("{prefix}_pw");
app.insert_user(&username, &password, "admin").await?;
let token = app.login_token(&username, &password).await?;
let first_id =
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
let second_id =
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
Ok(Self {
app,
token,
document_ids: vec![first_id, second_id],
sender_id,
receiver_id,
})
}
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
self.assign_with_action(correspondent_ids, None).await
}
async fn assign_with_action(
&self,
correspondent_ids: &[Uuid],
action: Option<&str>,
) -> Result<BulkCorrespondentResult> {
let assignments: Vec<_> = correspondent_ids
.iter()
.map(|id| json!({ "correspondent_id": id }))
.collect();
let mut payload = json!({
"document_ids": self.document_ids,
"assignments": assignments,
});
if let Some(action) = action {
if let Some(obj) = payload.as_object_mut() {
obj.insert("action".to_string(), json!(action));
}
}
let response = self
.app
.post_json(
"/api/documents/bulk/correspondents",
&payload,
Some(&self.token),
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn fetch_correspondents(
&self,
document_id: Uuid,
) -> Result<Vec<DocumentCorrespondentSummary>> {
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
Ok(detail.document.correspondents)
}
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
create_correspondent(&self.app, &self.token, name).await
}
}
#[tokio::test]
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_add").await?;
let result = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(result.assigned, 4);
assert_eq!(result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::SENDER_NAME));
assert!(names.contains(&TestContext::RECEIVER_NAME));
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.sender_id));
assert!(ids.contains(&context.receiver_id));
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_idempotent").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let repeat = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(repeat.assigned, 0);
assert_eq!(repeat.removed, 0);
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_remove").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let removal = context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
assert_eq!(removal.assigned, 0);
assert_eq!(removal.removed, 2);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 1);
let entry = &correspondents[0];
assert_eq!(entry.id, context.receiver_id);
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_append").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
let charlie_name = "Charlie";
let charlie_id = context.create_correspondent(charlie_name).await?;
let add_result = context.assign(&[charlie_id]).await?;
assert_eq!(add_result.assigned, 2);
assert_eq!(add_result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 2);
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.receiver_id));
assert!(ids.contains(&charlie_id));
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::RECEIVER_NAME));
assert!(names.contains(&charlie_name));
}
context.app.cleanup().await?;
Ok(())
}
async fn upload_document(
app: &TestApp,
token: &str,
filename: &str,
contents: &[u8],
) -> Result<Uuid> {
let response = app
.upload_document(
"/api/documents",
filename,
"text/plain",
contents,
None,
token,
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
Ok(detail.document.id)
}
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
let response = app
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn fetch_document_detail(
app: &TestApp,
token: &str,
document_id: Uuid,
) -> Result<DocumentDetail> {
let response = app
.get(&format!("/api/documents/{document_id}"), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -73,7 +73,7 @@ async fn folder_move_and_delete_flow() -> Result<()> {
Some(&token), Some(&token),
) )
.await?; .await?;
assert_eq!(folder_resp.status(), StatusCode::CREATED); assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?; let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?; let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
@@ -164,7 +164,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
Some(&token), Some(&token),
) )
.await?; .await?;
assert_eq!(parent_resp.status(), StatusCode::CREATED); assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?; let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?; let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
@@ -179,7 +179,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
Some(&token), Some(&token),
) )
.await?; .await?;
assert_eq!(child_resp.status(), StatusCode::CREATED); assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?; let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?; let child: FolderResponse = serde_json::from_slice(&child_body)?;
@@ -310,14 +310,14 @@ async fn create_folder_is_idempotent() -> Result<()> {
let first_resp = app let first_resp = app
.post_json("/api/folders", &payload, Some(&token)) .post_json("/api/folders", &payload, Some(&token))
.await?; .await?;
assert_eq!(first_resp.status(), StatusCode::CREATED); assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?; let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app let second_resp = app
.post_json("/api/folders", &payload, Some(&token)) .post_json("/api/folders", &payload, Some(&token))
.await?; .await?;
assert_eq!(second_resp.status(), StatusCode::OK); assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?; let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
@@ -424,7 +424,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
Some(&token), Some(&token),
) )
.await?; .await?;
assert_eq!(parent_resp.status(), StatusCode::CREATED); assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?; let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?; let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
@@ -438,7 +438,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
Some(&token), Some(&token),
) )
.await?; .await?;
assert_eq!(child_resp.status(), StatusCode::CREATED); assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?; let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?; let child: FolderResponse = serde_json::from_slice(&child_body)?;
+9 -5
View File
@@ -2,12 +2,12 @@ mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus}; use backend::models::{NewUser, NewUserMembership, Tag};
use backend::schema::{ use backend::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_dsl, users::dsl as users_dsl,
}; };
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -204,20 +204,23 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
let tenant_b_id = Uuid::new_v4(); let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4(); let user_b_id = Uuid::new_v4();
let password_b = "tenant-b";
app.with_conn(move |conn| { app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/"); let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants) diesel::insert_into(tenants_dsl::tenants)
.values(( .values((
tenants_dsl::id.eq(tenant_b_id), tenants_dsl::id.eq(tenant_b_id),
tenants_dsl::name.eq("tenant-b"), tenants_dsl::slug.eq("tenant-b"),
tenants_dsl::storage_root.eq(Some(storage_root)), tenants_dsl::storage_root.eq(Some(storage_root)),
tenants_dsl::status.eq(TenantStatus::Active),
)) ))
.execute(conn)?; .execute(conn)?;
let password_hash = hash_password(password_b)?;
let new_user = NewUser { let new_user = NewUser {
id: user_b_id, id: user_b_id,
username: "bob".to_string(), username: "bob".to_string(),
password_hash,
}; };
diesel::insert_into(users_dsl::users) diesel::insert_into(users_dsl::users)
.values(&new_user) .values(&new_user)
@@ -227,6 +230,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user_b_id, user_id: user_b_id,
tenant_id: tenant_b_id, tenant_id: tenant_b_id,
role: "admin".to_string(),
}; };
diesel::insert_into(memberships_dsl::user_memberships) diesel::insert_into(memberships_dsl::user_memberships)
.values(&membership) .values(&membership)
@@ -236,7 +240,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
}) })
.await?; .await?;
let token_b = app.login_token("bob", "").await?; let token_b = app.login_token("bob", password_b).await?;
let create_b = app let create_b = app
.post_json( .post_json(
-153
View File
@@ -1,153 +0,0 @@
mod common;
use anyhow::Result;
use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use backend::models::WebdavToken;
use backend::routes::webdav;
use backend::schema::webdav_tokens;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::json;
use tower::ServiceExt;
use uuid::Uuid;
#[derive(Deserialize)]
struct TokenInfo {
id: Uuid,
label: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
#[derive(Deserialize)]
struct CreateTokenResponse {
token: String,
#[serde(rename = "token_info")]
info: TokenInfo,
}
#[tokio::test]
async fn webdav_token_api_crud() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "alice";
let password = "correct horse battery";
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let create_response = app
.post_json(
"/api/profile/webdav-tokens",
&json!({ "label": "dav" }),
Some(&access_token),
)
.await?;
assert_eq!(create_response.status(), StatusCode::CREATED);
let create_body = body_to_vec(create_response.into_body()).await?;
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
let token_id = created.info.id;
assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none());
let list_response = app
.get("/api/profile/webdav-tokens", Some(&access_token))
.await?;
assert_eq!(list_response.status(), StatusCode::OK);
let list_body = body_to_vec(list_response.into_body()).await?;
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, token_id);
let delete_response = app
.delete(
&format!("/api/profile/webdav-tokens/{token_id}"),
Some(&access_token),
)
.await?;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let list_after = app
.get("/api/profile/webdav-tokens", Some(&access_token))
.await?;
let list_after_body = body_to_vec(list_after.into_body()).await?;
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
assert_eq!(listed_after.len(), 1);
assert_eq!(listed_after[0].id, token_id);
assert!(listed_after[0].revoked_at.is_some());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "bruce";
let password = "wayne";
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let create_response = app
.post_json(
"/api/profile/webdav-tokens",
&json!({ "label": "webdav" }),
Some(&access_token),
)
.await?;
let create_body = body_to_vec(create_response.into_body()).await?;
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone());
let auth_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, created.token))
);
let propfind = Method::from_bytes(b"PROPFIND")?;
let success_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, auth_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let used = app
.with_conn(move |conn| {
let record = webdav_tokens::table
.find(token_id)
.first::<WebdavToken>(conn)?;
Ok::<_, anyhow::Error>(record.last_used_at)
})
.await?;
assert!(used.is_some());
let delete_response = app
.delete(
&format!("/api/profile/webdav-tokens/{token_id}"),
Some(&access_token),
)
.await?;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let failure_request = Request::builder()
.method(propfind)
.uri("/")
.header(header::AUTHORIZATION, auth_header)
.header("depth", "0")
.body(Body::empty())?;
let response = router.oneshot(failure_request).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
+3 -7
View File
@@ -45,6 +45,7 @@ services:
S3_BUCKET: documents S3_BUCKET: documents
JWT_SECRET: change-me-super-secret JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit-test:7280 QUICKWIT_ENDPOINT: http://quickwit-test:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: [] entrypoint: []
command: > command: >
/bin/sh -c " /bin/sh -c "
@@ -56,17 +57,12 @@ services:
else else
papercrate-admin create-tenant admin; papercrate-admin create-tenant admin;
fi && fi &&
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
if [ -z "$ADMIN_TENANT_ID" ]; then
echo 'failed to resolve admin tenant id' >&2;
exit 1;
fi &&
echo 'Ensuring demo user credentials' && echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) && (papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' && echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" && papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' && echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID" papercrate-admin quickwit-create-index admin
" "
user: root user: root
restart: "no" restart: "no"
+3 -7
View File
@@ -80,6 +80,7 @@ services:
S3_BUCKET: documents S3_BUCKET: documents
JWT_SECRET: change-me-super-secret JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit:7280 QUICKWIT_ENDPOINT: http://quickwit:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: [] entrypoint: []
command: > command: >
/bin/sh -c " /bin/sh -c "
@@ -91,17 +92,12 @@ services:
else else
papercrate-admin create-tenant admin; papercrate-admin create-tenant admin;
fi && fi &&
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
if [ -z "$ADMIN_TENANT_ID" ]; then
echo 'failed to resolve admin tenant id' >&2;
exit 1;
fi &&
echo 'Ensuring demo user credentials' && echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) && (papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' && echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" && papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' && echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID" papercrate-admin quickwit-create-index admin
" "
user: root user: root
restart: "no" restart: "no"
+9 -11
View File
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
Authentication Authentication
-------------- --------------
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, name } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead. - POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, name } }`. - POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie. - POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
- GET /api/auth/me - Return the authenticated principal payload. - GET /api/auth/me - Return the authenticated principal payload.
@@ -16,24 +16,22 @@ Health
Documents Documents
--------- ---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata. - GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document. - POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
- POST /api/documents/bulk/move - Move multiple documents to a target folder. - POST /api/documents/bulk/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents. - POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. - POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document. - GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title). - PATCH /api/documents/:id - Update document metadata (currently title).
- DELETE /api/documents/:id - Soft-delete a document. - DELETE /api/documents/:id - Soft-delete a document.
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
- PATCH /api/documents/:id/folder - Move a document to another folder. - PATCH /api/documents/:id/folder - Move a document to another folder.
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
- GET /api/documents/:id/versions - List version history for a document.
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
- POST /api/documents/:id/tags - Assign one or more tags to a document. - POST /api/documents/:id/tags - Assign one or more tags to a document.
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document. - DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
- POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments). - POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment. - DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
Document Assets Document Assets
--------------- ---------------
@@ -64,7 +62,7 @@ Tags
Correspondents Correspondents
-------------- --------------
- GET /api/correspondents - List correspondents with usage totals. - GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON). - POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
- PATCH /api/correspondents/:id - Update name and/or metadata. - PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. - DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
-2
View File
@@ -1,2 +0,0 @@
dist
node_modules
-31
View File
@@ -1,31 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-use-before-define": [
"error",
{ "functions": false, "classes": true, "variables": true }
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off"
}
}
+2 -1
View File
@@ -12,9 +12,10 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist ./ COPY --from=build /app/dist ./
ENV API_PROXY_PASS="" ENV API_BASE_URL=""
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
+5 -39
View File
@@ -1,45 +1,11 @@
#!/bin/sh #!/bin/sh
set -euo pipefail set -euo pipefail
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}" API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}" API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
cat <<'BASE' > /etc/nginx/conf.d/default.conf cat <<CONFIG > /usr/share/nginx/html/config.js
server { window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
listen 80; CONFIG
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
BASE
if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
cat <<PROXY >> /etc/nginx/conf.d/default.conf
location /api/ {
proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
location /download/ {
proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
PROXY
fi
cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf
}
ENDCFG
exec "$@" exec "$@"
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
-2615
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "webpack serve --mode development --open", "dev": "webpack serve --mode development --open",
"build": "webpack --mode production", "build": "webpack --mode production",
"lint": "eslint src --ext .js,.jsx" "lint": "echo \"No linting configured\""
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
@@ -20,9 +20,6 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"eslint": "8.57.0",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.0",
"@svgr/webpack": "8.1.0", "@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
+1
View File
@@ -0,0 +1 @@
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
+10 -7
View File
@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="folderGradient" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979"> <linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
<stop offset="0" stop-color="var(--folder-icon-back, #62a0ea)"/> <stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.5" stop-color="var(--folder-icon-mid, #afd4ff)"/> <stop offset="0.0576991" stop-color="#afd4ff"/>
<stop offset="1" stop-color="var(--folder-icon-front, #62a0ea)"/> <stop offset="0.122204" stop-color="#62a0ea"/>
<stop offset="0.873306" stop-color="#62a0ea"/>
<stop offset="0.955997" stop-color="#c0d5ea"/>
<stop offset="1" stop-color="#62a0ea"/>
</linearGradient> </linearGradient>
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="var(--folder-icon-back, #438de6)"/> <path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="#438de6"/>
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#folderGradient)"/> <path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#a)"/>
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="var(--folder-icon-front, #a4caee)"/> <path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="#a4caee"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+3
View File
@@ -0,0 +1,3 @@
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
export default CORRESPONDENT_ROLES;
@@ -38,11 +38,10 @@ function CorrespondentsPanel({
try { try {
await onUpdate(editingId, { name: trimmed }); await onUpdate(editingId, { name: trimmed });
cancelEdit(); cancelEdit();
} catch (error) { } catch (
onNotify?.('Failed to update correspondent.', 'error'); // eslint-disable-next-line no-empty
console.error('[correspondents] update failed', error); error
setSaving(false); ) {}
}
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]); }, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -54,14 +53,16 @@ function CorrespondentsPanel({
if (editingId === correspondent.id) { if (editingId === correspondent.id) {
cancelEdit(); cancelEdit();
} }
} catch (error) { } catch (
onNotify?.('Failed to delete correspondent.', 'error'); // eslint-disable-next-line no-empty
console.error('[correspondents] delete failed', error); error
) {
return;
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
}, },
[onDelete, editingId, cancelEdit, onNotify], [onDelete, editingId, cancelEdit],
); );
const handleCreate = useCallback( const handleCreate = useCallback(
@@ -76,9 +77,11 @@ function CorrespondentsPanel({
try { try {
await onCreate({ name: trimmed }); await onCreate({ name: trimmed });
setCreateName(''); setCreateName('');
} catch (error) { } catch (
onNotify?.('Failed to create correspondent.', 'error'); // eslint-disable-next-line no-empty
console.error('[correspondents] create failed', error); error
) {
return;
} finally { } finally {
setCreating(false); setCreating(false);
} }
@@ -104,7 +107,14 @@ function CorrespondentsPanel({
return '0'; return '0';
} }
const total = typeof usage.total === 'number' ? usage.total : 0; const total = typeof usage.total === 'number' ? usage.total : 0;
return total.toString(); const entries = usage.by_role ? Object.entries(usage.by_role) : [];
if (!entries.length) {
return total.toString();
}
const roleSummary = entries
.map(([role, count]) => `${role}: ${count}`)
.join(', ');
return `${total} (${roleSummary})`;
}, []); }, []);
return ( return (
-17
View File
@@ -1,17 +0,0 @@
import React, { createContext, useContext } from 'react';
const DesktopContext = createContext(null);
export const DesktopProvider = ({ value, children }) => (
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
);
export const useDesktopContext = () => {
const context = useContext(DesktopContext);
if (!context) {
throw new Error('useDesktopContext must be used within a DesktopProvider');
}
return context;
};
export default DesktopContext;
-15
View File
@@ -1,15 +0,0 @@
export const preventAll = (event) => {
if (!event) {
return;
}
try {
event.preventDefault();
} catch (error) {
console.warn('[events] preventDefault failed', error);
}
try {
event.stopPropagation();
} catch (error) {
console.warn('[events] stopPropagation failed', error);
}
};
-8
View File
@@ -1,8 +0,0 @@
export const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
-255
View File
@@ -1,255 +0,0 @@
import { useCallback, useRef } from 'react';
import { useDesktopContext } from './context';
import { preventAll } from './events';
import { clamp, formatTransform } from './math';
const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const useDocumentDrag = () => {
const {
layoutRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
syncLayoutSnapshot,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
} = useDesktopContext();
const dragStateRef = useRef(null);
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== pointerId) {
return;
}
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (error) {
if (debugDrag) {
console.warn('[skeuo] releasePointerCapture failed', error);
}
}
}
dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
},
[debugDrag, setDraggingId, syncLayoutSnapshot],
);
const handlePointerDown = useCallback(
(event, docId) => {
if (debugDrag) {
console.log(
'[skeuo] handlePointerDown fired for doc',
docId,
'button',
event.button,
'pointerType',
event.pointerType,
'pointerId',
event.pointerId,
);
}
preventAll(event);
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const normalizedBaseScale =
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
bringToFront(docId);
const entry = layoutRef.current.get(docId) || null;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(docId, { ...entry, centerX, centerY });
}
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (error) {
if (debugDrag) {
console.warn('[skeuo] setPointerCapture failed', error);
}
}
}
dragStateRef.current = {
docId,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
moved: false,
locked: false,
width: docWidth,
height: docHeight,
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
};
setDraggingId(docId);
},
[
bringToFront,
canvasPadding,
documentLookup,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
setDraggingId,
debugDrag,
],
);
const handlePointerMove = useCallback(
(event) => {
const state = dragStateRef.current;
if (!state) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
}
return;
}
if (state.pointerId !== event.pointerId) {
if (debugDrag) {
console.log(
'[skeuo] handlePointerMove: pointer mismatch expected',
state.pointerId,
'got',
event.pointerId,
);
}
return;
}
preventAll(event);
if (state.locked) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
}
return;
}
const entry = layoutRef.current.get(state.docId);
if (!entry) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
}
bringToFront(state.docId);
state.moved = true;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX - state.width / 2,
clampedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
}
if (debugDrag) {
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
}
recalcVisibleDocIds();
},
[
bringToFront,
canvasPadding,
canvasSize.height,
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
itemRefs,
layoutRef,
recalcVisibleDocIds,
debugDrag,
],
);
const handlePointerUp = useCallback(
(event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
if (state.moved) {
finishDrag(event.pointerId);
return;
}
const docId = state.docId;
bringToFront(docId);
const originInfo = {
rotation: state.rotation || 0,
scale: state.baseScale || 1,
width: state.width,
height: state.height,
};
finishDrag(event.pointerId);
openOverlayForDoc(docId, originInfo);
return;
}
finishDrag(event.pointerId);
},
[bringToFront, finishDrag, openOverlayForDoc],
);
const handlePointerCancel = useCallback(
(event) => {
finishDrag(event.pointerId);
},
[finishDrag],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
export default useDocumentDrag;
File diff suppressed because it is too large Load Diff
+27 -93
View File
@@ -25,68 +25,10 @@ const PreviewZoomOverlay = ({
const portalTarget = ensureDocumentRoot(); const portalTarget = ensureDocumentRoot();
const [isNativeScale, setIsNativeScale] = useState(false); const [isNativeScale, setIsNativeScale] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
const [renderBackdrop, setRenderBackdrop] = useState(false);
const [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const imageRef = useRef(null); const imageRef = useRef(null);
const focusRef = useRef(null); const focusRef = useRef(null);
const previouslyFocusedRef = useRef(null); const previouslyFocusedRef = useRef(null);
const visibilityTimerRef = useRef(null);
const displayTimerRef = useRef(null);
useEffect(() => {
if (display?.url) {
setDisplaySnapshot(display);
}
}, [display]);
useEffect(() => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
visibilityTimerRef.current = null;
}
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
displayTimerRef.current = null;
}
if (open && display?.url) {
setRenderBackdrop(true);
displayTimerRef.current = requestAnimationFrame(() => {
displayTimerRef.current = requestAnimationFrame(() => {
setBackdropVisible(true);
});
});
return () => {
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
displayTimerRef.current = null;
}
};
}
setBackdropVisible(false);
visibilityTimerRef.current = setTimeout(() => {
setRenderBackdrop(false);
}, 260);
return () => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
visibilityTimerRef.current = null;
}
};
}, [open, display?.url]);
useEffect(() => () => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
}
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
}
}, []);
useEffect(() => { useEffect(() => {
setIsNativeScale(false); setIsNativeScale(false);
@@ -133,7 +75,7 @@ const PreviewZoomOverlay = ({
previouslyFocusedRef.current.focus(); previouslyFocusedRef.current.focus();
} }
previouslyFocusedRef.current = null; previouslyFocusedRef.current = null;
return; return undefined;
} }
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@@ -144,24 +86,24 @@ const PreviewZoomOverlay = ({
previouslyFocusedRef.current = null; previouslyFocusedRef.current = null;
} }
} }
}, [open]);
const activeDisplay = open && display?.url ? display : displaySnapshot; const scrollEl = scrollRef.current;
if (!scrollEl) {
useEffect(() => {
if (!renderBackdrop || !activeDisplay?.url) {
return undefined; return undefined;
} }
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
const scrollEl = scrollRef.current; scrollEl.focus();
if (scrollEl && typeof scrollEl.focus === 'function') {
scrollEl.focus({ preventScroll: true });
}
}); });
return () => cancelAnimationFrame(frame); return () => {
}, [renderBackdrop, activeDisplay?.url]); cancelAnimationFrame(frame);
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
previouslyFocusedRef.current = null;
}
};
}, [open]);
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -177,27 +119,26 @@ const PreviewZoomOverlay = ({
} }
if (event.key === 'ArrowLeft') { if (event.key === 'ArrowLeft') {
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) { if (display?.canGoPrev && display?.goPrev) {
event.preventDefault(); event.preventDefault();
activeDisplay.goPrev(); display.goPrev();
} }
return; return;
} }
if (event.key === 'ArrowRight') { if (event.key === 'ArrowRight') {
if (activeDisplay?.canGoNext && activeDisplay?.goNext) { if (display?.canGoNext && display?.goNext) {
event.preventDefault(); event.preventDefault();
activeDisplay.goNext(); display.goNext();
} }
} }
}; };
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { if (!open || !display?.url || !portalTarget) {
return null; return null;
} }
const effectiveDisplay = activeDisplay; const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
const stageClassName = [ const stageClassName = [
'preview-zoom__stage', 'preview-zoom__stage',
] ]
@@ -211,13 +152,6 @@ const PreviewZoomOverlay = ({
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const backdropClassName = [
'preview-zoom-backdrop',
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
]
.filter(Boolean)
.join(' ');
const imageStyle = isNativeScale const imageStyle = isNativeScale
? { ? {
cursor: 'zoom-out', cursor: 'zoom-out',
@@ -235,7 +169,7 @@ const PreviewZoomOverlay = ({
return createPortal( return createPortal(
( (
<div <div
className={backdropClassName} className="preview-zoom-backdrop"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Enlarged document preview" aria-label="Enlarged document preview"
@@ -252,8 +186,8 @@ const PreviewZoomOverlay = ({
tabIndex={-1} tabIndex={-1}
> >
<img <img
src={effectiveDisplay.url} src={display.url}
alt={effectiveDisplay.alt || 'Document preview'} alt={display.alt || 'Document preview'}
className="preview-zoom__image" className="preview-zoom__image"
ref={imageRef} ref={imageRef}
draggable={false} draggable={false}
@@ -293,12 +227,12 @@ const PreviewZoomOverlay = ({
className="preview-zoom__nav-button" className="preview-zoom__nav-button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) { if (display?.canGoPrev && display?.goPrev) {
effectiveDisplay.goPrev(); display.goPrev();
} }
}} }}
aria-label="Previous preview" aria-label="Previous preview"
disabled={!effectiveDisplay?.canGoPrev} disabled={!display?.canGoPrev}
> >
<ArrowLeftIcon /> <ArrowLeftIcon />
</button> </button>
@@ -307,12 +241,12 @@ const PreviewZoomOverlay = ({
className="preview-zoom__nav-button" className="preview-zoom__nav-button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) { if (display?.canGoNext && display?.goNext) {
effectiveDisplay.goNext(); display.goNext();
} }
}} }}
aria-label="Next preview" aria-label="Next preview"
disabled={!effectiveDisplay?.canGoNext} disabled={!display?.canGoNext}
> >
<ArrowRightIcon /> <ArrowRightIcon />
</button> </button>
+55 -313
View File
@@ -1,21 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import DetailPanel from '../detail/DetailPanel'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
import {
DownloadIcon,
EditIcon,
ViewListIcon,
ViewGridIcon,
FolderIcon,
TrashIcon,
RefreshIcon,
MinusVerticalIcon,
ArrowUpIcon,
} from '../ui/icons';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 144; const DEFAULT_GRID_ICON_SIZE = 96;
const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48; const LIST_ICON_SIZE = 48;
const getPageCount = (doc) => const getPageCount = (doc) =>
@@ -23,39 +13,6 @@ const getPageCount = (doc) =>
? doc.current_version.metadata.page_count ? doc.current_version.metadata.page_count
: null; : null;
const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry) return;
const id = entry.id ?? entry.correspondent_id ?? null;
const name = (entry.name || entry.label || entry.slug || '').trim();
if (!name) return;
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name,
key: id ?? `${name}-${index}`,
});
});
return results;
};
// Detects when an element becomes visible within a scroll container. // Detects when an element becomes visible within a scroll container.
const useLazyVisibility = (rootRef, resetKey) => { const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null); const targetRef = useRef(null);
@@ -154,25 +111,6 @@ const DocumentThumbnailImage = ({
innerClasses.push('document-thumbnail-inner--multipage'); innerClasses.push('document-thumbnail-inner--multipage');
} }
const aspectRatio = useMemo(() => {
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
return assetWidth / assetHeight;
}
return null;
}, [assetWidth, assetHeight]);
useEffect(() => {
const node = visibilityRef.current;
if (!node) {
return;
}
if (aspectRatio) {
node.dataset.thumbnailAspect = String(aspectRatio);
} else {
delete node.dataset.thumbnailAspect;
}
}, [aspectRatio, visibilityRef]);
return ( return (
<div className="document-thumbnail-wrapper" ref={visibilityRef}> <div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}> <div className={innerClasses.join(' ')} style={innerStyle}>
@@ -196,11 +134,15 @@ const DocumentThumbnailImage = ({
const DocumentsTable = ({ const DocumentsTable = ({
currentFolderName, currentFolderName,
breadcrumbs,
onRefresh, onRefresh,
onShowSkeuoWorkspace = () => {}, onShowSkeuoWorkspace = () => {},
onRequestCreateFolder,
creatingFolder = false,
subfolders, subfolders,
documents, documents,
searchResults, searchResults,
isFilterActive,
onFolderSelect, onFolderSelect,
onFolderDrop, onFolderDrop,
onFolderDragOver, onFolderDragOver,
@@ -214,6 +156,7 @@ const DocumentsTable = ({
onDocumentRowClick, onDocumentRowClick,
onDocumentOpen, onDocumentOpen,
selectedDocumentIds, selectedDocumentIds,
focusedDocumentId,
focusedRowKey, focusedRowKey,
draggingDocumentIds = [], draggingDocumentIds = [],
onDocumentDragStart, onDocumentDragStart,
@@ -222,7 +165,6 @@ const DocumentsTable = ({
onFolderRename, onFolderRename,
onDocumentRename, onDocumentRename,
tagLookupById, tagLookupById,
activeCorrespondentIds = [],
onDocumentListFocus, onDocumentListFocus,
onDocumentListKeyDown, onDocumentListKeyDown,
onFocusedRowChange, onFocusedRowChange,
@@ -230,7 +172,6 @@ const DocumentsTable = ({
getDocumentAsset = () => null, getDocumentAsset = () => null,
getDownloadHref, getDownloadHref,
onTagClick, onTagClick,
onCorrespondentClick,
isSearchLoading = false, isSearchLoading = false,
onDocumentTagDrop, onDocumentTagDrop,
viewMode = 'list', viewMode = 'list',
@@ -253,12 +194,7 @@ const DocumentsTable = ({
() => new Set(draggingDocumentIds || []), () => new Set(draggingDocumentIds || []),
[draggingDocumentIds], [draggingDocumentIds],
); );
const activeCorrespondentIdSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const suppressDocumentClickRef = useRef(false);
const [, forceVisibilityTick] = useState(0); const [, forceVisibilityTick] = useState(0);
const lastScrollNodeRef = useRef(null); const lastScrollNodeRef = useRef(null);
const assignScrollRef = useCallback((node) => { const assignScrollRef = useCallback((node) => {
@@ -389,86 +325,14 @@ const DocumentsTable = ({
if (parsed?.id && onDocumentTagDrop) { if (parsed?.id && onDocumentTagDrop) {
onDocumentTagDrop(documentId, parsed); onDocumentTagDrop(documentId, parsed);
} }
} catch (error) { } catch (
console.warn('[documents] Failed to parse tag drop payload', error); // eslint-disable-next-line no-empty
} error
) {}
}, },
[isTagDragEvent, onDocumentTagDrop], [isTagDragEvent, onDocumentTagDrop],
); );
const handleDocumentClick = useCallback(
(documentId, event) => {
if (suppressDocumentClickRef.current) {
return;
}
onDocumentRowClick?.(documentId, event);
},
[onDocumentRowClick],
);
const handleDocumentDragStartLocal = useCallback(
(event, doc) => {
suppressDocumentClickRef.current = true;
onDocumentDragStart?.(event, doc);
},
[onDocumentDragStart],
);
const handleDocumentDragEndLocal = useCallback(
(event) => {
onDocumentDragEnd?.(event);
requestAnimationFrame(() => {
suppressDocumentClickRef.current = false;
});
},
[onDocumentDragEnd],
);
const renderCorrespondentLinks = useCallback(
(correspondents) =>
correspondents.map((correspondent, index) => {
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
const classNames = ['doc-correspondent-link'];
if (isActive) classNames.push('is-active');
if (!hasClickHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1;
const label = isLast
? `${correspondent.name}:${String.fromCharCode(160)}`
: correspondent.name;
return (
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
<button
type="button"
className={classNames.join(' ')}
aria-disabled={hasClickHandler ? undefined : true}
onClick={(event) => {
if (!hasClickHandler) {
return;
}
event.stopPropagation();
onCorrespondentClick(correspondent.id, correspondent);
}}
onKeyDown={(event) => {
if (!hasClickHandler) {
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
>
{label}
</button>
{!isLast ? (
<span className="doc-correspondent-link__separator">, </span>
) : null}
</React.Fragment>
);
}),
[activeCorrespondentIdSet, onCorrespondentClick],
);
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0; const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
const showListSearchEmptyState = const showListSearchEmptyState =
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading; showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
@@ -509,6 +373,13 @@ const DocumentsTable = ({
<ViewGridIcon className="view-toggle__icon" size={18} /> <ViewGridIcon className="view-toggle__icon" size={18} />
</button> </button>
</div> </div>
<button
type="button"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
>
{creatingFolder ? 'Creating…' : 'New folder'}
</button>
<button className="secondary" onClick={onRefresh}> <button className="secondary" onClick={onRefresh}>
Refresh Refresh
</button> </button>
@@ -627,7 +498,6 @@ const DocumentsTable = ({
const tagList = Array.isArray(doc.tags) ? doc.tags : []; const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3); const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0; const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
const cardClasses = ['document-card', 'document']; const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected'); if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging'); if (isDraggingDoc) cardClasses.push('is-dragging');
@@ -638,11 +508,11 @@ const DocumentsTable = ({
role="listitem" role="listitem"
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={handleDocumentDragEndLocal} onDragEnd={onDocumentDragEnd}
onDragOver={(event) => handleDocumentTagDragOver(event)} onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)} onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave} onDragLeave={handleDocumentTagDragLeave}
@@ -663,12 +533,7 @@ const DocumentsTable = ({
className="document-card__title" className="document-card__title"
title={doc.title || doc.original_name} title={doc.title || doc.original_name}
> >
{correspondents.length > 0 ? ( {doc.title || doc.original_name}
<span className="doc-correspondents">
{renderCorrespondentLinks(correspondents)}
</span>
) : null}
<span>{doc.title || doc.original_name}</span>
</div> </div>
{visibleTags.length > 0 && ( {visibleTags.length > 0 && (
<div className="document-card__tags"> <div className="document-card__tags">
@@ -703,9 +568,10 @@ const DocumentsTable = ({
event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (error) { } catch (
console.warn('[documents] Failed to configure tag drag payload', error); // eslint-disable-next-line no-empty
} error
) {}
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -740,7 +606,8 @@ const DocumentsTable = ({
<tr> <tr>
<th className="thumb-column">Preview</th> <th className="thumb-column">Preview</th>
<th>Name</th> <th>Name</th>
<th>Issued</th> <th>Type</th>
<th>Updated</th>
<th className="actions-column">Actions</th> <th className="actions-column">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -804,13 +671,14 @@ const DocumentsTable = ({
<span>{folder.name}</span> <span>{folder.name}</span>
</div> </div>
</td> </td>
<td>Folder</td>
<td></td> <td></td>
<td className="actions"> <td className="actions">
<div className="action-buttons"> <div className="action-buttons">
{folder.id !== 'root' && onFolderRename && ( {folder.id !== 'root' && onFolderRename && (
<button <button
type="button" type="button"
className="icon-button" className="icon-button ghost"
title="Rename" title="Rename"
aria-label={`Rename folder ${folder.name}`} aria-label={`Rename folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
@@ -853,7 +721,6 @@ const DocumentsTable = ({
if (isSelected) rowClasses.push('selected'); if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging'); if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null; const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc);
return ( return (
<tr <tr
@@ -861,11 +728,11 @@ const DocumentsTable = ({
className={rowClasses.join(' ')} className={rowClasses.join(' ')}
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={handleDocumentDragEndLocal} onDragEnd={onDocumentDragEnd}
onDragOver={handleDocumentTagDragOver} onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave} onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)} onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
@@ -882,14 +749,7 @@ const DocumentsTable = ({
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-name"> <div className="doc-name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span className="doc-name__title"> <span className="doc-name__title">{doc.title || doc.original_name}</span>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
{renderCorrespondentLinks(correspondents)}
</span>
) : null}
<span>{doc.title || doc.original_name}</span>
</span>
</div> </div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
<div className="doc-name__tags"> <div className="doc-name__tags">
@@ -913,20 +773,21 @@ const DocumentsTable = ({
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.effectAllowed = 'copyMove';
} }
const payload = JSON.stringify({ const payload = JSON.stringify({
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
sourceDocId: doc.id, sourceDocId: doc.id,
}); });
event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (error) { } catch (
console.warn('[documents] Failed to configure tag drag payload', error); // eslint-disable-next-line no-empty
} error
) {}
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -949,25 +810,18 @@ const DocumentsTable = ({
)} )}
</div> </div>
</td> </td>
<td>{doc.content_type || 'Document'}</td>
<td> <td>
{(() => { {doc.updated_at
const issuedAt = doc.issued_at || doc.updated_at || null; ? new Date(doc.updated_at).toLocaleString()
if (!issuedAt) { : '—'}
return '—';
}
const timestamp = Date.parse(issuedAt);
if (Number.isNaN(timestamp)) {
return '—';
}
return new Date(timestamp).toLocaleDateString();
})()}
</td> </td>
<td className="actions"> <td className="actions">
<div className="action-buttons"> <div className="action-buttons">
{onDocumentRename && ( {onDocumentRename && (
<button <button
type="button" type="button"
className="icon-button" className="icon-button ghost"
title="Rename" title="Rename"
aria-label={`Rename document ${doc.title || doc.original_name}`} aria-label={`Rename document ${doc.title || doc.original_name}`}
onClick={(event) => { onClick={(event) => {
@@ -1042,115 +896,3 @@ const DocumentsTable = ({
export default DocumentsTable; export default DocumentsTable;
export { DocumentThumbnailImage }; export { DocumentThumbnailImage };
export const createDocumentsTableHeaderActions = ({
viewMode,
onViewModeChange,
onRefresh,
onShowSkeuoWorkspace,
}) => {
const isGridView = viewMode === 'grid';
return (
<>
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
<button
type="button"
className="icon-button"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</>
);
};
export const createDocumentsSurface = ({
tableProps,
parentBreadcrumb,
onNavigateParent,
renderSidebarToggle,
detailProps,
detailOpen = false,
}) => {
const {
currentFolderName,
searchResults,
viewMode,
onViewModeChange,
onRefresh,
onShowSkeuoWorkspace,
} = tableProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
const subtitle = Array.isArray(searchResults)
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
: null;
const actions = createDocumentsTableHeaderActions({
viewMode,
onViewModeChange,
onRefresh,
onShowSkeuoWorkspace,
});
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const parentControl = parentBreadcrumb
? (
<button
type="button"
className="icon-button"
onClick={onNavigateParent}
aria-label="Go to parent folder"
title="Go to parent folder"
>
<ArrowUpIcon />
</button>
)
: null;
const leading = sidebarToggle || parentControl
? (
<>
{sidebarToggle}
{parentControl}
</>
)
: null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
return {
key: 'documents',
variant: 'documents',
header: { title, subtitle, leading, actions },
content: <DocumentsTable {...tableProps} showHeader={false} />,
detail,
};
};
+1
View File
@@ -6,6 +6,7 @@
<title>Papercrate</title> <title>Papercrate</title>
</head> </head>
<body> <body>
<script src="/config.js"></script>
<main id="app"></main> <main id="app"></main>
</body> </body>
</html> </html>
+1378 -1705
View File
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,123 +0,0 @@
import React, { useState } from 'react';
const StatusBanner = ({ status }) => {
if (!status) return null;
return <div className={`status-banner ${status.variant}`}>{status.message}</div>;
};
const LoginView = ({
status,
tenantSelection,
onSelectTenant,
onCancelSelection,
selectingTenantId,
onPasskeyLogin,
onSignup,
passkeySupported = false,
passkeyLoading = false,
signupSupported = false,
signupLoading = false,
}) => {
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
const [username, setUsername] = useState('');
const handlePasskeyClick = () => {
if (!onPasskeyLogin) {
return;
}
onPasskeyLogin(username);
};
const handleSignupClick = () => {
if (!onSignup) {
return;
}
onSignup(username);
};
const handleSubmit = (event) => {
event.preventDefault();
if (!passkeySupported || passkeyLoading || !username.trim()) {
return;
}
handlePasskeyClick();
};
return (
<div className="login-screen">
<div className="login-card">
<h1>Papercrate</h1>
{hasTenantSelection ? (
<div className="login-card__selection">
<p>Select a tenant to finish signing in.</p>
<div className="login-card__tenant-list">
{tenantSelection.tenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => onSelectTenant?.(tenant)}
disabled={Boolean(selectingTenantId)}
className={
selectingTenantId === tenant.id
? 'login-card__tenant-button is-loading'
: 'login-card__tenant-button'
}
>
{tenant.name}
</button>
))}
</div>
<button
type="button"
className="login-card__back-button"
onClick={() => onCancelSelection?.()}
disabled={Boolean(selectingTenantId)}
>
Use a different account
</button>
</div>
) : (
<>
<p>Use your registered passkey to sign in or create a new account.</p>
<form className="login-card__fields" onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
autoComplete="username"
required
/>
{passkeySupported ? (
<button
type="submit"
className="login-card__passkey-button"
disabled={passkeyLoading || !username.trim()}
>
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
</button>
) : (
<p className="settings-empty">Passkeys are not supported in this browser.</p>
)}
</form>
{signupSupported ? (
<button
type="button"
className="login-card__signup-button"
onClick={handleSignupClick}
disabled={signupLoading || !username.trim()}
>
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
</button>
) : null}
</>
)}
<StatusBanner status={status} />
</div>
</div>
);
};
export default LoginView;
+55 -183
View File
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
import { openOcrTextInNewTab } from '../utils/ocr';
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
previewEntry, previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => { }) => {
if (!document) { if (!document) {
return null; return null;
@@ -13,63 +15,59 @@ const PreviewWorkspace = ({
const title = document.title || document.original_name || 'Document'; const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0; const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata = const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null; document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
const folderName = document.folder_path || document.folder_name || null;
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
const createdAt = document.created_at || null;
const updatedAt = document.updated_at || null;
const tags = Array.isArray(document.tags) ? document.tags : [];
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
const metadataSummary = (() => {
const rows = [];
if (mime) rows.push(['Type', mime]);
if (sizeLabel) rows.push(['Size', sizeLabel]);
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
if (folderName) rows.push(['Folder', folderName]);
if (tags.length) {
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
}
if (correspondents.length) {
rows.push([
'Correspondents',
correspondents
.map((entry) => entry.name || entry.label || entry.slug)
.filter(Boolean)
.join(', '),
]);
}
return rows;
})();
return ( return (
<section className="preview-workspace"> <section className="preview-workspace">
<aside className="preview-workspace__sidebar"> <header className="preview-workspace__header">
<div className="preview-workspace__info"> <div className="preview-workspace__meta">
{metadataSummary.length ? ( <button
<dl className="preview-workspace__summary"> type="button"
{metadataSummary.map(([label, value]) => ( className="secondary"
<div className="preview-workspace__summary-row" key={label}> onClick={() => onClose(document.folder_id ?? 'root')}
<dt>{label}</dt> >
<dd>{value || '—'}</dd> Back
</div> </button>
))} <div>
</dl> <h2>{title}</h2>
) : null} <span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div> </div>
{metadata ? ( <div className="preview-workspace__actions">
<section className="preview-workspace__metadata"> <a
<h4>Metadata payload</h4> className="button-link with-icon"
<pre>{JSON.stringify(metadata, null, 2)}</pre> href={downloadHref || '#'}
</section> target="_blank"
) : null} rel="noopener noreferrer"
</aside> aria-disabled={!downloadHref}
<div className="preview-workspace__viewer"> onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? ( {!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div> <div className="preview-workspace__message">Loading preview</div>
) : ( ) : (
@@ -80,141 +78,15 @@ const PreviewWorkspace = ({
/> />
)} )}
</div> </div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section> </section>
); );
}; };
export default PreviewWorkspace; export default PreviewWorkspace;
export const createPreviewWorkspaceHeaderActions = ({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}) => {
if (!document) {
return null;
}
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const ocrAsset = getDocumentAsset(document, 'ocr-text');
const hasOcr = Boolean(ocrAsset);
const handleOcrClick = async () => {
try {
const success = await openOcrTextInNewTab({
document,
getDocumentAsset,
ensureAssetUrl,
});
if (!success) {
throw new Error('OCR text URL unavailable.');
}
} catch (error) {
notifyApiError(error, 'Unable to open OCR text.');
}
};
return (
<>
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
{hasOcr ? (
<button
type="button"
className="icon-button"
onClick={handleOcrClick}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
<button
type="button"
className="icon-button"
onClick={() => onRegenerate(document.id)}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
</>
);
};
export const createPreviewSurface = ({
document,
previewEntry,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
onClose,
renderSidebarToggle,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document preview';
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const closeButton = onClose
? (
<button
type="button"
className="icon-button"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const leading = sidebarToggle || closeButton
? (
<>
{sidebarToggle}
{closeButton}
</>
)
: null;
const header = {
title,
subtitle: null,
leading,
actions: createPreviewWorkspaceHeaderActions({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}),
};
return {
key: 'preview',
variant: 'preview',
header,
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
supportsDetail: false,
};
};
+27 -8
View File
@@ -1,13 +1,18 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => { const DocumentViewerRoute = () => {
const { const {
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData, ensurePreviewData,
notifyApiError, notifyApiError,
resolveApiPath,
} = useAppShell(); } = useAppShell();
const { documentId } = useParams(); const { documentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -39,15 +44,29 @@ const DocumentViewerRoute = () => {
}; };
}, [documentId, ensurePreviewData, notifyApiError, navigate]); }, [documentId, ensurePreviewData, notifyApiError, navigate]);
if (!documentId) { const isReady =
return <Navigate to="/documents" replace />; documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
} }
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) { return (
return <div className="preview-workspace__message">Loading preview</div>; <main className="preview-main">
} <PreviewWorkspace
document={previewWorkspaceDocument}
return <Navigate to="/documents" replace />; previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}; };
export default DocumentViewerRoute; export default DocumentViewerRoute;
-445
View File
@@ -1,445 +0,0 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
const SECTIONS = [
{
id: 'passkeys',
label: 'Passkeys',
},
{
id: 'webdav',
label: 'WebDAV',
},
];
const SettingsModal = ({
open,
onClose,
tokens = [],
loading = false,
creating = false,
deletingId = null,
onRefresh,
onCreate,
onDelete,
createdToken = null,
onDismissCreatedToken,
passkeys = [],
passkeysSupported = null,
passkeysLoading = false,
registeringPasskey = false,
revokingPasskeyId = null,
onRefreshPasskeys,
onRegisterPasskey,
onRevokePasskey,
}) => {
const defaultSection = SECTIONS[0]?.id || 'passkeys';
const [activeSection, setActiveSection] = useState(defaultSection);
const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState('');
const [formError, setFormError] = useState(null);
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
const handleBackdropClick = useCallback(() => {
onClose?.();
}, [onClose]);
const handleInnerClick = useCallback((event) => {
event.stopPropagation();
}, []);
useEffect(() => {
if (!open) {
setActiveSection(defaultSection);
setNewTokenLabel('');
setNewTokenExpires('');
setFormError(null);
setNewPasskeyNickname('');
}
}, [open, defaultSection]);
const formatDateTime = useCallback((value) => {
if (!value) {
return '—';
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return value;
}
return timestamp.toLocaleString();
}, []);
const handleRefresh = useCallback(() => {
onRefresh?.();
}, [onRefresh]);
const handleCopyToken = useCallback(() => {
if (!createdToken) {
return;
}
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(createdToken).catch(() => {});
}
}, [createdToken]);
const handleDismissSecret = useCallback(() => {
onDismissCreatedToken?.();
}, [onDismissCreatedToken]);
const handlePasskeyRefresh = useCallback(() => {
onRefreshPasskeys?.();
}, [onRefreshPasskeys]);
const handlePasskeyRegister = useCallback(
async (event) => {
event.preventDefault();
const nickname = newPasskeyNickname.trim();
const result = await onRegisterPasskey?.({ nickname });
if (result?.ok) {
setNewPasskeyNickname('');
}
},
[newPasskeyNickname, onRegisterPasskey],
);
const handlePasskeyRevoke = useCallback(
async (passkey) => {
if (!passkey?.id) {
return;
}
const reasonInput = window.prompt('Optional reason for revoking this passkey:', '');
const reason = reasonInput ? reasonInput.trim() : undefined;
await onRevokePasskey?.(passkey.id, reason);
},
[onRevokePasskey],
);
const handleCreateToken = useCallback(
async (event) => {
event.preventDefault();
setFormError(null);
let normalizedLabel = newTokenLabel.trim();
if (normalizedLabel.length === 0) {
normalizedLabel = undefined;
}
let normalizedExpires;
if (newTokenExpires) {
const parsed = new Date(newTokenExpires);
if (Number.isNaN(parsed.getTime())) {
setFormError('Enter a valid expiration date.');
return;
}
normalizedExpires = parsed.toISOString();
}
const result = await onCreate?.({
label: normalizedLabel,
expires_at: normalizedExpires,
});
if (result !== false) {
setNewTokenLabel('');
setNewTokenExpires('');
setFormError(null);
}
},
[newTokenExpires, newTokenLabel, onCreate],
);
const renderWebdavSection = useMemo(() => {
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
return (
<div className="settings-section">
<div className="settings-actions">
<button
type="button"
className="secondary"
onClick={handleRefresh}
disabled={loading}
>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{createdToken ? (
<div className="settings-notice">
<p>
Copy this token now; you will not be able to view it again after closing this window.
</p>
<pre className="token-display">{createdToken}</pre>
<div className="settings-notice__actions">
<button type="button" className="secondary" onClick={handleCopyToken}>
Copy token
</button>
<button type="button" onClick={handleDismissSecret}>
Dismiss
</button>
</div>
</div>
) : null}
<form className="settings-form" onSubmit={handleCreateToken}>
<div className="settings-form__field">
<label htmlFor="webdav-token-label">Label</label>
<input
id="webdav-token-label"
type="text"
value={newTokenLabel}
onChange={(event) => setNewTokenLabel(event.target.value)}
placeholder="Personal WebDAV token"
/>
</div>
<div className="settings-form__field">
<label htmlFor="webdav-token-expires">Expires at</label>
<input
id="webdav-token-expires"
type="datetime-local"
value={newTokenExpires}
onChange={(event) => setNewTokenExpires(event.target.value)}
/>
</div>
<div className="settings-form__actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create token'}
</button>
</div>
</form>
{formError ? <p className="settings-form__error">{formError}</p> : null}
{loading && !hasTokens ? (
<p className="settings-empty">Loading tokens</p>
) : null}
{!loading && !hasTokens ? (
<p className="settings-empty">No WebDAV tokens yet.</p>
) : null}
{hasTokens ? (
<table className="settings-table">
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Expires</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const isRevoked = Boolean(token?.revoked_at);
return (
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
<td>{token.label || '—'}</td>
<td>{formatDateTime(token.created_at)}</td>
<td>{formatDateTime(token.last_used_at)}</td>
<td>{formatDateTime(token.expires_at)}</td>
<td className="settings-table__actions">
{isRevoked ? (
<span className="settings-status">Revoked</span>
) : (
<button
type="button"
className="danger"
onClick={() => onDelete?.(token.id)}
disabled={deletingId === token.id}
>
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}, [
tokens,
loading,
createdToken,
creating,
deletingId,
newTokenLabel,
newTokenExpires,
formError,
formatDateTime,
handleCopyToken,
handleCreateToken,
handleRefresh,
onDelete,
handleDismissSecret,
]);
const renderPasskeysSection = useMemo(() => {
const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0;
return (
<div className="settings-section">
<div className="settings-actions">
<button
type="button"
className="secondary"
onClick={handlePasskeyRefresh}
disabled={passkeysLoading}
>
{passkeysLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{passkeysSupported === false ? (
<p className="settings-empty">Passkeys are not enabled for this account.</p>
) : (
<>
<form className="settings-form" onSubmit={handlePasskeyRegister}>
<div className="settings-form__field">
<label htmlFor="passkey-nickname">Nickname (optional)</label>
<input
id="passkey-nickname"
type="text"
placeholder="e.g. MacBook"
value={newPasskeyNickname}
onChange={(event) => setNewPasskeyNickname(event.target.value)}
disabled={registeringPasskey}
/>
</div>
<div className="settings-form__actions">
<button type="submit" disabled={registeringPasskey}>
{registeringPasskey ? 'Registering…' : 'Register passkey'}
</button>
</div>
</form>
{passkeysLoading && !hasPasskeys ? (
<p className="settings-empty">Loading passkeys</p>
) : null}
{!passkeysLoading && !hasPasskeys ? (
<p className="settings-empty">No passkeys registered yet.</p>
) : null}
{hasPasskeys ? (
<table className="settings-table">
<thead>
<tr>
<th scope="col">Nickname</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Transports</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{passkeys.map((passkey) => {
const createdAt = passkey.created_at || passkey.createdAt;
const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt;
const revokedAt = passkey.revoked_at || passkey.revokedAt;
const revokedReason = passkey.revoked_reason || passkey.revokedReason;
const revoked = Boolean(revokedAt);
const transports = Array.isArray(passkey.transports)
? passkey.transports.filter(Boolean)
: [];
return (
<tr key={passkey.id} className={revoked ? 'is-revoked' : undefined}>
<td>{passkey.nickname || '—'}</td>
<td>{formatDateTime(createdAt)}</td>
<td>{formatDateTime(lastUsedAt)}</td>
<td>{transports.length ? transports.join(', ') : '—'}</td>
<td>
{revoked
? revokedReason
? `Revoked (${revokedReason})`
: 'Revoked'
: 'Active'}
</td>
<td className="settings-table__actions">
{revoked ? (
<span className="settings-status">Revoked</span>
) : (
<button
type="button"
className="danger"
onClick={() => handlePasskeyRevoke(passkey)}
disabled={revokingPasskeyId === passkey.id}
>
{revokingPasskeyId === passkey.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</>
)}
</div>
);
}, [
passkeys,
passkeysLoading,
passkeysSupported,
registeringPasskey,
revokingPasskeyId,
newPasskeyNickname,
formatDateTime,
handlePasskeyRefresh,
handlePasskeyRegister,
handlePasskeyRevoke,
]);
if (!open) {
return null;
}
return (
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
<div
className="modal modal--panel settings-modal"
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title"
onClick={handleInnerClick}
>
<div className="panel-modal__header">
<h3 id="settings-modal-title">Settings</h3>
<button type="button" className="secondary" onClick={onClose}>
Close
</button>
</div>
<div className="settings-modal__body">
<nav className="settings-modal__sidebar" aria-label="Settings sections">
<ul>
{SECTIONS.map((section) => (
<li key={section.id}>
<button
type="button"
className={section.id === activeSection ? 'active' : ''}
onClick={() => setActiveSection(section.id)}
>
{section.label}
</button>
</li>
))}
</ul>
</nav>
<div className="settings-modal__content">
{activeSection === 'passkeys' ? renderPasskeysSection : null}
{activeSection === 'webdav' ? renderWebdavSection : null}
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
<p>Select a settings section.</p>
) : null}
</div>
</div>
</div>
</div>
);
};
export default SettingsModal;
-140
View File
@@ -1,140 +0,0 @@
import { useState, useCallback } from 'react';
import {
isWebAuthnAvailable,
preparePublicKeyCreationOptions,
serializeRegistrationCredential,
} from '../utils/webauthn';
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => {
const [passkeys, setPasskeys] = useState([]);
const [passkeysSupported, setPasskeysSupported] = useState(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [revokingPasskeyId, setRevokingPasskeyId] = useState(null);
const refreshPasskeys = useCallback(async () => {
if (!token) {
return;
}
setPasskeysLoading(true);
try {
const { data } = await api.get('/profile/passkeys');
setPasskeys(Array.isArray(data) ? data : []);
setPasskeysSupported(true);
} catch (error) {
const status = error?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
setPasskeys([]);
} else {
notifyApiError(error, 'Failed to load passkeys.');
}
} finally {
setPasskeysLoading(false);
}
}, [api, notifyApiError, token]);
const registerPasskey = useCallback(
async ({ nickname } = {}) => {
if (!isWebAuthnAvailable()) {
setPasskeysSupported(false);
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return { ok: false, reason: 'unsupported' };
}
if (registeringPasskey) {
return { ok: false, reason: 'busy' };
}
setRegisteringPasskey(true);
try {
const { data } = await api.post('/auth/passkeys/register/start', {});
const challengeId = data?.challengeId || data?.challenge_id;
const publicKeyOptions =
data?.publicKey
|| data?.public_key
|| data?.challenge?.publicKey
|| data?.publicKeyCredentialCreationOptions;
if (!challengeId || !publicKeyOptions) {
throw new Error('Invalid passkey challenge response.');
}
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
return { ok: false, reason: 'cancelled' };
}
const serialized = serializeRegistrationCredential(credential);
const payload = {
challengeId,
credential: serialized,
};
const trimmedNickname = nickname?.trim();
if (trimmedNickname) {
payload.nickname = trimmedNickname;
}
await api.post('/auth/passkeys/register/finish', payload);
await refreshPasskeys();
setPasskeysSupported(true);
setStatusMessage('Passkey registered.', 'success');
return { ok: true };
} catch (error) {
if (error?.name === 'NotAllowedError') {
setStatusMessage('Passkey registration cancelled.', 'info');
return { ok: false, reason: 'cancelled' };
}
const status = error?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
}
const message = error?.response?.data?.error || 'Failed to register passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRegisteringPasskey(false);
}
},
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
);
const revokePasskey = useCallback(
async (passkeyId, reason) => {
if (!passkeyId) {
return { ok: false, reason: 'missing-id' };
}
setRevokingPasskeyId(passkeyId);
try {
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
await refreshPasskeys();
setStatusMessage('Passkey revoked.', 'success');
return { ok: true };
} catch (error) {
const message = error?.response?.data?.error || 'Failed to revoke passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRevokingPasskeyId(null);
}
},
[api, notifyApiError, refreshPasskeys, setStatusMessage],
);
return {
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
};
};
export default usePasskeys;
+65 -307
View File
@@ -1,18 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
ChevronsLeftIcon,
LogoutIcon,
ChevronDownIcon,
SettingsIcon,
CheckIcon,
PlusIcon,
FolderPlusIcon,
RestoreIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
@@ -33,13 +20,13 @@ const FolderNode = ({
draggingFolderId, draggingFolderId,
}) => { }) => {
const isRoot = node.id === 'root'; const isRoot = node.id === 'root';
const hasChildren = Boolean(node.hasChildren); const hasChildren = node.children.length > 0;
const canToggle = hasChildren || !node.loaded; const canToggle = !isRoot && (hasChildren || !node.loaded);
const showChevron = canToggle; const showChevron = !isRoot && hasChildren;
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null; const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
const canDrag = !isRoot; const canDrag = !isRoot;
const isDragging = draggingFolderId === node.id; const isDragging = draggingFolderId === node.id;
const isExpanded = Boolean(node.expanded); const isExpanded = isRoot ? true : Boolean(node.expanded);
const rowClasses = ['folder-row']; const rowClasses = ['folder-row'];
if (isSelected) { if (isSelected) {
rowClasses.push('active'); rowClasses.push('active');
@@ -71,12 +58,14 @@ const FolderNode = ({
} }
}} }}
> >
<span {!isRoot && (
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`} <span
onClick={handleToggleClick} className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
> onClick={handleToggleClick}
{icon} >
</span> {icon}
</span>
)}
<span className="name"> <span className="name">
<FolderIcon className="folder-icon-image" size={16} /> <FolderIcon className="folder-icon-image" size={16} />
{node.name} {node.name}
@@ -141,10 +130,6 @@ const Sidebar = ({
onFolderDragStart, onFolderDragStart,
onFolderDragEnd, onFolderDragEnd,
draggedFolderId, draggedFolderId,
onCreateFolder,
creatingFolder = false,
onNeutralHueChange,
neutralHue,
tags = [], tags = [],
activeTagIds = [], activeTagIds = [],
onToggleTagFilter, onToggleTagFilter,
@@ -153,21 +138,17 @@ const Sidebar = ({
onToggleCorrespondentFilter, onToggleCorrespondentFilter,
onManageTags, onManageTags,
onManageCorrespondents, onManageCorrespondents,
onCreateTag,
onCreateCorrespondent,
searchQuery = '', searchQuery = '',
onSearchChange, onSearchChange,
onSearchSubmit, onSearchSubmit,
onSearchClear, onSearchClear,
isFilterActive, isFilterActive,
appStatus,
loading,
previewActive,
onLogout, onLogout,
status, status,
onCollapse, onCollapse,
tenantName,
tenants = [],
activeTenantId = null,
onSelectTenant,
onOpenSettings,
}) => { }) => {
const sortedCorrespondents = useMemo( const sortedCorrespondents = useMemo(
() => () =>
@@ -184,49 +165,6 @@ const Sidebar = ({
const activeTagSet = new Set(activeTagIds); const activeTagSet = new Set(activeTagIds);
const handleManageTags = onManageTags || (() => {}); const handleManageTags = onManageTags || (() => {});
const handleManageCorrespondents = onManageCorrespondents || (() => {}); const handleManageCorrespondents = onManageCorrespondents || (() => {});
const handleCreateTag = useCallback(async () => {
const input = window.prompt('New tag name');
if (!input) {
return;
}
const trimmed = input.trim();
if (!trimmed) {
return;
}
try {
await onCreateTag?.(trimmed);
} catch (error) {
console.error('[sidebar] failed to create tag', error);
}
}, [onCreateTag]);
const handleCreateCorrespondent = useCallback(async () => {
const input = window.prompt('New correspondent name');
if (!input) {
return;
}
const trimmed = input.trim();
if (!trimmed) {
return;
}
try {
await onCreateCorrespondent?.(trimmed);
} catch (error) {
console.error('[sidebar] failed to create correspondent', error);
}
}, [onCreateCorrespondent]);
const handleCreateFolder = useCallback(() => {
if (creatingFolder) {
return;
}
onCreateFolder?.();
}, [creatingFolder, onCreateFolder]);
const handleNeutralHueReset = useCallback(() => {
onNeutralHueChange?.('');
}, [onNeutralHueChange]);
const handleSearchInputChange = useCallback( const handleSearchInputChange = useCallback(
(event) => { (event) => {
onSearchChange?.(event.target.value); onSearchChange?.(event.target.value);
@@ -243,68 +181,10 @@ const Sidebar = ({
const handleSearchClear = useCallback(() => { const handleSearchClear = useCallback(() => {
onSearchClear?.(); onSearchClear?.();
}, [onSearchClear]); }, [onSearchClear]);
const tenantButtonRef = useRef(null); const handleLogoutClick = useCallback(() => {
const tenantMenuRef = useRef(null);
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
const showTenantList = tenants.length > 1;
const toggleTenantMenu = useCallback(() => {
const next = !tenantMenuOpen;
setTenantMenuOpen(next);
if (next && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true });
}
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
useEffect(() => {
if (!tenantMenuOpen) {
return undefined;
}
const handlePointer = (event) => {
const menuNode = tenantMenuRef.current;
const buttonNode = tenantButtonRef.current;
if (!menuNode) return;
if (menuNode.contains(event.target)) return;
if (buttonNode && buttonNode.contains(event.target)) return;
setTenantMenuOpen(false);
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setTenantMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKeyDown);
};
}, [tenantMenuOpen]);
const handleTenantSelect = useCallback(
(tenant) => {
const targetId = tenant?.id || null;
if (!targetId) {
return;
}
setTenantMenuOpen(false);
onSelectTenant?.(tenant);
},
[onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onLogout?.(); onLogout?.();
}, [onLogout]); }, [onLogout]);
const handleSettingsFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onOpenSettings?.();
}, [onOpenSettings]);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
ids.map((id) => { ids.map((id) => {
@@ -347,80 +227,17 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
const hintText = appStatus === 'bootstrapping' && loading
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.';
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="panel-header sidebar__header"> <div className="panel-header sidebar__header">
<div className="panel-actions"> <div className="panel-actions">
<button <h1 className="sidebar__title">Papercrate</h1>
type="button"
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
onClick={toggleTenantMenu}
aria-haspopup="true"
aria-expanded={tenantMenuOpen}
ref={tenantButtonRef}
>
<span className="sidebar__title">
Papercrate
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuOpen ? (
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name || tenant?.slug || tenantId || 'Tenant';
return (
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
</>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
<div className="spacer" /> <div className="spacer" />
{onCollapse ? ( {onCollapse ? (
<button <button
@@ -436,6 +253,7 @@ const Sidebar = ({
</div> </div>
</div> </div>
<div className="panel-body sidebar__body"> <div className="panel-body sidebar__body">
<span className="sidebar__hint">{hintText}</span>
{status && ( {status && (
<div className="sidebar__status"> <div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div> <div className={`status-banner ${status.variant}`}>{status.message}</div>
@@ -460,19 +278,6 @@ const Sidebar = ({
<div className="sidebar-section sidebar-section--folders"> <div className="sidebar-section sidebar-section--folders">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<h3>Folders</h3> <h3>Folders</h3>
{onCreateFolder ? (
<div className="sidebar-section__actions">
<button
type="button"
className="icon-button"
onClick={handleCreateFolder}
aria-label="Create folder"
disabled={creatingFolder}
>
<FolderPlusIcon size={16} />
</button>
</div>
) : null}
</div> </div>
<ul className="folder-tree"> <ul className="folder-tree">
{rootNode && renderNodes([rootNode.id], 0)} {rootNode && renderNodes([rootNode.id], 0)}
@@ -480,26 +285,14 @@ const Sidebar = ({
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<h3>Tags</h3> <button
<div className="sidebar-section__actions"> type="button"
<button className="sidebar-section__title"
type="button" onClick={handleManageTags}
className="icon-button" >
onClick={handleCreateTag} <h3>Tags</h3>
aria-label="Create tag"
>
<PlusIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={handleManageTags}
aria-label="Manage tags"
>
<SettingsIcon size={16} />
</button>
<span className="meta">{tags.length}</span> <span className="meta">{tags.length}</span>
</div> </button>
</div> </div>
<div <div
className={`sidebar-tag-cloud${ className={`sidebar-tag-cloud${
@@ -520,50 +313,39 @@ const Sidebar = ({
style={style || undefined} style={style || undefined}
onClick={() => handleToggleTag(tag.id)} onClick={() => handleToggleTag(tag.id)}
aria-pressed={isActive} aria-pressed={isActive}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
try { try {
const payload = JSON.stringify({ const payload = JSON.stringify({
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color || null, color: tag.color || null,
}); });
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload); event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload); event.dataTransfer.setData('text/papercrate-tag', payload);
} catch (error) { } catch (
console.warn('[sidebar] Failed to set tag drag payload', error); // eslint-disable-next-line no-empty
} error
}} ) {}
> }}
{tag.label} >
</button> {tag.label}
); </button>
);
})} })}
</div> </div>
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<h3>Correspondents</h3> <button
<div className="sidebar-section__actions"> type="button"
<button className="sidebar-section__title"
type="button" onClick={handleManageCorrespondents}
className="icon-button" >
onClick={handleCreateCorrespondent} <h3>Correspondents</h3>
aria-label="Create correspondent"
>
<PlusIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={handleManageCorrespondents}
aria-label="Manage correspondents"
>
<SettingsIcon size={16} />
</button>
<span className="meta">{correspondents.length}</span> <span className="meta">{correspondents.length}</span>
</div> </button>
</div> </div>
<ul className="sidebar-correspondent-list"> <ul className="sidebar-correspondent-list">
{sortedCorrespondents.map((correspondent) => { {sortedCorrespondents.map((correspondent) => {
@@ -594,35 +376,11 @@ const Sidebar = ({
})} })}
</ul> </ul>
</div> </div>
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? ( <div className="sidebar__footer">
<div className="sidebar-section"> <button className="secondary" type="button" onClick={handleLogoutClick}>
<div className="sidebar-section__header"> Log out
<h3>Theme</h3> </button>
<div className="sidebar-section__actions"> </div>
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="sidebar-slider">
<span className="sidebar-slider__label">Neutral hue</span>
<input
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => onNeutralHueChange?.(event.target.value)}
/>
<span className="sidebar-slider__value">{neutralHue}°</span>
</label>
</div>
) : null}
</div> </div>
</aside> </aside>
); );

Some files were not shown because too many files have changed in this diff Show More