Review pass

This commit is contained in:
Andras Schmelczer 2026-09-19 19:51:08 +01:00
commit 99fa298b92
25 changed files with 621 additions and 94 deletions

View file

@ -2135,8 +2135,10 @@ dependencies = [
"serde_yaml",
"sha2",
"sqlx",
"tempfile",
"thiserror 2.0.17",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",

View file

@ -36,6 +36,10 @@ ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl", "no-serde-w
base64 = "0.22.1"
reconcile-text = { version = "0.8.0", features = ["serde"] }
[dev-dependencies]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
[profile.release]
codegen-units = 1
lto = true

View file

@ -93,6 +93,23 @@ endpoint uses the same history, so losing the final notification is recoverable.
A fresh client reads `/vault_snapshot`, applies it durably, then replays after its
watermark.
Cursor notifications are ephemeral and do not consume event IDs.
Cursor expiry and disconnect broadcast the remaining clients, including an empty
list when the last client leaves.
## Vault names and database files
Vault names and configured allowlists are trimmed and lowercased consistently.
Empty names, `.` and `..`, path separators, and control characters are rejected.
Other Unicode spellings remain distinct identities, including composed and
decomposed characters.
Databases use `vaults/<sha256-of-normalized-vault-name>.sqlite` underneath the
configured database directory. This prevents filesystem case and Unicode aliases
from sharing a database. This is the only supported storage layout; top-level
`<vault-name>.sqlite` files are not loaded or migrated. Back up the entire database
directory, including the `vaults` subdirectory.
Vault snapshots query metadata and byte lengths without loading document contents.
SQLite uses WAL, `synchronous=FULL`, and `fullfsync` where supported. On Unix, startup also
flushes database directory entries; the filesystem must support those durability

View file

@ -39,7 +39,9 @@ impl Cursors {
) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await;
let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new);
let all_device_cursors = vault_to_cursors
.entry(vault_id.clone())
.or_insert_with(Vec::new);
all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id);
all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors {
@ -48,8 +50,7 @@ impl Cursors {
documents_with_cursors: document_to_cursors,
}));
drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock
self.broadcast_cursors().await;
self.broadcast_cursors(&vault_id, all_device_cursors).await;
}
pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec<ClientCursors> {
@ -78,31 +79,42 @@ impl Cursors {
async fn remove_expired_cursors(&self) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await;
for (_vault_id, cursors) in vault_to_cursors.iter_mut() {
for (vault_id, cursors) in vault_to_cursors.iter_mut() {
let previous_len = cursors.len();
cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout));
if cursors.len() != previous_len {
self.broadcast_cursors(vault_id, cursors).await;
}
}
vault_to_cursors.retain(|_, cursors| !cursors.is_empty());
}
async fn broadcast_cursors(&self) {
let vault_to_cursors = self.vault_to_cursors.lock().await;
for (vault_id, cursors) in vault_to_cursors.iter() {
self.broadcasts
.send(
vault_id.clone(),
Notification::Cursors(CursorPositionFromServer {
clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(),
}),
)
.await;
}
async fn broadcast_cursors(&self, vault_id: &str, cursors: &[ClientCursorsWithTimeToLive]) {
self.broadcasts
.send(
vault_id.to_owned(),
Notification::Cursors(CursorPositionFromServer {
clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(),
}),
)
.await;
}
pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await;
if let Some(cursors) = vault_to_cursors.get_mut(vault_id) {
let previous_len = cursors.len();
cursors.retain(|c| c.client_cursors.device_id != device_id);
if cursors.len() != previous_len {
self.broadcast_cursors(vault_id, cursors).await;
}
if cursors.is_empty() {
vault_to_cursors.remove(vault_id);
}
}
}
}
@ -125,3 +137,96 @@ impl ClientCursorsWithTimeToLive {
self.last_updated.elapsed() > ttl
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::server_config::ServerConfig;
#[tokio::test]
async fn disconnect_broadcasts_remaining_clients_and_the_final_empty_state() {
let broadcasts = Broadcasts::new(&ServerConfig::default());
let cursors = Cursors::new(&DatabaseConfig::default(), &broadcasts);
for device in ["one", "two"] {
cursors
.update_cursors(
"vault".to_owned(),
"user".to_owned(),
&device.to_owned(),
vec![],
)
.await;
}
let mut receiver = broadcasts.get_receiver("vault".to_owned()).await;
let mut other = broadcasts.get_receiver("other".to_owned()).await;
cursors.remove_cursors_of_device("vault", "one").await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert_eq!(update.clients.len(), 1);
assert_eq!(update.clients[0].device_id, "two");
cursors.remove_cursors_of_device("vault", "two").await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert!(update.clients.is_empty());
assert!(other.try_recv().is_err());
cursors.remove_cursors_of_device("vault", "two").await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn expiration_broadcasts_only_changed_vaults_including_empty_ones() {
let broadcasts = Broadcasts::new(&ServerConfig::default());
let config = DatabaseConfig::default();
let cursors = Cursors::new(&config, &broadcasts);
for (vault, device) in [("vault", "expired"), ("vault", "live"), ("other", "live")] {
cursors
.update_cursors(
vault.to_owned(),
"user".to_owned(),
&device.to_owned(),
vec![],
)
.await;
}
cursors
.vault_to_cursors
.lock()
.await
.get_mut("vault")
.unwrap()[0]
.last_updated = std::time::Instant::now()
.checked_sub(config.cursor_timeout + Duration::from_secs(1))
.unwrap();
let mut receiver = broadcasts.get_receiver("vault".to_owned()).await;
let mut other = broadcasts.get_receiver("other".to_owned()).await;
cursors.remove_expired_cursors().await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert_eq!(update.clients.len(), 1);
assert_eq!(update.clients[0].device_id, "live");
assert!(other.try_recv().is_err());
cursors
.vault_to_cursors
.lock()
.await
.get_mut("vault")
.unwrap()[0]
.last_updated = std::time::Instant::now()
.checked_sub(config.cursor_timeout + Duration::from_secs(1))
.unwrap();
cursors.remove_expired_cursors().await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert!(update.clients.is_empty());
assert!(cursors.get_cursors(&"vault".to_owned()).await.is_empty());
cursors.remove_expired_cursors().await;
assert!(receiver.try_recv().is_err());
assert!(other.try_recv().is_err());
}
}

View file

@ -1,9 +1,14 @@
use core::time::Duration;
use std::{collections::HashMap, sync::Arc};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context as _, Result};
use log::info;
use models::VaultId;
use sha2::{Digest, Sha256};
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
use tokio::sync::Mutex;
@ -14,7 +19,15 @@ pub mod models;
mod mutations;
mod queries;
use crate::config::database_config::DatabaseConfig;
#[cfg(test)]
mod tests;
use crate::{
config::database_config::DatabaseConfig,
utils::normalize_vault_id::{normalize_string, validate_vault_id},
};
const VAULT_DATABASE_DIRECTORY: &str = "vaults";
#[derive(Clone)]
struct PoolWithTimestamp {
@ -41,7 +54,10 @@ pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
impl Database {
pub async fn try_new(config: &DatabaseConfig) -> Result<Self> {
tokio::fs::create_dir_all(&config.databases_directory_path)
let vault_directory = config
.databases_directory_path
.join(VAULT_DATABASE_DIRECTORY);
tokio::fs::create_dir_all(&vault_directory)
.await
.with_context(|| {
format!(
@ -50,34 +66,25 @@ impl Database {
)
})?;
let mut connection_pools = std::collections::HashMap::new();
info!("Applying pending database migrations");
let mut entries = tokio::fs::read_dir(&config.databases_directory_path).await?;
// Filenames encode only a digest, so their vault names cannot be
// recovered here. Check migrations now and open pools lazily by name.
let mut entries = tokio::fs::read_dir(&vault_directory).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_name().to_string_lossy().ends_with(".sqlite") {
continue;
if entry.file_type().await?.is_file()
&& entry.path().extension().is_some_and(|ext| ext == "sqlite")
{
Self::open_database(config, &entry.path())
.await?
.close()
.await;
}
let vault: VaultId = entry
.file_name()
.to_string_lossy()
.trim_end_matches(".sqlite")
.to_owned();
let pool = Self::create_vault_database(config, &vault).await?;
connection_pools.insert(
vault.clone(),
PoolWithTimestamp {
pool,
last_accessed: Instant::now(),
},
);
}
let database = Self {
config: config.clone(),
connection_pools: Arc::new(Mutex::new(connection_pools)),
connection_pools: Arc::default(),
};
// Start background task to cleanup idle connection pools
@ -86,16 +93,21 @@ impl Database {
Ok(database)
}
async fn create_vault_database(
config: &DatabaseConfig,
vault: &VaultId,
) -> Result<Pool<Sqlite>> {
let file_name = config
fn database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
// Hash the normalized vault name into a fixed-length ASCII filename so
// filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
// macOS) cannot make separately authorized vaults share a database.
// This is only a storage filename; the user-facing vault name is unchanged.
let digest = Sha256::digest(vault.as_bytes());
config
.databases_directory_path
.join(format!("{vault}.sqlite"));
.join(VAULT_DATABASE_DIRECTORY)
.join(format!("{digest:x}.sqlite"))
}
async fn open_database(config: &DatabaseConfig, file_name: &Path) -> Result<Pool<Sqlite>> {
let connection_options = SqliteConnectOptions::new()
.filename(file_name.clone())
.filename(file_name)
.create_if_missing(true)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full)
.busy_timeout(Duration::from_secs(3600))
@ -126,10 +138,13 @@ impl Database {
}
async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> {
let vault = normalize_string(vault);
validate_vault_id(&vault)?;
let mut pools = self.connection_pools.lock().await;
if !pools.contains_key(vault) {
let pool = Self::create_vault_database(&self.config, vault).await?;
if !pools.contains_key(&vault) {
let file_name = Self::database_path(&self.config, &vault);
let pool = Self::open_database(&self.config, &file_name).await?;
pools.insert(
vault.clone(),
PoolWithTimestamp {
@ -140,7 +155,7 @@ impl Database {
}
let pool_with_timestamp = pools
.get_mut(vault)
.get_mut(&vault)
.expect("Pool was just inserted or already exists");
// Update last accessed time

View file

@ -22,16 +22,18 @@ pub struct StoredDocumentVersion {
pub device_id: DeviceId,
}
#[derive(TS, Debug, Clone, Serialize, Deserialize)]
#[derive(TS, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DocumentVersionWithoutContent {
#[ts(as = "f64")]
pub vault_update_id: VaultUpdateId,
#[sqlx(try_from = "uuid::fmt::Hyphenated")]
pub document_id: DocumentId,
pub updated_date: DateTime<Utc>,
pub user_id: UserId,
pub device_id: DeviceId,
#[sqlx(try_from = "i64")]
pub content_size: usize,
}

View file

@ -1,8 +1,8 @@
use super::{
Database, Transaction,
models::{
DocumentId, EventBatch, FileManifest, StoredDocumentVersion, VaultId, VaultSnapshot,
VaultUpdateId,
DocumentId, DocumentVersionWithoutContent, EventBatch, FileManifest, StoredDocumentVersion,
VaultId, VaultSnapshot, VaultUpdateId,
},
};
use anyhow::{Result, ensure};
@ -99,8 +99,10 @@ impl Database {
let head_event_id = Self::latest_event_id(&mut tx).await?;
let file_manifest = Self::current_file_manifest(&mut tx).await?;
let versions = sqlx::query_as::<_, StoredDocumentVersion>(
"SELECT d.* FROM latest_document_versions d
let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
"SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id,
length(d.content) AS content_size
FROM latest_document_versions d
JOIN file_manifest_entries m ON m.document_id = d.document_id
WHERE m.file_manifest_id = ? ORDER BY d.vault_update_id",
)
@ -108,8 +110,6 @@ impl Database {
.fetch_all(&mut *tx)
.await?;
let documents = versions.into_iter().map(Into::into).collect();
Ok(VaultSnapshot {
head_event_id,
file_manifest,

View file

@ -0,0 +1,160 @@
use super::*;
use models::{DocumentId, EventRecord, FileManifest, StoredDocumentVersion, VaultEvent};
fn config(directory: &tempfile::TempDir) -> DatabaseConfig {
DatabaseConfig {
databases_directory_path: directory.path().to_owned(),
..Default::default()
}
}
async fn insert_content(
database: &Database,
vault: &str,
document_id: DocumentId,
content: &[u8],
) -> StoredDocumentVersion {
let mut tx = database
.create_write_transaction(&vault.to_owned())
.await
.unwrap();
let request_id = uuid::Uuid::new_v4();
let version = StoredDocumentVersion {
vault_update_id: Database::allocate_event(&mut tx, request_id, b"test")
.await
.unwrap(),
document_id,
updated_date: chrono::Utc::now(),
content: content.to_vec(),
user_id: "user".to_owned(),
device_id: "device".to_owned(),
};
Database::insert_document_version(&mut tx, &version)
.await
.unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: version.vault_update_id,
request_id,
event: VaultEvent::Content {
document: (&version).into(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap();
version
}
#[tokio::test]
async fn unicode_vaults_remain_isolated_after_reopening() {
let directory = tempfile::tempdir().unwrap();
let config = config(&directory);
let database = Database::try_new(&config).await.unwrap();
let document = uuid::Uuid::new_v4();
let names = ["café", "cafe\u{301}", "σ", "ς"];
for name in names {
assert!(
database
.get_latest_document_version(&name.to_owned(), &document, None)
.await
.unwrap()
.is_none()
);
insert_content(&database, name, document, name.as_bytes()).await;
}
let reopened = Database::try_new(&config).await.unwrap();
for name in names {
let version = reopened
.get_latest_document_version(&name.to_owned(), &document, None)
.await
.unwrap()
.unwrap();
assert_eq!(version.content, name.as_bytes());
}
}
#[tokio::test]
async fn rejects_invalid_vault_names_at_the_database_boundary() {
let directory = tempfile::tempdir().unwrap();
let config = DatabaseConfig {
databases_directory_path: directory.path().join("databases"),
..Default::default()
};
let database = Database::try_new(&config).await.unwrap();
for name in [
"",
" ",
".",
"..",
"../escaped",
"/absolute",
"a/b",
"a\\b",
"a\0b",
] {
assert!(
database.vault_snapshot(&name.to_owned()).await.is_err(),
"accepted {name:?}"
);
}
assert!(!directory.path().join("escaped.sqlite").exists());
assert!(database.connection_pools.lock().await.is_empty());
}
#[tokio::test]
async fn snapshot_returns_latest_referenced_metadata_and_byte_lengths() {
let directory = tempfile::tempdir().unwrap();
let database = Database::try_new(&config(&directory)).await.unwrap();
let vault = "snapshot".to_owned();
let text_id = uuid::Uuid::new_v4();
let binary_id = uuid::Uuid::new_v4();
insert_content(&database, &vault, text_id, b"old").await;
let binary = insert_content(&database, &vault, binary_id, &[0, 255, 0, 128]).await;
let text = insert_content(&database, &vault, text_id, "café 🌍".as_bytes()).await;
insert_content(&database, &vault, uuid::Uuid::new_v4(), b"unreferenced").await;
let mut tx = database.create_write_transaction(&vault).await.unwrap();
let request_id = uuid::Uuid::new_v4();
let manifest = FileManifest {
file_manifest_id: Database::allocate_event(&mut tx, request_id, b"manifest")
.await
.unwrap(),
entries: [
(text_id, "text.md".to_owned()),
(binary_id, "binary.dat".to_owned()),
]
.into(),
};
Database::insert_file_manifest(&mut tx, &manifest)
.await
.unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: manifest.file_manifest_id,
request_id,
event: VaultEvent::FileManifest {
file_manifest: manifest.clone(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let snapshot = database.vault_snapshot(&vault).await.unwrap();
assert_eq!(snapshot.head_event_id, manifest.file_manifest_id);
assert_eq!(snapshot.file_manifest.entries, manifest.entries);
assert_eq!(snapshot.documents.len(), 2);
for (actual, expected) in snapshot.documents.iter().zip([binary, text]) {
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(models::DocumentVersionWithoutContent::from(expected)).unwrap()
);
}
}

View file

@ -7,7 +7,7 @@ use crate::consts::{
DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS,
};
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
@ -28,6 +28,19 @@ pub struct ServerConfig {
pub mergeable_file_extensions: Vec<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
max_body_size_mb: default_max_body_size_mb(),
max_clients_per_vault: default_max_clients_per_vault(),
response_timeout: default_response_timeout(),
mergeable_file_extensions: default_mergeable_file_extensions(),
}
}
}
fn default_host() -> String {
debug!("Using default server host: {DEFAULT_HOST}");
DEFAULT_HOST.to_owned()
@ -60,3 +73,36 @@ fn default_mergeable_file_extensions() -> Vec<String> {
.map(|s| (*s).to_owned())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[tokio::test]
async fn fresh_and_omitted_server_configs_use_the_documented_defaults() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yml");
let fresh = Config::read_or_create(&path).await.unwrap();
let reread = Config::load_from_file(&path).await.unwrap();
let omitted: Config = serde_yaml::from_str("{}").unwrap();
let explicit: Config = serde_yaml::from_str("server: {}").unwrap();
for config in [fresh, reread, omitted, explicit] {
assert_eq!(config.server.host, DEFAULT_HOST);
assert_eq!(config.server.port, DEFAULT_PORT);
assert_eq!(config.server.max_body_size_mb, DEFAULT_MAX_BODY_SIZE_MB);
assert_eq!(
config.server.max_clients_per_vault,
DEFAULT_MAX_CLIENTS_PER_VAULT
);
assert_eq!(
config.server.response_timeout,
DEFAULT_RESPONSE_TIMEOUT_SECONDS
);
assert_eq!(
config.server.mergeable_file_extensions,
DEFAULT_MERGEABLE_FILE_EXTENSIONS
);
}
}
}

View file

@ -2,7 +2,10 @@ use bimap::BiHashMap;
use rand::{Rng, distr::Alphanumeric, rng};
use serde::{Deserialize, Deserializer, Serialize, de::Error};
use crate::app_state::database::models::VaultId;
use crate::{
app_state::database::models::VaultId,
utils::normalize_vault_id::{normalize_string, validate_vault_id},
};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UserConfig {
@ -71,9 +74,23 @@ pub enum VaultAccess {
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct AllowListedVaults {
#[serde(deserialize_with = "deserialize_allowed_vaults")]
pub allowed: Vec<VaultId>,
}
fn deserialize_allowed_vaults<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<VaultId>, D::Error> {
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|name| {
let vault = normalize_string(&name);
validate_vault_id(&vault).map_err(D::Error::custom)?;
Ok(vault)
})
.collect()
}
fn default_users() -> Vec<User> {
vec![User {
name: "admin".to_owned(),
@ -95,6 +112,24 @@ mod tests {
use super::*;
#[test]
fn allowlists_normalize_case_and_whitespace_but_preserve_unicode_identity() {
let list: AllowListedVaults = serde_json::from_value(json!({
"allowed": [" MyVault ", "CAFÉ", "CAFE\u{301}"]
}))
.unwrap();
assert_eq!(list.allowed, ["myvault", "café", "cafe\u{301}"]);
}
#[test]
fn allowlists_reject_invalid_vault_names() {
for name in ["", " ", ".", "..", "../escaped", "a/b", "a\\b", "a\0b"] {
assert!(
serde_json::from_value::<AllowListedVaults>(json!({"allowed": [name]})).is_err()
);
}
}
#[test]
fn test_validate_users_unique_names_and_tokens() {
let config_json = json!({

View file

@ -4,6 +4,9 @@ mod endpoints;
pub(crate) mod requests;
pub(crate) mod responses;
#[cfg(test)]
mod tests;
use anyhow::{Context as _, Result};
use auth::auth_middleware;
use axum::{

View file

@ -14,8 +14,8 @@ use log::info;
use crate::{
app_state::{AppState, database::models::VaultId},
config::user_config::{AllowListedVaults, User, VaultAccess},
errors::{SyncServerError, permission_denied_error, unauthenticated_error},
utils::normalize::normalize_string,
errors::{SyncServerError, client_error, permission_denied_error, unauthenticated_error},
utils::normalize_vault_id::{normalize_string, validate_vault_id},
};
pub async fn auth_middleware(
@ -40,6 +40,7 @@ pub async fn auth_middleware(
}
pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> {
validate_vault_id(vault_id).map_err(client_error)?;
let user = state
.config
.users

View file

@ -11,12 +11,12 @@ use crate::{
database::models::{EventBatch, VaultId},
},
errors::{SyncServerError, client_error, server_error},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct EventsPath {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
}

View file

@ -12,12 +12,12 @@ use crate::{
database::models::{DocumentId, VaultId, VaultUpdateId},
},
errors::{SyncServerError, not_found_error, server_error},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct FetchDocumentVersionContentPathParams {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
document_id: DocumentId,

View file

@ -12,12 +12,12 @@ use crate::{
database::models::{DocumentId, DocumentVersion, VaultId},
},
errors::{SyncServerError, not_found_error, server_error},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct FetchLatestDocumentVersionPathParams {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
document_id: DocumentId,

View file

@ -14,12 +14,12 @@ use crate::{
},
},
errors::{SyncServerError, server_error},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct GetFileManifestPath {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
}

View file

@ -14,12 +14,12 @@ use crate::{
app_state::{AppState, database::models::VaultId},
consts::SUPPORTED_API_VERSION,
errors::SyncServerError,
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct PingPathParams {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
}

View file

@ -14,7 +14,7 @@ use crate::{
requests::{PushContent, PutFileContent},
responses::DocumentUpdateResponse,
},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
use anyhow::anyhow;
use axum::{
@ -30,7 +30,7 @@ use sha2::{Digest, Sha256};
#[derive(Deserialize)]
pub struct PutFileContentPath {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
document_id: DocumentId,
}

View file

@ -18,12 +18,12 @@ use crate::{
},
errors::{SyncServerError, client_error, server_error},
server::{requests::PushFileManifest, responses::FileManifestUpdateResponse},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct PutFileManifestPath {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
}

View file

@ -11,12 +11,12 @@ use crate::{
database::models::{VaultId, VaultSnapshot},
},
errors::{SyncServerError, server_error},
utils::normalize::normalize,
utils::normalize_vault_id::normalize_vault_id,
};
#[derive(Deserialize)]
pub struct VaultSnapshotPath {
#[serde(deserialize_with = "normalize")]
#[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId,
}

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