Clean up Rust by renaming functions, extracting logic, and auto into-ing errors

This commit is contained in:
Andras Schmelczer 2026-09-27 19:25:50 +01:00
commit 419c29aa9d
29 changed files with 415 additions and 298 deletions

View file

@ -1 +0,0 @@
DATABASE_URL=sqlite://db.sqlite3

View file

@ -21,7 +21,7 @@ tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]} tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]}
humantime-serde = "1.1.1" humantime-serde = "1.1.1"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } sqlx = { version = "0.8.6", default-features = false, features = ["sqlite", "runtime-tokio", "uuid", "chrono", "macros"] }
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0.4.41", features = ["serde"] }
rand = "0.9.0" rand = "0.9.0"
regex = "1.12.2" regex = "1.12.2"

View file

@ -24,7 +24,7 @@ impl AppState {
let database = Database::try_new(&config.database).await?; let database = Database::try_new(&config.database).await?;
let cursors: Cursors = Cursors::new(&config.database, &broadcasts); let cursors: Cursors = Cursors::new(&config.database, &broadcasts);
Cursors::start_background_task(cursors.clone()); Cursors::start_cursor_expiration_task(cursors.clone());
Ok(Self { Ok(Self {
config, config,

View file

@ -95,7 +95,7 @@ impl Cursors {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn start_background_task(self) { pub fn start_cursor_expiration_task(self) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
self.remove_expired_cursors().await; self.remove_expired_cursors().await;

View file

@ -9,13 +9,14 @@ use anyhow::{Context as _, Result};
use log::info; use log::info;
use models::VaultId; use models::VaultId;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions}; use sqlx::{ConnectOptions, Executor, sqlite::SqliteConnectOptions};
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::Instant; use tokio::time::Instant;
pub mod models; pub mod models;
mod history;
mod mutations; mod mutations;
mod queries; mod queries;
@ -83,7 +84,7 @@ impl Database {
Ok(database) Ok(database)
} }
fn database_path(config: &DatabaseConfig, vault: &str) -> PathBuf { fn get_database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
// Hash the normalized vault name into a fixed-length ASCII filename so // Hash the normalized vault name into a fixed-length ASCII filename so
// filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on // filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
// macOS) cannot make separately authorized vaults share a database. // macOS) cannot make separately authorized vaults share a database.
@ -149,7 +150,7 @@ impl Database {
let vault = normalize_string(vault); let vault = normalize_string(vault);
validate_vault_id(&vault)?; validate_vault_id(&vault)?;
if let Some(pool) = self.existing_pool(&vault).await { if let Some(pool) = self.find_cached_pool(&vault).await {
return Ok(pool); return Ok(pool);
} }
@ -160,11 +161,11 @@ impl Database {
let _opening = opening.lock().await; let _opening = opening.lock().await;
if let Some(pool) = self.existing_pool(&vault).await { if let Some(pool) = self.find_cached_pool(&vault).await {
return Ok(pool); return Ok(pool);
} }
let file_name = Self::database_path(&self.config, &vault); let file_name = Self::get_database_path(&self.config, &vault);
let pool = Self::open_database(&self.config, &file_name).await?; let pool = Self::open_database(&self.config, &file_name).await?;
self.connection_pools.lock().await.insert( self.connection_pools.lock().await.insert(
@ -177,7 +178,7 @@ impl Database {
Ok(pool) Ok(pool)
} }
async fn existing_pool(&self, vault: &VaultId) -> Option<Pool<Sqlite>> { async fn find_cached_pool(&self, vault: &VaultId) -> Option<Pool<Sqlite>> {
self.connection_pools self.connection_pools
.lock() .lock()
.await .await

View file

@ -0,0 +1,48 @@
//! Checkpoints distinguish restored histories even when numeric event IDs are reused.
use super::Database;
use super::models::VaultId;
use anyhow::Result;
const EMPTY_HISTORY_CHECKPOINT: &str = "0:empty";
impl Database {
pub async fn get_history_checkpoint(&self, vault: &VaultId) -> Result<String> {
let mut tx = self.create_readonly_transaction(vault).await?;
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT event_id, event_token FROM events ORDER BY event_id DESC LIMIT 1",
)
.fetch_optional(&mut *tx)
.await?;
Ok(row.map_or_else(
|| EMPTY_HISTORY_CHECKPOINT.to_owned(),
|(id, token)| format!("{id}:{token}"),
))
}
pub async fn contains_checkpoint(&self, vault: &VaultId, checkpoint: &str) -> Result<bool> {
if checkpoint == EMPTY_HISTORY_CHECKPOINT {
return Ok(true);
}
let Some((id, token)) = checkpoint.split_once(':') else {
return Ok(false);
};
let Ok(id) = id.parse::<i64>() else {
return Ok(false);
};
let mut tx = self.create_readonly_transaction(vault).await?;
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM events WHERE event_id = ? AND event_token = ?)",
)
.bind(id)
.bind(token)
.fetch_one(&mut *tx)
.await?;
Ok(exists)
}
}

View file

@ -125,9 +125,8 @@ pub struct EventBatch {
pub head_event_id: VaultUpdateId, pub head_event_id: VaultUpdateId,
/// Last event in this page; `head_event_id` is the vault's current head. /// Last event in this page; `head_event_id` is the vault's current head.
#[serde(skip_serializing_if = "Option::is_none")] #[ts(as = "f64")]
#[ts(optional, as = "Option<f64>")] pub end_event_id: VaultUpdateId,
pub end_event_id: Option<VaultUpdateId>,
pub events: Vec<EventRecord>, pub events: Vec<EventRecord>,
} }

View file

@ -9,8 +9,6 @@ use anyhow::{Context as _, Result, ensure};
use crate::consts::MAX_EVENTS_PAGE_BYTES; use crate::consts::MAX_EVENTS_PAGE_BYTES;
const EMPTY_HISTORY_CHECKPOINT: &str = "0:empty";
impl Database { impl Database {
pub async fn get_missing_document( pub async fn get_missing_document(
tx: &mut Transaction<'_>, tx: &mut Transaction<'_>,
@ -36,9 +34,10 @@ impl Database {
.fetch_optional(&mut **tx) .fetch_optional(&mut **tx)
.await?; .await?;
match row { match row {
Some((fingerprint, event_id)) => { Some((fingerprint, event_id)) => Ok(Some((
Ok(Some((fingerprint, Self::event_by_id(tx, event_id).await?))) fingerprint,
} Self::get_event_by_id(tx, event_id).await?,
))),
None => Ok(None), None => Ok(None),
} }
} }
@ -96,7 +95,7 @@ impl Database {
}) })
} }
pub async fn current_file_manifest(tx: &mut Transaction<'_>) -> Result<FileManifest> { pub async fn get_current_file_manifest(tx: &mut Transaction<'_>) -> Result<FileManifest> {
let Some(file_manifest_id) = sqlx::query_scalar::<_, VaultUpdateId>( let Some(file_manifest_id) = sqlx::query_scalar::<_, VaultUpdateId>(
"SELECT file_manifest_id FROM file_manifests ORDER BY file_manifest_id DESC LIMIT 1", "SELECT file_manifest_id FROM file_manifests ORDER BY file_manifest_id DESC LIMIT 1",
) )
@ -106,12 +105,12 @@ impl Database {
return Ok(FileManifest::default()); return Ok(FileManifest::default());
}; };
Self::file_manifest_by_id(tx, file_manifest_id) Self::get_file_manifest_by_id(tx, file_manifest_id)
.await? .await?
.context("Latest file manifest is missing") .context("Latest file manifest is missing")
} }
async fn file_manifest_by_id( async fn get_file_manifest_by_id(
tx: &mut Transaction<'_>, tx: &mut Transaction<'_>,
file_manifest_id: VaultUpdateId, file_manifest_id: VaultUpdateId,
) -> Result<Option<FileManifest>> { ) -> Result<Option<FileManifest>> {
@ -140,7 +139,10 @@ impl Database {
})) }))
} }
async fn event_by_id(tx: &mut Transaction<'_>, event_id: VaultUpdateId) -> Result<VaultEvent> { async fn get_event_by_id(
tx: &mut Transaction<'_>,
event_id: VaultUpdateId,
) -> Result<VaultEvent> {
let document = sqlx::query_as::<_, DocumentVersionWithoutContent>( let document = sqlx::query_as::<_, DocumentVersionWithoutContent>(
"SELECT vault_update_id, document_id, updated_date, user_id, device_id, "SELECT vault_update_id, document_id, updated_date, user_id, device_id,
length(content) AS content_size length(content) AS content_size
@ -152,13 +154,13 @@ impl Database {
if let Some(document) = document { if let Some(document) = document {
return Ok(VaultEvent::Content { document }); return Ok(VaultEvent::Content { document });
} }
let file_manifest = Self::file_manifest_by_id(tx, event_id) let file_manifest = Self::get_file_manifest_by_id(tx, event_id)
.await? .await?
.context("Event has no normalized content or file manifest")?; .context("Event has no normalized content or file manifest")?;
Ok(VaultEvent::FileManifest { file_manifest }) Ok(VaultEvent::FileManifest { file_manifest })
} }
pub async fn latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> { pub async fn get_latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> {
Ok( Ok(
sqlx::query_scalar("SELECT COALESCE(MAX(event_id), 0) FROM events") sqlx::query_scalar("SELECT COALESCE(MAX(event_id), 0) FROM events")
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
@ -166,10 +168,10 @@ impl Database {
) )
} }
pub async fn vault_snapshot(&self, vault: &VaultId) -> Result<VaultSnapshot> { pub async fn get_vault_snapshot(&self, vault: &VaultId) -> Result<VaultSnapshot> {
let mut tx = self.create_readonly_transaction(vault).await?; let mut tx = self.create_readonly_transaction(vault).await?;
let head_event_id = Self::latest_event_id(&mut tx).await?; let head_event_id = Self::get_latest_event_id(&mut tx).await?;
let file_manifest = Self::current_file_manifest(&mut tx).await?; let file_manifest = Self::get_current_file_manifest(&mut tx).await?;
let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>( let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
"SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id, "SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id,
@ -189,14 +191,14 @@ impl Database {
}) })
} }
pub async fn events_after( pub async fn get_events_after(
&self, &self,
vault: &VaultId, vault: &VaultId,
after: VaultUpdateId, after: VaultUpdateId,
page_size: usize, page_size: usize,
) -> Result<EventBatch> { ) -> Result<EventBatch> {
let mut tx = self.create_readonly_transaction(vault).await?; let mut tx = self.create_readonly_transaction(vault).await?;
let head_event_id = Self::latest_event_id(&mut tx).await?; let head_event_id = Self::get_latest_event_id(&mut tx).await?;
ensure!( ensure!(
(0..=head_event_id).contains(&after), (0..=head_event_id).contains(&after),
@ -218,7 +220,7 @@ impl Database {
let event = EventRecord { let event = EventRecord {
event_id, event_id,
request_id: request_id.parse()?, request_id: request_id.parse()?,
event: Self::event_by_id(&mut tx, event_id).await?, event: Self::get_event_by_id(&mut tx, event_id).await?,
}; };
let size = serde_json::to_vec(&event)?.len(); let size = serde_json::to_vec(&event)?.len();
// One manifest is atomic and may exceed this budget. Always allow // One manifest is atomic and may exceed this budget. Always allow
@ -233,50 +235,8 @@ impl Database {
Ok(EventBatch { Ok(EventBatch {
head_event_id, head_event_id,
end_event_id: Some(end_event_id), end_event_id,
events, events,
}) })
} }
} }
impl Database {
pub async fn history_checkpoint(&self, vault: &VaultId) -> Result<String> {
let mut tx = self.create_readonly_transaction(vault).await?;
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT event_id, event_token FROM events ORDER BY event_id DESC LIMIT 1",
)
.fetch_optional(&mut *tx)
.await?;
Ok(row.map_or_else(
|| EMPTY_HISTORY_CHECKPOINT.to_owned(),
|(id, token)| format!("{id}:{token}"),
))
}
pub async fn contains_checkpoint(&self, vault: &VaultId, checkpoint: &str) -> Result<bool> {
if checkpoint == EMPTY_HISTORY_CHECKPOINT {
return Ok(true);
}
let Some((id, token)) = checkpoint.split_once(':') else {
return Ok(false);
};
let Ok(id) = id.parse::<i64>() else {
return Ok(false);
};
let mut tx = self.create_readonly_transaction(vault).await?;
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM events WHERE event_id = ? AND event_token = ?)",
)
.bind(id)
.bind(token)
.fetch_one(&mut *tx)
.await?;
Ok(exists)
}
}

