Migrate server to CAS only

This commit is contained in:
Andras Schmelczer 2026-09-16 10:36:20 +01:00
commit 1619c51ad9
36 changed files with 1163 additions and 1092 deletions

View file

@ -1,5 +1,38 @@
# Sync server # Sync server
## Document pushes (API v3)
The server stores immutable document versions. One compare-and-swap endpoint,
`PUT /vaults/:vault_id/documents/:document_id`, handles every mutation. The client
generates the document UUID; the JSON body contains:
- `requestId`: a UUID identifying this exact push.
- `parentVersionId`: `null` to create an absent document, otherwise its base version.
- `relativePath`: the desired filename.
- `content`: `{ "type": "Snapshot", "value": "<base64>" }`,
`{ "type": "Diff", "value": [3, "inserted text", -2] }`, or `{ "type": "Delete" }`.
All pushes return one of:
- `Accepted`: version metadata for the exact snapshot committed. A retry with the
same request ID returns the original acknowledgement, even after a restart or
subsequent updates. Clients must persist the ID with the unchanged request.
A payload fingerprint rejects reusing that ID for a different mutation.
- `StaleBase`: the current version, including `contentBase64`. Nothing was written;
the client merges locally and submits a new push with a new request ID.
The base check, path allocation, new version, and durable acknowledgement share
one SQLite write transaction. A deleted document stays deleted; recreating a file
uses a new document ID. Occupied paths receive an available numbered filename.
Text updates accept reconcile-text transport diffs against the base version;
the server only reconstructs snapshots and never merges concurrent edits.
Snapshot requests use base64, adding roughly one third to the raw file size.
Server and reverse-proxy request limits apply to the encoded JSON body.
API v3 uses a fresh schema. Update clients together with the server and initialise
new vault databases; compatibility with previous releases is not supported.
## Creating/resetting the Database for development ## Creating/resetting the Database for development
```sh ```sh

View file

@ -1,5 +1,5 @@
// generated by `sqlx migrate build-script` // generated by `sqlx migrate build-script`
fn main() { fn main() {
// trigger recompilation when a new migration is added // trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=src/app_state/database/migrations");
} }

View file

