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:
nvrl committed 2026-10-20 01:43:45 +02:00
1 parent aa34be8663
commit 336df03ee2
11 files changed
+248 -36

No files matched your search

+14 -1
View File
@@ -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
View File
@@ -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(),
+74 -14
View File
@@ -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)
.execute(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
for row in bodies {
let body: String = row.get("body");
let moved = MEDIA_REF.replace_all(&body, |caps: &regex::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)
.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)
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+4
View File
@@ -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` : ''