View file

@ -87,7 +87,7 @@ async fn rejects_invalid_vault_names_at_the_database_boundary() {
"a\0b", "a\0b",
] { ] {
assert!( assert!(
database.vault_snapshot(&name.to_owned()).await.is_err(), database.get_vault_snapshot(&name.to_owned()).await.is_err(),
"accepted {name:?}" "accepted {name:?}"
); );
} }
@ -96,15 +96,15 @@ async fn rejects_invalid_vault_names_at_the_database_boundary() {
} }
#[tokio::test] #[tokio::test]
async fn migrations_can_be_reapplied_without_losing_requests_or_content() { async fn schema_initialization_preserves_existing_requests_and_content() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = config(&directory); let config = config(&directory);
let database = Database::try_new(&config).await.unwrap(); let database = Database::try_new(&config).await.unwrap();
let vault = "migrations".to_owned(); let vault = "initialization".to_owned();
let version = insert_content(&database, &vault, uuid::Uuid::new_v4(), b"only copy").await; let version = insert_content(&database, &vault, uuid::Uuid::new_v4(), b"only copy").await;
let pool = database.get_connection_pool(&vault).await.unwrap(); let pool = database.get_connection_pool(&vault).await.unwrap();
Database::run_migrations(&pool).await.unwrap(); Database::initialize_schema(&pool).await.unwrap();
Database::run_migrations(&pool).await.unwrap(); Database::initialize_schema(&pool).await.unwrap();
let reopened = Database::try_new(&config).await.unwrap(); let reopened = Database::try_new(&config).await.unwrap();
assert_eq!( assert_eq!(
reopened reopened
@ -117,7 +117,7 @@ async fn migrations_can_be_reapplied_without_losing_requests_or_content() {
); );
assert_eq!( assert_eq!(
reopened reopened
.events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE) .get_events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
.await .await
.unwrap() .unwrap()
.events .events
@ -155,7 +155,7 @@ async fn snapshot_returns_latest_referenced_metadata_and_byte_lengths() {
.unwrap(); .unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
let snapshot = database.vault_snapshot(&vault).await.unwrap(); let snapshot = database.get_vault_snapshot(&vault).await.unwrap();
assert_eq!(snapshot.head_event_id, manifest.file_manifest_id); assert_eq!(snapshot.head_event_id, manifest.file_manifest_id);
assert_eq!(snapshot.file_manifest.entries, manifest.entries); assert_eq!(snapshot.file_manifest.entries, manifest.entries);
assert_eq!(snapshot.documents.len(), 2); assert_eq!(snapshot.documents.len(), 2);
@ -179,7 +179,7 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
let mut after = 0; let mut after = 0;
while after < 150 { while after < 150 {
let batch = database let batch = database
.events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE) .get_events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
.await .await
.unwrap(); .unwrap();
assert!(batch.events.len() <= 64, "catchup must bound each page"); assert!(batch.events.len() <= 64, "catchup must bound each page");
@ -192,7 +192,7 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
} }
assert!( assert!(
database database
.events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE) .get_events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
.await .await
.unwrap() .unwrap()
.events .events
@ -204,7 +204,10 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
async fn idle_cleanup_does_not_wait_for_an_active_vault() { async fn idle_cleanup_does_not_wait_for_an_active_vault() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let database = Database::try_new(&config(&directory)).await.unwrap(); let database = Database::try_new(&config(&directory)).await.unwrap();
database.vault_snapshot(&"other".to_owned()).await.unwrap(); database
.get_vault_snapshot(&"other".to_owned())
.await
.unwrap();
let vault = "slow".to_owned(); let vault = "slow".to_owned();
let transaction = database.create_write_transaction(&vault).await.unwrap(); let transaction = database.create_write_transaction(&vault).await.unwrap();
database database
@ -219,7 +222,7 @@ async fn idle_cleanup_does_not_wait_for_an_active_vault() {
.expect("cleanup must leave an active pool alone"); .expect("cleanup must leave an active pool alone");
let other = tokio::time::timeout( let other = tokio::time::timeout(
Duration::from_millis(200), Duration::from_millis(200),
database.vault_snapshot(&"other".to_owned()), database.get_vault_snapshot(&"other".to_owned()),
) )
.await .await
.expect("other vaults must remain accessible") .expect("other vaults must remain accessible")
@ -299,16 +302,13 @@ async fn replay_byte_budget_prevents_accumulating_large_historical_manifests() {
} }
tx.commit().await.unwrap(); tx.commit().await.unwrap();
let batch = database let batch = database
.events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE) .get_events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
.await .await
.unwrap(); .unwrap();
assert!(!batch.events.is_empty()); assert!(!batch.events.is_empty());
assert!(batch.events.len() < 8); assert!(batch.events.len() < 8);
assert!(serde_json::to_vec(&batch).unwrap().len() < MAX_EVENTS_PAGE_BYTES + 1024); assert!(serde_json::to_vec(&batch).unwrap().len() < MAX_EVENTS_PAGE_BYTES + 1024);
assert_eq!( assert_eq!(batch.end_event_id, batch.events.last().unwrap().event_id);
batch.end_event_id,
batch.events.last().map(|event| event.event_id)
);
} }
#[tokio::test] #[tokio::test]
@ -316,11 +316,14 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = config(&directory); let config = config(&directory);
let database = Database::try_new(&config).await.unwrap(); let database = Database::try_new(&config).await.unwrap();
database.vault_snapshot(&"other".to_owned()).await.unwrap(); database
.get_vault_snapshot(&"other".to_owned())
.await
.unwrap();
let vault = "locked-opening".to_owned(); let vault = "locked-opening".to_owned();
// A second process holds the writer lock while this process lazily checks // A second process holds the writer lock while this process initializes
// migrations. Even already-open, unrelated vaults must remain available. // the schema. Even already-open, unrelated vaults must remain available.
let external = Database::open_database(&config, &Database::database_path(&config, &vault)) let external = Database::open_database(&config, &Database::get_database_path(&config, &vault))
.await .await
.unwrap(); .unwrap();
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap(); let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
@ -330,7 +333,7 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
let opening_is_blocked = !opening.is_finished(); let opening_is_blocked = !opening.is_finished();
let other = tokio::time::timeout( let other = tokio::time::timeout(
Duration::from_millis(300), Duration::from_millis(300),
database.vault_snapshot(&"other".to_owned()), database.get_vault_snapshot(&"other".to_owned()),
) )
.await; .await;
transaction.rollback().await.unwrap(); transaction.rollback().await.unwrap();
@ -346,7 +349,7 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
); );
assert_eq!( assert_eq!(
other other
.expect("a blocked migration must not lock every vault") .expect("a blocked database open must not lock every vault")
.unwrap() .unwrap()
.head_event_id, .head_event_id,
0 0
@ -360,7 +363,7 @@ async fn concurrent_vault_opens_share_a_pool_and_cancelled_opens_can_retry() {
config.max_connections_per_vault = 1; config.max_connections_per_vault = 1;
let database = Database::try_new(&config).await.unwrap(); let database = Database::try_new(&config).await.unwrap();
let vault = "cancelled-opening".to_owned(); let vault = "cancelled-opening".to_owned();
let external = Database::open_database(&config, &Database::database_path(&config, &vault)) let external = Database::open_database(&config, &Database::get_database_path(&config, &vault))
.await .await
.unwrap(); .unwrap();
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap(); let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
@ -401,7 +404,7 @@ async fn concurrent_vault_opens_share_a_pool_and_cancelled_opens_can_retry() {
async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() { async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = config(&directory); let config = config(&directory);
let bad = Database::database_path(&config, "broken"); let bad = Database::get_database_path(&config, "broken");
tokio::fs::create_dir_all(bad.parent().unwrap()) tokio::fs::create_dir_all(bad.parent().unwrap())
.await .await
.unwrap(); .unwrap();
@ -409,7 +412,12 @@ async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() {
.await .await
.unwrap(); .unwrap();
let database = Database::try_new(&config).await.unwrap(); let database = Database::try_new(&config).await.unwrap();
assert!(database.vault_snapshot(&"broken".to_owned()).await.is_err()); assert!(
database
.get_vault_snapshot(&"broken".to_owned())
.await
.is_err()
);
let saved = insert_content(&database, "healthy", uuid::Uuid::new_v4(), b"safe").await; let saved = insert_content(&database, "healthy", uuid::Uuid::new_v4(), b"safe").await;
assert_eq!(saved.content, b"safe"); assert_eq!(saved.content, b"safe");
assert_eq!( assert_eq!(

View file

@ -39,7 +39,7 @@ impl Broadcasts {
limit.try_acquire_owned().ok() limit.try_acquire_owned().ok()
} }
async fn sender(&self, vault: VaultId) -> broadcast::Sender<Notification> { async fn get_or_create_sender(&self, vault: VaultId) -> broadcast::Sender<Notification> {
self.senders self.senders
.lock() .lock()
.await .await
@ -49,12 +49,12 @@ impl Broadcasts {
} }
pub async fn get_receiver(&self, vault: VaultId) -> broadcast::Receiver<Notification> { pub async fn get_receiver(&self, vault: VaultId) -> broadcast::Receiver<Notification> {
self.sender(vault).await.subscribe() self.get_or_create_sender(vault).await.subscribe()
} }
pub async fn send(&self, vault: VaultId, notification: Notification) { pub async fn send(&self, vault: VaultId, notification: Notification) {
// No listeners (or lagging listeners) is normal; they replay the log. // No listeners (or lagging listeners) is normal; they replay the log.
let _ = self.sender(vault).await.send(notification); let _ = self.get_or_create_sender(vault).await.send(notification);
} }
pub async fn notify_about_vault_update(&self, vault: VaultId) { pub async fn notify_about_vault_update(&self, vault: VaultId) {

View file

@ -8,7 +8,7 @@ use crate::{
app_state::{AppState, database::models::VaultId}, app_state::{AppState, database::models::VaultId},
config::user_config::User, config::user_config::User,
errors::{SyncServerError, server_error, unauthenticated_error}, errors::{SyncServerError, server_error, unauthenticated_error},
server::auth::auth, server::auth::authenticate_user,
}; };
pub struct AuthenticatedWebSocketHandshake { pub struct AuthenticatedWebSocketHandshake {
@ -28,7 +28,7 @@ pub fn get_authenticated_handshake(
match message { match message {
WebSocketClientMessage::Handshake(handshake) => { WebSocketClientMessage::Handshake(handshake) => {
let user = auth(state, handshake.token.trim(), vault_id)?; let user = authenticate_user(state, handshake.token.trim(), vault_id)?;
Ok(AuthenticatedWebSocketHandshake { handshake, user }) Ok(AuthenticatedWebSocketHandshake { handshake, user })
} }
WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error(

View file

@ -31,6 +31,26 @@ pub enum SyncServerError {
PermissionDeniedError(#[source] anyhow::Error), PermissionDeniedError(#[source] anyhow::Error),
} }
// Infrastructure failures are server errors. Request validation and missing
// resources opt into their HTTP status explicitly at the point of validation.
impl From<anyhow::Error> for SyncServerError {
fn from(error: anyhow::Error) -> Self {
server_error(error)
}
}
impl From<sqlx::Error> for SyncServerError {
fn from(error: sqlx::Error) -> Self {
server_error(error.into())
}
}
impl From<tokio::task::JoinError> for SyncServerError {
fn from(error: tokio::task::JoinError) -> Self {
server_error(error.into())
}
}
impl SyncServerError { impl SyncServerError {
pub fn serialize(&self) -> SerializedError { pub fn serialize(&self) -> SerializedError {
match self { match self {

View file

@ -46,7 +46,7 @@ pub async fn create_server(config: Config) -> Result<()> {
let server_config = app_state.config.server.clone(); let server_config = app_state.config.server.clone();
let app = Router::new() let app = Router::new()
.nest("/", get_authed_routes(app_state.clone())) .nest("/", create_authenticated_routes(app_state.clone()))
.route("/", get(endpoints::index::index)) .route("/", get(endpoints::index::index))
.route("/vaults/:vault_id/ping", get(endpoints::ping::ping)) .route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
.route( .route(
@ -100,7 +100,7 @@ pub async fn create_server(config: Config) -> Result<()> {
start_server(app, &server_config).await start_server(app, &server_config).await
} }
fn get_authed_routes(app_state: AppState) -> Router<AppState> { fn create_authenticated_routes(app_state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/vaults/:vault_id/file-manifest", "/vaults/:vault_id/file-manifest",

View file

@ -32,14 +32,18 @@ pub async fn auth_middleware(
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?, .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?,
); );
let user = auth(&state, token, &vault_id)?; let user = authenticate_user(&state, token, &vault_id)?;
req.extensions_mut().insert(user); req.extensions_mut().insert(user);
Ok(next.run(req).await) Ok(next.run(req).await)
} }
pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> { pub fn authenticate_user(
state: &AppState,
token: &str,
vault_id: &VaultId,
) -> Result<User, SyncServerError> {
validate_vault_id(vault_id).map_err(client_error)?; validate_vault_id(vault_id).map_err(client_error)?;
let user = state let user = state
.config .config

View file

@ -35,7 +35,7 @@ pub async fn events(
state state
.database .database
.events_after( .get_events_after(
&vault_id, &vault_id,
query.after, query.after,
query.page_size.unwrap_or(DEFAULT_EVENTS_PAGE_SIZE), query.page_size.unwrap_or(DEFAULT_EVENTS_PAGE_SIZE),

View file

@ -11,7 +11,7 @@ use crate::{
AppState, AppState,
database::models::{DocumentId, VaultUpdateId}, database::models::{DocumentId, VaultUpdateId},
}, },
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error},
}; };
#[axum::debug_handler] #[axum::debug_handler]
@ -30,16 +30,12 @@ pub async fn fetch_document_version_content(
let result = state let result = state
.database .database
.get_document_version(&vault_id, vault_update_id, None) .get_document_version(&vault_id, vault_update_id, None)
.await .await?
.map_err(server_error)? .ok_or_else(|| {
.map_or_else( not_found_error(anyhow!(
|| {
Err(not_found_error(anyhow!(
"Document with vault update id `{vault_update_id}` not found", "Document with vault update id `{vault_update_id}` not found",
))) ))
}, })?;
Ok,
)?;
if result.document_id != document_id { if result.document_id != document_id {
return Err(not_found_error(anyhow!( return Err(not_found_error(anyhow!(

View file

@ -14,7 +14,7 @@ use crate::{
models::{DocumentId, DocumentVersionWithoutContent}, models::{DocumentId, DocumentVersionWithoutContent},
}, },
}, },
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error},
}; };
#[axum::debug_handler] #[axum::debug_handler]
@ -27,12 +27,10 @@ pub async fn fetch_latest_document_metadata(
let mut tx = state let mut tx = state
.database .database
.create_readonly_transaction(&vault_id) .create_readonly_transaction(&vault_id)
.await .await?;
.map_err(server_error)?;
let metadata = Database::get_latest_document_metadata(&mut tx, &document_id) let metadata = Database::get_latest_document_metadata(&mut tx, &document_id)
.await .await?
.map_err(server_error)?
.ok_or_else(|| not_found_error(anyhow!("Document does not exist")))?; .ok_or_else(|| not_found_error(anyhow!("Document does not exist")))?;
Ok(Json(metadata)) Ok(Json(metadata))

View file

@ -11,7 +11,7 @@ use crate::{
AppState, AppState,
database::models::{DocumentId, DocumentVersion}, database::models::{DocumentId, DocumentVersion},
}, },
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error},
}; };
#[axum::debug_handler] #[axum::debug_handler]
@ -24,16 +24,8 @@ pub async fn fetch_latest_document_version(
let latest_version = state let latest_version = state
.database .database
.get_latest_document_version(&vault_id, &document_id, None) .get_latest_document_version(&vault_id, &document_id, None)
.await .await?
.map_err(server_error)? .ok_or_else(|| not_found_error(anyhow!("Document with id `{document_id}` not found")))?;
.map_or_else(
|| {
Err(not_found_error(anyhow!(
"Document with id `{document_id}` not found",
)))
},
Ok,
)?;
Ok(Json(latest_version.into())) Ok(Json(latest_version.into()))
} }

View file

@ -23,10 +23,9 @@ pub async fn get_file_manifest(
let mut transaction = state let mut transaction = state
.database .database
.create_readonly_transaction(&vault_id) .create_readonly_transaction(&vault_id)
.await .await?;
.map_err(server_error)?;
Database::current_file_manifest(&mut transaction) Database::get_current_file_manifest(&mut transaction)
.await .await
.map(Json) .map(Json)
.map_err(server_error) .map_err(server_error)

View file

@ -9,7 +9,7 @@ use axum_extra::{
use log::debug; use log::debug;
use super::VaultPath; use super::VaultPath;
use crate::server::{auth::auth, responses::PingResponse}; use crate::server::{auth::authenticate_user, responses::PingResponse};
use crate::{app_state::AppState, consts::SUPPORTED_API_VERSION, errors::SyncServerError}; use crate::{app_state::AppState, consts::SUPPORTED_API_VERSION, errors::SyncServerError};
#[axum::debug_handler] #[axum::debug_handler]
@ -20,8 +20,9 @@ pub async fn ping(
) -> Result<Json<PingResponse>, SyncServerError> { ) -> Result<Json<PingResponse>, SyncServerError> {
debug!("Pinging vault `{vault_id}`"); debug!("Pinging vault `{vault_id}`");
let is_authenticated = maybe_auth_header let is_authenticated = maybe_auth_header.is_some_and(|auth_header| {
.is_some_and(|auth_header| auth(&state, auth_header.token(), &vault_id).is_ok()); authenticate_user(&state, auth_header.token(), &vault_id).is_ok()
});
Ok(Json(PingResponse { Ok(Json(PingResponse {
server_version: env!("CARGO_PKG_VERSION").to_owned(), server_version: env!("CARGO_PKG_VERSION").to_owned(),

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