@ -3,21 +3,24 @@ use std::{collections::HashMap, sync::Arc};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use log::info; use log::info;
use models::{ use models::VaultId;
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId, use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
};
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc};
pub mod models;
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;
use uuid::fmt::Hyphenated;
use super::websocket::{ pub mod models;
broadcasts::Broadcasts,
models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, mod get_document_version;
}; mod get_latest_document;
mod get_latest_document_by_path;
mod get_latest_documents;
mod get_latest_documents_since;
mod get_max_update_id_in_vault;
mod get_push_acknowledgement;
mod insert_document_version;
use super::websocket::broadcasts::Broadcasts;
use crate::config::database_config::DatabaseConfig; use crate::config::database_config::DatabaseConfig;
#[derive(Clone)] #[derive(Clone)]
@ -176,305 +179,6 @@ impl Database {
Ok(transaction) Ok(transaction)
} }
/// Return the latest state of all documents in the vault
pub async fn get_latest_documents(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
order by vault_update_id
"#,
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest documents")
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
/// Return the latest state of all documents (including deleted) in the
/// vault which have changed since the given update id
pub async fn get_latest_documents_since(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
where vault_update_id > ?
order by vault_update_id
"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.with_context(|| {
format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`")
})
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
pub async fn get_max_update_id_in_vault(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<i64> {
let query = sqlx::query!(
r#"
select coalesce(max(vault_update_id), 0) as max_vault_update_id
from documents
"#,
);
if let Some(transaction) = transaction {
query.fetch_one(&mut **transaction).await
} else {
query
.fetch_one(&self.get_connection_pool(vault).await?)
.await
}
.map(|row| row.max_vault_update_id)
.context("Cannot fetch max update id in vault")
}
pub async fn get_latest_document_by_path(
&self,
vault: &VaultId,
relative_path: &str,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id,
has_been_merged
from latest_document_versions
where relative_path = ? and is_deleted = false
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
-- multiple documents can have the same `relative_path`, if they have been deleted. That's
-- why we only care about the latest version of the document with the given relative path.
limit 1
"#,
relative_path
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
pub async fn get_latest_document(
&self,
vault: &VaultId,
document_id: &DocumentId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let document_id = document_id.as_hyphenated();
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id,
has_been_merged
from latest_document_versions
where document_id = ?
"#,
document_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
pub async fn get_document_version(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id,
has_been_merged
from documents
where vault_update_id = ?"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch document version")
}
// inserting the document must be the last step of the transaction if there's one
pub async fn insert_document_version(
&self,
vault_id: &VaultId,
version: &StoredDocumentVersion,
transaction: Option<Transaction<'_>>,
) -> Result<()> {
let document_id = version.document_id.as_hyphenated();
let query = sqlx::query!(
r#"
insert into documents (
vault_update_id,
document_id,
relative_path,
updated_date,
content,
is_deleted,
user_id,
device_id
)
values (?, ?, ?, ?, ?, ?, ?, ?)
"#,
version.vault_update_id,
document_id,
version.relative_path,
version.updated_date,
version.content,
version.is_deleted,
version.user_id,
version.device_id
);
if let Some(mut transaction) = transaction {
query
.execute(&mut *transaction)
.await
.context("Cannot insert document version")?;
transaction
.commit()
.await
.context("Failed to commit transaction")?;
} else {
query
.execute(&self.get_connection_pool(vault_id).await?)
.await
.context("Cannot insert document version")?;
}
self.broadcasts
.send_document_update(
vault_id.clone(),
WebSocketServerMessageWithOrigin::with_origin(
version.device_id.clone(),
WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
documents: vec![version.clone().into()],
is_initial_sync: false,
}),
),
)
.await;
Ok(())
}
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes /// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
async fn cleanup_idle_pools(&self) { async fn cleanup_idle_pools(&self) {
let mut pools = self.connection_pools.lock().await; let mut pools = self.connection_pools.lock().await;

View file

@ -0,0 +1,41 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{StoredDocumentVersion, VaultId, VaultUpdateId};
use super::{Database, Transaction};
impl Database {
pub async fn get_document_version(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from documents
where vault_update_id = ?"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch document version")
}
}

View file

@ -0,0 +1,43 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentId, StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
pub async fn get_latest_document(
&self,
vault: &VaultId,
document_id: &DocumentId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let document_id = document_id.as_hyphenated();
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from latest_document_versions
where document_id = ?
"#,
document_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
}

View file

@ -0,0 +1,46 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
pub async fn get_latest_document_by_path(
&self,
vault: &VaultId,
relative_path: &str,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from latest_document_versions
where relative_path = ? and is_deleted = false
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
-- multiple documents can have the same `relative_path`, if they have been deleted. That's
-- why we only care about the latest version of the document with the given relative path.
limit 1
"#,
relative_path
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
}

View file

@ -0,0 +1,56 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentVersionWithoutContent, VaultId};
use super::{Database, Transaction};
impl Database {
/// Return the latest state of all documents in the vault
pub async fn get_latest_documents(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
order by vault_update_id
"#,
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest documents")
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
}

View file

@ -0,0 +1,62 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId};
use super::{Database, Transaction};
impl Database {
/// Return the latest state of all documents (including deleted) in the
/// vault which have changed since the given update id
pub async fn get_latest_documents_since(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
where vault_update_id > ?
order by vault_update_id
"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.with_context(|| {
format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`")
})
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
}

View file

@ -0,0 +1,29 @@
use anyhow::{Context as _, Result};
use super::models::VaultId;
use super::{Database, Transaction};
impl Database {
pub async fn get_max_update_id_in_vault(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<i64> {
let query = sqlx::query!(
r#"
select coalesce(max(vault_update_id), 0) as max_vault_update_id
from documents
"#,
);
if let Some(transaction) = transaction {
query.fetch_one(&mut **transaction).await
} else {
query
.fetch_one(&self.get_connection_pool(vault).await?)
.await
}
.map(|row| row.max_vault_update_id)
.context("Cannot fetch max update id in vault")
}
}

View file

@ -0,0 +1,38 @@
use anyhow::{Context as _, Result};
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
/// Look up the immutable version acknowledged by a previous push.
pub async fn get_push_acknowledgement(
&self,
vault_id: &VaultId,
request_id: uuid::Uuid,
transaction: &mut Transaction<'_>,
) -> Result<Option<(Vec<u8>, StoredDocumentVersion)>> {
let request_id = request_id.hyphenated().to_string();
let acknowledgement = sqlx::query!(
"select vault_update_id, request_fingerprint from push_acknowledgements where request_id = ?",
request_id
)
.fetch_optional(&mut **transaction)
.await
.context("Cannot fetch push acknowledgement")?;
match acknowledgement {
Some(acknowledgement) => {
let version = self
.get_document_version(
vault_id,
acknowledgement.vault_update_id,
Some(transaction),
)
.await?
.context("Acknowledged document version is missing")?;
Ok(Some((acknowledgement.request_fingerprint, version)))
}
None => Ok(None),
}
}
}

