use crate::config::{normalize_server_url, Config, SyncConfig};
use crate::store::LocalStore;
use anyhow::{bail, Context, Result};
use note_to_self_lib::{auth_token, DerivedKeys, SyncRequest, SyncResponse};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Serialize)]
struct AuthRequest<'a> {
username: &'a str,
auth_key: &'a str,
}
#[derive(Debug, Serialize)]
struct JournalRequest<'a> {
name: &'a str,
locked: bool,
verifier: Option<&'a str>,
}
#[derive(Debug, Serialize)]
struct JournalMetadataRequest<'a> {
locked: bool,
verifier: Option<&'a str>,
}
#[derive(Debug, Deserialize)]
struct JournalList {
journals: Vec<RemoteJournal>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RemoteJournal {
pub name: String,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub verifier: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
LoggedIn,
Registered,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthAttempt {
Success,
Unauthorized,
Conflict,
Failed { status: StatusCode, body: String },
}
#[derive(Debug)]
enum SyncAttempt {
Success(SyncResponse),
Unauthorized,
Failed { status: StatusCode, body: String },
}
#[cfg(test)]
pub async fn register(server_url: &str, username: &str, keys: &DerivedKeys) -> Result<()> {
match register_attempt(server_url, username, keys).await? {
AuthAttempt::Success => Ok(()),
AuthAttempt::Conflict => bail!("user {username} already exists"),
AuthAttempt::Unauthorized => bail!("registration was unauthorized"),
AuthAttempt::Failed { status, body } => {
bail!("registration failed with {status}: {body}")
}
}
}
pub async fn verify_auth_attempt(
server_url: &str,
username: &str,
keys: &DerivedKeys,
) -> Result<AuthAttempt> {
verify_attempt(server_url, username, keys).await
}
pub async fn register_auth_attempt(
server_url: &str,
username: &str,
keys: &DerivedKeys,
) -> Result<AuthAttempt> {
register_attempt(server_url, username, keys).await
}
pub async fn login_or_register(
server_url: &str,
username: &str,
keys: &DerivedKeys,
) -> Result<AuthMode> {
match verify_attempt(server_url, username, keys).await? {
AuthAttempt::Success => return Ok(AuthMode::LoggedIn),
AuthAttempt::Unauthorized => {}
AuthAttempt::Conflict => bail!("login failed because the account is in conflict"),
AuthAttempt::Failed { status, body } => bail!("login failed with {status}: {body}"),
}
match register_attempt(server_url, username, keys).await? {
AuthAttempt::Success => Ok(AuthMode::Registered),
AuthAttempt::Conflict => {
bail!("account {username} already exists, but the password did not match")
}
AuthAttempt::Unauthorized => bail!("registration was unauthorized"),
AuthAttempt::Failed { status, body } => {
bail!("registration failed with {status}: {body}")
}
}
}
async fn register_attempt(
server_url: &str,
username: &str,
keys: &DerivedKeys,
) -> Result<AuthAttempt> {
let client = client()?;
let token = auth_token(&keys.auth_key);
let url = format!("{}/api/v1/auth/register", normalize_server_url(server_url));
let response = client
.post(url)
.json(&AuthRequest {
username,
auth_key: &token,
})
.send()
.await
.context("registration request failed")?;
classify_auth_response(response).await
}
async fn verify_attempt(
server_url: &str,
username: &str,
keys: &DerivedKeys,
) -> Result<AuthAttempt> {
let client = client()?;
let url = format!("{}/api/v1/auth/verify", normalize_server_url(server_url));
let response = client
.post(url)
.headers(auth_headers(username, keys)?)
.send()
.await
.context("login verification request failed")?;
classify_auth_response(response).await
}
pub async fn sync_once(store: &mut LocalStore, config: &Config, keys: &DerivedKeys) -> Result<()> {
let Some(sync) = &config.sync else {
return Ok(());
};
match sync_attempt(store, config, keys).await? {
SyncAttempt::Success(response) => apply_sync_response(store, response),
SyncAttempt::Unauthorized => {
recover_missing_account(sync, keys).await?;
match sync_attempt(store, config, keys).await? {
SyncAttempt::Success(response) => apply_sync_response(store, response),
SyncAttempt::Unauthorized => bail!("sync failed: invalid username or password"),
SyncAttempt::Failed { status, body } => {
bail!("sync failed with {status}: {body}")
}
}
}
SyncAttempt::Failed { status, body } => bail!("sync failed with {status}: {body}"),
}
}
async fn sync_attempt(
store: &LocalStore,
config: &Config,
keys: &DerivedKeys,
) -> Result<SyncAttempt> {
let Some(sync) = &config.sync else {
return Ok(SyncAttempt::Success(SyncResponse {
download: Vec::new(),
ack_uploaded: Vec::new(),
server_manifest: store.manifest.clone(),
}));
};
let client = client()?;
let upload = store.upload_blobs()?;
let request = SyncRequest {
device_id: config.device_id,
manifest: store.client_manifest()?,
upload,
};
let url = format!(
"{}/api/v1/sync/{}",
normalize_server_url(&sync.server_url),
store.journal()
);
let response = client
.post(url)
.headers(auth_headers(&sync.username, keys)?)
.json(&request)
.send()
.await
.context("sync request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Ok(match status {
StatusCode::UNAUTHORIZED => SyncAttempt::Unauthorized,
_ => SyncAttempt::Failed { status, body },
});
}
let response: SyncResponse = response
.json()
.await
.context("failed to decode sync response")?;
Ok(SyncAttempt::Success(response))
}
fn apply_sync_response(store: &mut LocalStore, response: SyncResponse) -> Result<()> {
for blob in &response.download {
store.apply_download(blob)?;
}
store.adopt_server_manifest(response.server_manifest)?;
Ok(())
}
pub async fn create_remote_journal(
config: &Config,
keys: &DerivedKeys,
name: &str,
locked: bool,
verifier: Option<&str>,
) -> Result<()> {
let Some(sync) = &config.sync else {
return Ok(());
};
let client = client()?;
let mut response = client
.post(format!(
"{}/api/v1/journals",
normalize_server_url(&sync.server_url)
))
.headers(auth_headers(&sync.username, keys)?)
.json(&JournalRequest {
name,
locked,
verifier,
})
.send()
.await
.context("remote journal creation failed")?;
if response.status() == StatusCode::UNAUTHORIZED {
recover_missing_account(sync, keys).await?;
response = client
.post(format!(
"{}/api/v1/journals",
normalize_server_url(&sync.server_url)
))
.headers(auth_headers(&sync.username, keys)?)
.json(&JournalRequest {
name,
locked,
verifier,
})
.send()
.await
.context("remote journal creation failed")?;
}
ensure_success(response).await
}
pub async fn update_remote_journal_metadata(
config: &Config,
keys: &DerivedKeys,
name: &str,
locked: bool,
verifier: Option<&str>,
) -> Result<()> {
let Some(sync) = &config.sync else {
return Ok(());
};
let client = client()?;
let mut response = client
.patch(format!(
"{}/api/v1/journals/{}",
normalize_server_url(&sync.server_url),
name
))
.headers(auth_headers(&sync.username, keys)?)
.json(&JournalMetadataRequest { locked, verifier })
.send()
.await
.context("remote journal metadata update failed")?;
if response.status() == StatusCode::UNAUTHORIZED {
recover_missing_account(sync, keys).await?;
response = client
.patch(format!(
"{}/api/v1/journals/{}",
normalize_server_url(&sync.server_url),
name
))
.headers(auth_headers(&sync.username, keys)?)
.json(&JournalMetadataRequest { locked, verifier })
.send()
.await
.context("remote journal metadata update failed")?;
}
ensure_success(response).await
}
pub async fn delete_remote_journal(config: &Config, keys: &DerivedKeys, name: &str) -> Result<()> {
let Some(sync) = &config.sync else {
return Ok(());
};
let client = client()?;
let mut response = client
.delete(format!(
"{}/api/v1/journals/{}",
normalize_server_url(&sync.server_url),
name
))
.headers(auth_headers(&sync.username, keys)?)
.send()
.await
.context("remote journal deletion failed")?;
if response.status() == StatusCode::UNAUTHORIZED {
recover_missing_account(sync, keys).await?;
response = client
.delete(format!(
"{}/api/v1/journals/{}",
normalize_server_url(&sync.server_url),
name
))
.headers(auth_headers(&sync.username, keys)?)
.send()
.await
.context("remote journal deletion failed")?;
}
ensure_success(response).await
}
#[cfg(test)]
pub async fn list_remote_journals(config: &Config, keys: &DerivedKeys) -> Result<Vec<String>> {
Ok(list_remote_journal_infos(config, keys)
.await?
.into_iter()
.map(|journal| journal.name)
.collect())
}
pub async fn list_remote_journal_infos(
config: &Config,
keys: &DerivedKeys,
) -> Result<Vec<RemoteJournal>> {
let Some(sync) = &config.sync else {
return Ok(vec![]);
};
let client = client()?;
let mut response = client
.get(format!(
"{}/api/v1/journals",
normalize_server_url(&sync.server_url)
))
.headers(auth_headers(&sync.username, keys)?)
.send()
.await
.context("remote journal listing failed")?;
if response.status() == StatusCode::UNAUTHORIZED {
recover_missing_account(sync, keys).await?;
response = client
.get(format!(
"{}/api/v1/journals",
normalize_server_url(&sync.server_url)
))
.headers(auth_headers(&sync.username, keys)?)
.send()
.await
.context("remote journal listing failed")?;
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("journal listing failed with {status}: {body}");
}
let list: JournalList = response
.json()
.await
.context("failed to decode remote journal list")?;
Ok(list.journals)
}
async fn recover_missing_account(sync: &SyncConfig, keys: &DerivedKeys) -> Result<()> {
match login_or_register(&sync.server_url, &sync.username, keys).await? {
AuthMode::LoggedIn => Ok(()),
AuthMode::Registered => {
eprintln!(
"user {} not found on server; creating account",
sync.username
);
Ok(())
}
}
}
fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.context("failed to build HTTP client")
}
fn auth_headers(username: &str, keys: &DerivedKeys) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
let bearer = format!("Bearer {}", auth_token(&keys.auth_key));
headers.insert(AUTHORIZATION, HeaderValue::from_str(&bearer)?);
headers.insert("x-note-username", HeaderValue::from_str(username)?);
Ok(headers)
}
async fn ensure_success(response: reqwest::Response) -> Result<()> {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("request failed with {status}: {body}")
}
async fn classify_auth_response(response: reqwest::Response) -> Result<AuthAttempt> {
if response.status().is_success() {
return Ok(AuthAttempt::Success);
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
Ok(match status {
StatusCode::UNAUTHORIZED => AuthAttempt::Unauthorized,
StatusCode::CONFLICT => AuthAttempt::Conflict,
_ => AuthAttempt::Failed { status, body },
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::store::LocalStore;
use note_to_self_lib::derive_keys;
use note_to_self_server::bucket::{Bucket, MockS3};
use tokio::net::TcpListener;
struct TestServer {
base_url: String,
_task: tokio::task::JoinHandle<()>,
}
#[tokio::test]
async fn sync_once_creates_missing_remote_account_and_retries() {
let server = spawn_server().await;
let temp = tempfile::tempdir().unwrap();
let keys = derive_keys("new-user", "correct horse").unwrap();
let config = Config::new(
Some("new-user".to_string()),
"personal".to_string(),
Some(server.base_url.clone()),
);
let mut store =
LocalStore::open(temp.path(), "personal", &config, &keys.encryption_key).unwrap();
store
.add_journal_entry("created before account exists".to_string(), &[], false)
.unwrap();
sync_once(&mut store, &config, &keys).await.unwrap();
let journals = list_remote_journals(&config, &keys).await.unwrap();
assert_eq!(journals, vec!["personal"]);
}
#[tokio::test]
async fn sync_once_reports_password_mismatch_without_raw_401() {
let server = spawn_server().await;
let temp = tempfile::tempdir().unwrap();
let correct_keys = derive_keys("alice", "correct").unwrap();
let wrong_keys = derive_keys("alice", "wrong").unwrap();
let config = Config::new(
Some("alice".to_string()),
"personal".to_string(),
Some(server.base_url.clone()),
);
register(&server.base_url, "alice", &correct_keys)
.await
.unwrap();
let mut store =
LocalStore::open(temp.path(), "personal", &config, &wrong_keys.encryption_key).unwrap();
store
.add_journal_entry("should not sync".to_string(), &[], false)
.unwrap();
let error = sync_once(&mut store, &config, &wrong_keys)
.await
.unwrap_err()
.to_string();
assert!(error.contains("password did not match"), "{error}");
assert!(!error.contains("401"), "{error}");
}
async fn spawn_server() -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = note_to_self_server::app_with_bucket(Bucket::mock_s3(MockS3::new()))
.await
.unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base_url: format!("http://{address}"),
_task: task,
}
}
}