mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
style: cargo fmt, once, so it can be checked from here on
No change but layout. Kept apart from everything else so that blame can skip it (git config blame.ignoreRevsFile) and no real change hides in it.
This commit is contained in:
1 parent
2733ac1a3a
commit
e0f0ebb646
27 files changed
+1190
-343
No files matched your search
+30
-12
@@ -97,10 +97,13 @@ impl Epoch {
|
||||
|
||||
/// Sign every device out: the epoch moves on, and no cookie has it yet.
|
||||
pub async fn advance(state: &AppState) -> anyhow::Result<i64> {
|
||||
let epoch: i64 = sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
|
||||
let epoch: i64 =
|
||||
sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
state.epoch.store(epoch, std::sync::atomic::Ordering::SeqCst);
|
||||
state
|
||||
.epoch
|
||||
.store(epoch, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(epoch)
|
||||
}
|
||||
}
|
||||
@@ -114,9 +117,7 @@ pub fn credentials_match(config: &Config, username: &str, password: &str) -> boo
|
||||
pub fn session_cookie(config: &Config, token: &str) -> String {
|
||||
let max_age = config.session_days * 86_400;
|
||||
let secure = if config.secure_cookie { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
|
||||
)
|
||||
format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}")
|
||||
}
|
||||
|
||||
pub fn clear_cookie(config: &Config) -> String {
|
||||
@@ -165,9 +166,11 @@ impl OptionalFromRequestParts<AppState> for Session {
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Option<Self>, Self::Rejection> {
|
||||
Ok(<Session as FromRequestParts<AppState>>::from_request_parts(parts, state)
|
||||
Ok(
|
||||
<Session as FromRequestParts<AppState>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok())
|
||||
.ok(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,13 +318,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_same_origin_writes_get_through() {
|
||||
assert!(is_same_origin(&headers(&[("sec-fetch-site", "same-origin")])));
|
||||
assert!(!is_same_origin(&headers(&[("sec-fetch-site", "same-site")])));
|
||||
assert!(!is_same_origin(&headers(&[("sec-fetch-site", "cross-site")])));
|
||||
assert!(is_same_origin(&headers(&[(
|
||||
"sec-fetch-site",
|
||||
"same-origin"
|
||||
)])));
|
||||
assert!(!is_same_origin(&headers(&[(
|
||||
"sec-fetch-site",
|
||||
"same-site"
|
||||
)])));
|
||||
assert!(!is_same_origin(&headers(&[(
|
||||
"sec-fetch-site",
|
||||
"cross-site"
|
||||
)])));
|
||||
|
||||
let host = ("host", "diary.example.org");
|
||||
assert!(is_same_origin(&headers(&[("origin", "https://diary.example.org"), host])));
|
||||
assert!(!is_same_origin(&headers(&[("origin", "https://evil.example.org"), host])));
|
||||
assert!(is_same_origin(&headers(&[
|
||||
("origin", "https://diary.example.org"),
|
||||
host
|
||||
])));
|
||||
assert!(!is_same_origin(&headers(&[
|
||||
("origin", "https://evil.example.org"),
|
||||
host
|
||||
])));
|
||||
assert!(!is_same_origin(&headers(&[("origin", "null"), host])));
|
||||
|
||||
// curl, the CLI, a script with its own credentials.
|
||||
|
||||
+31
-7
@@ -83,7 +83,12 @@ impl Backup {
|
||||
backup
|
||||
}
|
||||
|
||||
fn with_store(db: SqlitePool, config: Arc<Config>, store: SessionStore, disabled: Option<String>) -> Arc<Self> {
|
||||
fn with_store(
|
||||
db: SqlitePool,
|
||||
config: Arc<Config>,
|
||||
store: SessionStore,
|
||||
disabled: Option<String>,
|
||||
) -> Arc<Self> {
|
||||
let configured = disabled.is_none() && matches!(store.load(), Ok(Some(_)));
|
||||
Arc::new(Self {
|
||||
db,
|
||||
@@ -103,7 +108,11 @@ impl Backup {
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Status {
|
||||
let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let mut status = self
|
||||
.status
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
status.pending = self.dirty.load(Ordering::Relaxed);
|
||||
status
|
||||
}
|
||||
@@ -189,7 +198,9 @@ impl Backup {
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
anyhow::bail!("another backup of this data directory is running — `backup-now` and the server take turns")
|
||||
}
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(e).context("could not lock the data directory for a backup"),
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
Err(e).context("could not lock the data directory for a backup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,14 +291,22 @@ impl Backup {
|
||||
.context("could not create the files folder on the device")?;
|
||||
self.state_set("device_files", &uid.to_string()).await?;
|
||||
// What the view remembers lived in a folder that is gone.
|
||||
sqlx::query("DELETE FROM backup_view").execute(&self.db).await?;
|
||||
sqlx::query("DELETE FROM backup_view_dirs").execute(&self.db).await?;
|
||||
sqlx::query("DELETE FROM backup_view")
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM backup_view_dirs")
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
uid
|
||||
}
|
||||
};
|
||||
|
||||
self.set(move |s| s.device = Some(name.clone()));
|
||||
Ok(sync::Folders { data, uploads, files })
|
||||
Ok(sync::Folders {
|
||||
data,
|
||||
uploads,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
/// A remembered node, if it is still remembered and still there.
|
||||
@@ -300,7 +319,12 @@ impl Backup {
|
||||
if stale {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(uid) = self.state_get(key).await?.as_deref().and_then(sync::parse_uid) else {
|
||||
let Some(uid) = self
|
||||
.state_get(key)
|
||||
.await?
|
||||
.as_deref()
|
||||
.and_then(sync::parse_uid)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A failed lookup is not a missing node — a network error must not be
|
||||
|
||||
+25
-7
@@ -29,7 +29,11 @@ const APP_NAME: &str = "narl_workspace";
|
||||
|
||||
fn app_version() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let channel = if version.starts_with("0.") { "alpha" } else { "stable" };
|
||||
let channel = if version.starts_with("0.") {
|
||||
"alpha"
|
||||
} else {
|
||||
"stable"
|
||||
};
|
||||
format!("external-drive-{APP_NAME}@{version}-{channel}")
|
||||
}
|
||||
|
||||
@@ -79,7 +83,11 @@ pub async fn login(
|
||||
})?;
|
||||
|
||||
let tokens = session.current_tokens().await;
|
||||
store.save(&StoredSession::from_session(&session, tokens, key_passphrases))?;
|
||||
store.save(&StoredSession::from_session(
|
||||
&session,
|
||||
tokens,
|
||||
key_passphrases,
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -204,7 +212,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let store = SessionStore::new(&path);
|
||||
let stored = store.load().unwrap().unwrap();
|
||||
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||
let session =
|
||||
ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||
|
||||
let upgraded = upgrade(&store, &session, stored).await.unwrap();
|
||||
assert_eq!(upgraded.key_passphrases.len(), 1);
|
||||
@@ -228,7 +237,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let store = SessionStore::new(&path);
|
||||
let stored = store.load().unwrap().unwrap();
|
||||
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||
let session =
|
||||
ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||
|
||||
let error = upgrade(&store, &session, stored).await.err().unwrap();
|
||||
assert!(error.to_string().contains("proton-login"), "{error}");
|
||||
@@ -239,13 +249,21 @@ mod tests {
|
||||
let version = app_version();
|
||||
let (prefix, rest) = version.split_once('@').expect("name@version");
|
||||
// Lowercase letters and underscores after the fixed `external-drive-`.
|
||||
let name = prefix.strip_prefix("external-drive-").expect("the required prefix");
|
||||
let name = prefix
|
||||
.strip_prefix("external-drive-")
|
||||
.expect("the required prefix");
|
||||
assert!(!name.is_empty());
|
||||
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}");
|
||||
assert!(
|
||||
name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
|
||||
"{name}"
|
||||
);
|
||||
|
||||
let (semver, channel) = rest.rsplit_once('-').expect("version-channel");
|
||||
assert!(matches!(channel, "stable" | "beta" | "alpha"), "{channel}");
|
||||
assert_eq!(semver.split('.').count(), 3, "{semver}");
|
||||
assert!(semver.split('.').all(|part| part.parse::<u32>().is_ok()), "{semver}");
|
||||
assert!(
|
||||
semver.split('.').all(|part| part.parse::<u32>().is_ok()),
|
||||
"{semver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+54
-15
@@ -31,10 +31,10 @@ use aes_gcm::{
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use rand::RngCore;
|
||||
use proton_drive_rs::KeySalt;
|
||||
use proton_sdk::account::KeyPassphrases;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -114,14 +114,20 @@ mod passphrases {
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub fn serialize<S: Serializer>(value: &KeyPassphrases, out: S) -> Result<S::Ok, S::Error> {
|
||||
out.collect_map(value.iter().map(|(id, passphrase)| (id, STANDARD.encode(passphrase))))
|
||||
out.collect_map(
|
||||
value
|
||||
.iter()
|
||||
.map(|(id, passphrase)| (id, STANDARD.encode(passphrase))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<KeyPassphrases, D::Error> {
|
||||
let encoded = BTreeMap::<String, Zeroizing<String>>::deserialize(input)?;
|
||||
let mut passphrases = KeyPassphrases::new();
|
||||
for (id, passphrase) in encoded {
|
||||
let bytes = STANDARD.decode(passphrase.as_bytes()).map_err(D::Error::custom)?;
|
||||
let bytes = STANDARD
|
||||
.decode(passphrase.as_bytes())
|
||||
.map_err(D::Error::custom)?;
|
||||
passphrases.insert(id, bytes);
|
||||
}
|
||||
Ok(passphrases)
|
||||
@@ -204,12 +210,18 @@ impl SessionStore {
|
||||
),
|
||||
(false, _) => bytes,
|
||||
};
|
||||
let stored = serde_json::from_slice(&json)
|
||||
.with_context(|| format!("{} is not a session this build understands", self.path.display()))?;
|
||||
let stored = serde_json::from_slice(&json).with_context(|| {
|
||||
format!(
|
||||
"{} is not a session this build understands",
|
||||
self.path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !sealed && self.key.is_some() {
|
||||
self.save(&stored)?;
|
||||
tracing::info!("the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE");
|
||||
tracing::info!(
|
||||
"the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE"
|
||||
);
|
||||
}
|
||||
Ok(Some(stored))
|
||||
}
|
||||
@@ -261,7 +273,13 @@ fn seal(key: &[u8; 32], json: &[u8]) -> Vec<u8> {
|
||||
let mut nonce = [0u8; NONCE];
|
||||
rand::thread_rng().fill_bytes(&mut nonce);
|
||||
let ciphertext = Aes256Gcm::new(key.into())
|
||||
.encrypt(Nonce::from_slice(&nonce), Payload { msg: json, aad: MAGIC })
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: json,
|
||||
aad: MAGIC,
|
||||
},
|
||||
)
|
||||
.expect("AES-GCM seals any input this size");
|
||||
|
||||
let mut out = Vec::with_capacity(MAGIC.len() + NONCE + ciphertext.len());
|
||||
@@ -278,7 +296,13 @@ fn open(key: &[u8; 32], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
|
||||
}
|
||||
let (nonce, ciphertext) = rest.split_at(NONCE);
|
||||
Aes256Gcm::new(key.into())
|
||||
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad: MAGIC })
|
||||
.decrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: ciphertext,
|
||||
aad: MAGIC,
|
||||
},
|
||||
)
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| anyhow!("the key does not open it, or it was altered"))
|
||||
}
|
||||
@@ -363,8 +387,10 @@ mod tests {
|
||||
fn passphrases_survive_the_file_and_the_password_never_reaches_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SessionStore::new(dir.path().join("proton-session.json"));
|
||||
let passphrases =
|
||||
KeyPassphrases::from_iter([("key-1", b"one\0\xff".to_vec()), ("key-2", b"two".to_vec())]);
|
||||
let passphrases = KeyPassphrases::from_iter([
|
||||
("key-1", b"one\0\xff".to_vec()),
|
||||
("key-2", b"two".to_vec()),
|
||||
]);
|
||||
|
||||
// Even a session that still holds the old fields in memory writes
|
||||
// neither of them.
|
||||
@@ -414,10 +440,15 @@ mod tests {
|
||||
let path = dir.path().join("proton-session.json");
|
||||
let passphrases = KeyPassphrases::from_iter([("key-1", b"secret".to_vec())]);
|
||||
|
||||
keyed(&path, [7; 32]).save(&stored(passphrases.clone())).unwrap();
|
||||
keyed(&path, [7; 32])
|
||||
.save(&stored(passphrases.clone()))
|
||||
.unwrap();
|
||||
let raw = std::fs::read(&path).unwrap();
|
||||
assert!(raw.starts_with(MAGIC));
|
||||
assert!(!raw.windows(7).any(|w| w == b"refresh"), "tokens left in the clear");
|
||||
assert!(
|
||||
!raw.windows(7).any(|w| w == b"refresh"),
|
||||
"tokens left in the clear"
|
||||
);
|
||||
|
||||
let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap();
|
||||
assert!(loaded.key_passphrases == passphrases);
|
||||
@@ -425,7 +456,10 @@ mod tests {
|
||||
let wrong = keyed(&path, [8; 32]).load().err().unwrap();
|
||||
assert!(format!("{wrong:#}").contains("does not open"), "{wrong:#}");
|
||||
let none = SessionStore::new(&path).load().err().unwrap();
|
||||
assert!(none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"), "{none}");
|
||||
assert!(
|
||||
none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"),
|
||||
"{none}"
|
||||
);
|
||||
|
||||
let mut flipped = raw.clone();
|
||||
*flipped.last_mut().unwrap() ^= 1;
|
||||
@@ -437,7 +471,9 @@ mod tests {
|
||||
fn a_plain_file_is_sealed_the_first_time_a_key_reads_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("proton-session.json");
|
||||
SessionStore::new(&path).save(&stored(KeyPassphrases::new())).unwrap();
|
||||
SessionStore::new(&path)
|
||||
.save(&stored(KeyPassphrases::new()))
|
||||
.unwrap();
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"{"));
|
||||
|
||||
let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap();
|
||||
@@ -457,7 +493,10 @@ mod tests {
|
||||
for bad in ["", "not base64 at all", &STANDARD.encode([3u8; 16])] {
|
||||
std::fs::write(&file, bad).unwrap();
|
||||
let error = read_key(&file).err().unwrap();
|
||||
assert!(error.to_string().contains("openssl rand -base64 32"), "{error}");
|
||||
assert!(
|
||||
error.to_string().contains("openssl rand -base64 32"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert!(read_key(&dir.path().join("missing")).is_err());
|
||||
}
|
||||
|
||||
+18
-3
@@ -89,7 +89,13 @@ pub async fn run(
|
||||
// Written once, and only ever re-read by a human who has lost everything
|
||||
// else — so it goes up before the data it explains.
|
||||
mirror
|
||||
.bytes(data_folder, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes())
|
||||
.bytes(
|
||||
data_folder,
|
||||
"RESTORE.txt",
|
||||
"RESTORE.txt",
|
||||
"text/plain",
|
||||
RESTORE.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
mirror.database(config, data_folder).await?;
|
||||
@@ -133,7 +139,13 @@ impl Mirror<'_> {
|
||||
.context("could not snapshot the database")?;
|
||||
|
||||
let result = self
|
||||
.file(data_folder, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
|
||||
.file(
|
||||
data_folder,
|
||||
"diary.db",
|
||||
"diary.db",
|
||||
"application/vnd.sqlite3",
|
||||
&snapshot,
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = tokio::fs::remove_file(&snapshot).await;
|
||||
@@ -232,7 +244,10 @@ impl Mirror<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let uids: Vec<NodeUid> = orphans.iter().filter_map(|(_, uid)| parse_uid(uid)).collect();
|
||||
let uids: Vec<NodeUid> = orphans
|
||||
.iter()
|
||||
.filter_map(|(_, uid)| parse_uid(uid))
|
||||
.collect();
|
||||
for (uid, result) in self.client.trash_nodes(&uids).await? {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(node = %uid, error = %e, "could not trash a pruned file");
|
||||
|
||||
+50
-10
@@ -65,7 +65,13 @@ impl Placed {
|
||||
pub(crate) fn clean(name: &str, slug: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
|
||||
.map(|c| {
|
||||
if c == '/' || c == '\\' || c.is_control() {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.trim();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
@@ -95,7 +101,10 @@ pub(crate) fn layout(nodes: &[TreeNode]) -> Vec<Placed> {
|
||||
let mut segment: HashMap<i64, String> = HashMap::new();
|
||||
let parent_of: HashMap<i64, Option<i64>> = nodes.iter().map(|n| (n.id, n.parent_id)).collect();
|
||||
|
||||
for node in sorted.iter().filter(|n| n.kind == "space" || n.kind == "folder") {
|
||||
for node in sorted
|
||||
.iter()
|
||||
.filter(|n| n.kind == "space" || n.kind == "folder")
|
||||
{
|
||||
let name = clean(&node.name, &node.slug);
|
||||
let names = taken.entry(node.parent_id).or_default();
|
||||
let name = if names.insert(name.to_lowercase()) {
|
||||
@@ -207,7 +216,11 @@ pub async fn run(
|
||||
Some(uid) => {
|
||||
tracked.insert(
|
||||
node_id,
|
||||
Tracked { path: row.get("path"), media_id: row.get("media_id"), uid },
|
||||
Tracked {
|
||||
path: row.get("path"),
|
||||
media_id: row.get("media_id"),
|
||||
uid,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => forget(db, node_id).await?,
|
||||
@@ -215,7 +228,10 @@ pub async fn run(
|
||||
}
|
||||
|
||||
let mut dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||
for row in sqlx::query("SELECT path, node_uid FROM backup_view_dirs").fetch_all(db).await? {
|
||||
for row in sqlx::query("SELECT path, node_uid FROM backup_view_dirs")
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
{
|
||||
if let Some(uid) = parse_uid(&row.get::<String, _>("node_uid")) {
|
||||
dirs.insert(row.get("path"), uid);
|
||||
}
|
||||
@@ -229,7 +245,14 @@ pub async fn run(
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut view = View { db, config, client, root, dirs, summary };
|
||||
let mut view = View {
|
||||
db,
|
||||
config,
|
||||
client,
|
||||
root,
|
||||
dirs,
|
||||
summary,
|
||||
};
|
||||
|
||||
// What is gone, or is no longer the same bytes, goes first: it frees the
|
||||
// names the moves below may want.
|
||||
@@ -426,12 +449,21 @@ impl View<'_> {
|
||||
Ok(Some(existing)) => {
|
||||
let uid = existing.uid.clone();
|
||||
self.client
|
||||
.upload_new_revision_from(&uid, std::fs::File::open(&path)?, size as i64, Vec::new(), Some(crate::now()))
|
||||
.upload_new_revision_from(
|
||||
&uid,
|
||||
std::fs::File::open(&path)?,
|
||||
size as i64,
|
||||
Vec::new(),
|
||||
Some(crate::now()),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("could not upload files/{}", placed.path()))?;
|
||||
uid
|
||||
}
|
||||
_ => return Err(e).with_context(|| format!("could not upload files/{}", placed.path())),
|
||||
_ => {
|
||||
return Err(e)
|
||||
.with_context(|| format!("could not upload files/{}", placed.path()))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -465,7 +497,9 @@ impl View<'_> {
|
||||
for (uid, result) in results {
|
||||
match result {
|
||||
Ok(()) => self.summary.pruned += 1,
|
||||
Err(e) => tracing::warn!(node = %uid, error = %e, "could not trash in files/"),
|
||||
Err(e) => {
|
||||
tracing::warn!(node = %uid, error = %e, "could not trash in files/")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,7 +537,10 @@ mod tests {
|
||||
node(4, Some(2), "document", "notes"),
|
||||
node(5, Some(1), "file", "syllabus.pdf"),
|
||||
];
|
||||
assert_eq!(paths(&layout(&tree)), ["uni/Algorithms/week 1.pdf", "uni/syllabus.pdf"]);
|
||||
assert_eq!(
|
||||
paths(&layout(&tree)),
|
||||
["uni/Algorithms/week 1.pdf", "uni/syllabus.pdf"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,7 +575,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_folder_is_missing_is_left_out() {
|
||||
let tree = [node(1, None, "space", "uni"), node(3, Some(9), "file", "lost.pdf")];
|
||||
let tree = [
|
||||
node(1, None, "space", "uni"),
|
||||
node(3, Some(9), "file", "lost.pdf"),
|
||||
];
|
||||
assert!(layout(&tree).is_empty());
|
||||
}
|
||||
|
||||
|
||||
+25
-7
@@ -50,7 +50,8 @@ pub async fn proton_login(config: &Config) -> Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
bail!("no terminal to ask for a password — set PROTON_PASSWORD, or run with `docker compose exec -it`");
|
||||
}
|
||||
rpassword::prompt_password("Proton password: ").context("could not read the password")?
|
||||
rpassword::prompt_password("Proton password: ")
|
||||
.context("could not read the password")?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +63,10 @@ pub async fn proton_login(config: &Config) -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
println!("Logged in. The session is stored at {}.", store.path().display());
|
||||
println!(
|
||||
"Logged in. The session is stored at {}.",
|
||||
store.path().display()
|
||||
);
|
||||
println!("Backups start with the next server start, or run `narl-workspace backup-now`.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -96,7 +100,10 @@ pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
||||
"Quiet: {}s after the last change",
|
||||
config.backup.debounce.as_secs()
|
||||
);
|
||||
println!("Prune: {}", if config.backup.prune { "on" } else { "off" });
|
||||
println!(
|
||||
"Prune: {}",
|
||||
if config.backup.prune { "on" } else { "off" }
|
||||
);
|
||||
|
||||
let mirrored: i64 = sqlx::query_scalar("SELECT count(*) FROM backup_files")
|
||||
.fetch_one(db)
|
||||
@@ -142,7 +149,9 @@ pub async fn mail_test(db: SqlitePool, config: Arc<Config>) -> Result<()> {
|
||||
.send_test()
|
||||
.await
|
||||
.context("the test mail could not be sent")?;
|
||||
println!("Sent. If it does not arrive, the relay accepted it and something after that did not.");
|
||||
println!(
|
||||
"Sent. If it does not arrive, the relay accepted it and something after that did not."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,13 +171,19 @@ pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
||||
println!("Timezone: {}", mail.timezone);
|
||||
match mail.reminder_at {
|
||||
Some(at) if mail.sends("reminder") => {
|
||||
println!("Reminder: {:02}:{:02} when nothing was written", at.hour, at.minute)
|
||||
println!(
|
||||
"Reminder: {:02}:{:02} when nothing was written",
|
||||
at.hour, at.minute
|
||||
)
|
||||
}
|
||||
_ => println!("Reminder: off"),
|
||||
}
|
||||
match mail.card_due_at {
|
||||
Some(at) if mail.sends("card_due") => {
|
||||
println!("Cards: {:02}:{:02} for due and overdue cards", at.hour, at.minute)
|
||||
println!(
|
||||
"Cards: {:02}:{:02} for due and overdue cards",
|
||||
at.hour, at.minute
|
||||
)
|
||||
}
|
||||
_ => println!("Cards: off"),
|
||||
}
|
||||
@@ -181,7 +196,10 @@ pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
||||
),
|
||||
_ => println!("Digest: off"),
|
||||
}
|
||||
println!("Logins: {}", if mail.sends("login") { "on" } else { "off" });
|
||||
println!(
|
||||
"Logins: {}",
|
||||
if mail.sends("login") { "on" } else { "off" }
|
||||
);
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT count(*) AS queued,
|
||||
|
||||
+35
-8
@@ -74,7 +74,9 @@ fn flag(key: &str) -> Result<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
_ => anyhow::bail!("WORKSPACE_{key} must be 1 or 0 (true/false, yes/no, on/off), not {raw:?}"),
|
||||
_ => anyhow::bail!(
|
||||
"WORKSPACE_{key} must be 1 or 0 (true/false, yes/no, on/off), not {raw:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +164,10 @@ fn parse_time(key: &str, raw: &str) -> Result<Option<TimeOfDay>> {
|
||||
.split_once(':')
|
||||
.with_context(|| format!("{key} must look like 20:00, or be `off`"))?;
|
||||
let time = TimeOfDay {
|
||||
hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?,
|
||||
hour: hour
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("{key}: {hour:?} is not an hour"))?,
|
||||
minute: minute
|
||||
.trim()
|
||||
.parse()
|
||||
@@ -265,7 +270,8 @@ impl Config {
|
||||
// arithmetic on it that must not overflow.
|
||||
let session_days = number("SESSION_DAYS", 30, 1..=3650)? as i64;
|
||||
// Up to a terabyte, in bytes.
|
||||
let max_upload_bytes = usize::try_from(number("MAX_UPLOAD_MB", 1024, 1..=1024 * 1024)? * 1024 * 1024)
|
||||
let max_upload_bytes =
|
||||
usize::try_from(number("MAX_UPLOAD_MB", 1024, 1..=1024 * 1024)? * 1024 * 1024)
|
||||
.context("WORKSPACE_MAX_UPLOAD_MB is more than this machine can address")?;
|
||||
// A vault that locks the moment it opens could not be used; one that
|
||||
// stays open for more than a day is not much of a vault.
|
||||
@@ -397,7 +403,10 @@ mod tests {
|
||||
#[test]
|
||||
fn numbers_out_of_bounds_stop_the_start() {
|
||||
assert_eq!(number("TEST_UNSET_NUMBER", 7, 1..=10).unwrap(), 7);
|
||||
assert_eq!(number(&with("TEST_NUMBER_OK", " 9 "), 7, 1..=10).unwrap(), 9);
|
||||
assert_eq!(
|
||||
number(&with("TEST_NUMBER_OK", " 9 "), 7, 1..=10).unwrap(),
|
||||
9
|
||||
);
|
||||
assert!(number(&with("TEST_NUMBER_ZERO", "0"), 7, 1..=10).is_err());
|
||||
assert!(number(&with("TEST_NUMBER_HUGE", "99999999999999999999"), 7, 1..=10).is_err());
|
||||
assert!(number(&with("TEST_NUMBER_NEGATIVE", "-1"), 7, 1..=10).is_err());
|
||||
@@ -416,11 +425,17 @@ mod tests {
|
||||
fn a_time_of_day_is_read_as_written() {
|
||||
assert_eq!(
|
||||
parse_time("T", "20:00").unwrap(),
|
||||
Some(TimeOfDay { hour: 20, minute: 0 })
|
||||
Some(TimeOfDay {
|
||||
hour: 20,
|
||||
minute: 0
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_time("T", "07:45").unwrap(),
|
||||
Some(TimeOfDay { hour: 7, minute: 45 })
|
||||
Some(TimeOfDay {
|
||||
hour: 7,
|
||||
minute: 45
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_time("T", "off").unwrap(), None);
|
||||
}
|
||||
@@ -436,11 +451,23 @@ mod tests {
|
||||
fn a_digest_is_a_weekday_and_a_time() {
|
||||
assert_eq!(
|
||||
parse_digest("sun 18:00").unwrap(),
|
||||
Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 }))
|
||||
Some((
|
||||
Weekday::Sun,
|
||||
TimeOfDay {
|
||||
hour: 18,
|
||||
minute: 0
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_digest("Monday 09:30").unwrap(),
|
||||
Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 }))
|
||||
Some((
|
||||
Weekday::Mon,
|
||||
TimeOfDay {
|
||||
hour: 9,
|
||||
minute: 30
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(parse_digest("off").unwrap(), None);
|
||||
assert!(parse_digest("someday 18:00").is_err());
|
||||
|
||||
+7
-5
@@ -50,9 +50,7 @@ impl Mailer {
|
||||
// A URL with credentials has already set them; this only covers the
|
||||
// relays that want them and a URL that keeps them out of the string.
|
||||
let transport = match (config::var("SMTP_USER"), config::var("SMTP_PASSWORD")) {
|
||||
(Some(user), Some(password)) => {
|
||||
transport.credentials(Credentials::new(user, password))
|
||||
}
|
||||
(Some(user), Some(password)) => transport.credentials(Credentials::new(user, password)),
|
||||
_ => transport,
|
||||
}
|
||||
.build();
|
||||
@@ -271,14 +269,18 @@ mod tests {
|
||||
async fn mails_given_up_on_do_not_hold_up_the_rest() {
|
||||
let db = outbox().await;
|
||||
for n in 0..60 {
|
||||
super::enqueue(&db, "reminder", &format!("dead:{n}"), "s", "b").await.unwrap();
|
||||
super::enqueue(&db, "reminder", &format!("dead:{n}"), "s", "b")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query("UPDATE notifications SET attempts = ?1, last_attempt_at = 0")
|
||||
.bind(MAX_ATTEMPTS)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
super::enqueue(&db, "reminder", "reminder:2026-10-20", "s", "b").await.unwrap();
|
||||
super::enqueue(&db, "reminder", "reminder:2026-10-20", "s", "b")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let next: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT dedupe_key FROM notifications WHERE sent_at IS NULL AND attempts < ?1
|
||||
|
||||
+11
-9
@@ -18,11 +18,9 @@ mod vault;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous,
|
||||
};
|
||||
use axum::http::{header, HeaderValue};
|
||||
use axum::Router;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
set_header::SetResponseHeaderLayer,
|
||||
@@ -200,9 +198,8 @@ async fn snapshot_before_migrating(
|
||||
migrator: &sqlx::migrate::Migrator,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
let applied: Option<i64> = match sqlx::query_scalar(
|
||||
"SELECT MAX(version) FROM _sqlx_migrations WHERE success",
|
||||
)
|
||||
let applied: Option<i64> =
|
||||
match sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
{
|
||||
@@ -305,7 +302,10 @@ fn is_slow(method: &axum::http::Method, path: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn deadline(request: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response {
|
||||
async fn deadline(
|
||||
request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if is_slow(request.method(), request.uri().path()) {
|
||||
return next.run(request).await;
|
||||
@@ -351,7 +351,9 @@ fn compressible(
|
||||
headers: &axum::http::HeaderMap,
|
||||
_: &axum::http::Extensions,
|
||||
) -> bool {
|
||||
if status == axum::http::StatusCode::PARTIAL_CONTENT || headers.contains_key(header::ACCEPT_RANGES) {
|
||||
if status == axum::http::StatusCode::PARTIAL_CONTENT
|
||||
|| headers.contains_key(header::ACCEPT_RANGES)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let kind = headers
|
||||
|
||||
+40
-11
@@ -289,9 +289,8 @@ impl Notifier {
|
||||
.rows_affected();
|
||||
|
||||
// The upsert touches a row either way, so "new" is asked separately.
|
||||
let first_seen: i64 = sqlx::query_scalar(
|
||||
"SELECT first_seen_at FROM login_events WHERE device_hash = ?",
|
||||
)
|
||||
let first_seen: i64 =
|
||||
sqlx::query_scalar("SELECT first_seen_at FROM login_events WHERE device_hash = ?")
|
||||
.bind(&hash)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
@@ -411,7 +410,11 @@ fn still_due(tz: Tz, key: &str, card: &CardNow) -> bool {
|
||||
let queued_for = key.split(':').nth(2);
|
||||
card.done_at.is_none()
|
||||
&& card.has_board == Some(true)
|
||||
&& card.due_at.map(|due| local_day(tz, due).to_string()).as_deref() == queued_for
|
||||
&& card
|
||||
.due_at
|
||||
.map(|due| local_day(tz, due).to_string())
|
||||
.as_deref()
|
||||
== queued_for
|
||||
}
|
||||
|
||||
/// Run the notifier for as long as the server lives.
|
||||
@@ -550,18 +553,39 @@ mod tests {
|
||||
#[test]
|
||||
fn a_card_mail_is_only_sent_while_it_is_true() {
|
||||
let tz = chrono_tz::UTC;
|
||||
let due = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 20, 12, 0, 0).unwrap().timestamp();
|
||||
let due = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 20, 12, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
let key = "card_due:7:2026-10-20:due";
|
||||
let open = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(true) };
|
||||
let open = super::CardNow {
|
||||
due_at: Some(due),
|
||||
done_at: None,
|
||||
has_board: Some(true),
|
||||
};
|
||||
assert!(super::still_due(tz, key, &open));
|
||||
|
||||
let finished = super::CardNow { done_at: Some(due), ..open };
|
||||
let finished = super::CardNow {
|
||||
done_at: Some(due),
|
||||
..open
|
||||
};
|
||||
assert!(!super::still_due(tz, key, &finished));
|
||||
let moved = super::CardNow { due_at: Some(due + 86_400), done_at: None, has_board: Some(true) };
|
||||
let moved = super::CardNow {
|
||||
due_at: Some(due + 86_400),
|
||||
done_at: None,
|
||||
has_board: Some(true),
|
||||
};
|
||||
assert!(!super::still_due(tz, key, &moved));
|
||||
let board_off = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(false) };
|
||||
let board_off = super::CardNow {
|
||||
due_at: Some(due),
|
||||
done_at: None,
|
||||
has_board: Some(false),
|
||||
};
|
||||
assert!(!super::still_due(tz, key, &board_off));
|
||||
let deleted = super::CardNow { due_at: None, done_at: None, has_board: None };
|
||||
let deleted = super::CardNow {
|
||||
due_at: None,
|
||||
done_at: None,
|
||||
has_board: None,
|
||||
};
|
||||
assert!(!super::still_due(tz, key, &deleted));
|
||||
}
|
||||
|
||||
@@ -591,7 +615,12 @@ mod tests {
|
||||
let (start, end) = day_bounds(Berlin, today);
|
||||
assert_eq!(end - start, 86_400);
|
||||
// 19:59 local is 17:59 UTC in summer time.
|
||||
assert!(!time_reached(Berlin, start + 19 * 3600 + 59 * 60, today, AT));
|
||||
assert!(!time_reached(
|
||||
Berlin,
|
||||
start + 19 * 3600 + 59 * 60,
|
||||
today,
|
||||
AT
|
||||
));
|
||||
assert!(time_reached(Berlin, start + 20 * 3600, today, AT));
|
||||
}
|
||||
|
||||
|
||||
+33
-15
@@ -159,8 +159,7 @@ pub struct AgendaCard {
|
||||
pub async fn agenda(_: Session, State(state): State<AppState>) -> AppResult<Json<Agenda>> {
|
||||
let cards = agenda_cards(&state.db, crate::now() + (AGENDA_DAYS + 1) * 86400).await?;
|
||||
|
||||
let has_diary: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
|
||||
let has_diary: Option<i64> = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let diary_today = match has_diary {
|
||||
@@ -209,7 +208,11 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard
|
||||
space_id: row.get("space_id"),
|
||||
space_slug: row.get("space_slug"),
|
||||
list_name: row.get("list_name"),
|
||||
role: if row.get::<bool, _>("is_first") { "inbox" } else { "doing" },
|
||||
role: if row.get::<bool, _>("is_first") {
|
||||
"inbox"
|
||||
} else {
|
||||
"doing"
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -411,8 +414,9 @@ pub(crate) async fn restore(
|
||||
|
||||
let mut count = 0;
|
||||
for (list, id) in lists.iter().zip(ids) {
|
||||
let mut position: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1")
|
||||
let mut position: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *db)
|
||||
.await?;
|
||||
@@ -638,9 +642,8 @@ pub async fn create_card(
|
||||
linkable(&state.db, input.node_id).await?;
|
||||
|
||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||
let position: i64 = sqlx::query(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1",
|
||||
)
|
||||
let position: i64 =
|
||||
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1")
|
||||
.bind(list_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
@@ -773,7 +776,9 @@ pub async fn move_card(
|
||||
|
||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||
let now = crate::now();
|
||||
let last = last_list(&mut *tx, to).await?.map_or(input.list_id, |(id, _)| id);
|
||||
let last = last_list(&mut *tx, to)
|
||||
.await?
|
||||
.map_or(input.list_id, |(id, _)| id);
|
||||
relocate(&mut tx, ¤t, input.list_id, last, input.position, now).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -802,7 +807,8 @@ async fn relocate(
|
||||
.execute(&mut *db)
|
||||
.await?;
|
||||
|
||||
let ids: Vec<i64> = sqlx::query_scalar("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
|
||||
let ids: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
|
||||
.bind(to)
|
||||
.fetch_all(&mut *db)
|
||||
.await?;
|
||||
@@ -863,7 +869,10 @@ mod tests {
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("an in-memory database");
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.expect("migrations apply");
|
||||
|
||||
let space: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, body, has_board, created_at, updated_at)
|
||||
@@ -916,7 +925,10 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
vec![("due soon".to_string(), "inbox"), ("in hand".to_string(), "doing")]
|
||||
vec![
|
||||
("due soon".to_string(), "inbox"),
|
||||
("in hand".to_string(), "doing")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -964,14 +976,18 @@ mod tests {
|
||||
|
||||
let current = card(&db, id).await.unwrap();
|
||||
let mut conn = db.acquire().await.unwrap();
|
||||
relocate(&mut conn, ¤t, done, done, None, 100).await.unwrap();
|
||||
relocate(&mut conn, ¤t, done, done, None, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
assert_eq!(done_at(db.clone()).await, Some(100));
|
||||
|
||||
// A list added after the last one becomes "done", so the card that
|
||||
// was finished in the old last list is open again.
|
||||
let later = list(2).await;
|
||||
settle(&mut db.acquire().await.unwrap(), space).await.unwrap();
|
||||
settle(&mut db.acquire().await.unwrap(), space)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done_at(db.clone()).await, None);
|
||||
|
||||
// Deleting the new last list hands "done" back to the old one.
|
||||
@@ -980,7 +996,9 @@ mod tests {
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
settle(&mut db.acquire().await.unwrap(), space).await.unwrap();
|
||||
settle(&mut db.acquire().await.unwrap(), space)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(done_at(db.clone()).await.is_some());
|
||||
}
|
||||
|
||||
|
||||
+65
-17
@@ -67,7 +67,9 @@ fn tree_path(nodes: &HashMap<i64, ExportNode>, parent: Option<i64>, leaf: String
|
||||
// cannot spin here.
|
||||
for _ in 0..nodes.len() {
|
||||
let Some(id) = parent else { break };
|
||||
let Some(ancestor) = nodes.get(&id) else { break };
|
||||
let Some(ancestor) = nodes.get(&id) else {
|
||||
break;
|
||||
};
|
||||
segments.push(ancestor.slug.clone());
|
||||
parent = ancestor.parent_id;
|
||||
}
|
||||
@@ -115,7 +117,13 @@ fn day_string(seconds: i64) -> String {
|
||||
fn safe_name(original: &str) -> String {
|
||||
let safe: String = original
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
safe.trim_matches(['.', '_']).to_string()
|
||||
}
|
||||
@@ -139,7 +147,11 @@ fn file_path(
|
||||
taken: &mut std::collections::HashSet<String>,
|
||||
) -> String {
|
||||
let safe = safe_name(&node.name);
|
||||
let name = if safe.is_empty() { node.slug.clone() } else { safe };
|
||||
let name = if safe.is_empty() {
|
||||
node.slug.clone()
|
||||
} else {
|
||||
safe
|
||||
};
|
||||
let path = tree_path(nodes, node.parent_id, name.clone());
|
||||
if taken.insert(path.clone()) {
|
||||
return path;
|
||||
@@ -266,7 +278,8 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
// way, by taking their id — so no two entries ever share a name.
|
||||
let mut ordered: Vec<&ExportNode> = nodes.values().collect();
|
||||
ordered.sort_by_key(|n| n.id);
|
||||
let mut taken: HashSet<String> = HashSet::from(["README.txt".into(), manifest::FILE_NAME.into()]);
|
||||
let mut taken: HashSet<String> =
|
||||
HashSet::from(["README.txt".into(), manifest::FILE_NAME.into()]);
|
||||
let containers: Vec<(&ExportNode, String)> = ordered
|
||||
.iter()
|
||||
.filter(|n| is_container(&n.kind))
|
||||
@@ -295,10 +308,17 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
let addresses: HashMap<String, String> = documents
|
||||
.iter()
|
||||
.map(|(_, path)| (format!("/n/{}", path.trim_end_matches(".md")), path.clone()))
|
||||
.chain(containers.iter().map(|(_, path)| (format!("/n/{path}"), path.clone())))
|
||||
.chain(
|
||||
containers
|
||||
.iter()
|
||||
.map(|(_, path)| (format!("/n/{path}"), path.clone())),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut meta = Manifest { format: manifest::FORMAT, ..Manifest::default() };
|
||||
let mut meta = Manifest {
|
||||
format: manifest::FORMAT,
|
||||
..Manifest::default()
|
||||
};
|
||||
let mut note = |path: &str, node: &ExportNode| {
|
||||
meta.nodes.insert(
|
||||
path.to_string(),
|
||||
@@ -335,9 +355,11 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
caps[0].to_string()
|
||||
}
|
||||
});
|
||||
let body = NODE_LINK.replace_all(&body, |caps: ®ex::Captures| match addresses.get(&caps[1]) {
|
||||
let body = NODE_LINK.replace_all(&body, |caps: ®ex::Captures| {
|
||||
match addresses.get(&caps[1]) {
|
||||
Some(target) => format!("]({}{}", relative_link(path, target), &caps[2]),
|
||||
None => caps[0].to_string(),
|
||||
}
|
||||
});
|
||||
let document = if node.name.trim().is_empty() {
|
||||
body.into_owned()
|
||||
@@ -364,9 +386,13 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
let source: Box<dyn std::io::Read> = match (&key, sealed.contains(id)) {
|
||||
// A sealed file that will not open is not a file to leave out
|
||||
// quietly: the archive would claim to be complete and not be.
|
||||
(Some(key), true) => Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| {
|
||||
AppError::BadRequest(format!("{entry} is sealed and would not open ({e}) — nothing was exported"))
|
||||
})?),
|
||||
(Some(key), true) => {
|
||||
Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| {
|
||||
AppError::BadRequest(format!(
|
||||
"{entry} is sealed and would not open ({e}) — nothing was exported"
|
||||
))
|
||||
})?)
|
||||
}
|
||||
// A row whose file has gone missing must not fail the whole
|
||||
// export; there is nothing to put in its place either way.
|
||||
_ => match std::fs::File::open(&path) {
|
||||
@@ -409,7 +435,11 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
.collect();
|
||||
meta.spaces.insert(
|
||||
path.clone(),
|
||||
SpaceMeta { has_board: node.has_board, role: node.role.clone(), lists },
|
||||
SpaceMeta {
|
||||
has_board: node.has_board,
|
||||
role: node.role.clone(),
|
||||
lists,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -490,8 +520,14 @@ mod tests {
|
||||
nodes.get_mut(&4).unwrap().name = "Slides 3.pdf".into();
|
||||
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
assert_eq!(file_path(&nodes, &nodes[&3], &mut taken), "uni/algo/Slides_3.pdf");
|
||||
assert_eq!(file_path(&nodes, &nodes[&4], &mut taken), "uni/algo/4-Slides_3.pdf");
|
||||
assert_eq!(
|
||||
file_path(&nodes, &nodes[&3], &mut taken),
|
||||
"uni/algo/Slides_3.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
file_path(&nodes, &nodes[&4], &mut taken),
|
||||
"uni/algo/4-Slides_3.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -502,7 +538,10 @@ mod tests {
|
||||
(3, Some(2), "document", "01-was-ist-eebus"),
|
||||
(4, Some(1), "document", "notes"),
|
||||
]);
|
||||
assert_eq!(document_path(&nodes, &nodes[&3]), "work/einarbeiten/01-was-ist-eebus.md");
|
||||
assert_eq!(
|
||||
document_path(&nodes, &nodes[&3]),
|
||||
"work/einarbeiten/01-was-ist-eebus.md"
|
||||
);
|
||||
assert_eq!(document_path(&nodes, &nodes[&4]), "work/notes.md");
|
||||
}
|
||||
|
||||
@@ -521,15 +560,24 @@ mod tests {
|
||||
fn media_names_cannot_escape_their_folder() {
|
||||
assert_eq!(media_filename("abc", "../../etc/passwd"), "abc-etc_passwd");
|
||||
// Nothing that would need escaping in the markdown link that points at it.
|
||||
assert_eq!(media_filename("abc", "my photo (1).png"), "abc-my_photo__1_.png");
|
||||
assert_eq!(
|
||||
media_filename("abc", "my photo (1).png"),
|
||||
"abc-my_photo__1_.png"
|
||||
);
|
||||
assert_eq!(media_filename("abc", "photo.jpg"), "abc-photo.jpg");
|
||||
assert_eq!(media_filename("abc", "..."), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_between_documents_become_file_paths() {
|
||||
assert_eq!(relative_link("uni/proj/readme.md", "uni/proj/sub/deep.md"), "sub/deep.md");
|
||||
assert_eq!(relative_link("uni/proj/sub/deep.md", "uni/proj/readme.md"), "../readme.md");
|
||||
assert_eq!(
|
||||
relative_link("uni/proj/readme.md", "uni/proj/sub/deep.md"),
|
||||
"sub/deep.md"
|
||||
);
|
||||
assert_eq!(
|
||||
relative_link("uni/proj/sub/deep.md", "uni/proj/readme.md"),
|
||||
"../readme.md"
|
||||
);
|
||||
assert_eq!(relative_link("work/a.md", "uni/b.md"), "../uni/b.md");
|
||||
assert_eq!(relative_link("work/a.md", "work/b.md"), "b.md");
|
||||
}
|
||||
|
||||
+125
-39
@@ -23,9 +23,9 @@ use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::{
|
||||
media,
|
||||
boards,
|
||||
manifest::{self, Manifest, NodeMeta},
|
||||
media,
|
||||
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
|
||||
},
|
||||
state::AppState,
|
||||
@@ -139,7 +139,14 @@ fn split_title(body: &str, path: &str) -> (String, String) {
|
||||
.map(str::to_string);
|
||||
|
||||
match heading {
|
||||
Some(title) => (title, lines.collect::<Vec<_>>().join("\n").trim_start().to_string()),
|
||||
Some(title) => (
|
||||
title,
|
||||
lines
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim_start()
|
||||
.to_string(),
|
||||
),
|
||||
None => {
|
||||
let name = file_name(path);
|
||||
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
|
||||
@@ -287,7 +294,8 @@ async fn collect(
|
||||
let limit = state.config.max_upload_bytes as u64;
|
||||
let archive = uploads_dir.join(&id);
|
||||
let opened = archive.clone();
|
||||
let expanded = tokio::task::spawn_blocking(move || expand_zip(&opened, &uploads_dir, limit))
|
||||
let expanded =
|
||||
tokio::task::spawn_blocking(move || expand_zip(&opened, &uploads_dir, limit))
|
||||
.await
|
||||
.map_err(anyhow::Error::from);
|
||||
let _ = tokio::fs::remove_file(&archive).await;
|
||||
@@ -328,7 +336,10 @@ async fn collect(
|
||||
continue;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&text).to_string();
|
||||
uploads.push(Upload { content: text_content(&path, text), path });
|
||||
uploads.push(Upload {
|
||||
content: text_content(&path, text),
|
||||
path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -340,7 +351,10 @@ async fn collect(
|
||||
continue;
|
||||
}
|
||||
written.push(id.clone());
|
||||
uploads.push(Upload { path, content: Content::File { id, mime, size } });
|
||||
uploads.push(Upload {
|
||||
path,
|
||||
content: Content::File { id, mime, size },
|
||||
});
|
||||
}
|
||||
|
||||
Ok(uploads)
|
||||
@@ -417,7 +431,10 @@ fn unpack(
|
||||
continue;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&text).to_string();
|
||||
uploads.push(Upload { content: text_content(&path, text), path });
|
||||
uploads.push(Upload {
|
||||
content: text_content(&path, text),
|
||||
path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -450,7 +467,11 @@ fn unpack(
|
||||
}
|
||||
|
||||
uploads.push(Upload {
|
||||
content: Content::File { id, mime: media::mime_of(&path, None), size },
|
||||
content: Content::File {
|
||||
id,
|
||||
mime: media::mime_of(&path, None),
|
||||
size,
|
||||
},
|
||||
path,
|
||||
});
|
||||
}
|
||||
@@ -486,7 +507,9 @@ pub async fn import(
|
||||
"a folder can only be imported into a space or a folder".into(),
|
||||
));
|
||||
}
|
||||
run(&state, Into::Node(Box::new(target)), multipart).await.map(Json)
|
||||
run(&state, Into::Node(Box::new(target)), multipart)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
/// `POST /api/import` — an export poured back in whole: each top-level
|
||||
@@ -512,7 +535,9 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult<Im
|
||||
files_as_they_were(state, manifest, &mut uploads, &mut written).await?;
|
||||
}
|
||||
if uploads.is_empty() {
|
||||
return Err(AppError::BadRequest("nothing importable in that upload".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"nothing importable in that upload".into(),
|
||||
));
|
||||
}
|
||||
build(state, &into, uploads, skipped, manifest).await
|
||||
}
|
||||
@@ -537,8 +562,13 @@ async fn files_as_they_were(
|
||||
written: &mut Vec<String>,
|
||||
) -> AppResult<()> {
|
||||
for upload in uploads.iter_mut() {
|
||||
let was_file = manifest.nodes.get(&upload.path).is_some_and(|m| m.kind == FILE);
|
||||
let Content::Markdown(text) = &upload.content else { continue };
|
||||
let was_file = manifest
|
||||
.nodes
|
||||
.get(&upload.path)
|
||||
.is_some_and(|m| m.kind == FILE);
|
||||
let Content::Markdown(text) = &upload.content else {
|
||||
continue;
|
||||
};
|
||||
if !was_file {
|
||||
continue;
|
||||
}
|
||||
@@ -693,7 +723,10 @@ async fn build(
|
||||
let mut dir_ids: HashMap<String, i64> = HashMap::new();
|
||||
let mut dir_paths: HashMap<String, String> = HashMap::new();
|
||||
if let Into::Node(target) = into {
|
||||
siblings.insert(target.id, nodes::sibling_slugs(&mut tx, Some(target.id), None).await?);
|
||||
siblings.insert(
|
||||
target.id,
|
||||
nodes::sibling_slugs(&mut tx, Some(target.id), None).await?,
|
||||
);
|
||||
dir_ids.insert(String::new(), target.id);
|
||||
dir_paths.insert(String::new(), prefix);
|
||||
}
|
||||
@@ -764,7 +797,10 @@ async fn build(
|
||||
(id, slug)
|
||||
};
|
||||
|
||||
let path = join(&dir_paths.get(parent_of(dir)).cloned().unwrap_or_default(), &slug);
|
||||
let path = join(
|
||||
&dir_paths.get(parent_of(dir)).cloned().unwrap_or_default(),
|
||||
&slug,
|
||||
);
|
||||
dir_ids.insert(dir.clone(), id);
|
||||
dir_paths.insert(dir.clone(), path.clone());
|
||||
landed.insert(dir.clone(), Landed::Node(format!("/n/{path}")));
|
||||
@@ -780,7 +816,12 @@ async fn build(
|
||||
let dated = meta(&upload.path);
|
||||
let (created, updated) = dated.map_or((now, now), |m| (m.created_at, m.updated_at));
|
||||
|
||||
if let Content::File { id: media_id, mime, size } = &upload.content {
|
||||
if let Content::File {
|
||||
id: media_id,
|
||||
mime,
|
||||
size,
|
||||
} = &upload.content
|
||||
{
|
||||
let original = manifest
|
||||
.as_ref()
|
||||
.and_then(|m| m.media.get(&upload.path))
|
||||
@@ -896,7 +937,9 @@ async fn build(
|
||||
let mut cards = 0usize;
|
||||
if let (Into::Root, Some(manifest)) = (into, &manifest) {
|
||||
for (dir, space) in &manifest.spaces {
|
||||
let Some(&space_id) = dir_ids.get(dir) else { continue };
|
||||
let Some(&space_id) = dir_ids.get(dir) else {
|
||||
continue;
|
||||
};
|
||||
cards += boards::restore(&mut tx, space_id, &space.lists, &by_path).await?;
|
||||
if space.role.as_deref() == Some(nodes::DIARY) {
|
||||
// Only where no space has the role yet: there is one diary.
|
||||
@@ -935,16 +978,25 @@ mod tests {
|
||||
};
|
||||
|
||||
fn text(path: &str, content: Content) -> Upload {
|
||||
Upload { path: path.into(), content }
|
||||
Upload {
|
||||
path: path.into(),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_uploaded_as_a_folder_is_read_from_where_its_manifest_is() {
|
||||
let manifest = r#"{"format": 1, "nodes": {"work/a.md": {"kind": "document", "name": "A", "created_at": 1, "updated_at": 2}}}"#;
|
||||
let mut uploads = vec![
|
||||
text("workspace-2026/.workspace.json", Content::Manifest(manifest.into())),
|
||||
text(
|
||||
"workspace-2026/.workspace.json",
|
||||
Content::Manifest(manifest.into()),
|
||||
),
|
||||
text("workspace-2026/work/a.md", Content::Markdown("a".into())),
|
||||
text("workspace-2026/work/deeper/.workspace.json", Content::Manifest("{}".into())),
|
||||
text(
|
||||
"workspace-2026/work/deeper/.workspace.json",
|
||||
Content::Manifest("{}".into()),
|
||||
),
|
||||
];
|
||||
let found = take_manifest(&mut uploads).unwrap().unwrap();
|
||||
assert_eq!(found.nodes["work/a.md"].created_at, 1);
|
||||
@@ -956,7 +1008,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_manifest_from_a_newer_version_is_refused() {
|
||||
let mut uploads = vec![text(".workspace.json", Content::Manifest(r#"{"format": 99}"#.into()))];
|
||||
let mut uploads = vec![text(
|
||||
".workspace.json",
|
||||
Content::Manifest(r#"{"format": 99}"#.into()),
|
||||
)];
|
||||
assert!(take_manifest(&mut uploads).is_err());
|
||||
}
|
||||
|
||||
@@ -979,18 +1034,27 @@ mod tests {
|
||||
#[test]
|
||||
fn a_zip_unpacks_markdown_to_memory_and_the_rest_to_disk() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (uploads, skipped, ids) =
|
||||
expand_zip(&archive(dir.path()), dir.path(), 1 << 20).unwrap_or_else(|(e, _)| panic!("{e:?}"));
|
||||
let (uploads, skipped, ids) = expand_zip(&archive(dir.path()), dir.path(), 1 << 20)
|
||||
.unwrap_or_else(|(e, _)| panic!("{e:?}"));
|
||||
|
||||
assert_eq!(skipped, ["../escape.txt"]);
|
||||
assert_eq!(ids.len(), 1);
|
||||
let blob = uploads.iter().find(|u| u.path == "notes/blob.bin").expect("the blob");
|
||||
let Content::File { id, size, .. } = &blob.content else { panic!("the blob is a file") };
|
||||
assert_eq!(*size, 4096);
|
||||
assert_eq!(std::fs::metadata(dir.path().join(id)).expect("on disk").len(), 4096);
|
||||
assert!(uploads
|
||||
let blob = uploads
|
||||
.iter()
|
||||
.any(|u| u.path == "notes/a.md" && matches!(&u.content, Content::Markdown(t) if t.contains("text"))));
|
||||
.find(|u| u.path == "notes/blob.bin")
|
||||
.expect("the blob");
|
||||
let Content::File { id, size, .. } = &blob.content else {
|
||||
panic!("the blob is a file")
|
||||
};
|
||||
assert_eq!(*size, 4096);
|
||||
assert_eq!(
|
||||
std::fs::metadata(dir.path().join(id))
|
||||
.expect("on disk")
|
||||
.len(),
|
||||
4096
|
||||
);
|
||||
assert!(uploads.iter().any(|u| u.path == "notes/a.md"
|
||||
&& matches!(&u.content, Content::Markdown(t) if t.contains("text"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1005,9 +1069,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn refuses_paths_that_escape_the_import() {
|
||||
assert_eq!(normalize_path("docs/readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(normalize_path("./docs//readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(normalize_path("docs\\readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(
|
||||
normalize_path("docs/readme.md").as_deref(),
|
||||
Some("docs/readme.md")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path("./docs//readme.md").as_deref(),
|
||||
Some("docs/readme.md")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path("docs\\readme.md").as_deref(),
|
||||
Some("docs/readme.md")
|
||||
);
|
||||
assert_eq!(normalize_path("../secrets"), None);
|
||||
assert_eq!(normalize_path("docs/../../etc/passwd"), None);
|
||||
assert_eq!(normalize_path("/etc/passwd"), None);
|
||||
@@ -1041,8 +1114,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn relative_links_resolve_against_their_document() {
|
||||
assert_eq!(join_relative("docs", "img/a.png").as_deref(), Some("docs/img/a.png"));
|
||||
assert_eq!(join_relative("docs/deep", "../a.md").as_deref(), Some("docs/a.md"));
|
||||
assert_eq!(
|
||||
join_relative("docs", "img/a.png").as_deref(),
|
||||
Some("docs/img/a.png")
|
||||
);
|
||||
assert_eq!(
|
||||
join_relative("docs/deep", "../a.md").as_deref(),
|
||||
Some("docs/a.md")
|
||||
);
|
||||
assert_eq!(join_relative("", "a.md").as_deref(), Some("a.md"));
|
||||
assert_eq!(join_relative("docs", "../../outside.md"), None);
|
||||
}
|
||||
@@ -1053,7 +1132,10 @@ mod tests {
|
||||
assert!(split_target("#section").is_none());
|
||||
assert!(split_target("/api/media/x").is_none());
|
||||
assert!(split_target("mailto:someone@example.org").is_none());
|
||||
assert_eq!(split_target("a%20b.md#top"), Some(("a b.md".into(), "#top")));
|
||||
assert_eq!(
|
||||
split_target("a%20b.md#top"),
|
||||
Some(("a b.md".into(), "#top"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1070,8 +1152,14 @@ mod tests {
|
||||
#[test]
|
||||
fn links_point_at_where_the_files_landed() {
|
||||
let landed = HashMap::from([
|
||||
("einarbeiten/01.md".to_string(), Landed::Node("/n/work/einarbeiten/01".into())),
|
||||
("einarbeiten/img/a.png".to_string(), Landed::Media("/api/media/uuid".into())),
|
||||
(
|
||||
"einarbeiten/01.md".to_string(),
|
||||
Landed::Node("/n/work/einarbeiten/01".into()),
|
||||
),
|
||||
(
|
||||
"einarbeiten/img/a.png".to_string(),
|
||||
Landed::Media("/api/media/uuid".into()),
|
||||
),
|
||||
]);
|
||||
|
||||
let body = "see [one](01.md#ziele) and , \
|
||||
@@ -1087,10 +1175,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_link_from_a_sibling_directory_still_resolves() {
|
||||
let landed = HashMap::from([(
|
||||
"a/one.md".to_string(),
|
||||
Landed::Node("/n/work/a/one".into()),
|
||||
)]);
|
||||
let landed =
|
||||
HashMap::from([("a/one.md".to_string(), Landed::Node("/n/work/a/one".into()))]);
|
||||
let (out, count) = rewrite_links("[x](../a/one.md)", "b", &landed);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(out, "[x](/n/work/a/one)");
|
||||
|
||||
+55
-19
@@ -25,7 +25,9 @@ use crate::{
|
||||
/// Media referenced from a document body always looks like `/api/media/<uuid>`,
|
||||
/// which is how a document claims ownership of the files it embeds.
|
||||
pub(crate) static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
|
||||
Regex::new(
|
||||
r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
|
||||
)
|
||||
.expect("static regex is valid")
|
||||
});
|
||||
|
||||
@@ -279,7 +281,13 @@ pub async fn upload_into(
|
||||
// segment, because the folder is the one in the URL.
|
||||
let filename = field
|
||||
.file_name()
|
||||
.map(|name| name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string())
|
||||
.map(|name| {
|
||||
name.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(name)
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
let mime = mime_of(&filename, field.content_type());
|
||||
@@ -290,7 +298,17 @@ pub async fn upload_into(
|
||||
continue;
|
||||
}
|
||||
|
||||
match insert_file(&state.db, parent, &id, &filename, &mime, size, key.is_some()).await {
|
||||
match insert_file(
|
||||
&state.db,
|
||||
parent,
|
||||
&id,
|
||||
&filename,
|
||||
&mime,
|
||||
size,
|
||||
key.is_some(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(node) => created.push(node),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await;
|
||||
@@ -410,7 +428,10 @@ pub async fn serve(
|
||||
/// `size` bytes. `Ok(None)` serves the whole file: no header, one that cannot
|
||||
/// be read, or several ranges — a whole file is always a valid answer.
|
||||
/// `Err` is a range that starts past the end.
|
||||
fn byte_range(header: Option<&axum::http::HeaderValue>, size: u64) -> Result<Option<(u64, u64)>, ()> {
|
||||
fn byte_range(
|
||||
header: Option<&axum::http::HeaderValue>,
|
||||
size: u64,
|
||||
) -> Result<Option<(u64, u64)>, ()> {
|
||||
let Some(spec) = header
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.trim().strip_prefix("bytes="))
|
||||
@@ -473,7 +494,11 @@ pub async fn stream_media(
|
||||
// Re-checked on the way out, not just on the way in, so rows written before
|
||||
// the allowlist existed cannot be served as something executable either.
|
||||
let mime = sanitize_mime(&row.get::<String, _>("mime"));
|
||||
let disposition = if is_inline(&mime) { "inline" } else { "attachment" };
|
||||
let disposition = if is_inline(&mime) {
|
||||
"inline"
|
||||
} else {
|
||||
"attachment"
|
||||
};
|
||||
|
||||
let key = match (encrypted, private) {
|
||||
(false, _) => None,
|
||||
@@ -520,14 +545,16 @@ pub async fn stream_media(
|
||||
// browser's cache long after the vault had been shut.
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
if encrypted { "no-store" } else { "private, max-age=31536000" }.to_string(),
|
||||
if encrypted {
|
||||
"no-store"
|
||||
} else {
|
||||
"private, max-age=31536000"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!(
|
||||
"{disposition}; filename*=UTF-8''{}",
|
||||
urlencode(&filename)
|
||||
),
|
||||
format!("{disposition}; filename*=UTF-8''{}", urlencode(&filename)),
|
||||
),
|
||||
// Belt and braces: never let the browser sniff past the type above,
|
||||
// and strip the ambient authority of the origin from the response.
|
||||
@@ -571,7 +598,9 @@ async fn open_stream(
|
||||
let first = start / vault::CHUNK as u64;
|
||||
if first > 0 {
|
||||
opener.skip_to(first)?;
|
||||
file.seek(std::io::SeekFrom::Start(vault::HEADER as u64 + first * vault::RECORD as u64))
|
||||
file.seek(std::io::SeekFrom::Start(
|
||||
vault::HEADER as u64 + first * vault::RECORD as u64,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
let skip = (start - first * vault::CHUNK as u64) as usize;
|
||||
@@ -589,7 +618,10 @@ async fn open_stream(
|
||||
let piece = &plain[skip.min(plain.len())..];
|
||||
let piece = &piece[..piece.len().min(usize::try_from(left).unwrap_or(usize::MAX))];
|
||||
let left = left - piece.len() as u64;
|
||||
Ok(Some((axum::body::Bytes::copy_from_slice(piece), (file, opener, 0, left))))
|
||||
Ok(Some((
|
||||
axum::body::Bytes::copy_from_slice(piece),
|
||||
(file, opener, 0, left),
|
||||
)))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -646,11 +678,7 @@ pub async fn remove_upload(uploads: &std::path::Path, id: &str) {
|
||||
/// Attach every media file the body embeds to this document, and detach the
|
||||
/// ones it no longer mentions so they stop being reachable through its share
|
||||
/// link. Other documents keep whatever they embed; this rewrites one node's rows.
|
||||
pub async fn link_to_node(
|
||||
db: &mut SqliteConnection,
|
||||
node_id: i64,
|
||||
body: &str,
|
||||
) -> AppResult<()> {
|
||||
pub async fn link_to_node(db: &mut SqliteConnection, node_id: i64, body: &str) -> AppResult<()> {
|
||||
let referenced: BTreeSet<String> = MEDIA_REF
|
||||
.captures_iter(body)
|
||||
.map(|c| c[1].to_lowercase())
|
||||
@@ -736,7 +764,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ranges_are_read_the_way_browsers_send_them() {
|
||||
let range = |raw: &'static str| byte_range(Some(&axum::http::HeaderValue::from_static(raw)), 1000);
|
||||
let range =
|
||||
|raw: &'static str| byte_range(Some(&axum::http::HeaderValue::from_static(raw)), 1000);
|
||||
assert_eq!(byte_range(None, 1000), Ok(None));
|
||||
assert_eq!(range("bytes=0-"), Ok(Some((0, 999))));
|
||||
assert_eq!(range("bytes=100-199"), Ok(Some((100, 199))));
|
||||
@@ -761,7 +790,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_types_that_cannot_execute() {
|
||||
for mime in ["image/png", "image/jpeg", "video/mp4", "audio/ogg", "text/plain", "application/pdf"] {
|
||||
for mime in [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"video/mp4",
|
||||
"audio/ogg",
|
||||
"text/plain",
|
||||
"application/pdf",
|
||||
] {
|
||||
assert_eq!(sanitize_mime(mime), mime);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -1,10 +1,10 @@
|
||||
pub mod backup;
|
||||
pub mod boards;
|
||||
pub mod nodes;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod manifest;
|
||||
pub mod media;
|
||||
pub mod nodes;
|
||||
pub mod seal;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
@@ -35,7 +35,10 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(session::health))
|
||||
.route("/me", get(session::me))
|
||||
.route("/login", post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)))
|
||||
.route(
|
||||
"/login",
|
||||
post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)),
|
||||
)
|
||||
.route("/logout", post(session::logout))
|
||||
.route("/export", get(export::export))
|
||||
.route("/backup", get(backup::status).post(backup::run))
|
||||
@@ -44,9 +47,15 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/agenda", get(boards::agenda))
|
||||
.route("/spaces/{id}/board", get(boards::board))
|
||||
.route("/spaces/{id}/lists", post(boards::create_list))
|
||||
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
|
||||
.route(
|
||||
"/lists/{id}",
|
||||
put(boards::rename_list).delete(boards::remove_list),
|
||||
)
|
||||
.route("/lists/{id}/cards", post(boards::create_card))
|
||||
.route("/cards/{id}", put(boards::update_card).delete(boards::remove_card))
|
||||
.route(
|
||||
"/cards/{id}",
|
||||
put(boards::update_card).delete(boards::remove_card),
|
||||
)
|
||||
.route("/cards/{id}/move", post(boards::move_card))
|
||||
.route("/nodes", get(nodes::list).post(nodes::create))
|
||||
.route("/resolve", get(nodes::resolve))
|
||||
@@ -59,12 +68,20 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/nodes/{id}/files", post(media::upload_into).layer(uploads))
|
||||
.route("/nodes/{id}/encrypt", post(seal::encrypt))
|
||||
.route("/nodes/{id}/decrypt", post(seal::decrypt))
|
||||
.route("/vault", get(vault::status).post(vault::setup).put(vault::change_password))
|
||||
.route(
|
||||
"/vault",
|
||||
get(vault::status)
|
||||
.post(vault::setup)
|
||||
.put(vault::change_password),
|
||||
)
|
||||
.route("/vault/unlock", post(vault::unlock))
|
||||
.route("/vault/lock", post(vault::lock))
|
||||
.route("/nodes/{id}/share", post(share::enable))
|
||||
.route("/nodes/{id}/share", delete(share::disable))
|
||||
.route("/media", post(media::upload).layer(uploads).get(media::list))
|
||||
.route(
|
||||
"/media",
|
||||
post(media::upload).layer(uploads).get(media::list),
|
||||
)
|
||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||
.route("/share/{token}/{key}", get(share::read))
|
||||
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
|
||||
|
||||
+93
-32
@@ -174,7 +174,8 @@ const HEAD_CHARS: usize = 1024;
|
||||
const EXCERPT_CHARS: usize = 180;
|
||||
|
||||
/// The columns every list query selects, so a row always converts the same way.
|
||||
const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role,
|
||||
const SUMMARY_COLUMNS: &str =
|
||||
"n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role,
|
||||
n.created_at, n.updated_at, n.share_token, n.media_id,
|
||||
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count,
|
||||
(SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime,
|
||||
@@ -305,9 +306,13 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
||||
.map(|media_id| FileInfo {
|
||||
url: format!("/api/media/{media_id}"),
|
||||
media_id,
|
||||
mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(),
|
||||
mime: row
|
||||
.get::<Option<String>, _>("file_mime")
|
||||
.unwrap_or_default(),
|
||||
size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(),
|
||||
encrypted: row.get::<Option<bool>, _>("file_encrypted").unwrap_or_default(),
|
||||
encrypted: row
|
||||
.get::<Option<bool>, _>("file_encrypted")
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
NodeSummary {
|
||||
id: row.get("id"),
|
||||
@@ -330,7 +335,10 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
||||
}
|
||||
|
||||
/// The spaces, in display order. Their child counts are what the switcher shows.
|
||||
pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||
pub async fn spaces(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||
list_spaces(&state.db).await.map(Json)
|
||||
}
|
||||
|
||||
@@ -358,7 +366,10 @@ pub struct SpaceOverview {
|
||||
/// Every space with the documents last written in it, from any depth: what
|
||||
/// the overview shows, the way the desktop's overview shows each workspace
|
||||
/// with its windows.
|
||||
pub async fn overview(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<SpaceOverview>>> {
|
||||
pub async fn overview(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<SpaceOverview>>> {
|
||||
let mut out = Vec::new();
|
||||
for space in list_spaces(&state.db).await? {
|
||||
let recent = recent_documents(&state.db, space.id, OVERVIEW_RECENT).await?;
|
||||
@@ -368,7 +379,11 @@ pub async fn overview(_: Session, State(state): State<AppState>) -> AppResult<Js
|
||||
}
|
||||
|
||||
/// The `limit` most recently edited documents anywhere under `root`.
|
||||
pub async fn recent_documents(db: &SqlitePool, root: i64, limit: i64) -> AppResult<Vec<NodeSummary>> {
|
||||
pub async fn recent_documents(
|
||||
db: &SqlitePool,
|
||||
root: i64,
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<NodeSummary>> {
|
||||
let rows = sqlx::query(&format!(
|
||||
"WITH RECURSIVE sub (id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?1
|
||||
@@ -413,7 +428,9 @@ pub async fn insert_space(db: &SqlitePool, name: &str, has_board: bool) -> AppRe
|
||||
let mut tx = crate::db::begin_write(db).await?;
|
||||
let slug = unique_slug(&mut tx, None, &slugify(name), None).await?;
|
||||
let now = crate::now();
|
||||
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL")
|
||||
let position: i64 = sqlx::query(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.get("next");
|
||||
@@ -502,7 +519,13 @@ pub async fn list(
|
||||
for node in &mut found {
|
||||
let path = path_of(&state.db, node.id).await?;
|
||||
let parents = &path[..path.len().saturating_sub(1)];
|
||||
node.location = Some(parents.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>().join("/"));
|
||||
node.location = Some(
|
||||
parents
|
||||
.iter()
|
||||
.map(|c| c.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Json(found))
|
||||
@@ -541,7 +564,9 @@ pub async fn resolve(
|
||||
.ok_or(AppError::NotFound)?;
|
||||
current = Some(row.get("id"));
|
||||
}
|
||||
load(&state, current.ok_or(AppError::NotFound)?).await.map(Json)
|
||||
load(&state, current.ok_or(AppError::NotFound)?)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
@@ -595,13 +620,17 @@ pub async fn update(
|
||||
// here is still free when it is written, and a file's name and its
|
||||
// download name never disagree.
|
||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||
let row = sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1")
|
||||
let row =
|
||||
sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if input.base_updated_at.is_some_and(|base| row.get::<i64, _>("updated_at") > base) {
|
||||
if input
|
||||
.base_updated_at
|
||||
.is_some_and(|base| row.get::<i64, _>("updated_at") > base)
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"changed somewhere else since it was opened — :e! takes that version, :w! writes over it".into(),
|
||||
));
|
||||
@@ -627,9 +656,15 @@ pub async fn update(
|
||||
|
||||
// A file has no text, only a name, and the name is also what a download
|
||||
// of it is called.
|
||||
let body = if kind == FILE { "" } else { input.body.as_str() };
|
||||
let body = if kind == FILE {
|
||||
""
|
||||
} else {
|
||||
input.body.as_str()
|
||||
};
|
||||
if kind == FILE {
|
||||
sqlx::query("UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)")
|
||||
sqlx::query(
|
||||
"UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
@@ -740,7 +775,9 @@ pub async fn move_node(
|
||||
// A space is a root: under a folder it would drop out of the switcher
|
||||
// while its board and its cards carried on as if nothing had happened.
|
||||
if kind == SPACE {
|
||||
return Err(AppError::BadRequest("a space cannot be moved into anything".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"a space cannot be moved into anything".into(),
|
||||
));
|
||||
}
|
||||
|
||||
container(&mut tx, input.parent_id).await?;
|
||||
@@ -775,10 +812,7 @@ pub async fn move_node(
|
||||
}
|
||||
|
||||
/// Refuse anything but a space or a folder as the place a node goes into.
|
||||
pub async fn container(
|
||||
db: &mut SqliteConnection,
|
||||
id: i64,
|
||||
) -> AppResult<()> {
|
||||
pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
||||
let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *db)
|
||||
@@ -794,10 +828,7 @@ pub async fn container(
|
||||
}
|
||||
|
||||
/// A node and everything beneath it, the node itself first.
|
||||
pub async fn descendants(
|
||||
db: &mut SqliteConnection,
|
||||
id: i64,
|
||||
) -> AppResult<Vec<i64>> {
|
||||
pub async fn descendants(db: &mut SqliteConnection, id: i64) -> AppResult<Vec<i64>> {
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE sub (id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?1
|
||||
@@ -970,7 +1001,10 @@ mod tests {
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("an in-memory database");
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.expect("migrations apply");
|
||||
|
||||
let insert = |parent: i64, kind: &'static str, slug: &'static str, at: i64| {
|
||||
let db = db.clone();
|
||||
@@ -1028,7 +1062,12 @@ mod tests {
|
||||
.expect("a database");
|
||||
|
||||
let mut before = sqlx::migrate!("./migrations");
|
||||
before.migrations = before.migrations.iter().filter(|m| m.version < 10).cloned().collect();
|
||||
before.migrations = before
|
||||
.migrations
|
||||
.iter()
|
||||
.filter(|m| m.version < 10)
|
||||
.cloned()
|
||||
.collect();
|
||||
before.run(&db).await.expect("migrations up to 0009");
|
||||
|
||||
let folder: i64 = sqlx::query_scalar(
|
||||
@@ -1055,7 +1094,10 @@ mod tests {
|
||||
let mut stale = db.acquire().await.expect("a second connection");
|
||||
stale.ping().await.expect("ping");
|
||||
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("0010 applies");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.expect("0010 applies");
|
||||
|
||||
let (name, slug, owned): (String, String, i64) = sqlx::query_as(
|
||||
"SELECT n.name, n.slug, (SELECT COUNT(*) FROM node_media nm WHERE nm.node_id = n.id)
|
||||
@@ -1065,8 +1107,12 @@ mod tests {
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("the adopted file");
|
||||
assert_eq!((name.as_str(), slug.as_str(), owned), ("slides.pdf", "file-0a1b2c3d", 1));
|
||||
let on_folder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1")
|
||||
assert_eq!(
|
||||
(name.as_str(), slug.as_str(), owned),
|
||||
("slides.pdf", "file-0a1b2c3d", 1)
|
||||
);
|
||||
let on_folder: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1")
|
||||
.bind(folder)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
@@ -1095,9 +1141,15 @@ mod tests {
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let check: String = sqlx::query_scalar("PRAGMA integrity_check").fetch_one(&db).await.expect("check");
|
||||
let check: String = sqlx::query_scalar("PRAGMA integrity_check")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("check");
|
||||
assert_eq!(check, "ok");
|
||||
let dangling = sqlx::query("PRAGMA foreign_key_check").fetch_all(&db).await.expect("fk check");
|
||||
let dangling = sqlx::query("PRAGMA foreign_key_check")
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("fk check");
|
||||
assert!(dangling.is_empty());
|
||||
}
|
||||
|
||||
@@ -1125,8 +1177,14 @@ mod tests {
|
||||
fn fts_query_discards_operators() {
|
||||
// Anything that could be read as FTS syntax must not survive as syntax.
|
||||
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
|
||||
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
|
||||
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
|
||||
assert_eq!(
|
||||
fts_query("a OR b"),
|
||||
Some("\"a\"* AND \"OR\"* AND \"b\"*".into())
|
||||
);
|
||||
assert_eq!(
|
||||
fts_query("foo\"NEAR/2\"bar"),
|
||||
Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into())
|
||||
);
|
||||
assert_eq!(fts_query(" "), None);
|
||||
assert_eq!(fts_query("***"), None);
|
||||
}
|
||||
@@ -1155,7 +1213,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_taken_slug_gets_a_number() {
|
||||
let taken: BTreeSet<String> = ["readme", "readme-2"].iter().map(|s| s.to_string()).collect();
|
||||
let taken: BTreeSet<String> = ["readme", "readme-2"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(next_slug(&taken, "readme").unwrap(), "readme-3");
|
||||
assert_eq!(next_slug(&taken, "notes").unwrap(), "notes");
|
||||
assert_eq!(next_slug(&BTreeSet::new(), "readme").unwrap(), "readme");
|
||||
|
||||
+18
-4
@@ -69,7 +69,11 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
|
||||
if subtree.is_empty() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let list = subtree.iter().map(i64::to_string).collect::<Vec<_>>().join(",");
|
||||
let list = subtree
|
||||
.iter()
|
||||
.map(i64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let targets: Vec<Target> = sqlx::query(&format!(
|
||||
"SELECT n.id, n.media_id, m.encrypted
|
||||
FROM nodes n JOIN media m ON m.id = n.media_id
|
||||
@@ -127,7 +131,12 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(Resealed { files, unchanged, failed: failed.len(), shared: shared as usize })
|
||||
Ok(Resealed {
|
||||
files,
|
||||
unchanged,
|
||||
failed: failed.len(),
|
||||
shared: shared as usize,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-write one file sealed or opened under a new id, then point everything
|
||||
@@ -140,8 +149,13 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool
|
||||
let partial = uploads.join(format!("{new}.part"));
|
||||
|
||||
let written = {
|
||||
let (key, old, new, source, partial) =
|
||||
(key.clone(), old.clone(), new.clone(), source.clone(), partial.clone());
|
||||
let (key, old, new, source, partial) = (
|
||||
key.clone(),
|
||||
old.clone(),
|
||||
new.clone(),
|
||||
source.clone(),
|
||||
partial.clone(),
|
||||
);
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||
if seal {
|
||||
let mut plain = std::fs::File::open(&source)?;
|
||||
|
||||
@@ -9,9 +9,9 @@ use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
throttle::FREE_ATTEMPTS,
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
throttle::FREE_ATTEMPTS,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -46,7 +46,10 @@ pub async fn login(
|
||||
.unwrap_or("unknown");
|
||||
state.notify.note_login(user_agent).await;
|
||||
|
||||
let token = auth::issue_token(&state.config, state.epoch.load(std::sync::atomic::Ordering::SeqCst));
|
||||
let token = auth::issue_token(
|
||||
&state.config,
|
||||
state.epoch.load(std::sync::atomic::Ordering::SeqCst),
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(
|
||||
@@ -98,7 +101,10 @@ pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Ok(_) => (StatusCode::OK, Json(json!({ "ok": true }))),
|
||||
Err(e) => {
|
||||
tracing::error!("health check: the database does not answer: {e}");
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "ok": false })))
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "ok": false })),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,10 @@ pub struct AddInput {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<SshKeySummary>>> {
|
||||
pub async fn list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<SshKeySummary>>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, fingerprint, created_at, last_used_at FROM ssh_keys ORDER BY created_at",
|
||||
)
|
||||
|
||||
+7
-1
@@ -99,7 +99,13 @@ pub async fn change_password(
|
||||
) -> AppResult<Json<Status>> {
|
||||
strong_enough(&input.password)?;
|
||||
let attempt = throttle(&state)?;
|
||||
answer(attempt, state.vault.change_password(input.current, input.password).await?)?;
|
||||
answer(
|
||||
attempt,
|
||||
state
|
||||
.vault
|
||||
.change_password(input.current, input.password)
|
||||
.await?,
|
||||
)?;
|
||||
tracing::info!("vault password changed");
|
||||
state.backup.signal();
|
||||
Ok(Json(state.vault.status().await?))
|
||||
|
||||
+291
-77
@@ -18,9 +18,7 @@
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use russh_sftp::protocol::{
|
||||
File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version,
|
||||
};
|
||||
use russh_sftp::protocol::{File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
@@ -300,7 +298,8 @@ impl SpaceFs {
|
||||
/// A name a document or a folder already has, or an earlier file, makes
|
||||
/// the later file carry its id.
|
||||
async fn files_in(&self, parent: i64) -> SftpResult<Vec<(String, FileNode)>> {
|
||||
let others = sqlx::query("SELECT kind, slug FROM nodes WHERE parent_id = ?1 AND kind <> 'file'")
|
||||
let others =
|
||||
sqlx::query("SELECT kind, slug FROM nodes WHERE parent_id = ?1 AND kind <> 'file'")
|
||||
.bind(parent)
|
||||
.fetch_all(&self.db)
|
||||
.await
|
||||
@@ -498,7 +497,12 @@ impl SpaceFs {
|
||||
/// An empty document under `parent`, named after the file it was created
|
||||
/// as until a heading says otherwise.
|
||||
async fn create_doc(&mut self, parent: i64, stem: &str) -> SftpResult<i64> {
|
||||
let slug = nodes::unique_slug(&mut *self.conn().await?, Some(parent), &nodes::slugify(stem), None)
|
||||
let slug = nodes::unique_slug(
|
||||
&mut *self.conn().await?,
|
||||
Some(parent),
|
||||
&nodes::slugify(stem),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
let id: i64 = sqlx::query(
|
||||
@@ -577,8 +581,14 @@ impl SpaceFs {
|
||||
if current > base && Some(current) != ours {
|
||||
let parent: i64 = row.get("parent_id");
|
||||
let slug: String = row.get("slug");
|
||||
let copy = self.create_doc(parent, &format!("{slug} (conflict)")).await?;
|
||||
tracing::warn!(id, copy, "sftp: the document changed elsewhere while open; kept this write as a copy");
|
||||
let copy = self
|
||||
.create_doc(parent, &format!("{slug} (conflict)"))
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
id,
|
||||
copy,
|
||||
"sftp: the document changed elsewhere while open; kept this write as a copy"
|
||||
);
|
||||
return self.save_doc(copy, buf).await;
|
||||
}
|
||||
self.save_doc(id, buf).await
|
||||
@@ -627,7 +637,15 @@ impl SpaceFs {
|
||||
/// the one it was opened over — under a new id, because the mirror treats
|
||||
/// an upload as never changing once written.
|
||||
async fn finish_upload(&self, upload: Upload) -> SftpResult<()> {
|
||||
let Upload { parent, name, replaces, media_id, partial, file, size } = upload;
|
||||
let Upload {
|
||||
parent,
|
||||
name,
|
||||
replaces,
|
||||
media_id,
|
||||
partial,
|
||||
file,
|
||||
size,
|
||||
} = upload;
|
||||
let landed = async {
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
@@ -635,7 +653,8 @@ impl SpaceFs {
|
||||
let mime = media::mime_of(&name, None);
|
||||
match replaces {
|
||||
None => {
|
||||
media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false).await?;
|
||||
media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false)
|
||||
.await?;
|
||||
}
|
||||
Some(node) => self.replace_bytes(node, &media_id, &mime, size).await?,
|
||||
}
|
||||
@@ -652,7 +671,13 @@ impl SpaceFs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace_bytes(&self, node: i64, media_id: &str, mime: &str, size: u64) -> AppResult<()> {
|
||||
async fn replace_bytes(
|
||||
&self,
|
||||
node: i64,
|
||||
media_id: &str,
|
||||
mime: &str,
|
||||
size: u64,
|
||||
) -> AppResult<()> {
|
||||
let mut tx = crate::db::begin_write(&self.db).await?;
|
||||
let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1")
|
||||
.bind(node)
|
||||
@@ -701,17 +726,35 @@ impl SpaceFs {
|
||||
}
|
||||
|
||||
/// A scratch file renamed onto a name worth keeping becomes a file node.
|
||||
async fn keep_scratch(&self, parent: i64, name: &str, data: Vec<u8>, replaces: Option<&FileNode>) -> SftpResult<()> {
|
||||
async fn keep_scratch(
|
||||
&self,
|
||||
parent: i64,
|
||||
name: &str,
|
||||
data: Vec<u8>,
|
||||
replaces: Option<&FileNode>,
|
||||
) -> SftpResult<()> {
|
||||
let mut upload = self.begin_upload(parent, name, replaces, true).await?;
|
||||
upload.file.write_all(&data).await.map_err(|_| StatusCode::Failure)?;
|
||||
upload
|
||||
.file
|
||||
.write_all(&data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
upload.size = data.len() as u64;
|
||||
self.finish_upload(upload).await
|
||||
}
|
||||
|
||||
/// Move a file node, and rename it when the name it lands under is not
|
||||
/// the one it already shows as.
|
||||
async fn move_file(&self, file: &FileNode, parent: i64, leaf: &str, shown: &str) -> SftpResult<()> {
|
||||
let mut tx = crate::db::begin_write(&self.db).await.map_err(|_| StatusCode::Failure)?;
|
||||
async fn move_file(
|
||||
&self,
|
||||
file: &FileNode,
|
||||
parent: i64,
|
||||
leaf: &str,
|
||||
shown: &str,
|
||||
) -> SftpResult<()> {
|
||||
let mut tx = crate::db::begin_write(&self.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
let name: String = if leaf == shown {
|
||||
sqlx::query_scalar("SELECT name FROM nodes WHERE id = ?1")
|
||||
.bind(file.id)
|
||||
@@ -724,7 +767,9 @@ impl SpaceFs {
|
||||
let slug = nodes::unique_slug(&mut tx, Some(parent), &nodes::slugify(&name), Some(file.id))
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
sqlx::query("UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5")
|
||||
sqlx::query(
|
||||
"UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(&name)
|
||||
.bind(&slug)
|
||||
@@ -782,7 +827,10 @@ impl SpaceFs {
|
||||
for (key, file) in &self.scratch {
|
||||
if let Some((parent, name)) = key.rsplit_once('/') {
|
||||
if parent == dir || (parent.is_empty() && dir == "/") {
|
||||
files.push(File::new(name, file_attrs(file.data.len() as u64, file.mtime)));
|
||||
files.push(File::new(
|
||||
name,
|
||||
file_attrs(file.data.len() as u64, file.mtime),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,7 +914,9 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
}
|
||||
// Truncating a file in place is a write; it is done by opening
|
||||
// it for one.
|
||||
Target::File(file) if size as i64 != file.size => return Err(StatusCode::OpUnsupported),
|
||||
Target::File(file) if size as i64 != file.size => {
|
||||
return Err(StatusCode::OpUnsupported)
|
||||
}
|
||||
Target::Root | Target::Dir { .. } | Target::File(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -894,7 +944,11 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
if size > self.max_upload {
|
||||
return Err(StatusCode::Failure);
|
||||
}
|
||||
upload.file.set_len(size).await.map_err(|_| StatusCode::Failure)?;
|
||||
upload
|
||||
.file
|
||||
.set_len(size)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
upload.size = size;
|
||||
}
|
||||
Ok(ok_status(id))
|
||||
@@ -957,9 +1011,19 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
return Err(StatusCode::Failure);
|
||||
}
|
||||
let (id, buf, base) = match existing {
|
||||
Some(doc) if pflags.contains(OpenFlags::TRUNCATE) => (doc.id, Vec::new(), doc.updated_at),
|
||||
Some(doc) => (doc.id, render_doc(&doc.name, &doc.body).into_bytes(), doc.updated_at),
|
||||
None => (self.create_doc(parent, stem).await?, Vec::new(), crate::now()),
|
||||
Some(doc) if pflags.contains(OpenFlags::TRUNCATE) => {
|
||||
(doc.id, Vec::new(), doc.updated_at)
|
||||
}
|
||||
Some(doc) => (
|
||||
doc.id,
|
||||
render_doc(&doc.name, &doc.body).into_bytes(),
|
||||
doc.updated_at,
|
||||
),
|
||||
None => (
|
||||
self.create_doc(parent, stem).await?,
|
||||
Vec::new(),
|
||||
crate::now(),
|
||||
),
|
||||
};
|
||||
OpenHandle::Write { id, buf, base }
|
||||
} else {
|
||||
@@ -1001,7 +1065,10 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
return Err(StatusCode::Failure);
|
||||
}
|
||||
let truncate = pflags.contains(OpenFlags::TRUNCATE);
|
||||
OpenHandle::Upload(self.begin_upload(parent, leaf, existing.as_ref(), truncate).await?)
|
||||
OpenHandle::Upload(
|
||||
self.begin_upload(parent, leaf, existing.as_ref(), truncate)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
let file = existing.ok_or(StatusCode::NoSuchFile)?;
|
||||
if file.encrypted {
|
||||
@@ -1010,7 +1077,10 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
let opened = tokio::fs::File::open(self.uploads.join(&file.media_id))
|
||||
.await
|
||||
.map_err(|_| StatusCode::NoSuchFile)?;
|
||||
OpenHandle::ReadFile { file: opened, size: file.size as u64 }
|
||||
OpenHandle::ReadFile {
|
||||
file: opened,
|
||||
size: file.size as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1039,8 +1109,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
}
|
||||
let want = (len as u64).min(size - offset) as usize;
|
||||
let mut data = vec![0u8; want];
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|_| StatusCode::Failure)?;
|
||||
file.read_exact(&mut data).await.map_err(|_| StatusCode::Failure)?;
|
||||
file.seek(std::io::SeekFrom::Start(offset))
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
file.read_exact(&mut data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
return Ok(russh_sftp::protocol::Data { id, data });
|
||||
}
|
||||
|
||||
@@ -1082,8 +1156,16 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
.checked_add(data.len() as u64)
|
||||
.filter(|&end| end <= self.max_upload)
|
||||
.ok_or(StatusCode::Failure)?;
|
||||
upload.file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|_| StatusCode::Failure)?;
|
||||
upload.file.write_all(&data).await.map_err(|_| StatusCode::Failure)?;
|
||||
upload
|
||||
.file
|
||||
.seek(std::io::SeekFrom::Start(offset))
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
upload
|
||||
.file
|
||||
.write_all(&data)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
upload.size = upload.size.max(end);
|
||||
Ok(ok_status(id))
|
||||
}
|
||||
@@ -1093,7 +1175,11 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
|
||||
async fn close(&mut self, id: u32, handle: String) -> SftpResult<russh_sftp::protocol::Status> {
|
||||
match self.handles.remove(&handle) {
|
||||
Some(OpenHandle::Write { id: node, buf, base }) => self.finish_doc(node, buf, base).await?,
|
||||
Some(OpenHandle::Write {
|
||||
id: node,
|
||||
buf,
|
||||
base,
|
||||
}) => self.finish_doc(node, buf, base).await?,
|
||||
Some(OpenHandle::Upload(upload)) => self.finish_upload(upload).await?,
|
||||
Some(OpenHandle::Scratch { path, buf }) => {
|
||||
// Removed while open: like an unlinked file, it is simply gone.
|
||||
@@ -1108,7 +1194,11 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
Ok(ok_status(id))
|
||||
}
|
||||
|
||||
async fn remove(&mut self, id: u32, filename: String) -> SftpResult<russh_sftp::protocol::Status> {
|
||||
async fn remove(
|
||||
&mut self,
|
||||
id: u32,
|
||||
filename: String,
|
||||
) -> SftpResult<russh_sftp::protocol::Status> {
|
||||
self.authorized().await?;
|
||||
match self.target(&filename).await? {
|
||||
Target::Doc(doc) => self.delete_node(doc.id).await?,
|
||||
@@ -1141,7 +1231,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
self.changed();
|
||||
return Ok(ok_status(id));
|
||||
};
|
||||
let slug = nodes::unique_slug(&mut *self.conn().await?, Some(parent), &nodes::slugify(name), None)
|
||||
let slug = nodes::unique_slug(
|
||||
&mut *self.conn().await?,
|
||||
Some(parent),
|
||||
&nodes::slugify(name),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
sqlx::query(
|
||||
@@ -1200,7 +1295,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
match source {
|
||||
Target::Root => return Err(StatusCode::PermissionDenied),
|
||||
|
||||
Target::Dir { id: node, kind, slug, .. } => {
|
||||
Target::Dir {
|
||||
id: node,
|
||||
kind,
|
||||
slug,
|
||||
..
|
||||
} => {
|
||||
match dest {
|
||||
Some(Target::Dir { id: other, .. }) if other == node => {}
|
||||
Some(_) => return Err(StatusCode::Failure),
|
||||
@@ -1222,8 +1322,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_slug =
|
||||
nodes::unique_slug(&mut *self.conn().await?, new_parent, &nodes::slugify(new_leaf), Some(node))
|
||||
let new_slug = nodes::unique_slug(
|
||||
&mut *self.conn().await?,
|
||||
new_parent,
|
||||
&nodes::slugify(new_leaf),
|
||||
Some(node),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
// A move that keeps the name keeps the title too.
|
||||
@@ -1248,7 +1352,9 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
return Err(StatusCode::PermissionDenied);
|
||||
};
|
||||
match (doc_slug(new_leaf), dest) {
|
||||
(_, Some(Target::Dir { .. } | Target::File(_))) => return Err(StatusCode::Failure),
|
||||
(_, Some(Target::Dir { .. } | Target::File(_))) => {
|
||||
return Err(StatusCode::Failure)
|
||||
}
|
||||
// Onto another document: that one takes this one's text
|
||||
// and keeps its own id, so its share link survives.
|
||||
(Some(_), Some(Target::Doc(other))) if other.id != doc.id => {
|
||||
@@ -1307,7 +1413,8 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
}
|
||||
Some(Target::Root) | None => {}
|
||||
}
|
||||
self.move_file(&file, new_parent, new_leaf, &old_leaf).await?;
|
||||
self.move_file(&file, new_parent, new_leaf, &old_leaf)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Target::Scratch(key) => {
|
||||
@@ -1319,11 +1426,20 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
// A temp file renamed over a file, or onto a name worth
|
||||
// keeping: what a sync tool's atomic write looks like.
|
||||
(None, Some(Target::File(other))) => {
|
||||
let data = self.scratch.remove(&key).map(|f| f.data).unwrap_or_default();
|
||||
self.keep_scratch(new_parent, new_leaf, data, Some(&other)).await?;
|
||||
let data = self
|
||||
.scratch
|
||||
.remove(&key)
|
||||
.map(|f| f.data)
|
||||
.unwrap_or_default();
|
||||
self.keep_scratch(new_parent, new_leaf, data, Some(&other))
|
||||
.await?;
|
||||
}
|
||||
(None, None) if !is_ephemeral(new_leaf) => {
|
||||
let data = self.scratch.remove(&key).map(|f| f.data).unwrap_or_default();
|
||||
let data = self
|
||||
.scratch
|
||||
.remove(&key)
|
||||
.map(|f| f.data)
|
||||
.unwrap_or_default();
|
||||
self.keep_scratch(new_parent, new_leaf, data, None).await?;
|
||||
}
|
||||
(Some(_), Some(Target::File(_))) => return Err(StatusCode::Failure),
|
||||
@@ -1367,7 +1483,9 @@ mod tests {
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use super::{doc_slug, normalize, render_doc, segments, split_heading, write_at, SpaceFs, MAX_FILE};
|
||||
use super::{
|
||||
doc_slug, normalize, render_doc, segments, split_heading, write_at, SpaceFs, MAX_FILE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn writes_past_the_limit_fail_instead_of_wrapping() {
|
||||
@@ -1381,7 +1499,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dot_dot_climbs_and_stops_at_the_root() {
|
||||
assert_eq!(segments("/proj/notes/../ideas/./a.md"), ["proj", "ideas", "a.md"]);
|
||||
assert_eq!(
|
||||
segments("/proj/notes/../ideas/./a.md"),
|
||||
["proj", "ideas", "a.md"]
|
||||
);
|
||||
assert_eq!(normalize("/../../proj"), "/proj");
|
||||
assert_eq!(normalize(""), "/");
|
||||
}
|
||||
@@ -1419,7 +1540,10 @@ mod tests {
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("an in-memory database");
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.expect("migrations apply");
|
||||
SpaceFs::with_db(db, uploads, None)
|
||||
}
|
||||
|
||||
@@ -1429,13 +1553,21 @@ mod tests {
|
||||
async fn files_are_nodes_on_disk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut fs = fs_in(dir.path().to_path_buf()).await;
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
put(&mut fs, "/proj/slides.pdf", "%PDF one").await;
|
||||
put(&mut fs, "/proj/notes.md", "# Notes\n\ntext").await;
|
||||
assert_eq!(get(&mut fs, "/proj/slides.pdf").await, "%PDF one");
|
||||
|
||||
async fn names(fs: &SpaceFs) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs.listing("/proj").await.unwrap().into_iter().map(|f| f.filename).collect();
|
||||
let mut names: Vec<String> = fs
|
||||
.listing("/proj")
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.filename)
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
@@ -1447,16 +1579,28 @@ mod tests {
|
||||
let uploads = std::fs::read_dir(dir.path()).unwrap().count();
|
||||
assert_eq!(uploads, 1, "the old bytes went with the old upload");
|
||||
|
||||
fs.rename(0, "/proj/slides.pdf".into(), "/proj/Lecture 1.pdf".into()).await.expect("rename");
|
||||
fs.rename(0, "/proj/slides.pdf".into(), "/proj/Lecture 1.pdf".into())
|
||||
.await
|
||||
.expect("rename");
|
||||
assert_eq!(names(&fs).await, ["Lecture 1.pdf", "notes.md"]);
|
||||
|
||||
// A sync tool's temp file, renamed onto the name it was for.
|
||||
put(&mut fs, "/proj/.handout.pdf.Xy12", "%PDF three").await;
|
||||
fs.rename(0, "/proj/.handout.pdf.Xy12".into(), "/proj/handout.pdf".into()).await.expect("rename");
|
||||
fs.rename(
|
||||
0,
|
||||
"/proj/.handout.pdf.Xy12".into(),
|
||||
"/proj/handout.pdf".into(),
|
||||
)
|
||||
.await
|
||||
.expect("rename");
|
||||
assert_eq!(get(&mut fs, "/proj/handout.pdf").await, "%PDF three");
|
||||
|
||||
fs.remove(0, "/proj/Lecture 1.pdf".into()).await.expect("remove");
|
||||
fs.remove(0, "/proj/handout.pdf".into()).await.expect("remove");
|
||||
fs.remove(0, "/proj/Lecture 1.pdf".into())
|
||||
.await
|
||||
.expect("remove");
|
||||
fs.remove(0, "/proj/handout.pdf".into())
|
||||
.await
|
||||
.expect("remove");
|
||||
assert_eq!(names(&fs).await, ["notes.md"]);
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
@@ -1466,7 +1610,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_write_over_a_newer_edit_is_kept_as_a_copy() {
|
||||
let mut fs = fs().await;
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
put(&mut fs, "/proj/plan.md", "# Plan\n\nfirst").await;
|
||||
|
||||
let handle = fs
|
||||
@@ -1478,21 +1624,34 @@ mod tests {
|
||||
.execute(&fs.db)
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(0, handle.clone(), 0, b"# Plan\n\nfrom sftp".to_vec()).await.unwrap();
|
||||
fs.write(0, handle.clone(), 0, b"# Plan\n\nfrom sftp".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
fs.close(0, handle).await.unwrap();
|
||||
|
||||
assert_eq!(get(&mut fs, "/proj/plan.md").await, "# Plan\n\nfrom the browser");
|
||||
assert_eq!(get(&mut fs, "/proj/plan-conflict.md").await, "# Plan\n\nfrom sftp");
|
||||
assert_eq!(
|
||||
get(&mut fs, "/proj/plan.md").await,
|
||||
"# Plan\n\nfrom the browser"
|
||||
);
|
||||
assert_eq!(
|
||||
get(&mut fs, "/proj/plan-conflict.md").await,
|
||||
"# Plan\n\nfrom sftp"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_removed_key_ends_the_mount() {
|
||||
let mut fs = fs().await;
|
||||
fs.key_id = Some(42);
|
||||
assert_eq!(fs.opendir(0, "/".into()).await.err(), Some(StatusCode::PermissionDenied));
|
||||
assert_eq!(
|
||||
fs.opendir(0, "/".into()).await.err(),
|
||||
Some(StatusCode::PermissionDenied)
|
||||
);
|
||||
}
|
||||
|
||||
const CREATE: OpenFlags = OpenFlags::WRITE.union(OpenFlags::CREATE).union(OpenFlags::TRUNCATE);
|
||||
const CREATE: OpenFlags = OpenFlags::WRITE
|
||||
.union(OpenFlags::CREATE)
|
||||
.union(OpenFlags::TRUNCATE);
|
||||
|
||||
async fn put(fs: &mut SpaceFs, path: &str, text: &str) {
|
||||
let handle = fs
|
||||
@@ -1500,7 +1659,9 @@ mod tests {
|
||||
.await
|
||||
.expect("open for writing")
|
||||
.handle;
|
||||
fs.write(0, handle.clone(), 0, text.as_bytes().to_vec()).await.expect("write");
|
||||
fs.write(0, handle.clone(), 0, text.as_bytes().to_vec())
|
||||
.await
|
||||
.expect("write");
|
||||
fs.close(0, handle).await.expect("close");
|
||||
}
|
||||
|
||||
@@ -1510,7 +1671,11 @@ mod tests {
|
||||
.await
|
||||
.expect("open for reading")
|
||||
.handle;
|
||||
let data = fs.read(0, handle.clone(), 0, u32::MAX).await.map(|d| d.data).unwrap_or_default();
|
||||
let data = fs
|
||||
.read(0, handle.clone(), 0, u32::MAX)
|
||||
.await
|
||||
.map(|d| d.data)
|
||||
.unwrap_or_default();
|
||||
fs.close(0, handle).await.expect("close");
|
||||
String::from_utf8(data).expect("utf-8")
|
||||
}
|
||||
@@ -1528,7 +1693,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn mkdir_at_the_root_makes_a_space_and_a_document_lands_in_it() {
|
||||
let mut fs = fs().await;
|
||||
fs.mkdir(0, "/Side Project".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/Side Project".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
let kind: String = sqlx::query_scalar("SELECT kind FROM nodes WHERE slug = 'side-project'")
|
||||
.fetch_one(&fs.db)
|
||||
.await
|
||||
@@ -1536,18 +1703,31 @@ mod tests {
|
||||
assert_eq!(kind, "space");
|
||||
|
||||
// Both the name it was made with and the slug it got resolve.
|
||||
fs.stat(0, "/Side Project".into()).await.expect("stat by name");
|
||||
fs.mkdir(0, "/side-project/Ideas".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.stat(0, "/Side Project".into())
|
||||
.await
|
||||
.expect("stat by name");
|
||||
fs.mkdir(0, "/side-project/Ideas".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
assert_eq!(
|
||||
fs.mkdir(0, "/side-project/ideas".into(), FileAttributes::empty()).await.err(),
|
||||
fs.mkdir(0, "/side-project/ideas".into(), FileAttributes::empty())
|
||||
.await
|
||||
.err(),
|
||||
Some(StatusCode::Failure)
|
||||
);
|
||||
|
||||
put(&mut fs, "/side-project/ideas/My Notes.md", "# First thoughts\n\nhello\n").await;
|
||||
put(
|
||||
&mut fs,
|
||||
"/side-project/ideas/My Notes.md",
|
||||
"# First thoughts\n\nhello\n",
|
||||
)
|
||||
.await;
|
||||
let (_, name, body) = node(&fs, "my-notes").await;
|
||||
assert_eq!(name, "First thoughts");
|
||||
assert_eq!(body, "hello\n");
|
||||
fs.stat(0, "/side-project/ideas/My Notes.md".into()).await.expect("stat by name");
|
||||
fs.stat(0, "/side-project/ideas/My Notes.md".into())
|
||||
.await
|
||||
.expect("stat by name");
|
||||
|
||||
let text = get(&mut fs, "/side-project/ideas/my-notes.md").await;
|
||||
assert_eq!(text, "# First thoughts\n\nhello\n");
|
||||
@@ -1563,18 +1743,28 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_vim_save_keeps_the_same_document() {
|
||||
let mut fs = fs().await;
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await;
|
||||
let (id, ..) = node(&fs, "plan").await;
|
||||
|
||||
// The sequence vim runs with `backupcopy=auto` on a mount.
|
||||
put(&mut fs, "/proj/4913", "").await;
|
||||
fs.remove(0, "/proj/4913".into()).await.expect("remove probe");
|
||||
fs.remove(0, "/proj/4913".into())
|
||||
.await
|
||||
.expect("remove probe");
|
||||
put(&mut fs, "/proj/.plan.md.swp", "swap").await;
|
||||
fs.rename(0, "/proj/plan.md".into(), "/proj/plan.md~".into()).await.expect("backup");
|
||||
fs.rename(0, "/proj/plan.md".into(), "/proj/plan.md~".into())
|
||||
.await
|
||||
.expect("backup");
|
||||
put(&mut fs, "/proj/plan.md", "# Plan\n\nv2\n").await;
|
||||
fs.remove(0, "/proj/plan.md~".into()).await.expect("drop backup");
|
||||
fs.remove(0, "/proj/.plan.md.swp".into()).await.expect("drop swap");
|
||||
fs.remove(0, "/proj/plan.md~".into())
|
||||
.await
|
||||
.expect("drop backup");
|
||||
fs.remove(0, "/proj/.plan.md.swp".into())
|
||||
.await
|
||||
.expect("drop swap");
|
||||
|
||||
let (after, name, body) = node(&fs, "plan").await;
|
||||
assert_eq!((after, name.as_str(), body.as_str()), (id, "Plan", "v2\n"));
|
||||
@@ -1588,7 +1778,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_temp_file_renamed_over_a_document_replaces_its_text() {
|
||||
let mut fs = fs().await;
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await;
|
||||
let (id, ..) = node(&fs, "plan").await;
|
||||
|
||||
@@ -1596,25 +1788,40 @@ mod tests {
|
||||
let listed = fs.listing("/proj").await.expect("listing");
|
||||
assert!(listed.iter().any(|f| f.filename == "plan.md.tmp"));
|
||||
|
||||
fs.rename(0, "/proj/plan.md.tmp".into(), "/proj/plan.md".into()).await.expect("rename");
|
||||
fs.rename(0, "/proj/plan.md.tmp".into(), "/proj/plan.md".into())
|
||||
.await
|
||||
.expect("rename");
|
||||
let (after, name, body) = node(&fs, "plan").await;
|
||||
assert_eq!((after, name.as_str(), body.as_str()), (id, "Renamed", "v2\n"));
|
||||
assert_eq!(
|
||||
(after, name.as_str(), body.as_str()),
|
||||
(id, "Renamed", "v2\n")
|
||||
);
|
||||
assert!(fs.scratch.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() {
|
||||
let mut fs = fs().await;
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj/a".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj/a/b".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.mkdir(0, "/proj".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
fs.mkdir(0, "/proj/a".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
fs.mkdir(0, "/proj/a/b".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
|
||||
fs.rename(0, "/proj".into(), "/Gig".into()).await.expect("rename space");
|
||||
fs.rename(0, "/proj".into(), "/Gig".into())
|
||||
.await
|
||||
.expect("rename space");
|
||||
let (_, name, _) = node(&fs, "gig").await;
|
||||
assert_eq!(name, "Gig");
|
||||
|
||||
assert_eq!(
|
||||
fs.rename(0, "/gig/a".into(), "/gig/a/b/a".into()).await.err(),
|
||||
fs.rename(0, "/gig/a".into(), "/gig/a/b/a".into())
|
||||
.await
|
||||
.err(),
|
||||
Some(StatusCode::Failure)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1626,8 +1833,15 @@ mod tests {
|
||||
Some(StatusCode::PermissionDenied)
|
||||
);
|
||||
|
||||
fs.mkdir(0, "/empty".into(), FileAttributes::empty()).await.expect("mkdir");
|
||||
fs.rmdir(0, "/empty".into()).await.expect("an empty space can go");
|
||||
assert_eq!(fs.rmdir(0, "/gig".into()).await.err(), Some(StatusCode::Failure));
|
||||
fs.mkdir(0, "/empty".into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
fs.rmdir(0, "/empty".into())
|
||||
.await
|
||||
.expect("an empty space can go");
|
||||
assert_eq!(
|
||||
fs.rmdir(0, "/gig".into()).await.err(),
|
||||
Some(StatusCode::Failure)
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -103,7 +103,9 @@ impl ServerHandler for SshSession {
|
||||
_user: &str,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
let fingerprint = public_key.fingerprint(russh::keys::HashAlg::Sha256).to_string();
|
||||
let fingerprint = public_key
|
||||
.fingerprint(russh::keys::HashAlg::Sha256)
|
||||
.to_string();
|
||||
let matched = sqlx::query("SELECT id FROM ssh_keys WHERE fingerprint = ?1")
|
||||
.bind(&fingerprint)
|
||||
.fetch_optional(&self.state.db)
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@ use std::sync::{atomic::AtomicI64, Arc};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault};
|
||||
use crate::{
|
||||
backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
|
||||
+18
-5
@@ -103,7 +103,9 @@ pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
|
||||
}
|
||||
let kind = if name.ends_with(".part") || name.starts_with(".import-") {
|
||||
&mut swept.partial
|
||||
} else if media::canonical_id(&name).as_deref() == Some(name.as_str()) && !known.contains(&name) {
|
||||
} else if media::canonical_id(&name).as_deref() == Some(name.as_str())
|
||||
&& !known.contains(&name)
|
||||
{
|
||||
&mut swept.stray
|
||||
} else {
|
||||
// Not ours to judge: anything that is not a uuid or a leftover of
|
||||
@@ -160,7 +162,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
// `kept` is linked the way a saved document links what it embeds.
|
||||
sqlx::query("INSERT INTO node_media (node_id, media_id) SELECT id, ?1 FROM nodes WHERE slug = 'a'")
|
||||
sqlx::query(
|
||||
"INSERT INTO node_media (node_id, media_id) SELECT id, ?1 FROM nodes WHERE slug = 'a'",
|
||||
)
|
||||
.bind(kept)
|
||||
.execute(&db)
|
||||
.await
|
||||
@@ -181,14 +185,23 @@ mod tests {
|
||||
std::fs::write(dir.path().join(fresh), b"x").unwrap();
|
||||
|
||||
let swept = run(&db, dir.path()).await.unwrap();
|
||||
assert_eq!(swept, Swept { partial: 1, stray: 2, unreferenced: 1 });
|
||||
assert_eq!(
|
||||
swept,
|
||||
Swept {
|
||||
partial: 1,
|
||||
stray: 2,
|
||||
unreferenced: 1
|
||||
}
|
||||
);
|
||||
|
||||
let left: std::collections::BTreeSet<String> = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let expected: std::collections::BTreeSet<String> =
|
||||
[kept, embedded, "notes.txt", fresh].iter().map(|s| s.to_string()).collect();
|
||||
let expected: std::collections::BTreeSet<String> = [kept, embedded, "notes.txt", fresh]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(left, expected);
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -30,7 +30,10 @@ const MAX_DELAY: i64 = 30;
|
||||
fn delay_after(failures: u32) -> i64 {
|
||||
match failures.checked_sub(FREE_ATTEMPTS) {
|
||||
None | Some(0) => 0,
|
||||
Some(over) => 1i64.checked_shl(over - 1).unwrap_or(MAX_DELAY).min(MAX_DELAY),
|
||||
Some(over) => 1i64
|
||||
.checked_shl(over - 1)
|
||||
.unwrap_or(MAX_DELAY)
|
||||
.min(MAX_DELAY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +117,16 @@ mod tests {
|
||||
let throttle = LoginThrottle::default();
|
||||
// Made directly: past the free attempts `begin` would make them wait.
|
||||
for _ in 0..10 {
|
||||
Attempt { throttle: &throttle }.failed();
|
||||
Attempt {
|
||||
throttle: &throttle,
|
||||
}
|
||||
.failed();
|
||||
}
|
||||
assert!(throttle.begin().is_err());
|
||||
Attempt { throttle: &throttle }.succeeded();
|
||||
Attempt {
|
||||
throttle: &throttle,
|
||||
}
|
||||
.succeeded();
|
||||
assert!(throttle.begin().is_ok());
|
||||
}
|
||||
|
||||
|
||||
+105
-25
@@ -87,7 +87,8 @@ fn random<const N: usize>() -> [u8; N] {
|
||||
|
||||
/// The key the password stands for, at the costs the vault was made with.
|
||||
fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
|
||||
let params = argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?;
|
||||
let params =
|
||||
argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?;
|
||||
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon
|
||||
@@ -99,7 +100,13 @@ fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
|
||||
fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
|
||||
let nonce = random::<WRAP_NONCE>();
|
||||
let sealed = Aes256Gcm::new(kek.as_ref().into())
|
||||
.encrypt(Nonce::from_slice(&nonce), Payload { msg: master.as_ref(), aad: MASTER_AAD })
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: master.as_ref(),
|
||||
aad: MASTER_AAD,
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("could not seal the master key"))?;
|
||||
Ok((nonce, sealed))
|
||||
}
|
||||
@@ -108,7 +115,13 @@ fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
|
||||
fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option<Key> {
|
||||
let plain = Zeroizing::new(
|
||||
Aes256Gcm::new(kek.as_ref().into())
|
||||
.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad: MASTER_AAD })
|
||||
.decrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: sealed,
|
||||
aad: MASTER_AAD,
|
||||
},
|
||||
)
|
||||
.ok()?,
|
||||
);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
@@ -133,7 +146,10 @@ impl Sealer {
|
||||
let sealed_key = Aes256Gcm::new(master.as_ref().into())
|
||||
.encrypt(
|
||||
Nonce::from_slice(&wrap_nonce),
|
||||
Payload { msg: file_key.as_ref(), aad: &file_aad(media_id) },
|
||||
Payload {
|
||||
msg: file_key.as_ref(),
|
||||
aad: &file_aad(media_id),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("could not seal a file key"))?;
|
||||
|
||||
@@ -147,7 +163,13 @@ impl Sealer {
|
||||
Aes256Gcm::new(file_key.as_ref().into()),
|
||||
stream_nonce.as_slice().into(),
|
||||
);
|
||||
Ok((Self { stream, pending: Vec::with_capacity(CHUNK) }, header))
|
||||
Ok((
|
||||
Self {
|
||||
stream,
|
||||
pending: Vec::with_capacity(CHUNK),
|
||||
},
|
||||
header,
|
||||
))
|
||||
}
|
||||
|
||||
/// Take more plaintext; returns whatever whole records it completes. A
|
||||
@@ -223,15 +245,25 @@ impl Opener {
|
||||
Aes256Gcm::new(master.as_ref().into())
|
||||
.decrypt(
|
||||
Nonce::from_slice(wrap_nonce),
|
||||
Payload { msg: sealed_key, aad: &file_aad(media_id) },
|
||||
Payload {
|
||||
msg: sealed_key,
|
||||
aad: &file_aad(media_id),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("this file was not sealed under this vault, or not as {media_id}"))?,
|
||||
.map_err(|_| {
|
||||
anyhow!("this file was not sealed under this vault, or not as {media_id}")
|
||||
})?,
|
||||
);
|
||||
let stream = StreamBE32::from_aead(
|
||||
Aes256Gcm::new(file_key.as_slice().into()),
|
||||
stream_nonce.into(),
|
||||
);
|
||||
Ok(Self { stream, position: 0, total: remaining, last })
|
||||
Ok(Self {
|
||||
stream,
|
||||
position: 0,
|
||||
total: remaining,
|
||||
last,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many bytes the next record takes on disk; 0 once all are read.
|
||||
@@ -282,12 +314,19 @@ pub struct Reader<R> {
|
||||
|
||||
impl Reader<std::fs::File> {
|
||||
pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result<Self> {
|
||||
let mut file = std::fs::File::open(path).with_context(|| format!("could not read {}", path.display()))?;
|
||||
let mut file = std::fs::File::open(path)
|
||||
.with_context(|| format!("could not read {}", path.display()))?;
|
||||
let len = file.metadata()?.len();
|
||||
let mut header = [0u8; HEADER];
|
||||
file.read_exact(&mut header).context("sealed file is truncated")?;
|
||||
file.read_exact(&mut header)
|
||||
.context("sealed file is truncated")?;
|
||||
let opener = Opener::new(master, media_id, &header, len)?;
|
||||
Ok(Self { inner: file, opener, buffer: Vec::new(), at: 0 })
|
||||
Ok(Self {
|
||||
inner: file,
|
||||
opener,
|
||||
buffer: Vec::new(),
|
||||
at: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +351,12 @@ impl<R: Read> Read for Reader<R> {
|
||||
|
||||
/// Seal `source` into `target` under `media_id`, on the calling thread.
|
||||
/// Returns the plaintext size.
|
||||
pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: &std::path::Path) -> Result<u64> {
|
||||
pub fn seal_file(
|
||||
master: &Key,
|
||||
media_id: &str,
|
||||
source: &mut impl Read,
|
||||
target: &std::path::Path,
|
||||
) -> Result<u64> {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(target)?);
|
||||
let (mut sealer, header) = Sealer::new(master, media_id)?;
|
||||
@@ -374,7 +418,11 @@ pub enum Unlock {
|
||||
|
||||
impl Vault {
|
||||
pub fn new(db: SqlitePool, idle: Duration) -> Arc<Self> {
|
||||
Arc::new(Self { db, idle, open: Mutex::new(None) })
|
||||
Arc::new(Self {
|
||||
db,
|
||||
idle,
|
||||
open: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn slot(&self) -> std::sync::MutexGuard<'_, Option<Unlocked>> {
|
||||
@@ -407,7 +455,10 @@ impl Vault {
|
||||
/// next look, with the key sitting in memory all the while.
|
||||
fn expire(&self) {
|
||||
let mut slot = self.slot();
|
||||
if slot.as_ref().is_some_and(|open| open.touched.elapsed() >= self.idle) {
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.touched.elapsed() >= self.idle)
|
||||
{
|
||||
*slot = None;
|
||||
tracing::info!("vault locked after {} idle", humantime(self.idle));
|
||||
}
|
||||
@@ -448,7 +499,9 @@ impl Vault {
|
||||
}
|
||||
|
||||
async fn row(&self) -> Result<Option<sqlx::sqlite::SqliteRow>> {
|
||||
Ok(sqlx::query("SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1")
|
||||
Ok(sqlx::query(
|
||||
"SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(&self.db)
|
||||
.await?)
|
||||
}
|
||||
@@ -486,7 +539,10 @@ impl Vault {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
*self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() });
|
||||
*self.slot() = Some(Unlocked {
|
||||
key: Arc::new(master),
|
||||
touched: Instant::now(),
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -512,7 +568,10 @@ impl Vault {
|
||||
None => Unlock::NotConfigured,
|
||||
Some(None) => Unlock::WrongPassword,
|
||||
Some(Some(key)) => {
|
||||
*self.slot() = Some(Unlocked { key: Arc::new(key), touched: Instant::now() });
|
||||
*self.slot() = Some(Unlocked {
|
||||
key: Arc::new(key),
|
||||
touched: Instant::now(),
|
||||
});
|
||||
Unlock::Opened
|
||||
}
|
||||
})
|
||||
@@ -545,7 +604,10 @@ impl Vault {
|
||||
.bind(sealed)
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
*self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() });
|
||||
*self.slot() = Some(Unlocked {
|
||||
key: Arc::new(master),
|
||||
touched: Instant::now(),
|
||||
});
|
||||
Ok(Unlock::Opened)
|
||||
}
|
||||
}
|
||||
@@ -611,7 +673,8 @@ mod tests {
|
||||
let plain: Vec<u8> = (0..3 * CHUNK + 17).map(|i| (i % 249) as u8).collect();
|
||||
let sealed = seal(&master, "id", &plain, CHUNK);
|
||||
for first in 0..4u64 {
|
||||
let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
||||
let mut opener =
|
||||
Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
||||
opener.skip_to(first).unwrap();
|
||||
let mut at = HEADER + first as usize * super::RECORD;
|
||||
let mut out = Vec::new();
|
||||
@@ -622,7 +685,8 @@ mod tests {
|
||||
}
|
||||
assert_eq!(out, plain[first as usize * CHUNK..], "from record {first}");
|
||||
}
|
||||
let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
||||
let mut opener =
|
||||
Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
||||
assert!(opener.skip_to(4).is_err());
|
||||
// A middle record presented where the last one should be does not open.
|
||||
opener.skip_to(3).unwrap();
|
||||
@@ -639,7 +703,11 @@ mod tests {
|
||||
let sealed = seal(&master, "id", &plain, step);
|
||||
let records = size.div_ceil(CHUNK).max(1);
|
||||
assert_eq!(sealed.len(), HEADER + size + records * TAG, "size {size}");
|
||||
assert_eq!(open(&master, "id", &sealed).expect("opens"), plain, "size {size} step {step}");
|
||||
assert_eq!(
|
||||
open(&master, "id", &sealed).expect("opens"),
|
||||
plain,
|
||||
"size {size} step {step}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,7 +720,10 @@ mod tests {
|
||||
let path = dir.path().join("x");
|
||||
std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write");
|
||||
let mut out = Vec::new();
|
||||
Reader::open(&master, "x", &path).expect("reader").read_to_end(&mut out).expect("read");
|
||||
Reader::open(&master, "x", &path)
|
||||
.expect("reader")
|
||||
.read_to_end(&mut out)
|
||||
.expect("read");
|
||||
assert_eq!(out, plain);
|
||||
}
|
||||
|
||||
@@ -663,12 +734,18 @@ mod tests {
|
||||
let sealed = seal(&master, "a", &plain, CHUNK);
|
||||
|
||||
assert!(open(&key(3), "a", &sealed).is_err(), "another vault");
|
||||
assert!(open(&master, "b", &sealed).is_err(), "renamed to another id");
|
||||
assert!(
|
||||
open(&master, "b", &sealed).is_err(),
|
||||
"renamed to another id"
|
||||
);
|
||||
|
||||
// Cut at a record boundary: what is left still parses as records, and
|
||||
// only the missing "last" mark gives it away.
|
||||
let cut = &sealed[..HEADER + CHUNK + TAG];
|
||||
assert!(open(&master, "a", cut).is_err(), "truncated at a record boundary");
|
||||
assert!(
|
||||
open(&master, "a", cut).is_err(),
|
||||
"truncated at a record boundary"
|
||||
);
|
||||
|
||||
let mut flipped = sealed.clone();
|
||||
flipped[HEADER + 10] ^= 1;
|
||||
@@ -681,7 +758,10 @@ mod tests {
|
||||
let kek = derive("correct horse", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
|
||||
let master = key(5);
|
||||
let (nonce, sealed) = seal_master(&kek, &master).expect("seal");
|
||||
assert_eq!(open_master(&kek, &nonce, &sealed).expect("opens").as_ref(), master.as_ref());
|
||||
assert_eq!(
|
||||
open_master(&kek, &nonce, &sealed).expect("opens").as_ref(),
|
||||
master.as_ref()
|
||||
);
|
||||
let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
|
||||
assert!(open_master(&wrong, &nonce, &sealed).is_none());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user