View file

@ -0,0 +1,79 @@
use anyhow::{Context as _, Result};
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
use crate::app_state::websocket::models::{
WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate,
};
impl Database {
/// Commit the snapshot and acknowledgement atomically, then notify subscribers.
pub async fn insert_document_version(
&self,
vault_id: &VaultId,
version: &StoredDocumentVersion,
request_id: uuid::Uuid,
request_fingerprint: &[u8],
mut transaction: Transaction<'_>,
) -> Result<()> {
let document_id = version.document_id.as_hyphenated();
sqlx::query!(
r#"
insert into documents (
vault_update_id,
document_id,
relative_path,
updated_date,
content,
is_deleted,
user_id,
device_id
)
values (?, ?, ?, ?, ?, ?, ?, ?)
"#,
version.vault_update_id,
document_id,
version.relative_path,
version.updated_date,
version.content,
version.is_deleted,
version.user_id,
version.device_id
)
.execute(&mut *transaction)
.await
.context("Cannot insert document version")?;
let request_id = request_id.hyphenated().to_string();
sqlx::query!(
"insert into push_acknowledgements (request_id, request_fingerprint, vault_update_id) values (?, ?, ?)",
request_id,
request_fingerprint,
version.vault_update_id
)
.execute(&mut *transaction)
.await
.context("Cannot save push acknowledgement")?;
transaction
.commit()
.await
.context("Failed to commit push")?;
// notifying the listeners here means that no update can circumvent this step
self.broadcasts
.send_document_update(
vault_id.clone(),
WebSocketServerMessageWithOrigin::with_origin(
version.device_id.clone(),
WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
documents: vec![version.clone().into()],
is_initial_sync: false,
}),
),
)
.await;
Ok(())
}
}

View file

@ -4,7 +4,9 @@ CREATE TABLE IF NOT EXISTS documents (
relative_path TEXT NOT NULL, relative_path TEXT NOT NULL,
updated_date TIMESTAMP NOT NULL, updated_date TIMESTAMP NOT NULL,
content BLOB NOT NULL, content BLOB NOT NULL,
is_deleted BOOLEAN NOT NULL is_deleted BOOLEAN NOT NULL,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL
); );
CREATE VIEW IF NOT EXISTS latest_document_versions AS CREATE VIEW IF NOT EXISTS latest_document_versions AS
@ -19,3 +21,10 @@ ON d.vault_update_id = max_versions.max_version_id;
CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path
ON documents (relative_path); ON documents (relative_path);
-- The immutable version and its acknowledgement commit in one transaction.
CREATE TABLE push_acknowledgements (
request_id TEXT NOT NULL PRIMARY KEY,
request_fingerprint BLOB NOT NULL,
vault_update_id INTEGER NOT NULL REFERENCES documents(vault_update_id)
);

View file

@ -1,2 +0,0 @@
ALTER TABLE documents ADD COLUMN user_id TEXT NOT NULL DEFAULT "";
ALTER TABLE documents ADD COLUMN device_id TEXT NOT NULL DEFAULT "";

View file

@ -1,13 +0,0 @@
ALTER TABLE documents ADD COLUMN has_been_merged BOOLEAN NOT NULL DEFAULT False;
DROP VIEW latest_document_versions;
CREATE VIEW IF NOT EXISTS latest_document_versions AS --recreate view as it now includes one more field
SELECT d.*
FROM documents d
INNER JOIN (
SELECT MAX(vault_update_id) AS max_version_id
FROM documents
GROUP BY document_id
) max_versions
ON d.vault_update_id = max_versions.max_version_id;

