asch/fix-everything #188
10 changed files with 176 additions and 54 deletions
Various server improvements
commit
233ce1254b
|
|
@ -22,6 +22,7 @@ pub struct StoredDocumentVersion {
|
||||||
pub device_id: DeviceId,
|
pub device_id: DeviceId,
|
||||||
#[allow(dead_code)] // This is for manual analysis
|
#[allow(dead_code)] // This is for manual analysis
|
||||||
pub has_been_merged: bool,
|
pub has_been_merged: bool,
|
||||||
|
pub idempotency_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq<Self> for StoredDocumentVersion {
|
impl PartialEq<Self> for StoredDocumentVersion {
|
||||||
|
|
@ -33,7 +34,7 @@ impl PartialEq<Self> for StoredDocumentVersion {
|
||||||
#[derive(TS, Debug, Clone, Serialize)]
|
#[derive(TS, Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DocumentVersionWithoutContent {
|
pub struct DocumentVersionWithoutContent {
|
||||||
#[ts(as = "i32")]
|
#[ts(type = "number")]
|
||||||
pub vault_update_id: VaultUpdateId,
|
pub vault_update_id: VaultUpdateId,
|
||||||
|
|
||||||
pub document_id: DocumentId,
|
pub document_id: DocumentId,
|
||||||
|
|
@ -43,7 +44,7 @@ pub struct DocumentVersionWithoutContent {
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub device_id: DeviceId,
|
pub device_id: DeviceId,
|
||||||
|
|
||||||
#[ts(as = "i32")]
|
#[ts(type = "number")]
|
||||||
pub content_size: u64,
|
pub content_size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +66,7 @@ impl From<StoredDocumentVersion> for DocumentVersionWithoutContent {
|
||||||
#[derive(TS, Debug, Clone, Serialize)]
|
#[derive(TS, Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DocumentVersion {
|
pub struct DocumentVersion {
|
||||||
#[ts(as = "i32")]
|
#[ts(type = "number")]
|
||||||
pub vault_update_id: VaultUpdateId,
|
pub vault_update_id: VaultUpdateId,
|
||||||
|
|
||||||
pub document_id: DocumentId,
|
pub document_id: DocumentId,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ pub struct WebSocketHandshake {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub device_id: DeviceId,
|
pub device_id: DeviceId,
|
||||||
|
|
||||||
#[ts(as = "Option<i32>")]
|
#[ts(type = "number | null")]
|
||||||
pub last_seen_vault_update_id: Option<VaultUpdateId>,
|
pub last_seen_vault_update_id: Option<VaultUpdateId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ pub struct DocumentWithCursors {
|
||||||
// that it exists and can be client-side
|
// that it exists and can be client-side
|
||||||
// interpolated. However, the actual
|
// interpolated. However, the actual
|
||||||
// position is meaningless.
|
// position is meaningless.
|
||||||
#[ts(as = "Option<u32>")]
|
#[ts(type = "number | null")]
|
||||||
pub vault_update_id: Option<VaultUpdateId>,
|
pub vault_update_id: Option<VaultUpdateId>,
|
||||||
|
|
||||||
pub document_id: DocumentId,
|
pub document_id: DocumentId,
|
||||||
|
|
@ -70,6 +70,7 @@ pub struct WebSocketVaultUpdate {
|
||||||
pub enum WebSocketClientMessage {
|
pub enum WebSocketClientMessage {
|
||||||
Handshake(WebSocketHandshake),
|
Handshake(WebSocketHandshake),
|
||||||
CursorPositions(CursorPositionFromClient),
|
CursorPositions(CursorPositionFromClient),
|
||||||
|
Ping {},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(TS, Serialize, Clone, Debug)]
|
#[derive(TS, Serialize, Clone, Debug)]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use crate::{
|
||||||
database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId},
|
database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId},
|
||||||
},
|
},
|
||||||
config::user_config::User,
|
config::user_config::User,
|
||||||
errors::{SyncServerError, server_error, unauthenticated_error},
|
errors::{SyncServerError, client_error, server_error, unauthenticated_error},
|
||||||
server::auth::auth,
|
server::auth::auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -26,16 +26,16 @@ pub fn get_authenticated_handshake(
|
||||||
if let Some(Message::Text(message)) = message {
|
if let Some(Message::Text(message)) = message {
|
||||||
let message: WebSocketClientMessage = serde_json::from_str(&message)
|
let message: WebSocketClientMessage = serde_json::from_str(&message)
|
||||||
.context("Failed to parse message")
|
.context("Failed to parse message")
|
||||||
.map_err(server_error)?;
|
.map_err(client_error)?;
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
WebSocketClientMessage::Handshake(handshake) => {
|
WebSocketClientMessage::Handshake(handshake) => {
|
||||||
let user = auth(state, handshake.token.trim(), vault_id)?;
|
let user = auth(state, handshake.token.trim(), vault_id)?;
|
||||||
Ok(AuthenticatedWebSocketHandshake { handshake, user })
|
Ok(AuthenticatedWebSocketHandshake { handshake, user })
|
||||||
}
|
}
|
||||||
WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error(
|
WebSocketClientMessage::CursorPositions(_) | WebSocketClientMessage::Ping {} => Err(
|
||||||
anyhow::anyhow!("Expected a handshake message"),
|
unauthenticated_error(anyhow::anyhow!("Expected a handshake message")),
|
||||||
)),
|
),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(unauthenticated_error(anyhow::anyhow!(
|
Err(unauthenticated_error(anyhow::anyhow!(
|
||||||
|
|
|
||||||
|
|
@ -28,23 +28,20 @@ pub struct Config {
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn read_or_create(path: &Path) -> Result<Self> {
|
pub async fn read_or_create(path: &Path) -> Result<Self> {
|
||||||
let config = if path.exists() {
|
let display_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
info!(
|
|
||||||
"Loading configuration from `{}`",
|
if path.exists() {
|
||||||
path.canonicalize().unwrap().display()
|
info!("Loading configuration from `{}`", display_path.display());
|
||||||
);
|
Self::load_from_file(path).await
|
||||||
Self::load_from_file(path).await?
|
|
||||||
} else {
|
} else {
|
||||||
Self::default()
|
let config = Self::default();
|
||||||
};
|
config.write(path).await?;
|
||||||
|
info!(
|
||||||
config.write(path).await?;
|
"Created default configuration at `{}`",
|
||||||
info!(
|
display_path.display()
|
||||||
"Updated configuration at `{}`",
|
);
|
||||||
path.canonicalize().unwrap().display()
|
Ok(config)
|
||||||
);
|
}
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_from_file(path: &Path) -> Result<Self> {
|
pub async fn load_from_file(path: &Path) -> Result<Self> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use bimap::BiHashMap;
|
use bimap::BiHashMap;
|
||||||
use rand::{Rng, distr::Alphanumeric, rng};
|
use rand::{Rng, distr::Alphanumeric, rng};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, de::Error};
|
use serde::{Deserialize, Deserializer, Serialize, de::Error};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::app_state::database::models::VaultId;
|
use crate::app_state::database::models::VaultId;
|
||||||
|
|
||||||
|
|
@ -19,10 +20,19 @@ where
|
||||||
let mut user_token_map = BiHashMap::new();
|
let mut user_token_map = BiHashMap::new();
|
||||||
for user in &users {
|
for user in &users {
|
||||||
if let Some(existing_name) = user_token_map.get_by_right(&user.token) {
|
if let Some(existing_name) = user_token_map.get_by_right(&user.token) {
|
||||||
|
let redacted = if user.token.len() > 6 {
|
||||||
|
format!(
|
||||||
|
"{}...{}",
|
||||||
|
&user.token[..3],
|
||||||
|
&user.token[user.token.len() - 3..]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"***".to_owned()
|
||||||
|
};
|
||||||
return Err(D::Error::custom(format!(
|
return Err(D::Error::custom(format!(
|
||||||
"Duplicate user token found: `{}` for users `{}` and `{}`. User tokens must be \
|
"Duplicate user token found: `{redacted}` for users `{}` and `{}`. User tokens \
|
||||||
unique.",
|
must be unique.",
|
||||||
user.token, existing_name, user.name
|
existing_name, user.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +51,9 @@ where
|
||||||
|
|
||||||
impl UserConfig {
|
impl UserConfig {
|
||||||
pub fn get_user(&self, token: &str) -> Option<&User> {
|
pub fn get_user(&self, token: &str) -> Option<&User> {
|
||||||
self.user_configs.iter().find(|u| u.token == token)
|
self.user_configs
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.token.as_bytes().ct_eq(token.as_bytes()).into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use log::{debug, error};
|
use log::{debug, error, warn};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
@ -29,6 +29,9 @@ pub enum SyncServerError {
|
||||||
|
|
||||||
#[error("Permission denied error: {0}")]
|
#[error("Permission denied error: {0}")]
|
||||||
PermissionDeniedError(#[source] anyhow::Error),
|
PermissionDeniedError(#[source] anyhow::Error),
|
||||||
|
|
||||||
|
#[error("Too many requests: {0}")]
|
||||||
|
TooManyRequests(#[source] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncServerError {
|
impl SyncServerError {
|
||||||
|
|
@ -39,7 +42,8 @@ impl SyncServerError {
|
||||||
| Self::ServerError(error)
|
| Self::ServerError(error)
|
||||||
| Self::NotFound(error)
|
| Self::NotFound(error)
|
||||||
| Self::Unauthenticated(error)
|
| Self::Unauthenticated(error)
|
||||||
| Self::PermissionDeniedError(error) => error.into(),
|
| Self::PermissionDeniedError(error)
|
||||||
|
| Self::TooManyRequests(error) => error.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +73,22 @@ impl Display for SerializedError {
|
||||||
|
|
||||||
impl IntoResponse for SyncServerError {
|
impl IntoResponse for SyncServerError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let body = Json(self.serialize());
|
let serialized = self.serialize();
|
||||||
|
|
||||||
|
match &self {
|
||||||
|
Self::InitError(_) | Self::ServerError(_) => {
|
||||||
|
error!("{serialized}");
|
||||||
|
}
|
||||||
|
Self::ClientError(_) | Self::NotFound(_) => {
|
||||||
|
warn!("{serialized}");
|
||||||
|
}
|
||||||
|
Self::TooManyRequests(_) => {
|
||||||
|
warn!("{serialized}");
|
||||||
|
}
|
||||||
|
Self::Unauthenticated(_) | Self::PermissionDeniedError(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = Json(serialized);
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Self::InitError(_) | Self::ServerError(_) => {
|
Self::InitError(_) | Self::ServerError(_) => {
|
||||||
|
|
@ -79,6 +98,9 @@ impl IntoResponse for SyncServerError {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(),
|
||||||
Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(),
|
Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(),
|
||||||
Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(),
|
Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(),
|
||||||
|
Self::TooManyRequests(_) => {
|
||||||
|
(StatusCode::TOO_MANY_REQUESTS, body).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +124,7 @@ impl From<&anyhow::Error> for SerializedError {
|
||||||
SyncServerError::NotFound(_) => "NotFound",
|
SyncServerError::NotFound(_) => "NotFound",
|
||||||
SyncServerError::Unauthenticated(_) => "Unauthenticated",
|
SyncServerError::Unauthenticated(_) => "Unauthenticated",
|
||||||
SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError",
|
SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError",
|
||||||
|
SyncServerError::TooManyRequests(_) => "TooManyRequests",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
|
|
@ -139,3 +162,18 @@ pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError {
|
||||||
debug!("Permission denied: {error:?}");
|
debug!("Permission denied: {error:?}");
|
||||||
SyncServerError::PermissionDeniedError(error)
|
SyncServerError::PermissionDeniedError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn too_many_requests_error(error: anyhow::Error) -> SyncServerError {
|
||||||
|
debug!("Too many requests: {error:?}");
|
||||||
|
SyncServerError::TooManyRequests(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a `create_write_transaction` error to 429 if the database is busy,
|
||||||
|
/// or 500 for all other failures.
|
||||||
|
pub fn write_transaction_error(error: anyhow::Error) -> SyncServerError {
|
||||||
|
if error.downcast_ref::<crate::app_state::database::WriteBusyError>().is_some() {
|
||||||
|
too_many_requests_error(error)
|
||||||
|
} else {
|
||||||
|
server_error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use axum_extra::{
|
||||||
TypedHeader,
|
TypedHeader,
|
||||||
headers::{Authorization, authorization::Bearer},
|
headers::{Authorization, authorization::Bearer},
|
||||||
};
|
};
|
||||||
use log::info;
|
use log::{debug, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_state::{AppState, database::models::VaultId},
|
app_state::{AppState, database::models::VaultId},
|
||||||
|
|
@ -21,10 +21,12 @@ use crate::{
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(path_params): Path<HashMap<String, String>>,
|
Path(path_params): Path<HashMap<String, String>>,
|
||||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, SyncServerError> {
|
) -> Result<Response, SyncServerError> {
|
||||||
|
let auth_header = auth_header
|
||||||
|
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?;
|
||||||
let token = auth_header.token().trim();
|
let token = auth_header.token().trim();
|
||||||
let vault_id = normalize_string(
|
let vault_id = normalize_string(
|
||||||
path_params
|
path_params
|
||||||
|
|
@ -51,8 +53,8 @@ pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, S
|
||||||
VaultAccess::AllowAccessToAll => true,
|
VaultAccess::AllowAccessToAll => true,
|
||||||
VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id),
|
VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id),
|
||||||
} {
|
} {
|
||||||
info!(
|
debug!(
|
||||||
"User `{}` is authenticated and is authorised to access to vault `{vault_id}`",
|
"User `{}` is authenticated and is authorised to access vault `{vault_id}`",
|
||||||
user.name
|
user.name
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
72
sync-server/src/server/rate_limit.rs
Normal file
72
sync-server/src/server/rate_limit.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||||
|
|
||||||
|
/// Simple token-bucket rate limiter that refills every second.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RateLimiter {
|
||||||
|
inner: Arc<TokenBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TokenBucket {
|
||||||
|
tokens: AtomicU64,
|
||||||
|
max_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
/// Create a new rate limiter. Spawns a background task that refills tokens
|
||||||
|
/// every second.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `max_per_second` is 0.
|
||||||
|
pub fn new(max_per_second: u64) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_per_second > 0,
|
||||||
|
"max_per_second must be > 0 (use 0 in config to disable rate limiting entirely)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bucket = Arc::new(TokenBucket {
|
||||||
|
tokens: AtomicU64::new(max_per_second),
|
||||||
|
max_tokens: max_per_second,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bucket_clone = bucket.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
bucket_clone
|
||||||
|
.tokens
|
||||||
|
.store(bucket_clone.max_tokens, Ordering::Release);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { inner: bucket }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_acquire(&self) -> bool {
|
||||||
|
self.inner
|
||||||
|
.tokens
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||||
|
if current > 0 { Some(current - 1) } else { None }
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rate_limit_middleware(
|
||||||
|
axum::extract::State(limiter): axum::extract::State<RateLimiter>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
if limiter.try_acquire() {
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::TOO_MANY_REQUESTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,21 +4,18 @@ use reconcile_text::NumberOrText;
|
||||||
use serde::{self, Deserialize};
|
use serde::{self, Deserialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::app_state::database::models::{DocumentId, VaultUpdateId};
|
use crate::app_state::database::models::VaultUpdateId;
|
||||||
|
|
||||||
#[derive(TS, Debug, TryFromMultipart)]
|
#[derive(TS, Debug, TryFromMultipart)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateDocumentVersion {
|
pub struct CreateDocumentVersion {
|
||||||
/// The client can decide the document id (if it wishes to) in order
|
|
||||||
/// to help with syncing. If the client does not provide a document id,
|
|
||||||
/// the server will generate one. If the client provides a document id
|
|
||||||
/// it must not already exist in the database.
|
|
||||||
pub document_id: Option<DocumentId>,
|
|
||||||
pub relative_path: String,
|
pub relative_path: String,
|
||||||
|
|
||||||
#[ts(as = "Vec<u8>")]
|
#[ts(as = "Vec<u8>")]
|
||||||
#[form_data(limit = "unlimited")]
|
#[form_data(limit = "unlimited")]
|
||||||
pub content: FieldData<Bytes>,
|
pub content: FieldData<Bytes>,
|
||||||
|
|
||||||
|
pub idempotency_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, TryFromMultipart)]
|
#[derive(Debug, TryFromMultipart)]
|
||||||
|
|
@ -34,7 +31,7 @@ pub struct UpdateBinaryDocumentVersion {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct UpdateTextDocumentVersion {
|
pub struct UpdateTextDocumentVersion {
|
||||||
#[ts(as = "i32")]
|
#[ts(type = "number")]
|
||||||
pub parent_version_id: VaultUpdateId,
|
pub parent_version_id: VaultUpdateId,
|
||||||
|
|
||||||
pub relative_path: String,
|
pub relative_path: String,
|
||||||
|
|
@ -43,9 +40,5 @@ pub struct UpdateTextDocumentVersion {
|
||||||
pub content: Vec<NumberOrText>,
|
pub content: Vec<NumberOrText>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(TS, Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
pub struct DeleteDocumentVersion {}
|
||||||
#[ts(export)]
|
|
||||||
pub struct DeleteDocumentVersion {
|
|
||||||
pub relative_path: String,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,31 @@
|
||||||
use crate::app_state::database::models::VaultId;
|
use crate::app_state::database::models::VaultId;
|
||||||
use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths};
|
use crate::utils::dedup_paths::dedup_paths;
|
||||||
use anyhow::Result;
|
use anyhow::{Result, bail};
|
||||||
use log::{debug, info};
|
use log::info;
|
||||||
|
use sqlx::sqlite::SqliteConnection;
|
||||||
|
|
||||||
|
|
||||||
pub async fn find_first_available_path(
|
pub async fn find_first_available_path(
|
||||||
vault_id: &VaultId,
|
vault_id: &VaultId,
|
||||||
sanitized_relative_path: &str,
|
sanitized_relative_path: &str,
|
||||||
database: &crate::app_state::database::Database,
|
database: &crate::app_state::database::Database,
|
||||||
transaction: &mut Transaction<'_>,
|
connection: &mut SqliteConnection,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
|
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
|
||||||
for candidate in dedup_paths(sanitized_relative_path) {
|
for candidate in dedup_paths(sanitized_relative_path) {
|
||||||
debug!("Checking candidate path for deconflicting names: `{candidate}`");
|
debug!("Checking candidate path for deconflicting names: `{candidate}`");
|
||||||
if database
|
if database
|
||||||
.get_latest_document_by_path(vault_id, &candidate, Some(transaction))
|
.get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(connection))
|
||||||
.await?
|
.await?
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
info!("Selected available path: `{candidate}`");
|
info!("Selected available path: `{candidate}`");
|
||||||
return Ok(candidate);
|
return Ok(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
unreachable!("dedup_paths produces infinite paths");
|
unreachable!("dedup_paths produces infinite paths");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue