mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
vault: a sealed folder stays sealed
`:encrypt` on a folder sealed the files in it once; whatever arrived afterwards was plaintext. A folder or space that is encrypted now carries `sealed` (0015), and so does every folder under it, so "is this place sealed?" is one column of the parent. What lands there is sealed on the way in, or refused with 423 while the vault is shut: - uploads, with or without `?encrypt`, encrypted as they stream - imports, sealed where they lie before any row names them - SFTP writes, sealed out of their `.part`, and refused at the open when the vault is shut - files and folders moved in, through the web or SFTP: seal::enter seals them first and the move happens only if that worked Triggers make it hold for any path: a folder made inside a sealed one is sealed, and a file node whose media is not encrypted cannot be inserted into or moved into a sealed folder (a 400, not a 500). Decrypting the sealed folder lifts the flag on its subtree and opens its files; a file or folder inside it cannot be decrypted on its own. An empty folder can be sealed. Documents are not files and stay as they are. The list shows a lock on sealed folders, the touch menu offers to unseal one, and an upload or import into one asks for the vault password first. Tests cover sealing, inheritance, the trigger, moves with the vault open and shut, unsealing, an SFTP write and an import.
This commit is contained in:
1 parent
1ae0665206
commit
889f99f8d6
15 files changed
+660
-40
No files matched your search
@@ -266,7 +266,8 @@ The SFTP mount shows files beside the documents, under their own names — a
|
|||||||
later file that shares one is told apart by its id, `slides (42).pdf` — and
|
later file that shares one is told apart by its id, `slides (42).pdf` — and
|
||||||
anything copied in that is not a `.md` becomes a file in the tree, streamed to
|
anything copied in that is not a `.md` becomes a file in the tree, streamed to
|
||||||
disk under the same size limit. A sealed file is listed but does not open
|
disk under the same size limit. A sealed file is listed but does not open
|
||||||
there. Editor droppings — swap files, `~` backups, dotted temp files — stay in
|
there. A file copied into a sealed folder is sealed while the vault is open in
|
||||||
|
the browser, and refused while it is shut. Editor droppings — swap files, `~` backups, dotted temp files — stay in
|
||||||
memory for the session, and a dotted temp file renamed onto a real name is kept
|
memory for the session, and a dotted temp file renamed onto a real name is kept
|
||||||
as that file, which is how `rsync` and most editors save.
|
as that file, which is how `rsync` and most editors save.
|
||||||
|
|
||||||
@@ -286,10 +287,21 @@ shut — to anyone, in short, who does not know the vault password.
|
|||||||
`:vault` sets the password the first time (ten characters at least) and says
|
`:vault` sets the password the first time (ten characters at least) and says
|
||||||
whether the vault is open afterwards. `:upload!` puts files into the folder
|
whether the vault is open afterwards. `:upload!` puts files into the folder
|
||||||
being browsed sealed — encrypted as they stream in, so the plaintext never
|
being browsed sealed — encrypted as they stream in, so the plaintext never
|
||||||
touches the disk. `:encrypt` seals the file under the cursor, or every file in
|
touches the disk. `:encrypt` seals the file under the cursor; `:decrypt` takes
|
||||||
the folder under it; `:decrypt` takes them back out. `:unlock` and `:lock` open
|
it back out. `:unlock` and `:lock` open and close it, `:passwd` changes the
|
||||||
and close it, `:passwd` changes the password. A sealed file carries a lock in
|
password. A sealed file carries a lock in the list, and the bar says `vault
|
||||||
the list, and the bar says `vault open` while it is.
|
open` while it is.
|
||||||
|
|
||||||
|
`:encrypt` on a folder or space seals it for good, not just once: every file
|
||||||
|
in it now, and every file that lands in it later. An upload, an import, an SFTP
|
||||||
|
write and a file or folder moved in are all sealed on the way in. While the
|
||||||
|
vault is shut they are refused rather than stored readable, and the browser
|
||||||
|
asks for the password instead. Folders made inside are sealed too, and a
|
||||||
|
sealed folder carries a lock like a sealed file. `:decrypt` on that folder
|
||||||
|
unseals it with everything in it; a file or folder inside it cannot be
|
||||||
|
decrypted on its own. Documents are not files and stay readable, as below.
|
||||||
|
The database holds the line as well: a file node whose bytes are not sealed
|
||||||
|
cannot be put in a sealed folder by any path.
|
||||||
|
|
||||||
The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes of nobody
|
The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes of nobody
|
||||||
using it (15 by default) — the key is dropped from memory then, not merely
|
using it (15 by default) — the key is dropped from memory then, not merely
|
||||||
@@ -549,7 +561,7 @@ sealed or not.
|
|||||||
| `GET` | `/api/spaces/overview` | every space with what was last written in it |
|
| `GET` | `/api/spaces/overview` | every space with what was last written in it |
|
||||||
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
|
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
|
||||||
| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) |
|
| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) |
|
||||||
| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out |
|
| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or a folder for good — what is in it and what lands later — or take it out |
|
||||||
| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it |
|
| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it |
|
||||||
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
|
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
|
||||||
| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one |
|
| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one |
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- A sealed folder or space keeps what lands in it sealed: an upload, an
|
||||||
|
-- import, an SFTP write, a file moved in. The flag is on every folder of a
|
||||||
|
-- sealed subtree, not only on the one `:encrypt` was given, so "is this
|
||||||
|
-- place sealed?" is one column of the parent rather than a walk up the tree.
|
||||||
|
ALTER TABLE nodes ADD COLUMN sealed INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- A folder made inside a sealed one is sealed from the start, whatever made it.
|
||||||
|
CREATE TRIGGER nodes_sealed_inherit AFTER INSERT ON nodes
|
||||||
|
WHEN new.kind = 'folder' AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1
|
||||||
|
BEGIN
|
||||||
|
UPDATE nodes SET sealed = 1 WHERE id = new.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The last word on it: whichever path a file takes into a sealed place, it
|
||||||
|
-- does not get there as plaintext. The code seals first and so never meets
|
||||||
|
-- these; a path that forgot to fails here instead of leaving the bytes open.
|
||||||
|
CREATE TRIGGER nodes_sealed_insert BEFORE INSERT ON nodes
|
||||||
|
WHEN new.kind = 'file'
|
||||||
|
AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1
|
||||||
|
AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER nodes_sealed_update BEFORE UPDATE OF parent_id, media_id ON nodes
|
||||||
|
WHEN new.kind = 'file'
|
||||||
|
AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1
|
||||||
|
AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- A folder moves into a sealed place only once it is sealed itself.
|
||||||
|
CREATE TRIGGER nodes_sealed_move BEFORE UPDATE OF parent_id ON nodes
|
||||||
|
WHEN new.kind = 'folder' AND new.sealed = 0
|
||||||
|
AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'sealed folder: a folder moved here must be sealed first');
|
||||||
|
END;
|
||||||
+9
-1
@@ -53,6 +53,14 @@ where
|
|||||||
E: Into<anyhow::Error>,
|
E: Into<anyhow::Error>,
|
||||||
{
|
{
|
||||||
fn from(err: E) -> Self {
|
fn from(err: E) -> Self {
|
||||||
AppError::Internal(err.into())
|
let err: anyhow::Error = err.into();
|
||||||
|
// The triggers of 0015 refuse plaintext in a sealed folder; that is
|
||||||
|
// an answer for the caller, not a fault of the server.
|
||||||
|
if let Some(sqlx::Error::Database(db)) = err.downcast_ref::<sqlx::Error>() {
|
||||||
|
if let Some(reason) = db.message().strip_prefix("sealed folder: ") {
|
||||||
|
return AppError::BadRequest(format!("{reason} — the folder is sealed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppError::Internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+99
-2
@@ -29,6 +29,7 @@ use crate::{
|
|||||||
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
|
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE},
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
vault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Markdown inline links and images: `[text](target)`, ``,
|
/// Markdown inline links and images: `[text](target)`, ``,
|
||||||
@@ -528,12 +529,21 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult<Im
|
|||||||
// nothing to the database; the orphans are swept up on the way out.
|
// nothing to the database; the orphans are swept up on the way out.
|
||||||
let mut skipped = Vec::new();
|
let mut skipped = Vec::new();
|
||||||
let mut written: Vec<String> = Vec::new();
|
let mut written: Vec<String> = Vec::new();
|
||||||
|
// Into a sealed folder, everything that is not a document goes sealed,
|
||||||
|
// and with the vault shut nothing is read at all.
|
||||||
|
let key = match &into {
|
||||||
|
Into::Node(target) if target.sealed => Some(state.vault.key().ok_or(AppError::Locked)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let result = async {
|
let result = async {
|
||||||
let mut uploads = collect(state, multipart, &mut skipped, &mut written).await?;
|
let mut uploads = collect(state, multipart, &mut skipped, &mut written).await?;
|
||||||
let manifest = take_manifest(&mut uploads)?;
|
let manifest = take_manifest(&mut uploads)?;
|
||||||
if let Some(manifest) = &manifest {
|
if let Some(manifest) = &manifest {
|
||||||
files_as_they_were(state, manifest, &mut uploads, &mut written).await?;
|
files_as_they_were(state, manifest, &mut uploads, &mut written).await?;
|
||||||
}
|
}
|
||||||
|
if let Some(key) = &key {
|
||||||
|
seal_in_place(state, key, &uploads).await?;
|
||||||
|
}
|
||||||
if uploads.is_empty() {
|
if uploads.is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"nothing importable in that upload".into(),
|
"nothing importable in that upload".into(),
|
||||||
@@ -553,6 +563,32 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult<Im
|
|||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seal every file of the upload where it lies, under the id it already has.
|
||||||
|
/// They were written plain, an archive's contents can only be — but no row
|
||||||
|
/// names them yet, so neither the app nor the mirror has seen them.
|
||||||
|
async fn seal_in_place(state: &AppState, key: &vault::Key, uploads: &[Upload]) -> AppResult<()> {
|
||||||
|
let ids: Vec<String> = uploads
|
||||||
|
.iter()
|
||||||
|
.filter_map(|upload| match &upload.content {
|
||||||
|
Content::File { id, .. } => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (key, dir) = (key.clone(), state.config.uploads_dir());
|
||||||
|
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||||
|
for id in ids {
|
||||||
|
let plain = dir.join(&id);
|
||||||
|
let sealed = dir.join(format!("{id}.part"));
|
||||||
|
vault::seal_file(&key, &id, &mut std::fs::File::open(&plain)?, &sealed)?;
|
||||||
|
std::fs::rename(&sealed, &plain)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// A file that happens to end in `.md` was a file, not a document, when it
|
/// A file that happens to end in `.md` was a file, not a document, when it
|
||||||
/// was exported, and the manifest says so: it goes back to being one.
|
/// was exported, and the manifest says so: it goes back to being one.
|
||||||
async fn files_as_they_were(
|
async fn files_as_they_were(
|
||||||
@@ -830,14 +866,15 @@ async fn build(
|
|||||||
.or(dated.map(|m| m.name.as_str()))
|
.or(dated.map(|m| m.name.as_str()))
|
||||||
.unwrap_or(file_name(&upload.path));
|
.unwrap_or(file_name(&upload.path));
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO media (id, filename, mime, size, created_at)
|
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
)
|
)
|
||||||
.bind(media_id)
|
.bind(media_id)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.bind(mime)
|
.bind(mime)
|
||||||
.bind(*size as i64)
|
.bind(*size as i64)
|
||||||
.bind(created)
|
.bind(created)
|
||||||
|
.bind(matches!(into, Into::Node(target) if target.sealed))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
landed.insert(
|
landed.insert(
|
||||||
@@ -977,6 +1014,66 @@ mod tests {
|
|||||||
split_target, split_title, take_manifest, Content, Landed, Upload,
|
split_target, split_title, take_manifest, Content, Landed, Upload,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Into a sealed folder, a file arrives sealed — readable back through
|
||||||
|
/// the vault — and the folders made for it are sealed too.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_import_into_a_sealed_folder_arrives_sealed() {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let uploads_dir = dir.path().join("uploads");
|
||||||
|
std::fs::create_dir_all(&uploads_dir).unwrap();
|
||||||
|
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||||
|
let key = zeroize::Zeroizing::new([5u8; 32]);
|
||||||
|
let folder: i64 = sqlx::query_scalar(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, sealed)
|
||||||
|
VALUES (NULL, 'space', 'locked', 'locked', 1, 1, 1) RETURNING id",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::write(uploads_dir.join("m1"), b"private bytes").unwrap();
|
||||||
|
let uploads = vec![
|
||||||
|
text(
|
||||||
|
"sub/a.bin",
|
||||||
|
Content::File {
|
||||||
|
id: "m1".into(),
|
||||||
|
mime: "application/octet-stream".into(),
|
||||||
|
size: 13,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
text("notes.md", Content::Markdown("# Notes".into())),
|
||||||
|
];
|
||||||
|
super::seal_in_place(&state, &key, &uploads).await.unwrap();
|
||||||
|
let target = crate::routes::nodes::load(&state, folder).await.unwrap();
|
||||||
|
super::build(
|
||||||
|
&state,
|
||||||
|
&super::Into::Node(Box::new(target)),
|
||||||
|
uploads,
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (encrypted, sub_sealed): (bool, bool) = sqlx::query_as(
|
||||||
|
"SELECT m.encrypted, p.sealed FROM nodes n
|
||||||
|
JOIN media m ON m.id = n.media_id JOIN nodes p ON p.id = n.parent_id
|
||||||
|
WHERE n.kind = 'file'",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(encrypted && sub_sealed);
|
||||||
|
let mut back = String::new();
|
||||||
|
crate::vault::Reader::open(&key, "m1", &uploads_dir.join("m1"))
|
||||||
|
.unwrap()
|
||||||
|
.read_to_string(&mut back)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(back, "private bytes");
|
||||||
|
}
|
||||||
|
|
||||||
fn text(path: &str, content: Content) -> Upload {
|
fn text(path: &str, content: Content) -> Upload {
|
||||||
Upload {
|
Upload {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
|
|||||||
+3
-2
@@ -17,7 +17,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::nodes,
|
routes::{nodes, seal},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
vault,
|
vault,
|
||||||
};
|
};
|
||||||
@@ -263,7 +263,8 @@ pub async fn upload_into(
|
|||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> AppResult<Json<Vec<nodes::Node>>> {
|
) -> AppResult<Json<Vec<nodes::Node>>> {
|
||||||
nodes::container(&mut *state.db.acquire().await?, parent).await?;
|
nodes::container(&mut *state.db.acquire().await?, parent).await?;
|
||||||
let key = if query.encrypt {
|
// A sealed folder seals what lands in it, asked or not.
|
||||||
|
let key = if query.encrypt || seal::sealed(&state, parent).await? {
|
||||||
Some(state.vault.key().ok_or(AppError::Locked)?)
|
Some(state.vault.key().ok_or(AppError::Locked)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
+11
-2
@@ -93,7 +93,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
pub(crate) mod tests {
|
||||||
use std::sync::{atomic::AtomicI64, Arc};
|
use std::sync::{atomic::AtomicI64, Arc};
|
||||||
|
|
||||||
use axum::{body::Body, http::Request};
|
use axum::{body::Body, http::Request};
|
||||||
@@ -113,13 +113,22 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
async fn state() -> AppState {
|
async fn state() -> AppState {
|
||||||
|
state_in(".".into()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A whole `AppState` on an in-memory database, with its uploads under
|
||||||
|
/// `data_dir`.
|
||||||
|
pub(crate) async fn state_in(data_dir: std::path::PathBuf) -> AppState {
|
||||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
.connect("sqlite::memory:")
|
.connect("sqlite::memory:")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
||||||
let config = Arc::new(Config::for_tests(30));
|
let config = Arc::new(Config {
|
||||||
|
data_dir,
|
||||||
|
..Config::for_tests(30)
|
||||||
|
});
|
||||||
AppState {
|
AppState {
|
||||||
vault: Vault::new(db.clone(), config.vault_idle),
|
vault: Vault::new(db.clone(), config.vault_idle),
|
||||||
backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()),
|
backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()),
|
||||||
|
|||||||
+18
-1
@@ -61,6 +61,8 @@ pub struct NodeSummary {
|
|||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
pub shared: bool,
|
pub shared: bool,
|
||||||
pub share_token: Option<String>,
|
pub share_token: Option<String>,
|
||||||
|
/// Folders and spaces only: what lands here is sealed in the vault.
|
||||||
|
pub sealed: bool,
|
||||||
/// Files only.
|
/// Files only.
|
||||||
pub file: Option<FileInfo>,
|
pub file: Option<FileInfo>,
|
||||||
/// Search results only: where the match sits, `work/notes`, so two
|
/// Search results only: where the match sits, `work/notes`, so two
|
||||||
@@ -88,6 +90,7 @@ pub struct Node {
|
|||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
pub shared: bool,
|
pub shared: bool,
|
||||||
pub share_token: Option<String>,
|
pub share_token: Option<String>,
|
||||||
|
pub sealed: bool,
|
||||||
pub file: Option<FileInfo>,
|
pub file: Option<FileInfo>,
|
||||||
/// This node's ancestors, the space first and the node itself last — the
|
/// This node's ancestors, the space first and the node itself last — the
|
||||||
/// breadcrumb, and everything needed to build its /n/... path.
|
/// breadcrumb, and everything needed to build its /n/... path.
|
||||||
@@ -176,7 +179,7 @@ const EXCERPT_CHARS: usize = 180;
|
|||||||
/// The columns every list query selects, so a row always converts the same way.
|
/// The columns every list query selects, so a row always converts the same way.
|
||||||
const SUMMARY_COLUMNS: &str =
|
const SUMMARY_COLUMNS: &str =
|
||||||
"n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role,
|
"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,
|
n.created_at, n.updated_at, n.share_token, n.media_id, n.sealed,
|
||||||
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count,
|
(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,
|
(SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime,
|
||||||
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size,
|
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size,
|
||||||
@@ -329,6 +332,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
|||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
shared: share_token.is_some(),
|
shared: share_token.is_some(),
|
||||||
share_token,
|
share_token,
|
||||||
|
sealed: row.get("sealed"),
|
||||||
file,
|
file,
|
||||||
location: None,
|
location: None,
|
||||||
}
|
}
|
||||||
@@ -762,6 +766,18 @@ pub async fn move_node(
|
|||||||
if input.parent_id == id {
|
if input.parent_id == id {
|
||||||
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
||||||
}
|
}
|
||||||
|
// Into a sealed folder, the files go sealed or not at all — and only
|
||||||
|
// into a place it may go at all, which the transaction checks again.
|
||||||
|
{
|
||||||
|
let mut conn = state.db.acquire().await?;
|
||||||
|
container(&mut conn, input.parent_id).await?;
|
||||||
|
if descendants(&mut conn, id).await?.contains(&input.parent_id) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"a folder cannot be moved into itself".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::routes::seal::enter(&state, id, input.parent_id).await?;
|
||||||
|
|
||||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||||
|
|
||||||
@@ -898,6 +914,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
|||||||
updated_at: summary.updated_at,
|
updated_at: summary.updated_at,
|
||||||
shared: summary.shared,
|
shared: summary.shared,
|
||||||
share_token: summary.share_token,
|
share_token: summary.share_token,
|
||||||
|
sealed: summary.sealed,
|
||||||
file: summary.file,
|
file: summary.file,
|
||||||
path: path_of(&state.db, id).await?,
|
path: path_of(&state.db, id).await?,
|
||||||
})
|
})
|
||||||
|
|||||||
+271
-3
@@ -3,6 +3,13 @@
|
|||||||
//! `POST /api/nodes/{id}/encrypt` seals a file node, or every file node under
|
//! `POST /api/nodes/{id}/encrypt` seals a file node, or every file node under
|
||||||
//! a folder or space; `/decrypt` does the opposite. Either needs the vault open.
|
//! a folder or space; `/decrypt` does the opposite. Either needs the vault open.
|
||||||
//!
|
//!
|
||||||
|
//! A folder or space that is encrypted stays sealed: it and every folder under
|
||||||
|
//! it carry `sealed`, and whatever lands there later — an upload, an import, an
|
||||||
|
//! SFTP write, a file moved in — is sealed on the way in, or refused while the
|
||||||
|
//! vault is shut. `enter` is that step for a move. Decrypting the folder lifts
|
||||||
|
//! it; a file or folder inside a sealed one cannot be decrypted on its own.
|
||||||
|
//! Documents are not files and stay as they are.
|
||||||
|
//!
|
||||||
//! A file is re-written under a **new** media id rather than in place. The
|
//! A file is re-written under a **new** media id rather than in place. The
|
||||||
//! mirror treats `uploads/<uuid>` as immutable, so rewriting one would leave
|
//! mirror treats `uploads/<uuid>` as immutable, so rewriting one would leave
|
||||||
//! the old bytes on Drive as the previous revision of the same file — the
|
//! the old bytes on Drive as the previous revision of the same file — the
|
||||||
@@ -27,7 +34,7 @@ use crate::{
|
|||||||
vault,
|
vault,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Default)]
|
||||||
pub struct Resealed {
|
pub struct Resealed {
|
||||||
/// How many files changed.
|
/// How many files changed.
|
||||||
pub files: usize,
|
pub files: usize,
|
||||||
@@ -38,6 +45,8 @@ pub struct Resealed {
|
|||||||
/// Shared documents that embed a file just sealed: their readers now see
|
/// Shared documents that embed a file just sealed: their readers now see
|
||||||
/// a gap where it was, because a share link never serves a sealed file.
|
/// a gap where it was, because a share link never serves a sealed file.
|
||||||
pub shared: usize,
|
pub shared: usize,
|
||||||
|
/// Whether what was asked about is now a sealed folder or space.
|
||||||
|
pub sealed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn encrypt(
|
pub async fn encrypt(
|
||||||
@@ -45,7 +54,7 @@ pub async fn encrypt(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> AppResult<Json<Resealed>> {
|
) -> AppResult<Json<Resealed>> {
|
||||||
reseal(&state, id, true).await.map(Json)
|
seal_node(&state, id).await.map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn decrypt(
|
pub async fn decrypt(
|
||||||
@@ -53,7 +62,119 @@ pub async fn decrypt(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> AppResult<Json<Resealed>> {
|
) -> AppResult<Json<Resealed>> {
|
||||||
reseal(&state, id, false).await.map(Json)
|
open_node(&state, id).await.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seal_node(state: &AppState, id: i64) -> AppResult<Resealed> {
|
||||||
|
let (kind, _) = place(state, id).await?;
|
||||||
|
if !nodes::is_container(&kind) {
|
||||||
|
return reseal(state, id, true).await;
|
||||||
|
}
|
||||||
|
state.vault.key().ok_or(AppError::Locked)?;
|
||||||
|
// Flagged first, so nothing that lands while the files are re-written
|
||||||
|
// slips in as plaintext; and an empty folder is worth sealing too.
|
||||||
|
mark(state, id, true).await?;
|
||||||
|
let mut done = reseal(state, id, true).await?;
|
||||||
|
done.sealed = true;
|
||||||
|
Ok(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_node(state: &AppState, id: i64) -> AppResult<Resealed> {
|
||||||
|
let (kind, parent) = place(state, id).await?;
|
||||||
|
if let Some(parent) = parent {
|
||||||
|
if sealed(state, parent).await? {
|
||||||
|
let what = if kind == nodes::FILE {
|
||||||
|
"file"
|
||||||
|
} else {
|
||||||
|
"folder"
|
||||||
|
};
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"this {what} is inside a sealed folder — decrypt that folder instead"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nodes::is_container(&kind) {
|
||||||
|
return reseal(state, id, false).await;
|
||||||
|
}
|
||||||
|
state.vault.key().ok_or(AppError::Locked)?;
|
||||||
|
mark(state, id, false).await?;
|
||||||
|
reseal(state, id, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `id` under `parent`'s rules before it moves there: when `parent` is
|
||||||
|
/// sealed, flag the folders of `id` and seal its files. The caller moves it
|
||||||
|
/// only if this succeeds — the triggers of 0015 refuse it otherwise.
|
||||||
|
pub async fn enter(state: &AppState, id: i64, parent: i64) -> AppResult<()> {
|
||||||
|
if !sealed(state, parent).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Already sealed — a rename inside the folder, or a move between two
|
||||||
|
// sealed ones — needs nothing, not even the vault open.
|
||||||
|
let ready: Option<bool> = sqlx::query_scalar(
|
||||||
|
"SELECT CASE WHEN n.kind = 'file' THEN m.encrypted
|
||||||
|
WHEN n.kind = 'document' THEN 1
|
||||||
|
ELSE n.sealed END
|
||||||
|
FROM nodes n LEFT JOIN media m ON m.id = n.media_id WHERE n.id = ?1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
match ready {
|
||||||
|
None => return Err(AppError::NotFound),
|
||||||
|
// A document is not sealed; it moves in as it is.
|
||||||
|
Some(true) => return Ok(()),
|
||||||
|
Some(false) => {}
|
||||||
|
}
|
||||||
|
state.vault.key().ok_or(AppError::Locked)?;
|
||||||
|
let (kind, _) = place(state, id).await?;
|
||||||
|
if nodes::is_container(&kind) {
|
||||||
|
mark(state, id, true).await?;
|
||||||
|
}
|
||||||
|
let done = reseal(state, id, true).await?;
|
||||||
|
if done.failed > 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"{} file(s) would not seal, so nothing moved into the sealed folder",
|
||||||
|
done.failed
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether files that land in this folder or space are sealed.
|
||||||
|
pub async fn sealed(state: &AppState, id: i64) -> AppResult<bool> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn place(state: &AppState, id: i64) -> AppResult<(String, Option<i64>)> {
|
||||||
|
let row = sqlx::query("SELECT kind, parent_id FROM nodes WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
Ok((row.get("kind"), row.get("parent_id")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set or lift `sealed` on a folder or space and every folder under it.
|
||||||
|
async fn mark(state: &AppState, id: i64, on: bool) -> AppResult<()> {
|
||||||
|
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||||
|
let list = nodes::descendants(&mut tx, id)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(i64::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"UPDATE nodes SET sealed = ?1 WHERE kind IN ('space', 'folder') AND id IN ({list})"
|
||||||
|
))
|
||||||
|
.bind(on)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Target {
|
struct Target {
|
||||||
@@ -90,6 +211,11 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
|
// An empty folder is nothing to re-write, but it is no mistake either.
|
||||||
|
let (kind, _) = place(state, id).await?;
|
||||||
|
if nodes::is_container(&kind) {
|
||||||
|
return Ok(Resealed::default());
|
||||||
|
}
|
||||||
return Err(AppError::BadRequest("there are no files there".into()));
|
return Err(AppError::BadRequest("there are no files there".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +262,7 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
|
|||||||
unchanged,
|
unchanged,
|
||||||
failed: failed.len(),
|
failed: failed.len(),
|
||||||
shared: shared as usize,
|
shared: shared as usize,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,3 +389,144 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool
|
|||||||
tracing::info!(node = target.node_id, from = %old, to = %new, sealed = seal, "file re-written");
|
tracing::info!(node = target.node_id, from = %old, to = %new, sealed = seal, "file re-written");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::{error::AppError, routes::media};
|
||||||
|
|
||||||
|
struct World {
|
||||||
|
state: AppState,
|
||||||
|
_dir: tempfile::TempDir,
|
||||||
|
space: i64,
|
||||||
|
folder: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A space with a folder in it, and a vault that is open.
|
||||||
|
async fn world() -> World {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("uploads")).unwrap();
|
||||||
|
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||||
|
state.vault.open_with(Zeroizing::new([7u8; 32]));
|
||||||
|
let space = node(&state, None, "space", "vaulted").await;
|
||||||
|
let folder = node(&state, Some(space), "folder", "keys").await;
|
||||||
|
World {
|
||||||
|
state,
|
||||||
|
_dir: dir,
|
||||||
|
space,
|
||||||
|
folder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn node(state: &AppState, parent: Option<i64>, kind: &str, name: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?3, 1, 1) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(parent)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plaintext file, written the way an upload writes one.
|
||||||
|
async fn plain_file(state: &AppState, parent: i64, name: &str) -> AppResult<i64> {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
std::fs::write(state.config.uploads_dir().join(&id), b"secret bytes").unwrap();
|
||||||
|
media::insert_file(&state.db, parent, &id, name, "text/plain", 12, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn encrypted(state: &AppState, node: i64) -> bool {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT m.encrypted FROM nodes n JOIN media m ON m.id = n.media_id WHERE n.id = ?1",
|
||||||
|
)
|
||||||
|
.bind(node)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_sealed_folder_stays_sealed() {
|
||||||
|
let w = world().await;
|
||||||
|
let file = plain_file(&w.state, w.folder, "a.txt").await.unwrap();
|
||||||
|
|
||||||
|
let done = seal_node(&w.state, w.folder).await.unwrap();
|
||||||
|
assert!(done.sealed);
|
||||||
|
assert_eq!(done.files, 1);
|
||||||
|
assert!(encrypted(&w.state, file).await);
|
||||||
|
assert!(sealed(&w.state, w.folder).await.unwrap());
|
||||||
|
assert!(!sealed(&w.state, w.space).await.unwrap());
|
||||||
|
|
||||||
|
// A folder made in it later is sealed from the start.
|
||||||
|
let inner = node(&w.state, Some(w.folder), "folder", "inner").await;
|
||||||
|
assert!(sealed(&w.state, inner).await.unwrap());
|
||||||
|
|
||||||
|
// And no path puts plaintext in it: the database refuses.
|
||||||
|
let refused = plain_file(&w.state, inner, "b.txt").await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(&refused, AppError::BadRequest(m) if m.contains("sealed")),
|
||||||
|
"{refused:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_folder_can_be_sealed() {
|
||||||
|
let w = world().await;
|
||||||
|
let done = seal_node(&w.state, w.folder).await.unwrap();
|
||||||
|
assert_eq!((done.files, done.sealed), (0, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn moving_in_seals_and_a_shut_vault_refuses() {
|
||||||
|
let w = world().await;
|
||||||
|
seal_node(&w.state, w.folder).await.unwrap();
|
||||||
|
let outside = node(&w.state, Some(w.space), "folder", "outside").await;
|
||||||
|
let file = plain_file(&w.state, outside, "c.txt").await.unwrap();
|
||||||
|
|
||||||
|
w.state.vault.lock();
|
||||||
|
assert!(matches!(
|
||||||
|
enter(&w.state, file, w.folder).await,
|
||||||
|
Err(AppError::Locked)
|
||||||
|
));
|
||||||
|
assert!(!encrypted(&w.state, file).await);
|
||||||
|
|
||||||
|
w.state.vault.open_with(Zeroizing::new([7u8; 32]));
|
||||||
|
enter(&w.state, outside, w.folder).await.unwrap();
|
||||||
|
assert!(encrypted(&w.state, file).await);
|
||||||
|
assert!(sealed(&w.state, outside).await.unwrap());
|
||||||
|
|
||||||
|
// Already sealed, it moves about inside without the vault.
|
||||||
|
w.state.vault.lock();
|
||||||
|
enter(&w.state, file, w.folder).await.unwrap();
|
||||||
|
// A document is no file and goes in as it is.
|
||||||
|
let doc = node(&w.state, Some(w.space), "document", "notes").await;
|
||||||
|
enter(&w.state, doc, w.folder).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_the_sealed_folder_itself_opens() {
|
||||||
|
let w = world().await;
|
||||||
|
let inner = node(&w.state, Some(w.folder), "folder", "inner").await;
|
||||||
|
let file = plain_file(&w.state, inner, "d.txt").await.unwrap();
|
||||||
|
seal_node(&w.state, w.folder).await.unwrap();
|
||||||
|
|
||||||
|
for id in [inner, file] {
|
||||||
|
assert!(matches!(
|
||||||
|
open_node(&w.state, id).await,
|
||||||
|
Err(AppError::BadRequest(m)) if m.contains("inside a sealed folder")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let done = open_node(&w.state, w.folder).await.unwrap();
|
||||||
|
assert_eq!(done.files, 1);
|
||||||
|
assert!(!encrypted(&w.state, file).await);
|
||||||
|
assert!(!sealed(&w.state, w.folder).await.unwrap());
|
||||||
|
assert!(!sealed(&w.state, inner).await.unwrap());
|
||||||
|
plain_file(&w.state, inner, "e.txt").await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-8
@@ -9,7 +9,9 @@
|
|||||||
//! export writes, and a leading heading written back becomes the title again.
|
//! export writes, and a leading heading written back becomes the title again.
|
||||||
//! A file is streamed from `uploads/` and written back to it through a
|
//! A file is streamed from `uploads/` and written back to it through a
|
||||||
//! `.part`, never held in memory. A sealed file is listed but cannot be
|
//! `.part`, never held in memory. A sealed file is listed but cannot be
|
||||||
//! opened here: the vault is opened in the browser, for the browser.
|
//! opened here: the vault is opened in the browser, for the browser. A file
|
||||||
|
//! written into a sealed folder, or moved into one, is sealed while the vault
|
||||||
|
//! is open there and refused while it is shut.
|
||||||
//!
|
//!
|
||||||
//! What an editor writes next to a document — vim's `.swp` and `4913` probe,
|
//! What an editor writes next to a document — vim's `.swp` and `4913` probe,
|
||||||
//! a `foo.md~` backup, a dotted temp file it renames over the original — lives
|
//! a `foo.md~` backup, a dotted temp file it renames over the original — lives
|
||||||
@@ -29,8 +31,9 @@ use crate::{
|
|||||||
Backup,
|
Backup,
|
||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{media, nodes},
|
routes::{media, nodes, seal},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
vault,
|
||||||
};
|
};
|
||||||
|
|
||||||
type SftpResult<T> = Result<T, StatusCode>;
|
type SftpResult<T> = Result<T, StatusCode>;
|
||||||
@@ -243,13 +246,21 @@ pub struct SpaceFs {
|
|||||||
handles: HashMap<String, OpenHandle>,
|
handles: HashMap<String, OpenHandle>,
|
||||||
next_handle: u64,
|
next_handle: u64,
|
||||||
scratch: HashMap<String, Scratch>,
|
scratch: HashMap<String, Scratch>,
|
||||||
|
/// For the vault: a file written into a sealed folder is sealed with its
|
||||||
|
/// key. `None` only in tests, where a sealed folder takes nothing.
|
||||||
|
state: Option<AppState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpaceFs {
|
impl SpaceFs {
|
||||||
pub fn new(state: AppState, key_id: Option<i64>) -> Self {
|
pub fn new(state: AppState, key_id: Option<i64>) -> Self {
|
||||||
let mut fs = Self::with_db(state.db, state.config.uploads_dir(), Some(state.backup));
|
let mut fs = Self::with_db(
|
||||||
|
state.db.clone(),
|
||||||
|
state.config.uploads_dir(),
|
||||||
|
Some(state.backup.clone()),
|
||||||
|
);
|
||||||
fs.key_id = key_id;
|
fs.key_id = key_id;
|
||||||
fs.max_upload = state.config.max_upload_bytes as u64;
|
fs.max_upload = state.config.max_upload_bytes as u64;
|
||||||
|
fs.state = Some(state);
|
||||||
fs
|
fs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +275,39 @@ impl SpaceFs {
|
|||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_handle: 0,
|
next_handle: 0,
|
||||||
scratch: HashMap::new(),
|
scratch: HashMap::new(),
|
||||||
|
state: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key to seal a file written into `parent` with: none when the folder
|
||||||
|
/// is not sealed, and a refusal when it is and the vault is shut.
|
||||||
|
async fn seal_key(&self, parent: i64) -> SftpResult<Option<Arc<vault::Key>>> {
|
||||||
|
let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1")
|
||||||
|
.bind(parent)
|
||||||
|
.fetch_optional(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !sealed {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match self.state.as_ref().and_then(|state| state.vault.key()) {
|
||||||
|
Some(key) => Ok(Some(key)),
|
||||||
|
None => Err(StatusCode::PermissionDenied),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal what moves into a sealed folder before it moves; see `seal::enter`.
|
||||||
|
async fn enter(&self, node: i64, parent: i64) -> SftpResult<()> {
|
||||||
|
match &self.state {
|
||||||
|
Some(state) => seal::enter(state, node, parent).await.map_err(|e| match e {
|
||||||
|
AppError::Locked => StatusCode::PermissionDenied,
|
||||||
|
e => {
|
||||||
|
tracing::warn!("sftp: could not seal what moved into a sealed folder: {e:?}");
|
||||||
|
StatusCode::Failure
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None => self.seal_key(parent).await.map(|_| ()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +647,8 @@ impl SpaceFs {
|
|||||||
replaces: Option<&FileNode>,
|
replaces: Option<&FileNode>,
|
||||||
truncate: bool,
|
truncate: bool,
|
||||||
) -> SftpResult<Upload> {
|
) -> SftpResult<Upload> {
|
||||||
|
// Refused at the open, not at the close, when the vault is shut.
|
||||||
|
self.seal_key(parent).await?;
|
||||||
let media_id = uuid::Uuid::new_v4().to_string();
|
let media_id = uuid::Uuid::new_v4().to_string();
|
||||||
let partial = self.uploads.join(format!("{media_id}.part"));
|
let partial = self.uploads.join(format!("{media_id}.part"));
|
||||||
let mut size = 0;
|
let mut size = 0;
|
||||||
@@ -646,17 +692,45 @@ impl SpaceFs {
|
|||||||
file,
|
file,
|
||||||
size,
|
size,
|
||||||
} = upload;
|
} = upload;
|
||||||
|
// Asked again: the vault may have locked while the bytes came in.
|
||||||
|
let key = match self.seal_key(parent).await {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tokio::fs::remove_file(&partial).await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
let landed = async {
|
let landed = async {
|
||||||
file.sync_all().await?;
|
file.sync_all().await?;
|
||||||
drop(file);
|
drop(file);
|
||||||
tokio::fs::rename(&partial, self.uploads.join(&media_id)).await?;
|
let target = self.uploads.join(&media_id);
|
||||||
|
match &key {
|
||||||
|
// The plaintext sat only in the `.part` file, which no row
|
||||||
|
// names and the mirror never reads.
|
||||||
|
Some(key) => {
|
||||||
|
let (key, id, plain, sealed) =
|
||||||
|
(key.clone(), media_id.clone(), partial.clone(), target);
|
||||||
|
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||||
|
vault::seal_file(&key, &id, &mut std::fs::File::open(&plain)?, &sealed)?;
|
||||||
|
std::fs::remove_file(&plain)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)??;
|
||||||
|
}
|
||||||
|
None => tokio::fs::rename(&partial, target).await?,
|
||||||
|
}
|
||||||
let mime = media::mime_of(&name, None);
|
let mime = media::mime_of(&name, None);
|
||||||
|
let sealed = key.is_some();
|
||||||
match replaces {
|
match replaces {
|
||||||
None => {
|
None => {
|
||||||
media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false)
|
media::insert_file(&self.db, parent, &media_id, &name, &mime, size, sealed)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Some(node) => self.replace_bytes(node, &media_id, &mime, size).await?,
|
Some(node) => {
|
||||||
|
self.replace_bytes(node, &media_id, &mime, size, sealed)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AppResult::Ok(())
|
AppResult::Ok(())
|
||||||
}
|
}
|
||||||
@@ -677,6 +751,7 @@ impl SpaceFs {
|
|||||||
media_id: &str,
|
media_id: &str,
|
||||||
mime: &str,
|
mime: &str,
|
||||||
size: u64,
|
size: u64,
|
||||||
|
encrypted: bool,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
let mut tx = crate::db::begin_write(&self.db).await?;
|
let mut tx = crate::db::begin_write(&self.db).await?;
|
||||||
let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1")
|
let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1")
|
||||||
@@ -684,14 +759,15 @@ impl SpaceFs {
|
|||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO media (id, filename, mime, size, created_at)
|
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
|
||||||
SELECT ?1, filename, ?2, ?3, ?4 FROM media WHERE id = ?5",
|
SELECT ?1, filename, ?2, ?3, ?4, ?6 FROM media WHERE id = ?5",
|
||||||
)
|
)
|
||||||
.bind(media_id)
|
.bind(media_id)
|
||||||
.bind(mime)
|
.bind(mime)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(crate::now())
|
.bind(crate::now())
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
|
.bind(encrypted)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("UPDATE nodes SET media_id = ?1, updated_at = ?2 WHERE id = ?3")
|
sqlx::query("UPDATE nodes SET media_id = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
@@ -752,6 +828,7 @@ impl SpaceFs {
|
|||||||
leaf: &str,
|
leaf: &str,
|
||||||
shown: &str,
|
shown: &str,
|
||||||
) -> SftpResult<()> {
|
) -> SftpResult<()> {
|
||||||
|
self.enter(file.id, parent).await?;
|
||||||
let mut tx = crate::db::begin_write(&self.db)
|
let mut tx = crate::db::begin_write(&self.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
@@ -1320,6 +1397,7 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
if below.contains(&parent) {
|
if below.contains(&parent) {
|
||||||
return Err(StatusCode::Failure);
|
return Err(StatusCode::Failure);
|
||||||
}
|
}
|
||||||
|
self.enter(node, parent).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let new_slug = nodes::unique_slug(
|
let new_slug = nodes::unique_slug(
|
||||||
@@ -1649,6 +1727,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A write into a sealed folder lands sealed while the vault is open, and
|
||||||
|
/// is refused at the open while it is shut; a file moved in is sealed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_sealed_folder_takes_only_sealed_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("uploads")).unwrap();
|
||||||
|
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||||
|
state.vault.open_with(zeroize::Zeroizing::new([9u8; 32]));
|
||||||
|
let mut fs = SpaceFs::new(state.clone(), None);
|
||||||
|
fs.max_upload = 1024;
|
||||||
|
for path in ["/proj", "/proj/vault", "/proj/open"] {
|
||||||
|
fs.mkdir(0, path.into(), FileAttributes::empty())
|
||||||
|
.await
|
||||||
|
.expect("mkdir");
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE nodes SET sealed = 1 WHERE slug = 'vault'")
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let sealed_files = || async {
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM nodes n JOIN media m ON m.id = n.media_id
|
||||||
|
WHERE m.encrypted = 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
put(&mut fs, "/proj/vault/key.pem", "-----BEGIN-----").await;
|
||||||
|
assert_eq!(sealed_files().await, 1);
|
||||||
|
let on_disk = std::fs::read_dir(dir.path().join("uploads"))
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| std::fs::read(e.unwrap().path()).unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(on_disk.len(), 1);
|
||||||
|
assert!(!on_disk[0].windows(5).any(|w| w == b"BEGIN"));
|
||||||
|
|
||||||
|
put(&mut fs, "/proj/open/notes.txt", "plain").await;
|
||||||
|
fs.rename(
|
||||||
|
0,
|
||||||
|
"/proj/open/notes.txt".into(),
|
||||||
|
"/proj/vault/notes.txt".into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("move in");
|
||||||
|
assert_eq!(sealed_files().await, 2);
|
||||||
|
|
||||||
|
state.vault.lock();
|
||||||
|
let refused = fs
|
||||||
|
.open(
|
||||||
|
0,
|
||||||
|
"/proj/vault/more.txt".into(),
|
||||||
|
CREATE,
|
||||||
|
FileAttributes::empty(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(refused, Err(StatusCode::PermissionDenied)));
|
||||||
|
}
|
||||||
|
|
||||||
const CREATE: OpenFlags = OpenFlags::WRITE
|
const CREATE: OpenFlags = OpenFlags::WRITE
|
||||||
.union(OpenFlags::CREATE)
|
.union(OpenFlags::CREATE)
|
||||||
.union(OpenFlags::TRUNCATE);
|
.union(OpenFlags::TRUNCATE);
|
||||||
|
|||||||
@@ -450,6 +450,16 @@ impl Vault {
|
|||||||
*self.slot() = None;
|
*self.slot() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open with a key as it is, skipping Argon2, for tests of what an open
|
||||||
|
/// vault allows.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn open_with(&self, key: Key) {
|
||||||
|
*self.slot() = Some(Unlocked {
|
||||||
|
key: Arc::new(key),
|
||||||
|
touched: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the key once it has gone unused past the idle time. Without this
|
/// Drop the key once it has gone unused past the idle time. Without this
|
||||||
/// a vault nobody looks at would only notice it should be shut on the
|
/// a vault nobody looks at would only notice it should be shut on the
|
||||||
/// next look, with the key sitting in memory all the while.
|
/// next look, with the key sitting in memory all the while.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
const actions = $derived.by((): Action[] => {
|
const actions = $derived.by((): Action[] => {
|
||||||
if (!node) return []
|
if (!node) return []
|
||||||
const sealed = node.file?.encrypted ?? false
|
const sealed = node.file?.encrypted ?? node.sealed
|
||||||
const holdsFiles = isContainer(node) || node.kind === 'file'
|
const holdsFiles = isContainer(node) || node.kind === 'file'
|
||||||
const list: Action[] = [
|
const list: Action[] = [
|
||||||
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
|
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
|
||||||
@@ -32,11 +32,12 @@
|
|||||||
)
|
)
|
||||||
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') })
|
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') })
|
||||||
}
|
}
|
||||||
if (holdsFiles && node.kind !== 'space') {
|
// Inside a sealed folder only that folder opens, as a whole.
|
||||||
|
if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) {
|
||||||
list.push(
|
list.push(
|
||||||
sealed
|
sealed
|
||||||
? { label: 'take out of the vault', run: () => runCommand('decrypt') }
|
? { label: node.kind === 'file' ? 'take out of the vault' : 'unseal — take every file out', run: () => runCommand('decrypt') }
|
||||||
: { label: node.kind === 'file' ? 'seal in the vault' : 'seal every file in it', run: () => runCommand('encrypt') },
|
: { label: node.kind === 'file' ? 'seal in the vault' : 'seal it — now and what lands later', run: () => runCommand('encrypt') },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
list.push({ label: 'delete', run: () => runCommand('delete'), danger: true })
|
list.push({ label: 'delete', run: () => runCommand('delete'), danger: true })
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
|
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
|
||||||
{#if node.shared}<span class="shared" title="shared"></span>{/if}
|
{#if node.shared}<span class="shared" title="shared"></span>{/if}
|
||||||
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
|
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||||
|
{#if node.sealed}<span class="sealed" title="sealed — files added here go into the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||||
<span class="aside">{aside(node)}</span>
|
<span class="aside">{aside(node)}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if isTouchDevice}
|
{#if isTouchDevice}
|
||||||
|
|||||||
+7
-2
@@ -30,6 +30,8 @@ export interface NodeSummary {
|
|||||||
updated_at: number
|
updated_at: number
|
||||||
shared: boolean
|
shared: boolean
|
||||||
share_token: string | null
|
share_token: string | null
|
||||||
|
/** Folders and spaces only: whatever lands here is sealed in the vault. */
|
||||||
|
sealed: boolean
|
||||||
/** Files only. */
|
/** Files only. */
|
||||||
file: FileInfo | null
|
file: FileInfo | null
|
||||||
/** Search results only: where the match sits, `work/notes`. */
|
/** Search results only: where the match sits, `work/notes`. */
|
||||||
@@ -433,9 +435,12 @@ export const api = {
|
|||||||
|
|
||||||
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
|
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
|
||||||
|
|
||||||
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
|
/**
|
||||||
|
* Seal a file into the vault, or a folder with everything in it and all
|
||||||
|
* that lands there later — or take it back out.
|
||||||
|
*/
|
||||||
seal: (id: number, encrypt: boolean) =>
|
seal: (id: number, encrypt: boolean) =>
|
||||||
request<{ files: number; unchanged: number; failed: number; shared: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
request<{ files: number; unchanged: number; failed: number; shared: number; sealed: boolean }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -75,12 +75,16 @@ const sealTarget = (encrypt: boolean) =>
|
|||||||
if (node.file && node.file.encrypted === encrypt) {
|
if (node.file && node.file.encrypted === encrypt) {
|
||||||
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
|
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
|
||||||
}
|
}
|
||||||
if (isContainer(node)) {
|
if (!encrypt && workspace.parentSealed) {
|
||||||
const verb = encrypt ? 'seal every file in' : 'take every file in'
|
return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error')
|
||||||
const tail = encrypt ? 'into the vault' : 'out of the vault'
|
}
|
||||||
if (!(await workspace.ask(`${verb} ${node.kind} "${node.name || node.slug}" ${tail}?`))) return
|
|
||||||
} else if (!encrypt) {
|
|
||||||
const name = node.name || node.slug
|
const name = node.name || node.slug
|
||||||
|
if (isContainer(node)) {
|
||||||
|
const question = encrypt
|
||||||
|
? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable`
|
||||||
|
: `unseal ${node.kind} "${name}" and take every file in it out of the vault?`
|
||||||
|
if (!(await workspace.ask(question))) return
|
||||||
|
} else if (!encrypt) {
|
||||||
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
||||||
}
|
}
|
||||||
if (!(await workspace.ensureUnlocked())) return
|
if (!(await workspace.ensureUnlocked())) return
|
||||||
@@ -865,14 +869,14 @@ export const commands: CommandSpec[] = [
|
|||||||
name: 'encrypt',
|
name: 'encrypt',
|
||||||
aliases: ['seal'],
|
aliases: ['seal'],
|
||||||
group: 'vault',
|
group: 'vault',
|
||||||
help: 'seal the file under the cursor in the vault — on a folder, every file inside it',
|
help: 'seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later',
|
||||||
run: () => sealTarget(true),
|
run: () => sealTarget(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'decrypt',
|
name: 'decrypt',
|
||||||
aliases: ['unseal'],
|
aliases: ['unseal'],
|
||||||
group: 'vault',
|
group: 'vault',
|
||||||
help: 'take the file under the cursor back out of the vault — on a folder, every file inside it',
|
help: 'take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside',
|
||||||
run: () => sealTarget(false),
|
run: () => sealTarget(false),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class Workspace {
|
|||||||
parentId = $state<number | null>(null)
|
parentId = $state<number | null>(null)
|
||||||
/** Ancestors of the current folder, the space first and the folder last. */
|
/** Ancestors of the current folder, the space first and the folder last. */
|
||||||
path = $state<Crumb[]>([])
|
path = $state<Crumb[]>([])
|
||||||
|
/** The current folder is sealed: uploads into it go into the vault. */
|
||||||
|
parentSealed = $state(false)
|
||||||
|
|
||||||
/** Children of the current folder, or the results of a search. */
|
/** Children of the current folder, or the results of a search. */
|
||||||
nodes = $state<NodeSummary[]>([])
|
nodes = $state<NodeSummary[]>([])
|
||||||
@@ -250,14 +252,15 @@ class Workspace {
|
|||||||
|
|
||||||
/** Seal what is under the cursor, or take it back out, and show the result. */
|
/** Seal what is under the cursor, or take it back out, and show the result. */
|
||||||
async seal(id: number, encrypt: boolean) {
|
async seal(id: number, encrypt: boolean) {
|
||||||
const { files, unchanged, failed, shared } = await api.seal(id, encrypt)
|
const { files, unchanged, failed, shared, sealed } = await api.seal(id, encrypt)
|
||||||
const verb = encrypt ? 'encrypted' : 'decrypted'
|
const verb = encrypt ? 'encrypted' : 'decrypted'
|
||||||
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
|
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
|
||||||
const broken = failed > 0 ? `, ${failed} could not be` : ''
|
const broken = failed > 0 ? `, ${failed} could not be` : ''
|
||||||
|
const later = sealed ? ' — what lands in the folder from now on is sealed too' : ''
|
||||||
const readers =
|
const readers =
|
||||||
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
|
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
|
||||||
this.say(
|
this.say(
|
||||||
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${readers}`,
|
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${later}${readers}`,
|
||||||
failed > 0 || shared > 0 ? 'error' : undefined,
|
failed > 0 || shared > 0 ? 'error' : undefined,
|
||||||
)
|
)
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
@@ -573,6 +576,7 @@ class Workspace {
|
|||||||
// Walking into another space by a link leaves this one where it was.
|
// Walking into another space by a link leaves this one where it was.
|
||||||
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
|
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
|
||||||
this.parentId = id
|
this.parentId = id
|
||||||
|
this.parentSealed = folder.sealed
|
||||||
this.query = ''
|
this.query = ''
|
||||||
this.globalSearch = false
|
this.globalSearch = false
|
||||||
this.path = folder.path
|
this.path = folder.path
|
||||||
@@ -1199,6 +1203,7 @@ class Workspace {
|
|||||||
async importFolder(files: File[], root = false) {
|
async importFolder(files: File[], root = false) {
|
||||||
const parent = this.parentId
|
const parent = this.parentId
|
||||||
if (parent === null && !root) return
|
if (parent === null && !root) return
|
||||||
|
if (!root && this.parentSealed && !(await this.ensureUnlocked())) return
|
||||||
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
|
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
|
||||||
|
|
||||||
const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files)
|
const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files)
|
||||||
@@ -1224,6 +1229,11 @@ class Workspace {
|
|||||||
async putFiles(files: File[], encrypt = false) {
|
async putFiles(files: File[], encrypt = false) {
|
||||||
const parent = this.parentId
|
const parent = this.parentId
|
||||||
if (parent === null) return
|
if (parent === null) return
|
||||||
|
// A sealed folder seals them anyway, and needs the vault open for it.
|
||||||
|
if (this.parentSealed) {
|
||||||
|
if (!(await this.ensureUnlocked())) return
|
||||||
|
encrypt = true
|
||||||
|
}
|
||||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||||
const what = files.length === 1 ? files[0].name : `${files.length} files`
|
const what = files.length === 1 ? files[0].name : `${files.length} files`
|
||||||
this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)
|
this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)
|
||||||
|
|||||||
Reference in new issue
Block a user