View file

@ -20,8 +20,6 @@ pub struct StoredDocumentVersion {
pub is_deleted: bool, pub is_deleted: bool,
pub user_id: UserId, pub user_id: UserId,
pub device_id: DeviceId, pub device_id: DeviceId,
#[allow(dead_code)] // This is for manual analysis
pub has_been_merged: bool,
} }
impl PartialEq<Self> for StoredDocumentVersion { impl PartialEq<Self> for StoredDocumentVersion {

View file

@ -20,4 +20,4 @@ pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];
pub const SUPPORTED_API_VERSION: u32 = 2; pub const SUPPORTED_API_VERSION: u32 = 3;

View file

@ -1,17 +1,8 @@
pub mod auth; pub mod auth;
mod create_document;
mod delete_document;
mod device_id_header; mod device_id_header;
mod fetch_document_version; mod endpoints;
mod fetch_document_version_content;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod index;
mod ping;
mod requests; mod requests;
mod responses; mod responses;
mod update_document;
mod websocket;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use auth::auth_middleware; use auth::auth_middleware;
@ -21,7 +12,7 @@ use axum::{
http::{self, HeaderValue, Method}, http::{self, HeaderValue, Method},
middleware, middleware,
response::IntoResponse, response::IntoResponse,
routing::{IntoMakeService, delete, get, post, put}, routing::{IntoMakeService, get},
}; };
use device_id_header::DEVICE_ID_HEADER_NAME; use device_id_header::DEVICE_ID_HEADER_NAME;
use log::info; use log::info;
@ -53,9 +44,12 @@ pub async fn create_server(config: Config) -> Result<()> {
let app = Router::new() let app = Router::new()
.nest("/", get_authed_routes(app_state.clone())) .nest("/", get_authed_routes(app_state.clone()))
.route("/", get(index::index)) .route("/", get(endpoints::index::index))
.route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) .route(
"/vaults/:vault_id/ws",
get(endpoints::websocket::websocket_handler),
)
.layer(DefaultBodyLimit::disable()) .layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new( .layer(RequestBodyLimitLayer::new(
app_state.config.server.max_body_size_mb * 1024 * 1024, app_state.config.server.max_body_size_mb * 1024 * 1024,
@ -69,7 +63,7 @@ pub async fn create_server(config: Config) -> Result<()> {
http::header::AUTHORIZATION, http::header::AUTHORIZATION,
DEVICE_ID_HEADER_NAME.clone(), DEVICE_ID_HEADER_NAME.clone(),
]) ])
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]), .allow_methods([Method::GET, Method::PUT]),
) )
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
@ -102,35 +96,16 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/vaults/:vault_id/documents", "/vaults/:vault_id/documents",
get(fetch_latest_documents::fetch_latest_documents), get(endpoints::fetch_latest_documents::fetch_latest_documents),
)
.route(
"/vaults/:vault_id/documents",
post(create_document::create_document),
) )
.route( .route(
"/vaults/:vault_id/documents/:document_id", "/vaults/:vault_id/documents/:document_id",
get(fetch_latest_document_version::fetch_latest_document_version), get(endpoints::fetch_latest_document_version::fetch_latest_document_version)
) .put(endpoints::push_document::push_document),
.route(
"/vaults/:vault_id/documents/:document_id/binary",
put(update_document::update_binary),
)
.route(
"/vaults/:vault_id/documents/:document_id/text",
put(update_document::update_text),
)
.route(
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id",
get(fetch_document_version::fetch_document_version),
) )
.route( .route(
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content", "/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content",
get(fetch_document_version_content::fetch_document_version_content), get(endpoints::fetch_document_version_content::fetch_document_version_content),
)
.route(
"/vaults/:vault_id/documents/:document_id",
delete(delete_document::delete_document),
) )
.layer(middleware::from_fn_with_state(app_state, auth_middleware)) .layer(middleware::from_fn_with_state(app_state, auth_middleware))
} }

View file

