mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
vault: a lock that lets go, and seals that say what they broke
The idle timeout was checked only when the key was next asked for, so a vault nobody touched kept its key in memory indefinitely while status reported it locked. A ticker now drops the key once it is idle, and status() expires it too. Vault passwords are Zeroizing from the request body to Argon2. Re-sealing a file rewrote links with a case-sensitive replace, and only in documents linked through node_media. Links are now matched by regex, whatever their case, in every document and card body that names the id. A folder re-seal carries on past a file that fails and reports how many failed. Two re-seals of one file no longer both commit. :share reports how many sealed files the document embeds, since a share link never serves them, and :encrypt reports how many shared documents embed what it just sealed. Vault status counts sealed files, so :export! asks for the password first rather than navigating into a 423 page. A sealed embed that fails to load while the vault is shut becomes a button that unlocks it and puts the embed back.
This commit is contained in:
1 parent
aa34be8663
commit
336df03ee2
11 files changed
+247
-35
No files matched your search
@@ -209,7 +209,8 @@ and close it, `:passwd` changes the password. A sealed file carries a lock in
|
|||||||
the list, and the bar says `vault open` while it is.
|
the list, and the bar says `vault open` while it is.
|
||||||
|
|
||||||
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), on logout, and whenever the server restarts. While
|
using it (15 by default) — the key is dropped from memory then, not merely
|
||||||
|
refused on the next look — on logout, and whenever the server restarts. While
|
||||||
it is closed a sealed file shows as sealed and nothing more; a share link never
|
it is closed a sealed file shows as sealed and nothing more; a share link never
|
||||||
serves one, open or not; and `:export!` refuses until it is open, because an
|
serves one, open or not; and `:export!` refuses until it is open, because an
|
||||||
archive meant to be read without this application has to hold the files
|
archive meant to be read without this application has to hold the files
|
||||||
@@ -222,6 +223,18 @@ id, and its bytes are AES-256-GCM in 64 KiB records, the last one marked as
|
|||||||
last — so a file that is swapped, cut short or tampered with refuses to open.
|
last — so a file that is swapped, cut short or tampered with refuses to open.
|
||||||
Changing the password re-seals the master key and rewrites no file.
|
Changing the password re-seals the master key and rewrites no file.
|
||||||
|
|
||||||
|
A sealed file is served with `Cache-Control: no-store`, so the browser keeps
|
||||||
|
no copy of what it showed once the vault shuts. A sealed image in a document
|
||||||
|
shows as a button while the vault is shut, which asks for the password and
|
||||||
|
puts the image back. `:share` says how many sealed files a document holds,
|
||||||
|
since its readers will not see them, and `:encrypt` says how many shared
|
||||||
|
documents just lost a picture to the vault. `:encrypt` on a folder seals
|
||||||
|
what it can and counts what it could not, rather than stopping at the first.
|
||||||
|
|
||||||
|
Only the bytes are sealed. A sealed file's name, and the names and text of
|
||||||
|
the documents around it, stay readable in `diary.db` — and in the mirror of
|
||||||
|
it — like everything else outside the vault.
|
||||||
|
|
||||||
**There is no recovery.** Forget the vault password and every sealed file is
|
**There is no recovery.** Forget the vault password and every sealed file is
|
||||||
gone — from the server and from every backup — which is exactly what makes it
|
gone — from the server and from every backup — which is exactly what makes it
|
||||||
worth having. The password is not in `.env`, not in `diary.db`, and not in the
|
worth having. The password is not in `.env`, not in `diary.db`, and not in the
|
||||||
|
|||||||
+3
-1
@@ -114,8 +114,10 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
sweep::spawn(db.clone(), config.uploads_dir());
|
sweep::spawn(db.clone(), config.uploads_dir());
|
||||||
|
|
||||||
|
let vault = vault::Vault::new(db.clone(), config.vault_idle);
|
||||||
|
vault.spawn_idle_lock();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
vault: vault::Vault::new(db.clone(), config.vault_idle),
|
vault,
|
||||||
db,
|
db,
|
||||||
config: Arc::clone(&config),
|
config: Arc::clone(&config),
|
||||||
login_throttle: Arc::default(),
|
login_throttle: Arc::default(),
|
||||||
|
|||||||
+73
-13
@@ -22,7 +22,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::nodes,
|
routes::{media::MEDIA_REF, nodes},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
vault,
|
vault,
|
||||||
};
|
};
|
||||||
@@ -33,6 +33,11 @@ pub struct Resealed {
|
|||||||
pub files: usize,
|
pub files: usize,
|
||||||
/// Of what was asked about, how many were already that way.
|
/// Of what was asked about, how many were already that way.
|
||||||
pub unchanged: usize,
|
pub unchanged: usize,
|
||||||
|
/// How many could not be re-written; the rest still were.
|
||||||
|
pub failed: usize,
|
||||||
|
/// 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.
|
||||||
|
pub shared: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn encrypt(
|
pub async fn encrypt(
|
||||||
@@ -84,21 +89,45 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
|
|||||||
return Err(AppError::BadRequest("there are no files there".into()));
|
return Err(AppError::BadRequest("there are no files there".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A folder is many files, and one that will not re-write — gone from the
|
||||||
|
// disk, cut short — is no reason to leave the rest as they were.
|
||||||
let mut files = 0;
|
let mut files = 0;
|
||||||
let mut unchanged = 0;
|
let mut unchanged = 0;
|
||||||
for target in targets {
|
let mut failed = Vec::new();
|
||||||
|
for target in &targets {
|
||||||
if target.encrypted == seal {
|
if target.encrypted == seal {
|
||||||
unchanged += 1;
|
unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
rewrite(state, &key, &target, seal).await?;
|
match rewrite(state, &key, target, seal).await {
|
||||||
files += 1;
|
Ok(()) => files += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(node = target.node_id, "could not re-write a file: {e:?}");
|
||||||
|
failed.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if files > 0 {
|
if files > 0 {
|
||||||
state.backup.signal();
|
state.backup.signal();
|
||||||
}
|
}
|
||||||
Ok(Resealed { files, unchanged })
|
// Asked about one file, the reason it failed is the answer.
|
||||||
|
if files == 0 && unchanged == 0 && failed.len() == 1 {
|
||||||
|
return Err(failed.remove(0));
|
||||||
|
}
|
||||||
|
let shared: i64 = if seal && files > 0 {
|
||||||
|
sqlx::query_scalar(&format!(
|
||||||
|
"SELECT COUNT(DISTINCT d.id) FROM nodes d
|
||||||
|
JOIN node_media nm ON nm.node_id = d.id
|
||||||
|
JOIN nodes f ON f.media_id = nm.media_id
|
||||||
|
WHERE d.kind = 'document' AND d.share_token IS NOT NULL AND f.id IN ({list})"
|
||||||
|
))
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
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
|
/// Re-write one file sealed or opened under a new id, then point everything
|
||||||
@@ -136,8 +165,8 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool
|
|||||||
tokio::fs::rename(&partial, uploads.join(&new)).await?;
|
tokio::fs::rename(&partial, uploads.join(&new)).await?;
|
||||||
|
|
||||||
let committed = async {
|
let committed = async {
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||||
sqlx::query(
|
let copied = sqlx::query(
|
||||||
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
|
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
|
||||||
SELECT ?1, filename, mime, size, created_at, ?2 FROM media WHERE id = ?3",
|
SELECT ?1, filename, mime, size, created_at, ?2 FROM media WHERE id = ?3",
|
||||||
)
|
)
|
||||||
@@ -145,21 +174,52 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool
|
|||||||
.bind(seal)
|
.bind(seal)
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
// Another re-write of the same file got there first.
|
||||||
|
if copied == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
sqlx::query("UPDATE nodes SET media_id = ?1 WHERE media_id = ?2")
|
sqlx::query("UPDATE nodes SET media_id = ?1 WHERE media_id = ?2")
|
||||||
.bind(&new)
|
.bind(&new)
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
// Every document that embeds the file keeps showing it.
|
// Every document and card that embeds the file keeps showing it —
|
||||||
sqlx::query(
|
// however the link was written: an id typed in capitals is the
|
||||||
"UPDATE nodes SET body = replace(body, '/api/media/' || ?2, '/api/media/' || ?1)
|
// same file.
|
||||||
WHERE kind = 'document' AND id IN (SELECT node_id FROM node_media WHERE media_id = ?2)",
|
let bodies = sqlx::query(
|
||||||
|
"SELECT 'nodes' AS tbl, id, body FROM nodes WHERE instr(lower(body), ?1) > 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'cards', id, body FROM cards WHERE instr(lower(body), ?1) > 0",
|
||||||
)
|
)
|
||||||
.bind(&new)
|
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
for row in bodies {
|
||||||
|
let body: String = row.get("body");
|
||||||
|
let moved = MEDIA_REF.replace_all(&body, |caps: ®ex::Captures| {
|
||||||
|
if caps[1].eq_ignore_ascii_case(&old) {
|
||||||
|
format!("/api/media/{new}")
|
||||||
|
} else {
|
||||||
|
caps[0].to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if moved == body {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let table: String = row.get("tbl");
|
||||||
|
let update = if table == "cards" {
|
||||||
|
"UPDATE cards SET body = ?1 WHERE id = ?2"
|
||||||
|
} else {
|
||||||
|
"UPDATE nodes SET body = ?1 WHERE id = ?2"
|
||||||
|
};
|
||||||
|
sqlx::query(update)
|
||||||
|
.bind(moved.as_ref())
|
||||||
|
.bind(row.get::<i64, _>("id"))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
sqlx::query("UPDATE node_media SET media_id = ?1 WHERE media_id = ?2")
|
sqlx::query("UPDATE node_media SET media_id = ?1 WHERE media_id = ?2")
|
||||||
.bind(&new)
|
.bind(&new)
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
|
|||||||
@@ -82,11 +82,22 @@ pub async fn enable(
|
|||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// A share link never serves a sealed file, so a reader of this one sees
|
||||||
|
// a gap wherever the document embeds one. Worth saying while sharing.
|
||||||
|
let sealed: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM node_media nm JOIN media m ON m.id = nm.media_id
|
||||||
|
WHERE nm.node_id = ?1 AND m.encrypted",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
state.backup.signal();
|
state.backup.signal();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"token": token,
|
"token": token,
|
||||||
"key": key,
|
"key": key,
|
||||||
"path": format!("/s/{token}#{key}"),
|
"path": format!("/s/{token}#{key}"),
|
||||||
|
"sealed": sealed,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -8,18 +8,18 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
throttle::Attempt,
|
throttle::Attempt,
|
||||||
vault::{Status, Unlock, MIN_PASSWORD},
|
vault::{Password, Status, Unlock, MIN_PASSWORD},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PasswordInput {
|
pub struct PasswordInput {
|
||||||
pub password: String,
|
pub password: Password,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ChangeInput {
|
pub struct ChangeInput {
|
||||||
pub current: String,
|
pub current: Password,
|
||||||
pub password: String,
|
pub password: Password,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strong_enough(password: &str) -> AppResult<()> {
|
fn strong_enough(password: &str) -> AppResult<()> {
|
||||||
|
|||||||
+64
-4
@@ -316,6 +316,16 @@ pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: &
|
|||||||
Ok(size)
|
Ok(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A vault password on its way to Argon2, wiped from memory once it is done.
|
||||||
|
pub type Password = Zeroizing<String>;
|
||||||
|
|
||||||
|
fn humantime(d: Duration) -> String {
|
||||||
|
match d.as_secs() {
|
||||||
|
s if s % 60 == 0 => format!("{}m", s / 60),
|
||||||
|
s => format!("{s}s"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The vault's state: whether it exists, and its key while it is open.
|
/// The vault's state: whether it exists, and its key while it is open.
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
db: SqlitePool,
|
db: SqlitePool,
|
||||||
@@ -335,6 +345,8 @@ pub struct Status {
|
|||||||
pub unlocked: bool,
|
pub unlocked: bool,
|
||||||
/// Seconds until it locks itself if nothing uses it.
|
/// Seconds until it locks itself if nothing uses it.
|
||||||
pub locks_in: Option<u64>,
|
pub locks_in: Option<u64>,
|
||||||
|
/// How many files are sealed: what an export needs the vault open for.
|
||||||
|
pub sealed: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a password check came to.
|
/// What a password check came to.
|
||||||
@@ -374,8 +386,38 @@ impl Vault {
|
|||||||
*self.slot() = None;
|
*self.slot() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 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) {
|
||||||
|
*slot = None;
|
||||||
|
tracing::info!("vault locked after {} idle", humantime(self.idle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for an idle vault every few seconds, for as long as the vault
|
||||||
|
/// itself is around.
|
||||||
|
pub fn spawn_idle_lock(self: &Arc<Self>) {
|
||||||
|
let vault = Arc::downgrade(self);
|
||||||
|
let every = (self.idle / 4).clamp(Duration::from_secs(1), Duration::from_secs(30));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(every);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let Some(vault) = vault.upgrade() else { return };
|
||||||
|
vault.expire();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn status(&self) -> Result<Status> {
|
pub async fn status(&self) -> Result<Status> {
|
||||||
let configured = self.row().await?.is_some();
|
let configured = self.row().await?.is_some();
|
||||||
|
let sealed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media WHERE encrypted")
|
||||||
|
.fetch_one(&self.db)
|
||||||
|
.await?;
|
||||||
|
self.expire();
|
||||||
let slot = self.slot();
|
let slot = self.slot();
|
||||||
let remaining = slot
|
let remaining = slot
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -385,6 +427,7 @@ impl Vault {
|
|||||||
configured,
|
configured,
|
||||||
unlocked: remaining.is_some(),
|
unlocked: remaining.is_some(),
|
||||||
locks_in: remaining.map(|left| left.as_secs()),
|
locks_in: remaining.map(|left| left.as_secs()),
|
||||||
|
sealed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +439,7 @@ impl Vault {
|
|||||||
|
|
||||||
/// Make the vault, and leave it open. Refuses when there already is one:
|
/// Make the vault, and leave it open. Refuses when there already is one:
|
||||||
/// a second setup would orphan every file sealed under the first.
|
/// a second setup would orphan every file sealed under the first.
|
||||||
pub async fn setup(&self, password: String) -> Result<bool> {
|
pub async fn setup(&self, password: Password) -> Result<bool> {
|
||||||
if self.row().await?.is_some() {
|
if self.row().await?.is_some() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -432,7 +475,7 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The master key the password stands for, if it is the right one.
|
/// The master key the password stands for, if it is the right one.
|
||||||
async fn check(&self, password: String) -> Result<Option<Option<Key>>> {
|
async fn check(&self, password: Password) -> Result<Option<Option<Key>>> {
|
||||||
let Some(row) = self.row().await? else {
|
let Some(row) = self.row().await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -448,7 +491,7 @@ impl Vault {
|
|||||||
Ok(Some(key))
|
Ok(Some(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unlock(&self, password: String) -> Result<Unlock> {
|
pub async fn unlock(&self, password: Password) -> Result<Unlock> {
|
||||||
Ok(match self.check(password).await? {
|
Ok(match self.check(password).await? {
|
||||||
None => Unlock::NotConfigured,
|
None => Unlock::NotConfigured,
|
||||||
Some(None) => Unlock::WrongPassword,
|
Some(None) => Unlock::WrongPassword,
|
||||||
@@ -461,7 +504,7 @@ impl Vault {
|
|||||||
|
|
||||||
/// Re-seal the master key under a new password. No file changes: they are
|
/// Re-seal the master key under a new password. No file changes: they are
|
||||||
/// sealed under the master key, which stays what it was.
|
/// sealed under the master key, which stays what it was.
|
||||||
pub async fn change_password(&self, current: String, new: String) -> Result<Unlock> {
|
pub async fn change_password(&self, current: Password, new: Password) -> Result<Unlock> {
|
||||||
let master = match self.check(current).await? {
|
let master = match self.check(current).await? {
|
||||||
None => return Ok(Unlock::NotConfigured),
|
None => return Ok(Unlock::NotConfigured),
|
||||||
Some(None) => return Ok(Unlock::WrongPassword),
|
Some(None) => return Ok(Unlock::WrongPassword),
|
||||||
@@ -527,6 +570,23 @@ mod tests {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Idle means the key is gone from memory, not only that the next look
|
||||||
|
/// will refuse it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_idle_vault_lets_go_of_its_key() {
|
||||||
|
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
let vault = super::Vault::new(db, std::time::Duration::from_millis(20));
|
||||||
|
*vault.slot() = Some(super::Unlocked {
|
||||||
|
key: std::sync::Arc::new(key(1)),
|
||||||
|
touched: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
vault.expire();
|
||||||
|
assert!(vault.slot().is_some(), "still within the idle time");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
|
vault.expire();
|
||||||
|
assert!(vault.slot().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn files_of_every_awkward_size_round_trip() {
|
fn files_of_every_awkward_size_round_trip() {
|
||||||
let master = key(7);
|
let master = key(7);
|
||||||
|
|||||||
@@ -40,6 +40,38 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A sealed file embedded here will not load while the vault is shut: the
|
||||||
|
* server answers 423 and the browser shows a broken image. Errors do not
|
||||||
|
* bubble, so they are caught on the way down, and the broken embed is
|
||||||
|
* swapped for a button that opens the vault and puts it back.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const root = article
|
||||||
|
if (!root) return
|
||||||
|
const sealed = async (event: Event) => {
|
||||||
|
const element = event.target as HTMLElement
|
||||||
|
const media = element.closest('img, video, audio') as HTMLImageElement | HTMLMediaElement | null
|
||||||
|
if (!media || !(media.getAttribute('src') ?? media.querySelector('source')?.getAttribute('src') ?? '').includes('/api/media/')) return
|
||||||
|
await workspace.loadVault()
|
||||||
|
if (!workspace.vault?.configured || workspace.vault.unlocked || !media.isConnected) return
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.className = 'sealed-embed'
|
||||||
|
button.textContent = 'sealed — unlock to show'
|
||||||
|
button.onclick = async (click) => {
|
||||||
|
click.preventDefault()
|
||||||
|
click.stopPropagation()
|
||||||
|
if (!(await workspace.ensureUnlocked())) return
|
||||||
|
button.replaceWith(media)
|
||||||
|
if (media instanceof HTMLImageElement) media.src = media.src
|
||||||
|
else media.load()
|
||||||
|
}
|
||||||
|
media.replaceWith(button)
|
||||||
|
}
|
||||||
|
root.addEventListener('error', sealed, true)
|
||||||
|
return () => root.removeEventListener('error', sealed, true)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/n/<space>/<slug>` links — the ones the importer writes between imported
|
* `/n/<space>/<slug>` links — the ones the importer writes between imported
|
||||||
* documents — are followed in place rather than reloading the application.
|
* documents — are followed in place rather than reloading the application.
|
||||||
@@ -152,6 +184,19 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.pane :global(.sealed-embed) {
|
||||||
|
display: block;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+6
-2
@@ -145,6 +145,8 @@ export interface VaultStatus {
|
|||||||
unlocked: boolean
|
unlocked: boolean
|
||||||
/** Seconds until it locks itself if nothing uses it. */
|
/** Seconds until it locks itself if nothing uses it. */
|
||||||
locks_in: number | null
|
locks_in: number | null
|
||||||
|
/** How many files are sealed: what an export needs the vault open for. */
|
||||||
|
sealed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Proton Drive mirror, as `/api/backup` reports it. */
|
/** The Proton Drive mirror, as `/api/backup` reports it. */
|
||||||
@@ -261,7 +263,9 @@ export const api = {
|
|||||||
|
|
||||||
/** Publishes the document, or mints a fresh key for one already published. */
|
/** Publishes the document, or mints a fresh key for one already published. */
|
||||||
share: (id: number) =>
|
share: (id: number) =>
|
||||||
request<{ token: string; key: string; path: string }>(`/nodes/${id}/share`, { method: 'POST' }),
|
request<{ token: string; key: string; path: string; sealed: number }>(`/nodes/${id}/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
|
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
|
||||||
|
|
||||||
@@ -351,7 +355,7 @@ export const api = {
|
|||||||
|
|
||||||
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
|
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
|
||||||
seal: (id: number, encrypt: boolean) =>
|
seal: (id: number, encrypt: boolean) =>
|
||||||
request<{ files: number; unchanged: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
request<{ files: number; unchanged: number; failed: number; shared: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -1,6 +1,6 @@
|
|||||||
import { api, isContainer, type BackupStatus } from './api'
|
import { api, isContainer, type BackupStatus } from './api'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
|
import { formatDay, parseDay, parseDue, sealedNote, type ScrollAmount } from './util'
|
||||||
|
|
||||||
export type { ScrollAmount }
|
export type { ScrollAmount }
|
||||||
|
|
||||||
@@ -596,8 +596,8 @@ export const commands: CommandSpec[] = [
|
|||||||
if (!id) return workspace.say('nothing selected', 'error')
|
if (!id) return workspace.say('nothing selected', 'error')
|
||||||
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
|
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
|
||||||
await workspace.guard(async () => {
|
await workspace.guard(async () => {
|
||||||
const url = await workspace.mintShareLink(id)
|
const { url, sealed } = await workspace.mintShareLink(id)
|
||||||
workspace.say(`new link → ${url} (copied) — the previous one is dead`)
|
workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -636,6 +636,13 @@ export const commands: CommandSpec[] = [
|
|||||||
// A plain navigation, so the browser streams the archive straight to
|
// A plain navigation, so the browser streams the archive straight to
|
||||||
// disk instead of the tab holding all of it in memory first.
|
// disk instead of the tab holding all of it in memory first.
|
||||||
await workspace.flush()
|
await workspace.flush()
|
||||||
|
// Sealed files go into the archive opened, which needs the vault:
|
||||||
|
// asked here, because a refusal after the navigation would replace
|
||||||
|
// the whole app with an error page.
|
||||||
|
await workspace.loadVault()
|
||||||
|
if ((workspace.vault?.sealed ?? 0) > 0 && !(await workspace.ensureUnlocked())) {
|
||||||
|
return workspace.say('the export holds sealed files — it needs the vault open', 'error')
|
||||||
|
}
|
||||||
workspace.say('building the archive — the download starts on its own')
|
workspace.say('building the archive — the download starts on its own')
|
||||||
location.href = '/api/export'
|
location.href = '/api/export'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type SpaceOverview,
|
type SpaceOverview,
|
||||||
type VaultStatus,
|
type VaultStatus,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { formatBytes } from './util'
|
import { formatBytes, sealedNote } from './util'
|
||||||
|
|
||||||
export type Pane = 'list' | 'document'
|
export type Pane = 'list' | 'document'
|
||||||
/** The tree of documents, or the board of the space being browsed. */
|
/** The tree of documents, or the board of the space being browsed. */
|
||||||
@@ -242,10 +242,16 @@ 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 } = await api.seal(id, encrypt)
|
const { files, unchanged, failed, shared } = 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` : ''
|
||||||
this.say(`${verb} ${files} file${files === 1 ? '' : 's'}${already}`)
|
const broken = failed > 0 ? `, ${failed} could not be` : ''
|
||||||
|
const readers =
|
||||||
|
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
|
||||||
|
this.say(
|
||||||
|
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${readers}`,
|
||||||
|
failed > 0 || shared > 0 ? 'error' : undefined,
|
||||||
|
)
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
|
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
|
||||||
}
|
}
|
||||||
@@ -1096,8 +1102,8 @@ class Workspace {
|
|||||||
await api.unshare(id)
|
await api.unshare(id)
|
||||||
this.say(`#${id} is private again`)
|
this.say(`#${id} is private again`)
|
||||||
} else {
|
} else {
|
||||||
const url = await this.mintShareLink(id)
|
const { url, sealed } = await this.mintShareLink(id)
|
||||||
this.say(`shared → ${url} (copied)`)
|
this.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
|
||||||
}
|
}
|
||||||
if (this.open?.id === id) this.open = await api.getNode(id)
|
if (this.open?.id === id) this.open = await api.getNode(id)
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
@@ -1110,10 +1116,10 @@ class Workspace {
|
|||||||
* retires the old one.
|
* retires the old one.
|
||||||
*/
|
*/
|
||||||
async mintShareLink(id: number) {
|
async mintShareLink(id: number) {
|
||||||
const { path } = await api.share(id)
|
const { path, sealed } = await api.share(id)
|
||||||
const url = `${location.origin}${path}`
|
const url = `${location.origin}${path}`
|
||||||
await this.copy(url)
|
await this.copy(url)
|
||||||
return url
|
return { url, sealed }
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(text: string) {
|
async copy(text: string) {
|
||||||
|
|||||||
@@ -130,3 +130,7 @@ export function scrollPage(el: HTMLElement, amount: ScrollAmount) {
|
|||||||
el.scrollBy({ top: amount * line * 3 })
|
el.scrollBy({ top: amount * line * 3 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a share link's reader will miss: a share never serves a sealed file. */
|
||||||
|
export const sealedNote = (sealed: number) =>
|
||||||
|
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''
|
||||||
Reference in new issue
Block a user