@ -1,109 +0,0 @@
use axum::{
Extension, Json,
extract::{Path, State},
};
use axum_extra::TypedHeader;
use axum_typed_multipart::TypedMultipart;
use log::{debug, info};
use serde::Deserialize;
use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion};
use crate::{
app_state::{
AppState,
database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId},
},
config::user_config::User,
errors::{SyncServerError, client_error, server_error},
utils::{
find_first_available_path::find_first_available_path, normalize::normalize,
sanitize_path::sanitize_path,
},
};
#[derive(Deserialize)]
pub struct CreateDocumentPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
}
/// Create a new document in case a document with the same doesn't exist
/// already. If a document with the same path exists, a new version is created
/// with their content merged.
#[axum::debug_handler]
pub async fn create_document(
Path(CreateDocumentPathParams { vault_id }): Path<CreateDocumentPathParams>,
Extension(user): Extension<User>,
TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
State(state): State<AppState>,
TypedMultipart(request): TypedMultipart<CreateDocumentVersion>,
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> {
debug!("Creating document in vault `{vault_id}`");
let mut transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(server_error)?;
let document_id = match request.document_id {
Some(document_id) => {
let existing_version = state
.database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
.await
.map_err(server_error)?;
if existing_version.is_some() {
return Err(client_error(anyhow::anyhow!(
"Document with the same ID `{document_id}` already exists"
)));
}
document_id
}
None => uuid::Uuid::new_v4(),
};
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let sanitized_relative_path = sanitize_path(&request.relative_path);
let deduped_path = find_first_available_path(
&vault_id,
&sanitized_relative_path,
&state.database,
&mut transaction,
)
.await
.map_err(server_error)?;
if deduped_path != sanitized_relative_path {
info!(
"Document already exists at new location: `{sanitized_relative_path}` when trying to create it in vault `{vault_id}`, deconflicting by creating at `{deduped_path}`"
);
}
let new_version = StoredDocumentVersion {
vault_update_id: last_update_id + 1,
document_id,
relative_path: deduped_path,
content: request.content.contents.to_vec(),
updated_date: chrono::Utc::now(),
is_deleted: false,
user_id: user.name,
device_id: device_id.0,
has_been_merged: false,
};
state
.database
.insert_document_version(&vault_id, &new_version, Some(transaction))
.await
.map_err(server_error)?;
Ok(Json(new_version.into()))
}

View file

@ -1,96 +0,0 @@
use anyhow::Context;
use axum::{
Extension, Json,
extract::{Path, State},
};
use axum_extra::TypedHeader;
use log::{debug, info};
use serde::Deserialize;
use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion};
use crate::{
app_state::{
AppState,
database::models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId,
},
},
config::user_config::User,
errors::{SyncServerError, server_error},
utils::{normalize::normalize, sanitize_path::sanitize_path},
};
#[derive(Deserialize)]
pub struct DeleteDocumentPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
document_id: DocumentId,
}
#[axum::debug_handler]
pub async fn delete_document(
Path(DeleteDocumentPathParams {
vault_id,
document_id,
}): Path<DeleteDocumentPathParams>,
Extension(user): Extension<User>,
TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
State(state): State<AppState>,
Json(request): Json<DeleteDocumentVersion>,
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> {
debug!("Deleting document `{document_id}` in vault `{vault_id}`");
let mut transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(server_error)?;
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let latest_version = state
.database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
.await
.map_err(server_error)?;
if let Some(latest_version) = &latest_version
&& latest_version.is_deleted
{
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
info!("Document `{document_id}` has already been deleted",);
return Ok(Json(latest_version.clone().into()));
}
let latest_content = latest_version.map_or_else(Vec::new, |version| version.content); // in case the document has never existed before deleting it
let new_version = StoredDocumentVersion {
vault_update_id: last_update_id + 1,
document_id,
relative_path: sanitize_path(&request.relative_path),
content: latest_content, // copy the content from the latest version
updated_date: chrono::Utc::now(),
is_deleted: true,
user_id: user.name,
device_id: device_id.0,
has_been_merged: false,
};
state
.database
.insert_document_version(&vault_id, &new_version, Some(transaction))
.await
.map_err(server_error)?;
Ok(Json(new_version.into()))
}

View file

@ -0,0 +1,7 @@
pub mod fetch_document_version_content;
pub mod fetch_latest_document_version;
pub mod fetch_latest_documents;
pub mod index;
pub mod ping;
pub mod push_document;
pub mod websocket;

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